DEV ℧ Developer Diary

[Refactoring] 냄새 11. 기본형 집착 (1)

해당 포스트는 inflearn의 백기선님의 강의인 리팩토링 을 듣고 정리한 글입니다.

냄새 11. 기본형 집착 (1)

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

리팩토링 30. 기본형을 객체로 바꾸기

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

  • Order
public class Order {

    private String priority;

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

    public String getPriority() {
        return priority;
    }
}
  • OrderProcessor
public class OrderProcessor {

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

해당 코드는 Order의 주문 정보의 우선순위(priority)를 String 으로 받고, OrderProcessor에서 high와 rush의 빈도수를 계산하는 로직이다.

먼저 테스트코드는 아래와 같이 작성되었으며, 테스트가 돌아가는지 실행해 보자.

@Test
void numberOfHighPriorityOrders() {
    OrderProcessor orderProcessor = new OrderProcessor();
    long highPriorityOrders = orderProcessor.numberOfHighPriorityOrders(
            List.of(new Order("low"),
                    new Order("normal"),
                    new Order("high"),
                    new Order("rush")));
    assertEquals(2, highPriorityOrders);
}

정상적으로 돌아가는 것을 확인 할 수 있다.

리팩토링001

먼저 String 타입으로 받던 priority 를 클래스로 추출해주자.

String으로 받아준다면, priority변수에 ‘ㄱ’이나 ‘ㄴ’같은 문자를 넣어도 아무 문자나 들어갈 수 있기 때문에 Type Safety가 보장되지 않기 때문이다.

이렇게 Priority 클래스로 따로 추출해 준 뒤 low, normal high, rush 네가지 타입 외에는 IllegalArgumentException 예외처리를 반환하도록 하여 데이터의 정합성을 높여준다. 이후 해당 타입별로 필요한 메소드들을 구현해준다.

여기서 각각의 higherThanindexOrderProcessor의 로직중 filter 부분의 책임을 Priority로 옮겼다고 보면 된다.

public class Priority {

    private String value;

    private List<String> legalValues = List.of("low", "normal", "high", "rush");

    public Priority(String value) {
        if (this.legalValues.contains(value))
            this.value = value;
        else
            throw new IllegalArgumentException("illegal value for priority " + value);
    }

    @Override
    public String toString() {
        return this.value;
    }

    public int index() {
        return this.legalValues.indexOf(this.value);
    }

    public boolean higherThan(Priority other) {
        return this.index() > other.index();
    }
}

그러면 나머지 OrderOrderProcessor의 로직도 변경 해 주도록 하자.

여기서 중요한것이, 리팩토링으로 인한 side effect가 걱정될 경우 String 타입을 생성자로 받는 그대로 사용하여 Priority객체로 넘겨주면 된다. 이 기술을 메소드 체이닝이라고 하는데, 체이닝을 통하여 기존 코드의 변경없이도 리팩토링을 진행 할 수 있다.

  • Order
public class Order {

    private Priority priority;

    // 메소드 체이닝(Method Chaining)
    public Order(String priorityValue) {
        this(new Priority(priorityValue));
    }

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

    public Priority getPriority() {
        return this.priority;
    }
}

OrderProcessor의 경우 앞서 설명한대로, OR 조건을 제거하고, Priority객체에서 우선순위를 비교하여, 반환하도록 변경하였다.

  • OrderProcessor
public class OrderProcessor {

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

다시 테스트를 돌려본다면, 동일한 결과가 나오는것을 확인 할 수 있다.

리팩토링002

리팩토링 31. 타입 코드를 서브클래스로 바꾸기

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

먼저 두가지의 경우가 있다.

  1. 직접적인 상속이 가능한 경우
  2. 이미 상속 관계로 정의되어 있어서 간접적인 상속을 진행할 경우

각각을 알아보도록 하자.

먼저 직접적인 상속이 가능한 경우이다.

  • Employee
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 + '\'' +
                '}';
    }
}

먼저 테스트 코드를 진행 했을 때는 정상적으로 출력되는 것을 확인 할 수 있다.

@Test
void employeeType() {
    assertEquals("engineer", new Employee("keesun", "engineer").getType());
    assertEquals("manager", new Employee("keesun", "manager").getType());
    assertThrows(IllegalArgumentException.class, () -> new Employee("keesun", "wrong type"));
}

리팩토링003

Employee 클래스는 각각 “엔지니어”, “매니저”, “영업” 타입의 직원 종류를 가지고 있다.

직원 타입을 서브클래스로 추출해 상속관계로 연결해 보도록 하자. 먼저 각각의 타입을 클래스로 추출하고 Employee 클래스를 상속받도록 변경해주자.

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

    @Override
    public String getType() {
        return super.getType();
    }
}

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

  @Override
  public String getType() {
    return super.getType();
  }
}

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

  @Override
  public String getType() {
    return super.getType();
  }
}

이후 팩토리 메소드 패턴을 적용하여, Employee 클래스에서 name 필드와 type 필드를 받아, 해당하는 타입을 반환하도록 static 메소드를 하나 생성해준다.

public static Employee createEmployee(String name, String type) {
    return switch (type) {
        case "engineer" -> new Engineer(name, type);
        case "manager" -> new Manager(name, type);
        case "salesman" -> new Salesman(name, type);
        default -> throw new IllegalArgumentException(type);
    };
}

이후 Employee의 생성자는 외부에서 호출해주지 못하도록 protected로 접근제어자를 변경해준다. private가 아닌 이유는 하위 클래스에서 해당 생성자를 호출하고 있기 때문이다.

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

이제 서브클래스로 추출한 type 필드는 필요가 없어졌으니, 부모 클래스와 자식클래스 모두 type 필드를 삭제해준다.

이후, type값을 반환하는 메소드인 getType은 각각 하위 클래스에서 구현을 하여 타입을 반환하게 하면 되므로 부모클래스는 추상화 시키고, 부모클래스 또한 추상화한다.

public abstract class Employee {
  ...
  protected abstract String getType();
  ...
}

이후 하위 클래스에서는 각각 의 String타입의 type을 반환해주는 메소드로 @Override 받아 구현해준다.

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

전체적인 코드를 살펴보면 많이 간단해진 것을 볼 수있다.

  • Employee
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 + '\'' +
                '}';
    }
}
  • Engineer, Manager, Salesman
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";
    }
}

이제 Employee를 호출하는 방식이 달라졌으므로, 해당 코드를 호출하던 테스트 코드도 수정해 주도록 하자.

팩토리 메소드 패턴을 이용해 만든 createEmployee 메소드를 통해서 호출하면 된다.

@Test
void employeeType() {
    assertEquals("engineer", Employee.createEmployee("keesun", "engineer").getType());
    assertEquals("manager", Employee.createEmployee("keesun", "manager").getType());
    assertThrows(IllegalArgumentException.class, () -> Employee.createEmployee("keesun", "wrong type"));
}

동일하게 성공하는 것을 확인 할 수 있다.

리팩토링004

이번엔 이미 상속관계가 정의되어있어 간접적으로 상속을 진행할 경우를 살펴보자.

  • Employee
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 capitalizedType() {
        return this.type.substring(0, 1).toUpperCase() + this.type.substring(1).toLowerCase();
    }

    @Override
    public String toString() {
        return "Employee{" +
                "name='" + name + '\'' +
                ", type='" + type + '\'' +
                '}';
    }
}
  • FullTimeEmployee
public class FullTimeEmployee extends Employee {
    public FullTimeEmployee(String name, String type) {
        super(name, type);
    }
}
  • PartTimeEmployee
public class PartTimeEmployee extends Employee {
    public PartTimeEmployee(String name, String type) {
        super(name, type);
    }
}

해당 경우를 살펴보면 이미 Employee를 상속받는 클래스인 FullTimeEmployee, PartTimeEmployee 클래스가 있다.

이때는 type에 해당하는 클래스를 새롭게 하나 더 만들어줘서 각각의 type을 상속받도록 한다.

// 부모 클래스
public class EmployeeType {
}

// 자식클래스
public class Engineer extends EmployeeType {
    @Override
    public String toString() {
        return "engineer";
    }
}

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

public class Salesman extends EmployeeType {
    @Override
    public String toString() {
        return "salesman";
    }
}

그리고 Employee에 있는 type필드의 타입을 EmployeeType 타입으로 변경해 주도록 하자.

public class Employee {

    private String name;
    private EmployeeType type;

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

    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);
        };
    }

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

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

capitalizedType() 메소드는 EmployeeType 객체에 각각 하위 클래스의 toString을 반환하도록 하여, 다형성을 이용하여 type 정보를 가져오도록 하였다.

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

이후에 테스트 코드를 실행하여 정상적으로 동작하는지 여부를 확인 해주면 리팩토링은 마무리된다

@Test
void capitalizedType() {
    assertEquals("Manager", new FullTimeEmployee("keesun", "manager").capitalizedType());
    assertEquals("Engineer", new PartTimeEmployee("keesun", "engineer").capitalizedType());
    assertThrows(IllegalArgumentException.class, () -> new Employee("keesun", "wrong type"));
}

리팩토링004