리팩토링 32. 조건부 로직을 다형성으로 바꾸기

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다.


    리팩토링 32. 조건부 로직을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)

    • 조건부 로직을 다형성으로 바꾸기
      • 복잡한 조건식을 상속과 다형성을 사용해 코드를 보다 명확하게 분리할 수 있다. 
      • 기본 동작과 타입에 따른 특수한 기능이 섞여있는 경우, 상속 구조를 만들어서 기본 동작을 상위클래스에 두고 특수한 기능을 하위클래스로 옮겨서 각 타입에 따른 '차이점'을 강조할 수 있다.
    • 조건부 로직의 문제점
      • Switch 문을 사용해서 타입에 따라 각기 다른 로직을 사용하는 코드가 문제가 될 수 있음. 클래스 전체적으로 반복되는 조건문이 사용될 것이기 때문이고, 더욱 복잡해진다. 
    • 모든 조건문을 다형성으로 옮겨야 하는가?
      • 단순한 조건문은 그대로 두어도 좋다. 오직 복잡한 조건문을 다형성을 활용해 좀 더 나은 코드로 만들 수 있는 경우에만 적용한다. (과용을 조심하자.) 

     

    Swtich문 / if-else문이 여러 번 등장하는 경우, 이걸 서브 클래스로 옮기는 것을 고려해 볼 수 있다. 이런 식으로 반복해서 등장하면, 사실상 해당 클래스는 타입 코드를 가지고 있는 것으로 볼 수 있기 때문인데, 대표적인 두 가지 경우는 다음과 같다. 

    1. 여러 타입이 있고, 타입에 따라 각기 다른 식으로 동작해야하는 경우. 
    2. 일반적인 로직이 하나 있고, 파생되는 특수한 로직이 들어가는 경우 
    3. 조건문으로 이 때는 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;
            };
        }
    }

    다음 순서대로 리팩토링하면 된다.

    1. FullTimeEmployee 클래스를 생성하고, Employee 클래스를 상속받는다. FullTimeEmployee의 생성자는 아무런 매개변수를 받지 않는다. 왜냐하면 vacationHours() / canAccessTo()에서 항상 상수값을 반환하기 때문에 어떠한 필드도 사용할 필요가 없기 때문이다. 
    2. FullTimeEmployee 클래스의 생성자에서 사용할 수 있도록 Employee 클래스의 기본 생성자를 생성한다. 또한 하는 김에 AvailableProject 매개변수만 받는 생성자도 하나 만든다. 
    3. FullTimeEmployee 클래스에서 vacationHours(), canAccessTo() 메서드를 재정의한다. 각각 120, true를 반환하도록 작성한다. 
    4. PartTimeEmployee 클래스를 생성하고, Employee 클래스를 상속받는다. canAccessTo() 메서드에서 availableProject가 필요하기 때문에 availableProject를 받는 생성자를 생성한다. vacationHours(), canAccessTo() 메서드를 재정의한다. 각각 80 리턴, 기존 canAccessTo() 로직을 사용하도록 한다. 이 때, Employee에서 availableProject 필드를 private → protected로 변경해준다. 
    5. TemporalEmployee 클래스도 동일하게 만들어준다. 
    6. Employee에서 타입 필드를 제거한다.
    7. PartTimeEmployee의 canAccessTo()를 Employee 클래스로 올려주고, TemporalEmployee 클래스에서 해당 메서드를 삭제한다. (슈퍼 클래스 메서드 사용하도록)
    8. 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;
    }

    정리

    • 조건부 로직을 다형성으로 바꾸는 경우는 각 서브 클래스를 만들고, 서브 클래스의 변종 로직이 독립적으로 동작하도록 작성되었다. 
    • 공통 코드에 약간의 변종 코드가 들어가는 경우는 서브 클래스를 만들고, 서브 클래스의 메서드가 부모 클래스의 메서드를 호출하고, 호출 결과에 자신의 변종 코드를 더 추가하는 형식으로 분리되었다. 

    댓글

    Designed by JB FACTORY