리팩토링 13. 조건문을 다형성으로 바꾸기

    들어가기 전

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


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

    • 여러 타입에 따라 각기 다른 로직으로 처리해야 하는 경우다형성을 적용해서 조건문을 보다 명확하게 분리할 수 있다. (예, 책, 음악, 음식 등...) 반복되는 switch문을 각기 다른 클래스를 만들어 제거 할 수 있다.
    • 공통으로 사용되는 로직은 상위 클래스에 두고 달라지는 부분만 하위 클래스에 둠으로써, 달라지는 부분만 강조할 수 있다. 
    • 모든 조건문을 다형성으로 바꿔야 하는 것은 아니다. 

    Switch 문이나 If문은 일반적으로 조건이 다를 때, 각기 다른 액션을 취하도록 하는 문법이다. 조건문을 다형성으로 바꾸는 경우는 '조건에 타입이 전달되는 경우'일 때 고려해볼 수 있다. 이 때 조건문을 다형성으로 변경하면 다음 장점을 가질 수 있다.

    1. 복잡한 Switch / If문이 없어짐.
    2. 특정 클래스 내에 있던 각기 다른 메서드들이 하위 클래스로 잘 분리되게 됨. 따라서 전체적인 코드 복잡도가 감소함. 

    상위 클래스에서는 기본적인 오퍼레이션을 유지하고, 하위 클래스에서 특수한 동작만 사용하도록 하면 '달라지는 부분만 강조'되기 때문에 유용하게 사용 할 수 있는 리팩토링이다. 하지만 모든 조건문을 다형성으로 바꿀 필요는 없다. 주로 '타입'에 의해서 동작이 달라질 때 조건문을 다형성으로 처리해 볼 수 있게 된다. 


    리팩토링 (Before)

    현재 StudyPrinter는 PrinterMode라는 타입 토큰을 받아서, 타입 토큰에 따라 Switch 문으로 각기 다른 동작을 하도록 구성되어있다. 하지만 Switch문이 있으며, 그 Switch문 안에서 동작하는 코드가 너무 크기 때문에 코드를 읽는데 문제를 가져온다. 

    '타입에 따라 서로 다른 동작을 하는 조건문'이기 때문에 '다형성'을 이용해서 이 부분을 해결해 줄 수 있다. 

    1. StudyPrinter 클래스를 상속받은 ConsolePrinter, CVSPrinter, MarkdownPrinter를 생성한다. 
    2. 각 하위 클래스는 부모 클래스의 execute() 메서드를 재정의한다. 
    3. 각 execute()를 실행하는데 필요한 내부 메서드들을 부모 클래스에서 필요한 하위 클래스로 모두 이동한다. 
    4. 부모 클래스의 execute()는 사용하지 않는다. 따라서 abstract 메서드로 만들고, StudyPrinter도 abstract 클래스가 되도록 한다. 

    이런 형태로 리팩토링을 해볼 수 있는데, 이렇게 하면 어떤 장점이 있을까?

    1. 긴 Switch문이 짧아진다. 
    2. StudyPrinter 클래스에 뭉쳐있던 불필요한 내부 메서드들이 각각의 하위 클래스로 퍼지기 때문에 코드 복잡도가 감소한다. 

    이런 것들을 감안한 후에 코드를 리팩토링해보자.

    // StudyPrinter.class
    public void execute() throws IOException {
        // Switch 문이 각각의 타입을 의미하고,
        // 각 타입에 따라서 액션해야 할 것이 너무 복잡하다.
        // 다형성으로 처리하면 좋을 듯.
        switch (printerMode) {
            case CVS -> {
                try (FileWriter fileWriter = new FileWriter("participants.cvs");
                     PrintWriter writer = new PrintWriter(fileWriter)) {
                    writer.println(cvsHeader(this.participants.size()));
                    this.participants.forEach(p -> {
                        writer.println(getCvsForParticipant(p));
                    });
                }
            }
            case CONSOLE -> {
                this.participants.forEach(p -> {
                    System.out.printf("%s %s:%s\n", p.username(), checkMark(p), p.getRate(this.totalNumberOfEvents));
                });
            }
            case MARKDOWN -> {
                try (FileWriter fileWriter = new FileWriter("participants.md");
                     PrintWriter writer = new PrintWriter(fileWriter)) {
    
                    writer.print(header(this.participants.size()));
    
                    this.participants.forEach(p -> {
                        String markdownForHomework = getMarkdownForParticipant(p);
                        writer.print(markdownForHomework);
                    });
                }
            }
        }
    }

    리팩토링 (After)

    리팩토링을 하면 다음과 같이 코드가 변경된다. 

    1. StudyPrinter는 추상 클래스가 된다. (공통 로직은 StudyPrinter에 있고, 달라지는 부분만 하위 클래스에 존재함)
    2. ConsolePrinter, MarkdownPrinter 들은 필요한 내부 메서드를 가지고 있으며, execute() 메서드를 재정의한다. 
    // StudyPrinter.class
    public abstract class StudyPrinter {
    
        protected int totalNumberOfEvents;
        protected List<Participant> participants;
    
        public StudyPrinter(int totalNumberOfEvents, List<Participant> participants) {
            this.totalNumberOfEvents = totalNumberOfEvents;
            this.participants = participants;
            this.participants.sort(Comparator.comparing(Participant::username));
        }
    
        public abstract void execute() throws IOException;
    
    
        protected String checkMark(Participant p) {
            StringBuilder line = new StringBuilder();
            for (int i = 1 ; i <= this.totalNumberOfEvents ; i++) {
                if(p.homework().containsKey(i) && p.homework().get(i)) {
                    line.append("|:white_check_mark:");
                } else {
                    line.append("|:x:");
                }
            }
            return line.toString();
        }
    }
    public class ConsolePrinter extends StudyPrinter{
    
        public ConsolePrinter(int totalNumberOfEvents, List<Participant> participants) {
            super(totalNumberOfEvents, participants);
        }
    
        @Override
        public void execute() throws IOException {
            this.participants.forEach(p -> {
                System.out.printf("%s %s:%s\n", p.username(), checkMark(p), p.getRate(this.totalNumberOfEvents));
            });
        }
    }
    
    public class CVSPrinter extends StudyPrinter{
    
        public CVSPrinter(int totalNumberOfEvents, List<Participant> participants) {
            super(totalNumberOfEvents, participants);
        }
    
        @Override
        public void execute() throws IOException {
            try (FileWriter fileWriter = new FileWriter("participants.cvs");
                 PrintWriter writer = new PrintWriter(fileWriter)) {
                writer.println(cvsHeader(this.participants.size()));
                this.participants.forEach(p -> {
                    writer.println(getCvsForParticipant(p));
                });
            }
        }
    
        private String getCvsForParticipant(Participant participant) {
            StringBuilder line = new StringBuilder();
            line.append(participant.username());
            for (int i = 1 ; i <= this.totalNumberOfEvents ; i++) {
                if(participant.homework().containsKey(i) && participant.homework().get(i)) {
                    line.append(",O");
                } else {
                    line.append(",X");
                }
            }
            line.append(",").append(participant.getRate(this.totalNumberOfEvents));
            return line.toString();
        }
    
        private String cvsHeader(int totalNumberOfParticipants) {
            StringBuilder header = new StringBuilder(String.format("참여자 (%d),", totalNumberOfParticipants));
            for (int index = 1; index <= this.totalNumberOfEvents; index++) {
                header.append(String.format("%d주차,", index));
            }
            header.append("참석율");
            return header.toString();
        }
    }
    ..

    팩토리 메서드를 사용하지 않는 이유?

    현재 코드에서는 사용하는 쪽에 필요한 객체 (ConsolePrinter, MarkdownPrinter)를 생성해서 처리하도록 되어있다. 동적 타입의 인스턴스 생성을 위해서 팩토리 메서드를 도입할 수도 있을텐데, 현 시점에서는 큰 의미가 없다. 

    서로 다른 타입을 사용해서 출력하기 위해서는 결국 사용하는 쪽에서 타입을 어떤 방식으로든 전달해주어야 한다. 즉, 팩토리 메서드에 타입이 전달되면, 팩토리 메서드는 내부적으로 Swtich문을 한번 사용해서 동적 인스턴스를 생성해줄 것이다. 어차피 코드를 바꾸어야 한다면, 타입을 없애고 각각의 모드에 해당하는 프린터 클래스를 선택해서 사용하는 것도 나쁘지 않은 선택이 될 수 있기 때문에 사용하지 않았다. 

    댓글

    Designed by JB FACTORY