리팩토링 31. 타입 코드를 서브클래스로 바꾸기
- etc/리팩토링
- 2023. 5. 10.
들어가기 전
이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다.
리팩토링 31. 타입 코드를 서브 클래스로 바꾸기 (Replace Type Code with Subclasses)
- 타입 코드
- 비슷하지만 다른 것들을 표현해야 하는 경우, 문자열(String), 열거형 (enum), 숫자(int) 등으로 표현하기도 한다. 이것을 타입코드라고 한다.
- 타입 코드는 각각의 특별한 케이스에 대한 처리가 필요하기 때문에 하나의 클래스가 거대해지는 문제가 있다.
- 주로 Order 클래스에서 특정 필드가 Normal / Fast인지에 따라 다르게 동작하는 클래스가 된다.
- 예) 주문 타입, "일반 주문", "빠른 주문" / 직원 타입, "엔지니어", "매니저", "세일즈"
- 타입을 서브클래스로 바꾸는 계기
- 조건문을 다형성으로 표현할 수 있을 때, 서브클래스를 만들고 '조건부 로직을 다형성으로 바꾸기'를 적용한다.
- 특정 타입에만 유효한 필드가 있을 때, 서브클래스를 만들고 '필드 내리기'를 이용한다.
비슷한 클래스인데 타입을 분류해야 하는 경우가 있다. 예를 들어 주문의 경우 일반 주문 / 빠른 주문을 나누는 경우가 있다. 각각의 경우에 따라 필드 / 메서드가 달라질 수 있는데 이처럼 분류를 나눌 때 타입 코드로 문자열 / Enum / 숫자등을 사용하기도 한다. 그런데 이렇게 타입 코드를 이용해 서브 클래스를 표현하는 경우라면, 각 타입 코드에 대한 로직을 If-Else / Switch 문을 많이 사용해야하기 때문에 코드가 복잡해진다.
타입 코드가 있는 경우라면 서브 클래스로 바꾸는 것이 좋은데, 타입 코드를 서브 클래스로 바꾸는 리팩토링 기법은 다음과 같다.
- 조건문을 다형성으로 표현할 수 있을 때, 서브클래스를 만들고 '조건부 로직을 다형성으로 바꾸기'를 적용한다.
- 특정 타입에만 유효한 필드가 있을 때, 서브클래스를 만들고 '필드 내리기'를 이용한다.
필드 내리기에 대한 예시를 들어보면 다음과 같다. 예를 들어 스터디가 있고 온라인 스터디/오프라인 스터디가 있다고 가정해보자. 오프라인 스터디에는 '장소'라는 필드가 중요할 수 있다. '온라인 스터디'는 장소 개념보다는 '줌/구글미트'를 쓸꺼냐로 달라진다. 이처럼 타입 코드에 따라 전혀 달라지는 필드들이 있을 때는, 서브 클래스로 나누고 필드를 내려주는 리팩토링을 진행하면 좀 더 효율적일 것이다.
직접 상속 사용하는 구조 리팩토링
Employee 클래스는 하나지만, 클래스 내부에서는 사실상 3개의 타입이 존재한다. 각 타입은 SalesMan, Engineer, Manager를 의미한다. 추후 각 타입마다 구현해야 할 내용이 늘어나면 If ~ Else를 이용해서 코드가 지저분해질 것이다. 따라서 타입 코드를 서브 클래스로 분리를 해야한다.
이 때, 타입 코드는 서브 클래스로 분리되기 때문에 Employee 클래스에서는 Type이라는 필드가 없어지고 getType()을 통해서 각 타입을 제공할 수 있어야 한다.
// Employee는 Type이라는 값으로 Enginner / Manager / Salesman을 각각 표현하고 있음.
// 타입 코드를 서브 클래스로 나누는 작업이 필요하다.
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 + '\'' +
'}';
}
}
위 내용을 감안해서 리팩토링을 진행하는데, 아래 순서대로 진행할 수 있다.
- Engineer / Manager / SalesMan 클래스를 생성한다. 각 클래스가 Employee 클래스를 상속 받도록 한다.
- Employee 클래스에서 서브 클래스를 만들어주는 팩토리성 메서드 createEmployee()를 만든다. createEmployee()는 입력받은 type에 따라 적절한 클래스를 반환하도록 Switch문을 이용해서 만든다. default 값은 적절한 값이 없기 때문에 Employee 인스턴스를 생성해서 전달한다. 이 때, Validation 되기 때문에 validate() 메서드는 삭제한다.
- 타입이 각 서브 클래스로 분화되었기 때문에 타입 필드가 필요 없어진다. 따라서 Type 필드를 제거한다. 생성자에서도 type을 제거한다.
- Employee 클래스는 타입이 없어진다. 따라서 getType() 메서드를 추상 메서드로 변경한다. 따라서 Employee 클래스도 추상 클래스로 변경된다. 그리고 팩토리 메서드에서 default 값은 Exception을 던지도록 수정한다.
- Engineer / Manager / SalesMan 하위 클래스에서 각각 getType() 메서드를 재정의하고 각 타입을 반환하도록 한다.
이렇게 리팩토링을 수행할 수 있다.
// Employee는 Type이라는 값으로 Enginner / Manager / 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();
};
}
public abstract String getType();
@Override
public String toString() {
return "Employee{" +
"name='" + name + '\'' +
", type='" + "employee" + '\'' +
'}';
}
}
public class Engineer extends Employee{
public Engineer(String name) {
super(name);
}
@Override
public String getType() {
return "engineer";
}
}
이미 상속구조가 있는 경우의 코드 (타입 부분만 클래스 생성 후, 위임처리)
Employee 클래스에는 현재 sales, manager, engineer라는 타입이 존재한다. 이 부분을 서브 클래스로 추출을 해보려고 하는데, 이미 FullTimeEmployee / PartTimeEmployee가 존재한다. 따라서 sales / manager / engineer를 클래스로 추출하는 것은 어렵다. 이런 환경에서는 다음과 같이 처리할 수 있다.
- 타입을 의미하는 새로운 클래스를 생성하고, 해당 클래스의 서브 클래스를 만든다.
- 기존 클래스는 타입 클래스를 필드로 가지고, 위임을 통해서 필요한 동작을 처리한다.
// Employee에 enginner, manager, salesman 타입이 존재한다.
// 타입을 서브 클래스로 추출하려고 했지만, 이미 FullTimeEmployee 같은 서브 클래스가 존재한다.
// 이 때는 타입을 서브 클래스로 추출하는 것이 아니라, 타입에 대한 클래스를 만들고 그 클래스에 필요한 메서드를 구현한 다음, 위임하도록 한다.
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 + '\'' +
'}';
}
}
public class FullTimeEmployee extends Employee {
public FullTimeEmployee(String name, String type) {super(name, type);}
}
public class PartTimeEmployee extends Employee {
public PartTimeEmployee(String name, String type) {super(name, type);}
}
리팩토링은 아래에서 자세히 단계별로 살펴본다.
Step1. EmployeeType 타입 클래스 생성 및 서브 클래스 생성
Employee 클래스의 type이라는 필드에서 문자열로 현재 타입이 설정되어서 관리되고 있었다. 이 부분을 문자열 기본형이 아닌, 새로운 타입인 EmployeeType을 생성하고 각 타입을 나타내는 Engineer / Manager / SalesMan 클래스를 생성하고자 한다.
또한 각 타입 서브 클래스가 타입을 표현할 수 있도록 toString()을 재정의하고, 해당 메서드에서 필요한 타입을 각각 반환하도록 바꿔준다.
public class EmployeeType {
}
public class Manager extends EmployeeType{
@Override
public String toString() {return "manager";}
}
public class SalesMan extends EmployeeType{
@Override
public String toString() {return "salesMan";}
}
public class Engineer extends EmployeeType{
@Override
public String toString() {return "engineer";}
}
Step2. Employee에 EmployeeType 필드 추가 및 팩토리 메서드 생성타입 클래스 생성 및 서브 클래스 생성
처음 코드는 아래와 같다.
public class Employee {
private String name;
private String typeValue;
public Employee(String name, String typeValue) {
this.validate(typeValue);
this.name = name;
this.typeValue = 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.typeValue.substring(0, 1).toUpperCase() + this.typeValue.substring(1).toLowerCase();
}
@Override
public String toString() {
return "Employee{" +
"name='" + name + '\'' +
", type='" + typeValue + '\'' +
'}';
}
}
다음 순서대로 리팩토링 한다.
- 기존에 존재하던 필드 type은 typeValue로 리팩토링한다.
- EmployeeType 타입의 필드 type을 추가한다. (문자열 Type을 EmployeeType의 type으로 바꾸게 됨)
- 생성자에서 type을 생성할 수 있도록 팩토리 메서드 createType()을 구현한다.
- createType()은 스위치 문으로 작성된다. 이 때, 문자열에 따라서 객체 생성 및 에러가 던져지기 때문에 Validation 로직이 필요없어진다. 따라서 validate() 메서드가 삭제된다.
public class Employee {
private String name;
private String typeValue;
private EmployeeType type;
public Employee(String name, String typeValue) {
this.name = name;
this.type = createType(typeValue);
}
private EmployeeType createType(String typeValue) {
return switch (typeValue) {
case "engineer" -> new Engineer();
case "manager" -> new Manager();
case "salesMan" -> new SalesMan();
default -> throw new IllegalArgumentException();
};
}
public String capitalizedType() {
return this.typeValue.substring(0, 1).toUpperCase() + this.typeValue.substring(1).toLowerCase();
}
@Override
public String toString() {
return "Employee{" +
"name='" + name + '\'' +
", type='" + typeValue + '\'' +
'}';
}
}
Step3. capitalizedType() 메서드 위임
현재 코드는 다음과 같다. 아래 코드에서 capitalizedType()의 변경을 할 것이다.
public class Employee {
private String name;
private String typeValue;
private EmployeeType type;
public Employee(String name, String typeValue) {
this.name = name;
this.type = createType(typeValue);
}
private EmployeeType createType(String typeValue) {
return switch (typeValue) {
case "engineer" -> new Engineer();
case "manager" -> new Manager();
case "salesMan" -> new SalesMan();
default -> throw new IllegalArgumentException();
};
}
public String capitalizedType() {
return this.typeValue.substring(0, 1).toUpperCase() + this.typeValue.substring(1).toLowerCase();
}
@Override
public String toString() {
return "Employee{" +
"name='" + name + '\'' +
", type='" + typeValue + '\'' +
'}';
}
}
capitalizedType()은 typeValue를 참조하고 있는데, typeValue는 삭제될 것이다. 따라서 typeValue(기본형) 대신에 새롭게 들어온 type을 참조해서 처리해야한다. 그런데 type을 참조하게 되면 capitazliedType() 메서드 내부에서는 EmployeeType의 변수만 참조하기 때문에 해당 로직을 EmployeeType으로 옮기고, Employee 클래스에서는 단순히 위임만 하도록 변경해야 한다.
이 때 capitalizedType()은 공통된 로직으로 출력할 것이기 때문에 슈퍼 클래스인 EmployeeType에만 있으면 되고, 서브 클래스에서는 따로 구현할 필요가 없어진다.
기존에는 this.typeValue.subString().. 형식으로 코드를 작성했었는데, typeValue는 없어졌기 때문에 재정의한 toString()을 쓴다. toString()은 기존의 타입을 나타내주는 형태로 사용되고 있기 때문이다.
public class EmployeeType {
public String capitalizedType() {
return this.toString().substring(0, 1).toUpperCase() + this.toString().substring(1).toLowerCase();
}
}
public class Employee {
private String name;
private EmployeeType type;
public Employee(String name, String typeValue) {
this.name = name;
this.type = createType(typeValue);
}
private EmployeeType createType(String typeValue) {
return switch (typeValue) {
case "engineer" -> new Engineer();
case "manager" -> new Manager();
case "salesMan" -> new SalesMan();
default -> throw new IllegalArgumentException();
};
}
public String capitalizedType() {
return this.type.capitalizedType();
}
@Override
public String toString() {
return "Employee{" +
"name='" + name + '\'' +
'}';
}
}
결론
- 타입 코드를 서브 클래스로 분리하면, 내부적으로 사용되던 타입 필드는 삭제되어야 한다.
- 만약 이미 서브 클래스가 있어서 타입 코드를 서브 클래스로 분리할 수 없다면 다음과 같이 처리한다.
- 타입을 의미하는 새로운 클래스를 생성하고, 해당 클래스의 서브 클래스를 만든다.
- 기존 클래스는 타입 클래스를 필드로 가지고, 위임을 통해서 필요한 동작을 처리한다.
'etc > 리팩토링' 카테고리의 다른 글
리팩토링 33. 반복문을 파이프라인으로 바꾸기 (0) | 2023.05.10 |
---|---|
리팩토링 32. 조건부 로직을 다형성으로 바꾸기 (0) | 2023.05.10 |
리팩토링 30. 기본형을 객체로 바꾸기 (0) | 2023.05.10 |
냄새 11. 기본형 집착 (0) | 2023.05.10 |
냄새 10. 데이터 뭉치 (Data Clumps) (0) | 2023.05.10 |