ETC

[Refactoring] 11. 기본형 집착

mxruhxn 2025. 1. 4. 15:08
728x90
반응형

기본형 집착(Primitive Obsession)

  • 애플리케이션이 다루고 있는 도메인에 필요한 기본 타입을 만들지 않고 프로그래밍 언어가 제공하는 기본 타입을 사용하는 경우가 많음
    • ex) 전화번호, 좌표, 돈, 범위, 수량 등
  • 기본형으로는 단위(인치 vs. 미터) 또는 표기법을 표현하기 어렵다
  • 관련 리팩토링 기술
    • "기본형을 객체로 바꾸기(Replace Primitive with Object)"
    • "타입 코드를 서브 클래스로 바꾸기(Replace Type Code with Subclasses)"
    • "조건부 로직을 다형성으로 바꾸기(Replace Conditional with Polymorphism)"
    • "클래스 추출하기(Extract Class)"
    • "매개변수 객체 만들기(Introduce Parameter Object)"

기본형을 객체로 바꾸기(Replace Primitive with Object)

  • 개발 초기에는 기본형으로 표현한 데이터가 나중에는 해당 데이터와 관련있는 다양한 기능을 필요로 하는 경우가 발생
    • ex) 문자열로 표현하던 전화번호의 지역 코드가 필요하거나 다양한 포맷을 지원하는 경우
    • ex) 숫자로 표현하던 온도의 단위(화씨, 섭씨)를 변환하는 경우
  • 기본형을 사용한 데이터를 감싸 줄 클래스를 만들면, 필요한 기능을 추가할 수 있음

예제 코드

public class Order {

    private String priority;

    public Order(String priority) {
        this.priority = priority;
    }

    public String getPriority() {
        return priority;
    }
}

---

public class OrderProcessor {

    public long numberOfHighPriorityOrders(List<Order> orders) {
        return orders.stream()
                .filter(o -> o.getPriority() == "high" || o.getPriority() == "rush")
                .count();
    }
}

위 코드는 우선순위를 단순히 기본형으로 표현하여 비교하고 있다. 기본형을 사용하다보니 priority에는 아무 문자열이나 올 수 있다는 문제가 존재한다. 즉, type-safety가 보장되지 않고 있다. 또한, priority와 관련된 기능을 Order에서 판단하고 사용하고 있다보니 응집도 부분에서 아쉬운 점이 있다. 여기에 Priority Enum을 만들어보자.

public enum Priority {
    LOW, NORMAL, HIGH, RUSH;

    public boolean higherThan(Priority other) {
        return this.ordinal() > other.ordinal();
    }
}
public class Order {

    private Priority priority;

    public Order(Priority priority) {
        this.priority = priority;
    }

    public Priority getPriority() {
        return priority;
    }
}


---

public class OrderProcessor {

    public long numberOfHighPriorityOrders(List<Order> orders) {
        return orders.stream()
                .filter(o -> o.getPriority().higherThan(Priority.NORMAL))
                .count();
    }
}

 

Priority와 관련된 기능을 해당 클래스(혹은 enum) 안으로 옮겨와서 더 응집도 높은 설계를 달성하였다

타입 코드를 서브 클래스로 바꾸기(Replace Type Code with Subclasses)

  • 비슷하지만 다른 것들을 표현해야 하는 경우, 문자열, 열거형, 숫자 등으로 표현하기도 한다
    • ex) 주문 타입 -> "일반 주문", "빠른 주문"
    • ex) 직원 타입 -> "엔지니어", "매니저", "세일즈"
  • 타입을 서브클래스로 바꾸는 계기
    • 조건문을 다형성으로 표현할 수 있을 때 -> 서브클래스를 만들고 "조건부 로직을 다형성으로 바꾸기"르 적용
    • 특정 타입에만 유효한 필드가 있을 때 -> 서브클래스를 만들고 "필드 내리기"를 활용

예제 코드 1 - 직접 상속하는 클래스를 만들 수 있는 경우

public class Employee {

    private String name;

    private String type;

    public Employee(String name, String type) {
        this.validate(type);
        this.name = name;
        this.type = type;
    }

    private void validate(String type) {
        List<String> legalTypes = List.of("engineer", "manager", "salesman");
        if (!legalTypes.contains(type)) {
            throw new IllegalArgumentException(type);
        }
    }

    public String getType() {
        return type;
    }

    @Override
    public String toString() {
        return "Employee{" +
                "name='" + name + '\'' +
                ", type='" + type + '\'' +
                '}';
    }
}

 

Employee는 각 타입에 따라 하는 일이 달라진다고 가정하고, 이를 서브클래스로 만들어보자.

public class Engineer extends Employee {
    public Engineer(String name) {
        super(name);
    }

    @Override
    public String getType() {
        return "engineer";
    }
}

---

public class Manager extends Employee {
    public Manager(String name) {
        super(name);
    }

    @Override
    public String getType() {
        return "manager";
    }
}

---

public class Salesman extends Employee {
    public Salesman(String name) {
        super(name);
    }

    @Override
    public String getType() {
        return "salesman";
    }
}
public abstract class Employee {

    private String name;

    protected Employee(String name) {
        this.name = name;
    }

    // 팩토리 메서드 사용
    public static Employee createEmployee(String name, String type) {
        return switch (type) {
            case "engineer" -> new Engineer(name);
            case "manager" -> new Manager(name);
            case "salesman" -> new Salesman(name);
            default -> throw new IllegalArgumentException(type);
        };
    }

    protected abstract String getType();

    @Override
    public String toString() {
        return "Employee{" +
                "name='" + name + '\'' +
                ", type='" + getType() + '\'' +
                '}';
    }
}

 

예제 코드 2 - 이미 상속구조가 존재해서 간접적인 상속을 활용하는 경우

=> 기본형을 포현할 수 있는, 감싸는 새로운 클래스를 만들고 이를 상속&오버라이딩 하는 구조로 해결할 수 있다

public class EmployeeType {
    public String capitalizedType() {
        return this.toString().substring(0, 1).toUpperCase() + this.toString().substring(1).toLowerCase();
    }
}

public class Engineer extends EmployeeType {
    @Override
    public String toString() {
        return "engineer";
    }
}

public class Manager extends EmployeeType {
    @Override
    public String toString() {
        return "manager";
    }
}

public class Manager extends EmployeeType {
    @Override
    public String toString() {
        return "manager";
    }
}
public class Employee {

    private String name;

    private EmployeeType type;

    public Employee(String name, String type) {
        this.validate(type);
        this.name = name;
        this.type = this.employeeType(type);
    }

    private EmployeeType employeeType(String typeValue) {
        return switch (typeValue) {
            case "engineer" -> new Engineer();
            case "manager" -> new Manager();
            case "salesman" -> new Salesman();
            default -> throw new IllegalArgumentException(typeValue);
        };
    }

    private void validate(String type) {
        List<String> legalTypes = List.of("engineer", "manager", "salesman");
        if (!legalTypes.contains(type)) {
            throw new IllegalArgumentException(type);
        }
    }

    public String capitalizedType() {
        return this.type.capitalizedType();
    }

    @Override
    public String toString() {
        return "Employee{" +
                "name='" + name + '\'' +
                ", type='" + type.toString() + '\'' +
                '}';
    }
}

조건부 로직을 다형성으로 바꾸기(Replace Conditional with Polymorphism)

  • 복잡한 조건식(switch, if-else)
    • ex) switch 문을 사용해서 타입에 따라 각기 다른 로직을 사용하는 코드
    • => 다형성을 사용해 코드를 보다 명확하게 분리할 수 있음
  • 기본 동작과 (타입에 따른) 특수한 기능이 섞여있는 경우
    • => 상속 구조를 만들어서 기본 동작을 상위 클래스에 두고 특수한 기능을 하위 클래스로 옮겨서 각 타입에 따른 "차이점"을 강조할 수 있음
  • 모든 조건문을 다형성으로 옮겨야 하는가?
    • 단순한 조건문은 그대로 두어도 좋다
    • 오직 복잡한 조건문을 다형성을 활용해 좀 더 나은 코드로 만들 수 있는 경우에만 적용하자(과용을 조심)

예제 코드 1 - 스위치문 예제

public class Employee {

    private String type;

    private List<String> availableProjects;

    public Employee(String type, List<String> availableProjects) {
        this.type = type;
        this.availableProjects = availableProjects;
    }

    public int vacationHours() {
        return switch (type) {
            case "full-time" -> 120;
            case "part-time" -> 80;
            case "temporal" -> 32;
            default -> 0;
        };
    }

    public boolean canAccessTo(String project) {
        return switch (type) {
            case "full-time" -> true;
            case "part-time", "temporal" -> this.availableProjects.contains(project);
            default -> false;
        };
    }
}

 

위 코드에서 발생한 swich 문을 다형성을 적용해 해소해보자

public class FullTimeEmployee extends Employee {

    @Override
    public int vacationHours() {
        return 120;
    }

    @Override
    public boolean canAccessTo(String project) {
        return true;
    }
}

---

public class PartTimeEmployee extends Employee {

    public PartTimeEmployee(List<String> availableProjects) {
        super(availableProjects);
    }

    @Override
    public int vacationHours() {
        return 80;
    }

}

---

public class TemporalEmployee extends Employee {

    public TemporalEmployee(List<String> availableProjects) {
        super(availableProjects);
    }

    @Override
    public int vacationHours() {
        return 32;
    }
}
public abstract class Employee {
    protected List<String> availableProjects;

    public Employee(List<String> availableProjects) {
        this.availableProjects = availableProjects;
    }

    public Employee() {
    }

    public abstract int vacationHours();

    public boolean canAccessTo(String project) {
        return this.availableProjects.contains(project);
    }
}

 

예제 코드 2 - 일반적인 로직 사이에 매우 특수한 조건이 섞여있는 경우

public class VoyageRating {

    private Voyage voyage;

    private List<VoyageHistory> history;

    public VoyageRating(Voyage voyage, List<VoyageHistory> history) {
        this.voyage = voyage;
        this.history = history;
    }

    public char value() {
        final int vpf = this.voyageProfitFactor();
        final int vr = this.voyageRisk();
        final int chr = this.captainHistoryRisk();
        return (vpf * 3 > (vr + chr * 2)) ? 'A' : 'B';
    }

    private int captainHistoryRisk() {
        int result = 1;
        if (this.history.size() < 5) result += 4;
        result += this.history.stream().filter(v -> v.profit() < 0).count();
        if (this.voyage.zone().equals("china") && this.hasChinaHistory()) result -= 2; // 비슷한 조건
        return Math.max(result, 0);
    }

    private int voyageRisk() {
        int result = 1;
        if (this.voyage.length() > 4) result += 2;
        if (this.voyage.length() > 8) result += this.voyage.length() - 8;
        if (List.of("china", "east-indies").contains(this.voyage.zone())) result += 4; // 비슷한 조건
        return Math.max(result, 0);
    }

    private int voyageProfitFactor() {
        int result = 2;

        if (this.voyage.zone().equals("china")) result += 1;
        if (this.voyage.zone().equals("east-indies")) result +=1 ;
        if (this.voyage.zone().equals("china") && this.hasChinaHistory()) { // 비슷한 조건
            result += 3;
            if (this.history.size() > 10) result += 1;
            if (this.voyage.length() > 12) result += 1;
            if (this.voyage.length() > 18) result -= 1;
        } else {
            if (this.history.size() > 8) result +=1 ;
            if (this.voyage.length() > 14) result -= 1;
        }

        return result;
    }

    private boolean hasChinaHistory() { // 비슷한 조건
        return this.history.stream().anyMatch(v -> v.zone().equals("china")); 
    }
}

 

항해에 대한 평가를 하는 코드이다. 코드를 보면 각 메서드에 china와 관련된 비슷한 조건이 발생하고 있는 것을 볼 수 있다. 즉, 이 조건 자체가 변종에 해당하는 것이다. 우리는 이 경우에 해당하는 것만 따로 로직을 분리할 필요가 있다.

 

일단 china 경험이 있는 voyage를 구분하여 rating하기 위한 ChinaExperiencedVoyageRating을 VoyageRating을 상속하여 만들자

public class ChinaExperiencedVoyageRating extends VoyageRating {

    public ChinaExperiencedVoyageRating(Voyage voyage, List<VoyageHistory> history) {
        super(voyage, history);
    }

}

 

이후, 팩토리 클래스를 만들어 조건에 따라 생성하도록 만들자

public class RatingFactory {
    public static VoyageRating createRating(Voyage voyage, List<VoyageHistory> history) {
        if (voyage.zone().equals("china") && hasChinaHistory(history)) {
            return new ChinaExperiencedVoyageRating(voyage, history);
        } else {
            return new VoyageRating(voyage, history);
        }
    }

    private static boolean hasChinaHistory(List<VoyageHistory> history) {
        return history.stream().anyMatch(v -> v.zone().equals("china"));
    }
}

 

이제, 각 메서드에 있던 china에 대한 조건문 내용을 모두 ChinaExperiencedVoyageRating으로 옮겨오자.

public class VoyageRating {

    protected Voyage voyage;

    protected List<VoyageHistory> history;

    public VoyageRating(Voyage voyage, List<VoyageHistory> history) {
        this.voyage = voyage;
        this.history = history;
    }

    public char value() {
        final int vpf = this.voyageProfitFactor();
        final int vr = this.voyageRisk();
        final int chr = this.captainHistoryRisk();
        return (vpf * 3 > (vr + chr * 2)) ? 'A' : 'B';
    }

    protected int captainHistoryRisk() {
        int result = 1;
        if (this.history.size() < 5) result += 4;
        result += this.history.stream().filter(v -> v.profit() < 0).count();
        return Math.max(result, 0);
    }

    private int voyageRisk() {
        int result = 1;
        if (this.voyage.length() > 4) result += 2;
        if (this.voyage.length() > 8) result += this.voyage.length() - 8;
        if (List.of("china", "east-indies").contains(this.voyage.zone())) result += 4;
        return Math.max(result, 0);
    }

    protected int voyageProfitFactor() {
        int result = 2;

        if (this.voyage.zone().equals("china")) result += 1;
        if (this.voyage.zone().equals("east-indies")) result += 1;
        result += voyageLengthFactor();
        result += historyLengthFactor();

        return result;
    }

    protected int voyageLengthFactor() {
        return (this.voyage.length() > 14) ? -1 : 0;
    }

    protected int historyLengthFactor() {
        return (this.history.size() > 8) ? 1 : 0;
    }

}
public class ChinaExperiencedVoyageRating extends VoyageRating {

    public ChinaExperiencedVoyageRating(Voyage voyage, List<VoyageHistory> history) {
        super(voyage, history);
    }

    @Override
    protected int captainHistoryRisk() {
        int result = super.captainHistoryRisk() - 2;
        return Math.max(result, 0);
    }

    @Override
    protected int voyageProfitFactor() {
        return super.voyageProfitFactor() + 3; // 3을 더하는 책임을 옮김
    }

    @Override
    protected int voyageLengthFactor() {
        int result = 0;
        if (this.voyage.length() > 12) result += 1;
        if (this.voyage.length() > 18) result -= 1;

        return result;
    }

    @Override
    protected int historyLengthFactor() {
        return (this.history.size() > 10) ? 1 : 0;
    }
}

 

책임을 옮겨오는 것 뿐만 아니라, 각종 분기문을 메서드로 추출(= 조건문 분해하기)하고 코드 정리하기를 통해 더 정리해주었다.

728x90
반응형