리팩토링 32. 조건부 로직을 다형성으로 바꾸기
- etc/리팩토링
- 2023. 5. 10.
들어가기 전
이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다.
리팩토링 32. 조건부 로직을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)
- 조건부 로직을 다형성으로 바꾸기
- 복잡한 조건식을 상속과 다형성을 사용해 코드를 보다 명확하게 분리할 수 있다.
- 기본 동작과 타입에 따른 특수한 기능이 섞여있는 경우, 상속 구조를 만들어서 기본 동작을 상위클래스에 두고 특수한 기능을 하위클래스로 옮겨서 각 타입에 따른 '차이점'을 강조할 수 있다.
- 조건부 로직의 문제점
- Switch 문을 사용해서 타입에 따라 각기 다른 로직을 사용하는 코드가 문제가 될 수 있음. 클래스 전체적으로 반복되는 조건문이 사용될 것이기 때문이고, 더욱 복잡해진다.
- 모든 조건문을 다형성으로 옮겨야 하는가?
- 단순한 조건문은 그대로 두어도 좋다. 오직 복잡한 조건문을 다형성을 활용해 좀 더 나은 코드로 만들 수 있는 경우에만 적용한다. (과용을 조심하자.)
Swtich문 / if-else문이 여러 번 등장하는 경우, 이걸 서브 클래스로 옮기는 것을 고려해 볼 수 있다. 이런 식으로 반복해서 등장하면, 사실상 해당 클래스는 타입 코드를 가지고 있는 것으로 볼 수 있기 때문인데, 대표적인 두 가지 경우는 다음과 같다.
- 여러 타입이 있고, 타입에 따라 각기 다른 식으로 동작해야하는 경우.
- 일반적인 로직이 하나 있고, 파생되는 특수한 로직이 들어가는 경우
- 조건문으로 이 때는 1, 저 때는 2, 다를 때는 3, 같은 형식인 경우.
위와 같은 상황이라면 조건식에 다형성을 적용해서 코드를 좀 더 간소화하고, 읽기 쉽게 만들 수 있다.
코드
- Switch문의 예제는 단순 조건문을 다형성으로 바꾸는 것이다.
- Variation은 변종에 해당하는 로직이 생기는 경우를 의미한다. Variartion은 대부분 일반적인 로직을 쓰지만, 특수한 경우에는 특수한 로직이 필요하다는 것인데, 이 때 상속을 활용할 수 있다는 것이다.
Switch 예제 (단순 조건문 다형성으로 바꾸기)
아래의 Employee 클래스는 풀타임 / 파트 타임 / 임시직이냐에 따라서 휴가 기간/접근 권한이 다른 코드다. 내부적으로 타입 코드를 가지고 있고, 이 타입 코드에 대해서 Switch문을 이용해서 다르게 동작하는 것을 알 수 있다. 또한, 하위 클래스가 없기 때문에 조건문을 다형성으로 바꾸기 쉽다.
// 타입 코드가 존재하고, 타입 코드에 따라서 다른 동작을 한다
// 상속 클래스가 없기 때문에 서브 클래스로 만들어, 조건문을 다형성으로 분리할 수 있다.
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;
};
}
}
다음 순서대로 리팩토링하면 된다.
- FullTimeEmployee 클래스를 생성하고, Employee 클래스를 상속받는다. FullTimeEmployee의 생성자는 아무런 매개변수를 받지 않는다. 왜냐하면 vacationHours() / canAccessTo()에서 항상 상수값을 반환하기 때문에 어떠한 필드도 사용할 필요가 없기 때문이다.
- FullTimeEmployee 클래스의 생성자에서 사용할 수 있도록 Employee 클래스의 기본 생성자를 생성한다. 또한 하는 김에 AvailableProject 매개변수만 받는 생성자도 하나 만든다.
- FullTimeEmployee 클래스에서 vacationHours(), canAccessTo() 메서드를 재정의한다. 각각 120, true를 반환하도록 작성한다.
- PartTimeEmployee 클래스를 생성하고, Employee 클래스를 상속받는다. canAccessTo() 메서드에서 availableProject가 필요하기 때문에 availableProject를 받는 생성자를 생성한다. vacationHours(), canAccessTo() 메서드를 재정의한다. 각각 80 리턴, 기존 canAccessTo() 로직을 사용하도록 한다. 이 때, Employee에서 availableProject 필드를 private → protected로 변경해준다.
- TemporalEmployee 클래스도 동일하게 만들어준다.
- Employee에서 타입 필드를 제거한다.
- PartTimeEmployee의 canAccessTo()를 Employee 클래스로 올려주고, TemporalEmployee 클래스에서 해당 메서드를 삭제한다. (슈퍼 클래스 메서드 사용하도록)
- Employee 클래스의 vacationHours()는 지원하지 않을 것이기 때문에 abstract 메서드로 만들어준다.
이렇게 처리하면 아래 코드가 생성된다.
// 타입 코드가 존재하고, 타입 코드에 따라서 다른 동작을 한다
// 상속 클래스가 없기 때문에 서브 클래스로 만들어, 조건문을 다형성으로 분리할 수 있다.
public abstract class Employee {
private List<String> availableProjects;
public Employee() {}
public Employee(List<String> availableProjects) {
this.availableProjects = availableProjects;
}
public boolean canAccessTo(String project) {
return this.availableProjects.contains(project);
}
public abstract int vacationHours();
}
public class FullTimeEmployee extends Employee{
public FullTimeEmployee() {
super();
}
@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;
}
}
Variartion 살펴보기 (대부분 일반적인 로직이지만, 특수한 로직이 들어가기도 함)
VoyageRating 클래스를 살펴보면 다음 코드가 자주 등장하는 것을 알 수 있다. 이런 코드는 '일반적인 로직이 있고, 이런 경우에는 추가적으로 이런 일을 해라'라고 하는 '변종'을 나타낸다. 이처럼 코드 내에서 나타는 '변종'이 많아질 경우에도 서브 클래스로 분리하는게 괜찮은 방법이 될 수 있다.
if (this.voyage.zone().equals("china") && this.hasChinaHistory())
시작하기 전의 전에 코드는 다음과 같다.
// this.voyage.zone().equals("china") 같은 코드가 많이 있다.
// 일반적인 로직이 존재하는데, 위 경우에는 약간 추가되는 것이 있다.
// 이 부분을 ChinaExperiencedVoyageRating으로 분리해 볼 수 있음.
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"));
}
}
Step1. ChinaExperiencedVoyageRating 클래스 / RatingFactory 클래스 생성
아래 코드를 가진 녀석들을 ChinaExperiencedVoyageRating으로 분리할 것이다. 그리고 해당 분기로 처리되는 녀석들은 이 클래스로 옮겨와서 처리하도록 로직을 변경할 것이다.
if (this.voyage.zone().equals("china") && this.hasChinaHistory())
아래와 같이 ChinaExperiencedVoyageRating 클래스를 생성하고 생성자를 셋팅해준다.
public class ChinaExperiencedVoyageRating extends VoyageRating{
public ChinaExperiencedVoyageRating(Voyage voyage, List<VoyageHistory> history) {
super(voyage, history);
}
}
이제 타입 코드가 두 클래스로 분리되었기 때문에 팩토리 메서드를 제공할 팩토리 클래스 RatingFactory를 생성하고, 위의 If문을 RatingFactory 클래스로 넘겨준다. 그리고 조건문에 따라서 VoyageRating / ChinaExperiencedVoyageRating 인스턴스를 생성하도록 코드를 작성한다. 이제 VoyageRating에 있던 hasChinaHistory() 메서드는 사용을 하지 않아도 되게 된다.
public class RatingFactory {
public static VoyageRating createVoyage(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"));
}
}
Step2. VoyageRating 클래스 protected로 수정
ChinaExperiencedVoyageRating 클래스로 VoyageRating의 변종 로직을 옮길 것이고, 상속으로 구현할 것이다. 따라서 서브 클래스에서 사용할 수 있도록 필드와 메서드를 Protected로 수정해준다.
Step3. captainHistroyRisk() 메서드의 분리
아래에서 중국과 관련된 부분의 코드를 ChinaExperiencedVoyageRating으로 이동시킨다.
// VoyageRating.java
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);
}
중국 경험이 있는 경우에는 일반적인 로직을 완수한 후에 값을 빼야하는 것으로 작성되어 있기 때문에 아래 로직으로 작성해 볼 수 있다.
@Override
protected int captainHistoryRisk() {
int result = super.captainHistoryRisk() - 2;
return Math.max(result, 0);
}
Step4. VoyageProfitFactor() 메서드의 분리
voyageProfitFactor()는 아래 코드다. 그런데 아래에서 중국이냐 아니냐에 따라서 처리해야하는 If - else 조건문의 Action 블록쪽이 제법 두껍다. 따라서 메서드를 voyageAndHistoryFactor() 메서드로 해당 로직을 분리시켜줘야한다.
// VoyageRating.java
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;
}
메서드를 분리하면 다음처럼 분리 한다. 이 때 int result라는 매개변수를 넘기지 않고, 내부에서 선언해서 사용하도록 한다. 매개변수를 줄여주기 때문에 더 읽기 편해지기 때문이다.
private int voyageProfitFactor() {
int result = 2;
if (this.voyage.zone().equals("china")) result += 1;
if (this.voyage.zone().equals("east-indies")) result +=1 ;
result += voyageAndHistoryFactor();
return result;
}
private int voyageAndHistoryFactor() {
int result = 0;
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;
}
Step5. voyageAndHistoryFactor() 변종 로직을 서브 클래스로 이동
voyageAndHistoryFactor()의 변종 로직은 ChinaExperiencedVoyageRating 클래스로 옮겨준다. 아래처럼 분리될 수 있다.
// VoyageRating.java
protected int voyageAndHistoryFactor() {
int result = 0;
if (this.history.size() > 8) result +=1 ;
if (this.voyage.length() > 14) result -= 1;
return result;
}
// ChinaExperiencedVoyageRating.java
@Override
protected int voyageAndHistoryFactor() {
int result = 0;
result += 3;
if (this.history.size() > 10) result += 1;
if (this.voyage.length() > 12) result += 1;
if (this.voyage.length() > 18) result -= 1;
return result;
}
Step6. voyageAndHistoryFactor()에서 and 분리하기
and라는 문구가 들어가는 것은 이 메서드가 두 가지 일을 하고 있음을 의미한다. 따라서 and를 분리하는 작업이 필요한데, 이를 위해서 voyageLengthFactor() / historyLengthFactor() 메서드로 분리시켜준다. 그리고 서브 클래스에도 동일하게 메서드를 재정의해준다.
그 결과 아래와 같은 코드를 작성할 수 있다.
// VoyageRating.java
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() {
int result = 0;
if (this.voyage.length() > 14) result -= 1;
return result;
}
protected int historyLengthFactor() {
int result = 0;
if (this.history.size() > 8) result +=1 ;
return result;
}
// ChinaExpereiencedVoyageRating.java
@Override
protected int voyageLengthFactor() {
int result = 0;
result += 3;
if (this.voyage.length() > 12) result += 1;
if (this.voyage.length() > 18) result -= 1;
return result;
}
@Override
protected int historyLengthFactor() {
int result = 0;
if (this.history.size() > 10) result += 1;
return result;
}
Step7. 서브 클래스의 VoyageProfitFactor 코드 정리하기
이대로 리팩토링이 끝나도 되지만, 조금 더 리팩토링을 하자면 VoyageProfitFactor() 메서드도 정리할 수 있다. 현재 ChinaExperiencedVoyageRating 클래스의 코드는 다음과 같다. 그런데 result +=3 이라는 코드를 voyageProfitFactor()로 옮기면서 변종을 한번 더 표현해 줄 수 있다.
// ChinaExperiencedVoyageRating.java
@Override
protected int voyageLengthFactor() {
int result = 0;
result += 3;
if (this.voyage.length() > 12) result += 1;
if (this.voyage.length() > 18) result -= 1;
return result;
}
@Override
protected int historyLengthFactor() {
int result = 0;
if (this.history.size() > 10) result += 1;
return result;
}
// VoyageRating.java
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() {
int result = 0;
if (this.voyage.length() > 14) result -= 1;
return result;
}
protected int historyLengthFactor() {
int result = 0;
if (this.history.size() > 8) result +=1 ;
return result;
}
서브 클래스는 voyageProfitFactor() 관점에서 슈퍼 클래스와 동일한 작업을 하기 때문에 있는 것을 그대로 써도 되지만, result +=3 이라는 코드를 voyageProfitFactor()로 옮겨주면서 다른 작업을 하는 것을 강조할 수 있다.
// ChinaExperiencedVoyageRating.java
@Override
protected int voyageProfitFactor() {
return super.voyageProfitFactor() + 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() {
int result = 0;
if (this.history.size() > 10) result += 1;
return result;
}
정리
- 조건부 로직을 다형성으로 바꾸는 경우는 각 서브 클래스를 만들고, 서브 클래스의 변종 로직이 독립적으로 동작하도록 작성되었다.
- 공통 코드에 약간의 변종 코드가 들어가는 경우는 서브 클래스를 만들고, 서브 클래스의 메서드가 부모 클래스의 메서드를 호출하고, 호출 결과에 자신의 변종 코드를 더 추가하는 형식으로 분리되었다.
'etc > 리팩토링' 카테고리의 다른 글
냄새 12. 반복되는 Switch 문 (0) | 2023.05.10 |
---|---|
리팩토링 33. 반복문을 파이프라인으로 바꾸기 (0) | 2023.05.10 |
리팩토링 31. 타입 코드를 서브클래스로 바꾸기 (0) | 2023.05.10 |
리팩토링 30. 기본형을 객체로 바꾸기 (0) | 2023.05.10 |
냄새 11. 기본형 집착 (0) | 2023.05.10 |