BackEnd/Refactoring

[Refactoring] 긴 함수 (Long Function)

샤아이인 2022. 2. 22.

백기선 님의 리팩터링 강의를 들으며 요약한 내용입니다.

 

3. 긴 함수

긴함수와 잛은 함수의 기준은 몇줄일까? 이는 사람마다 다를 수 있다.

다만, 코드를 읽어 나갈때 "의도"가 한눈에 전달이 된다면 짧은 코드이고, "구현"에 해당하는 부분이 많아 한줄 한줄 읽어 나가야 한다면 긴 코드라고 할 수 있다.

 

"과거에는" 작은 함수를 여러번 호출하면 더 많은 서브루틴의 호출로 인해 오버헤드가 있었지만, 요즘의 하드웨어는 너무나 성능이 좋기 때문에 고려하지 않아도 좋다.

 

사용해볼 리팩토링 기술 들로는~

  • 99%는 함수 추출하기로 해결 가능하다.
  • 함수로 분리하면서 해당 함수로 전달해야 할 매개변수가 많아진다면 다음과 같은 리팩토링을 고려해볼 수 있다.
    • 임시 변수를 질의 함수로 바꾸기
    • 매개변수 객체 만들기
    • 객체 통째로 넘기기
  • 조건문 분해하기 (Decompose Conditional)”를 사용해 조건문을 분리할 수 있다.
  • 같은 조건으로 여러개의 Swtich 문이 있다면, “조건문을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)”을 사용할 수 있다.
  • 반복문 안에서 여러 작업을 하고 있어서 하나의 메소드로 추출하기 어렵다면, “반복문 쪼개기 (Split Loop)”를 적용할 수 있다.

 

1. 임시 변수를 질의 함수로 바꾸기 (Replace Temp with Query)

함수를 분리할때 전달해야 하는 매개변수가 너무 많다면 인자로 전달하는 것 이 아니라, 추출한 메서드 내부에서 호출하도록 변경해야 한다.

 

우선 변경전의 코드부터 살펴보면 다음과 같다.

try (FileWriter fileWriter = new FileWriter("participants.md");
     PrintWriter writer = new PrintWriter(fileWriter)) {
    participants.sort(Comparator.comparing(Participant::username));

    writer.print(header(totalNumberOfEvents, participants.size()));

    participants.forEach(p -> {
        long count = p.homework().values().stream()
                .filter(v -> v == true)
                .count();
        double rate = count * 100 / totalNumberOfEvents;

        String markdownForHomework = String.format("| %s %s | %.2f%% |\n", p.username(), checkMark(p, totalNumberOfEvents), rate);
        writer.print(markdownForHomework);
    });
}

String.format부분이 읽기가 복잡하여 메서드로 추출하면 다음과 같아진다.

String markdownForHomework = getMarkdownForParticipant(totalNumberOfEvents, p, rate);

분리는 했지만 함수의 파라미터가 3개나 되어버렸다... 보통 2개가 만족할수있는 수준이다.

또한 rate라는 값은 totalNumberOfEvents와 P로부터 추출 가능한 값이다.

 

rate를 계산하는 부분을 단순하게 값을 반환하고, 부수효과는 없는 쿼리 함수로 만들어보자! => getRate()로 추출

private double getRate(int totalNumberOfEvents, Participant p) {
    long count = p.homework().values().stream()
            .filter(v -> v == true)
            .count();
    double rate = count * 100 / totalNumberOfEvents;
    return rate;
}

이제 getRate를 getMarkdownForParticipant() 내부에서 호출하도록 리팩터링 하자.

 

try (FileWriter fileWriter = new FileWriter("participants.md");
    PrintWriter writer = new PrintWriter(fileWriter)) {
    participants.sort(Comparator.comparing(Participant::username));

    writer.print(header(totalNumberOfEvents, participants.size()));

    participants.forEach(p -> {
        String markdownForHomework = getMarkdownForParticipant(totalNumberOfEvents, p);
        writer.print(markdownForHomework);
    });
}

private String getMarkdownForParticipant(int totalNumberOfEvents, Participant p) {
    double rate = getRate(totalNumberOfEvents, p);
    String markdownForHomework = String.format("| %s %s | %.2f%% |\n", p.username(), checkMark(p, totalNumberOfEvents), rate);
    return markdownForHomework;
}

getMarkdownForParticipant 가 전달받는 인자가 2개로 줄어들었다.

내부에서 쿼리 메서드를 통해 rate를 받을 수 있기 때문이다.

 

2. 매개변수 객체 만들기 (Introduce Parameter Object)

같은 매개변수들이 여러 메서드에 상용되고 있다면, 이를 하나로 묶는 자료구조를 만드는 것 또한 좋다.

이렇게 되면 해당 데이터의 의미를 명시적으로 부여할 수 있고, 함수에 전달할 매개변수 개수 또한 줄일 수 있다.

private double getRate(int totalNumberOfEvents, Participant p) {
    long count = p.homework().values().stream()
            .filter(v -> v == true)
            .count();
    double rate = count * 100 / this.totalNumberOfEvents;
    return rate;
}

private String getMarkdownForParticipant(int totalNumberOfEvents, Participant p) {
    return String.format("| %s %s | %.2f%% |\n", p.username(), checkMark(p, this.totalNumberOfEvents), getRate(totalNumberOfEvents, p));
}

위 두 메서드 에서는 totalNumberOfEvents 와 Participant가 중복된다. 이를 하나의 레코드로 만들수 있다.

private double getRate(ParticipantPrinter pp) {
    long count = pp.p().homework().values().stream()
            .filter(v -> v == true)
            .count();
    double rate = count * 100 / pp.totalNumberOfEvents();
    return rate;
}

private String getMarkdownForParticipant(ParticipantPrinter pp) {
   생략...
}

다음에 살펴볼 Preserve Whole Object와 뭐가 다르냐? 라는 질문이 나처럼 들 수 있다.

Introduce Parameter Object는 아예 그 파라미터들이 파생되어 record, class가 아예 없는 경우이며,

 

Preserve Whole Object는 이미 여러 매개변수가 하나의 인스턴스로부터 오는 경우에 적용해볼 수 있는 기술이다.

 

3. 객체 통째로 넘기기 (Preserve Whole Object)

파라미터로 넘기는 값들이 같은 객체로부터 나온 값을 전달한다면, 아예 그 하나의 객체를 전달하는것이 좋을 수도 있다.

 

직전에 기술한 introduce parameter object와 거의 비슷한데, introduce parameter object는 파라미터들이 파생되어 온 레코드나 class가 애당초 없을 때 적용되지만,

 

preserve whole object는 이미 그러한 type이 있는 경우에 사용한다.

다만 적용한 후 고민할 점으로는,

 

1) 파리미터가 변경된 메서드가 변경된 객체에 의존하는것이 적절한가? 다음 코드를 살펴보자.

// Before
private String getMarkdownForParticipant(String username, Map<Integer, Boolean> homework) {
    return String.format("| %s %s | %.2f%% |\n", username,
            checkMark(homework, this.totalNumberOfEvents),
            getRate(homework));
}

String markdownForHomework = getMarkdownForParticipant(p.username(), p.homework());

// After
private String getMarkdownForParticipant(Participant participant) {
    return String.format("| %s %s | %.2f%% |\n", username,
            checkMark(homework, this.totalNumberOfEvents),
            getRate(homework));
}

String markdownForHomework = getMarkdownForParticipant(participant);

 

2) 이 함수를 다른 domain에도 적용할 계획이 있는가?

만약 다른 곳에서 재사용을 할 계획이라면, Participant를 받기 보단, 이전의 username, homework를 받는것이 더 좋을수도 있다.

 

3) 어쩌면 해당 메소드의 위치가 적절하지 않을수도 있다.

예를 들어 다음 코드를 살펴보자.

double getRate(Participant participant) {
    long count = participant.homework().values().stream()
            .filter(v -> v == true)
            .count();
    return (double) (count * 100 / this.totalNumberOfEvents);
}

getRate는 비율을 구하기 위해 participant를 인자로 전달받게 된다. 하지만 이 메서드는 적절한 위치일까?

생각해보면 count값을 구할때 사용하는 homework에 대한 정보는 participant 스스로 알고 있다.

그럼 남은 totalNumbreOfEvents만 인자로 전달받게 되면 participant 스스로 구할수 있지 않을까?

=> 적절한 생각이다, getRate를 Participant class 내부로 이동시키자!

 

4. 함수를 명령으로 바꾸기 (Replace Function with Command)

함수를 독립적인 객체인, Command로 분리하여 사용할 수 있다.

물론 대부분의 경우 함수를 사용하지만, 커맨드 말고 다른 방법이 없을 때 사용한다.

 

커맨드 패턴의 장점은 다음과 같다.

  • 부가적인 기능으로 undo 기능을 만들 수도 있다.
  • 더 복잡한 기능을 구현하는데 필요한 여러 메소드를 추가할 수 있다.
  • 상속이나 템플릿을 활용할 수도 있다.
  • 복잡한 메소드를 여러 메소드나 필드를 활용해 쪼갤 수도 있다.

예시로 살펴봅시다.

// Before
function score(candidate, medicalExam, scoringGuide) {
    let result = 0;
    let healthLevel = 0;
    // long body code
}

// After
class Scrorer {
    constructor(candidate, medicatExam, scroingGuide) {
    	this._candidate = candidate;
        this._medicalExam = medicalExam;
        this._scoringGuide = scoringGuide;
    }
    
    execute() {
    	this._result = 0;
        this._healthLevel = 0;
        // long body code
    }
}

독립적이든, 객체에 붙어있는 메서드든, 함수는 프로그래밍의 주축돌 과 같다.

하지만 캡슐화하는것이 유용한 순간들이 있다.

저자는 이런 경우를 "command object"나 command라고 부른다고 한다.

 

과정은 다음과 같다.

  1. 기존 함수에 기반한 이름을 가진 빈 클래스를 생성한다.
  2. 함수 이동을 사용하여 함수를 빈 클래스로 이동시킨다.
    • 기존 함수는 포워딩 함수로 리팩토링의 마지막까지 남겨두어라.
    • 네이밍 컨벤션을 따라야 한다. 컨벤션이 없으면, 일반적인 이름을 지어야한다. 
  3. 각 인자에 맞는 필드를 만들고, 인자를 constructor로 이동시킨다.

 

5. 조건문 분해하기 (Decompose Conditional)

조건문을 여러 메서드로 분해해 가는 과정 중 에 의미를 부여해 나가는 방법이다.

코드를 통해 살펴보자.

private Participant findParticipant(String username, List<Participant> participants) {
    Participant participant = null;
    if (participants.stream().noneMatch(p -> p.username().equals(username))) {
        participant = new Participant(username);
        participants.add(participant);
    } else {
        participant = participants.stream().filter(p -> p.username().equals(username)).findFirst().orElseThrow();
    }
    return participant;
}

이를 decompose conditional을 진행하면 다음과 같아진다.

private Participant findParticipant(String username, List<Participant> participants) {
    Participant participant = null;
    if (isNewParticipant(username, participants)) {
        participant = createNewParicipant(username, participants);
    } else {
        participant = findExistingParticipant(username, participants);
    }
    return participant;
}

한발 더 나아가, 3항 연산자를 사용하면 다음과 같이 간단해진다.

private Participant findParticipant(String username, List<Participant> participants) {
    return isNewParticipant(username, participants) ?
            createNewParticipant(username, participants) :
            findExistingParticipant(username, participants);
}

 

6. 반복문 쪼개기 (Split Loop)

하나의 반복문 안에서 여러 다른 작업을 하는 코드는 쉽게 찾아볼수 있다.

하지만 하나의 반복문 안에 여러 작업을 수행하다 보면, 하나의 기능을 수정할때 다른 여러 작업을 모두 고민해야할 수 있다.

 

따라서 만약 성능에 치명적인 부분이 아니라면, 반복문을 각각의 작업마다 쪼개서 분리하는 것 이 좋다.

리팩토링은 성능 최적화 와 별개의 작업이다.

일단 리팩토링을 마친 이후, 다시 성능 개선에 대하여 생각해볼 수 있다.

 

7.  조건문을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)

다음과 같이 switch-case문을 통해 각 mode따라 실행되는 코드를 다형성을 통해 개선할 수 있다.

public abstract 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);
                    });
                }
            }
        }
    }
}

StudyPrinter를 상속받는 CsvPrinter, ConsolePrinter, MarkdownPrinter를 만들어 주면 된다.

각 자식 class들은 부모의 execute() 메서드를 오버라이딩 시키면 된다.

 

이를 사용하는 Client 입장에서는 원하는 Printer를 선택하여 사용하면 된다.

 

// 코드 생략

 

(인텔리제이 tip : command + 1 좌측 디렉터리 목록으로 이동, command + N 클래스 생성, esc 는 코드 본문으로 다시 복귀)

댓글