Clean Code

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

1. 알고자 하는 것

  • 조건문을 다형성으로 바꾸기 (Replace Conditional with PolyMorphism)

 

 

2. 알게된 것

  • 여러 타입에 따라 각각 다른 로직으로 처리해야 하는 경우, 일반적으로 중첩 if문으로 분기하거나, switch-case 문으로 분기하곤 한다.
// if문 분기
if (Type.A) {
   A_Logic();
} else if (Type.B) {
   B_Logic();
} else {
   C_Logic();
}

// switch문 분기
switch(Type) {
   case A -> {
       A_Logic();
   }
   case B -> {
       B_Logic();
   }
   case C -> {
       C_Logic();
   }
}
  • 하지만, 만약 타입 간 공통으로 사용되는 로직이 많고, 타입에 따라 일부분의 로직만 달라지는 경우 다음과 같이 중복된 코드가 많아져 가독성이 떨어지고 코드가 불필요하게 장황해질 수 있다.
// 타입에 따라 달라지는 로직은 일부인데, 공통 로직이 타입 개수만큼 중복된다.
switch(Type) {
   case A -> {
       Common_Logic_1();
       Common_Logic_2();
       A_Logic();
       Common_Logic_3();
   }
   case B -> {
       Common_Logic_1();
       Common_Logic_2();
       B_Logic();
       Common_Logic_3();
   }
   case C -> {
       Common_Logic_1();
       Common_Logic_2();
       C_Logic();
       Common_Logic_3();
   }
}

 

  • 이 때, 공통으로 사용되는 로직은 상위 클래스에 구현하고, 달라지는 부분에 대해서만 하위클래스로 두어 중복 코드를 제거하고, 타입에 따라 달라지는 일부 로직에 대해서만 강조함으로써 가독성을 높일 수 있다. (다형성 활용)
public class StudyPrinter {
    ...
    // 각 타입에 따른 복잡한 로직이 혼재되어 있음
    public void execute() throws IOException {
            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);
                    });
                }
            }
        }
    }
    
    // 복잡한 로직과 더불어 부가적인 private method까지 추가됨
    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();
    }
    
    private 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();
    }
    
    private String getMarkdownForParticipant(Participant p) {
        return String.format("| %s %s | %.2f%% |\n", p.username(), checkMark(p),
                p.getRate(this.totalNumberOfEvents));
    }
}
  • StudyPrinter 클래스 내에서 printerMode에 따라 CVS, CONSOLE, MARKDOWN 형식으로 데이터를 출력한다.
  • 각 타입에 따른 로직이 복잡하게 구성되어 있어 execute 메서드가 길어지고, 가독성이 떨어진다.
  • 또한, 각 로직에 필요한 메서드를 추출함으로써 부가적인 메서드도 많아졌다.
  • 각 타입을 StudyPrinter를 상속받는 3개의 하위 클래스로 생성하고 각 로직을 옮긴다. (다형성 적용)
  • 이 때, 공통으로 사용하는 로직은 StudyPrinter 내 protected 메서드로 두고, 개별적으로 사용되는 로직은 하위 클래스에 두어 로직을 분리한다.
// 각 타입에서 수행할 메서드는 execute라는 abstract method를 구현한다.
// 타입에서 공통으로 수행하는 checkMark 메서드는 protected method로 상위 클래스에서 공통으로 둔다.
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 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) {
       ...
    }

    private String cvsHeader(int totalNumberOfParticipants) {
        ...
    }
}

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 MarkdownPrinter extends StudyPrinter {

    public MarkdownPrinter(int totalNumberOfEvents, List<Participant> participants) {
        super(totalNumberOfEvents, participants);
    }

    // 타입 별 개별 수행 로직
    @Override
    public void execute() throws IOException {
        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);
            });
        }
    }

    // 타입 별 개별 부가로직
    private String getMarkdownForParticipant(Participant p) {
        return String.format("| %s %s | %.2f%% |\n", p.username(), checkMark(p),
                p.getRate(this.totalNumberOfEvents));
    }

    private String header(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(" 참석율 |\n");

        header.append("| --- ".repeat(Math.max(0, this.totalNumberOfEvents + 2)));
        header.append("|\n");

        return header.toString();
    }
}
  • 이 후, 해당 로직을 사용하는 코드에서는 팩토리 메서드 등을 활용해 타입에 따라 해당하는 하위 클래스를 반환해 로직을 수행하도록 처리할 수 있다.
// 팩토리 메서드를 통해 case에 따른 하위 클래스 반환
public class PrinterFactory {

    private int totalNumberOfEvents;

    private List<Participant> participants;

    public PrinterFactory(int totalNumberOfEvents, List<Participant> participants) {
        this.totalNumberOfEvents = totalNumberOfEvents;
        this.participants = participants;
    }

    public StudyPrinter getPrinter(PrinterMode printerMode) {
        return switch (printerMode) {
            case CVS -> new CvsPrinter(totalNumberOfEvents, participants);
            case CONSOLE -> new ConsolePrinter(totalNumberOfEvents, participants);
            case MARKDOWN -> new MarkdownPrinter(totalNumberOfEvents, participants);
        };
    }
}

 

// 사용하는 부분에서는 단순히 팩토리 메서드에 타입만 넘겨서 로직을 수행한다.
new PrinterFactory(totalNumberOfEvents, participants).getPrinter(TYPE).execute();

 

  • 주의할 점은, 모든 switch문에 대해서 해당 리팩토링을 적용하는 것은 올바르지 않다.
  • 분기에 따른 n개의 하위클래스를 생성 및 구현해야 하므로 이에 따른 비용이 존재한다.
  • 복잡하지 않은 로직이나, 공통된 로직이 많지 않은 경우 등에서는 해당 리팩토링을 수행함으로써 드는 위 비용이 더욱 클 수 있다.
  • 또한, 기존 간단한 로직에서 다형성을 통해 추상화함으로써 가독성이 더욱 떨어지는 경우도 있다.
  • 상황에 따라 공통된 중복 로직이 많거나, 각 case에 대해 로직들이 복잡한 경우 등을 고려해 해당 리팩토링을 적용해야 한다.

 

 

 

 

3. 정리

  • 여러 타입에 따라 로직을 다르게 처리해야 하는 경우, 일반적으로 if문 또는 switch문을 통해 분기 처리한다.
  • 이 때, 각 타입에 대한 로직이 복잡하게 구성되어 있거나, 공통된 로직이 많을 경우 다형성을 활용해 리팩토링 할 수 있다.
  • 공통된 로직을 상위 클래스에서 구현하고, 이를 상속받는 하위 클래스를 두어 개별 로직을 구현한다.
  • 팩토리 메서드 등을 활용해 타입에 따라 해당하는 하위 클래스를 반환해 로직을 수행하도록 처리할 수 있다.
  • 상위 클래스에서 공통 로직을 재사용하고, 변경되는 부분만을 강조할 수 있다.
  • 하지만 해당 리팩토링은 추상화, 하위클래스 생성에 따른 비용이 존재하므로, 공통된 중복로직이 많거나 각 case에 대해 로직이 복잡한 경우에 비용을 고려해 리팩토링을 진행할 수 있도록 해야한다.

 


 

Reference 

강의 - 코딩으로 학습하는 리팩토링 (백기선 강사님)