Devin.KR

종합 실습 - 오래된 정산 모듈 다시 쓰기

개발자KR 조회 5

이 장에서 배우는 것

오래된 코드를 고칠 때는 어디부터 손대야 할지보다 무엇을 유지해야 할지 먼저 정해야 한다. 정산 금액이 같아도 수강생의 출력 순서가 바뀌거나 같은 이름의 항목이 사라지면 사용자가 받는 결과는 달라진다. 이번 장에서는 수강료 정산 모듈의 기존 동작을 실행 가능한 기준으로 남긴 뒤, 이름과 함수, 조건문, 책임을 차례로 정리한다.

앞 장에서 정한 포매터와 리뷰 기준은 변경을 일정한 모양으로 유지하는 데 도움을 준다. 이제 그 기준을 실제 수정 순서에 연결한다. 각 단계의 코드를 함께 실행하고, 결과가 같은지와 읽는 부담이 줄었는지를 따로 확인한다. 마지막 단계에서는 짧아진 코드뿐 아니라 유지한 동작과 남겨 둔 제약도 설명할 수 있어야 한다.

  • 기존 정산 결과에서 금액·순서·중복 항목을 보존할 기준을 정한다.
  • 이름 변경과 함수 추출을 먼저 적용하고 조건과 책임을 정리한다.
  • 같은 입력을 변경 전·중간·완성 코드에 넣어 결과를 대조한다.
  • 메서드 길이·중첩·중복을 같은 기준으로 세고 변화의 이유를 해석한다.

문제 상황

학원 수강 관리 프로그램에는 결제한 수강 신청을 모아 정산하는 오래된 메서드가 있다. 담당자는 정산 내역을 보고 수강생별 청구 금액과 합계를 확인한다. 수강 신청을 취소했거나 아직 결제하지 않은 항목은 정산에서 빠진다. 재수강 항목에는 예제에서 정한 10퍼센트 할인을 적용한다. 이 비율은 실습용 규칙이며 실제 기관의 요금을 나타내지 않는다.

기존 구현에는 반복문 안에 취소 여부, 결제 여부, 재수강 여부를 검사하는 조건문이 겹쳐 있다. 할인 계산식도 두 갈래에 나타난다. 수정 요청은 “할인 규칙을 찾기 쉽게 해 달라”는 것이지만, 곧바로 계산식을 간단하게 바꾸면 원 단위 처리까지 달라질 수 있다. 읽기 쉽게 만드는 작업과 업무 규칙을 바꾸는 작업을 한 번에 하면 결과가 달라진 원인을 찾기 어려워진다.

이 모듈에서 입력은 이미 검사된 수강 신청 목록이다. 목록과 이름은 null이 아니며, 수강료는 음수가 아니다. 이번 실습에서는 항목별 수강료를 2,000,000원 이하로 제한하고 합계도 int 범위 안에 둔다. 이 조건은 계산 중 곱셈과 합계가 범위를 넘는 문제를 실습 범위에서 제외하기 위한 것이다. 완성 코드가 이 조건을 직접 검사하는 것은 아니다.

정산 결과는 항목 문자열 목록과 총액으로 구성된다. 목록의 순서는 입력 순서를 따르며 같은 이름이 여러 번 나타나도 각각 남긴다. 저장 파일이나 현재 시각은 사용하지 않는다. 입력 순서를 가진 목록만 사용하므로 실행할 때마다 같은 결과가 나온다.

수정 전에 고정할 정산 동작
입력 상태항목 처리금액 규칙확인할 사례
취소됨제외한다합계에 더하지 않는다결제했어도 제외한다
미결제제외한다합계에 더하지 않는다취소하지 않았어도 제외한다
일반 수강입력 순서대로 남긴다원래 수강료를 사용한다10,000원은 10,000원이다
재수강입력 순서대로 남긴다할인액의 소수 부분을 버린 뒤 뺀다101원은 91원이다

결과를 먼저 고정한다

먼저 기존 코드를 별도 클래스로 남긴다. 이 클래스는 실습 중 결과를 비교할 기준 구현이다. 실제 저장소에서는 변경 전 이력도 보관하지만, 여기서는 한 번의 실행으로 모든 단계를 비교하기 위해 세 구현을 함께 둔다. 완성 뒤 운영 코드에 세 구현을 계속 유지하라는 뜻은 아니다.

동일한 입력을 두 구현에 넣고 결과가 같은지 확인하는 방법을 특성화 테스트(characterization test)로 사용할 수 있다. 다만 기존 구현과 같다는 확인만으로 업무 요구까지 맞다고 말할 수는 없다. 기존 코드와 새 코드가 같은 잘못을 공유할 수도 있기 때문이다. 따라서 사람이 검산한 예상 결과도 별도로 둔다. 이 실습은 단계 사이의 동등성과 정해 둔 사례의 기대 결과를 함께 확인한다.

대표적인 함정은 정수 나눗셈의 순서다. 기존 계산식은 수강료에서 할인액을 뺀다. 수강료가 101원이면 할인액은 101 곱하기 10 나누기 100의 정수 결과인 10원이고, 청구액은 91원이다. 수강료에 90을 곱한 뒤 100으로 나누면 90원이 된다. 두 식은 실수 계산에서는 같은 의미처럼 보이지만 여기서는 같은 동작이 아니다.

결과 대조에는 합계만 사용하지 않는다. 합계가 같아도 항목이 서로 바뀌거나 순서가 뒤집힐 수 있다. Report는 목록과 합계를 함께 보관하고, record가 제공하는 동등성 비교를 사용한다. 목록 비교에는 요소의 값뿐 아니라 순서와 개수도 반영된다. 같은 이름의 두 항목을 하나로 합치면 이 비교에서 차이가 드러난다.

같은 입력을 세 구현에 전달하고 목록과 합계를 함께 비교한다

비교 입력은 빈 목록, 일반 수강, 재수강, 제외 대상, 원 단위 절사, 혼합 목록으로 나눈다. 빈 목록은 초기 합계와 빈 결과를 확인한다. 제외 사례는 취소와 미결제를 따로 포함한다. 혼합 사례는 출력 순서와 같은 이름의 반복을 확인한다. 이 여섯 사례가 가능한 모든 입력을 증명하지는 않지만, 이번 변경이 건드리는 분기와 관찰 가능한 결과를 구체적으로 드러낸다.

이름에서 조건과 책임으로 이동한다

첫 번째 구현인 LegacyCalculator는 오래된 코드의 모양을 보여 준다. r, t, x 같은 이름은 역할을 읽어 내기 어렵고, 정산 대상인지 판단하는 흐름과 할인 계산이 한곳에 섞여 있다. 먼저 이 구현을 실행해 예상 결과와 맞는지 확인한다. 기존 코드를 고치기 전에 비교 기준 자체가 입력을 제대로 받는지 확인하는 과정이다.

두 번째 구현인 ExtractedCalculator에서는 이름을 바꾸고 할인 계산을 feeFor로 추출한다. r은 lines, t는 total, x는 enrollment가 된다. 정산 메서드에서는 항목을 고르고 결과를 모으는 흐름이 드러난다. 할인 조건과 중복 계산식은 추출한 메서드에 그대로 둔다. 이 단계에서 조건까지 동시에 뒤집지 않으므로 결과가 달라지면 이름 변경이나 추출 과정부터 살펴볼 수 있다.

세 번째 구현인 FinalCalculator에서는 제외 대상을 먼저 건너뛴다. 취소되었거나 결제하지 않았다면 continue로 다음 항목으로 이동한다. 정상 처리 흐름을 둘러싸던 두 조건이 사라진다. 이때 “취소되지 않았고 결제했다”를 “취소되었거나 결제하지 않았다”로 바꾸는 논리 관계를 확인해야 한다. 부정 기호만 옮기면서 연결 연산자를 그대로 두면 다른 항목을 제외하게 된다.

그다음 할인 계산의 책임을 FeePolicy로 옮긴다. FeePolicy는 재수강 여부로 할인율을 선택하고 청구액을 계산한다. FinalCalculator는 어떤 항목을 정산할지, 어떤 순서로 결과를 모을지 담당한다. 이번에는 할인 규칙이 하나이므로 인터페이스나 여러 정책 클래스를 추가하지 않는다. 규칙의 위치가 분명해지는 정도로 책임을 나누면 충분하다.

Report는 생성할 때 항목 목록을 복사한다. 계산 도중 사용하는 변경 가능한 목록을 그대로 결과에 노출하면 나중의 수정이 이미 반환한 결과까지 바꿀 수 있다. 여기서는 결과가 확정되는 경계를 생성자로 정한다. 이름이나 수강료를 새 값 객체로 모두 바꾸지는 않는다. 이번 변경의 목적과 관계가 적은 타입 변경은 결과 대조를 복잡하게 만들 수 있다.

지표는 같은 범위와 규칙으로 센다

메서드 길이는 줄바꿈 방식에 영향을 받기 쉬우므로 이 실습에서는 문장 수로 센다. 변수 선언, 대입, 메서드 호출 문장, return, continue를 각각 하나로 세고 for와 if의 머리도 하나로 센다. 중괄호, else, 메서드 선언은 세지 않는다. 한 문장 안의 메서드 호출과 삼항식은 따로 더하지 않는다. 각 계산 구현에 속한 업무 메서드 가운데 가장 큰 값을 기록하며, 완성 단계에는 FeePolicy도 포함한다.

중첩은 메서드 본문을 0으로 두고 for나 if의 본문에 들어갈 때 하나씩 더한다. 중복은 이 예제의 할인 후 금액 계산식이 나타나는 위치의 수다. 일반 수강 분기의 할인율 0과 재수강 분기의 할인율 10은 같은 계산 구조로 본다. 따라서 중복 수치는 프로그램 전체의 유사도를 나타내지 않는다. 무엇을 셌는지 제한해서 말해야 다른 사람이 같은 값을 확인할 수 있다.

동일한 측정 기준으로 비교한 단계별 변화
단계최대 메서드 길이최대 중첩계산식 위치 수
기존 구현12문장42
이름 변경·함수 추출9문장32
보호 구문·책임 분리10문장21

완성 단계의 최대 길이는 중간 단계보다 한 문장 늘어난다. continue가 추가되기 때문이다. 그러나 정상 처리 부분을 읽을 때 유지해야 하는 조건의 깊이는 줄어든다. 모든 수치가 매번 줄어야 좋은 변경이라는 기준을 세우면 보호 구문의 이점을 놓친다. 길이·중첩·중복을 함께 보고, 실제로 어떤 판단이 쉬워졌는지 설명해야 한다.

완성 단계는 문장이 하나 늘어도 중첩과 계산식 중복을 줄인다

각 단계가 끝날 때는 전체 비교를 다시 실행한다. 이름 변경과 추출을 마친 뒤 통과한 결과가 있어야 조건 변경의 영향을 따로 볼 수 있다. 아래 프로그램은 세 단계를 한 파일에 보존한다. 실제 수정할 때는 해당 단계까지 작성한 후 실행하고, 그다음 단계를 추가하는 순서로 진행한다. 실패가 생기면 마지막으로 통과한 단계와의 차이부터 확인한다.

완성 코드

다음 내용을 Main.java로 저장한다. 파일 하나에 실행 진입점과 비교 대상들을 함께 둔다. public 클래스는 파일명과 같은 Main 하나다. 주석의 A부터 F까지는 뒤의 해설에서 위치를 가리키는 표식이다.

Main.java

import java.util.List;
import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        // A: 입력과 사람이 검산한 결과를 준비한다.
        List<Scenario> scenarios = List.of(
                new Scenario("빈 목록", List.of(),
                        new Report(List.of(), 0)),
                new Scenario("일반", List.of(
                        new Enrollment("가람", 10000, false, true, false)),
                        new Report(List.of("가람:10000"), 10000)),
                new Scenario("재수강", List.of(
                        new Enrollment("나래", 10000, false, true, true)),
                        new Report(List.of("나래:9000"), 9000)),
                new Scenario("제외", List.of(
                        new Enrollment("다온", 10000, true, true, false),
                        new Enrollment("라온", 10000, false, false, false)),
                        new Report(List.of(), 0)),
                new Scenario("절사", List.of(
                        new Enrollment("마루", 101, false, true, true)),
                        new Report(List.of("마루:91"), 91)),
                new Scenario("혼합", List.of(
                        new Enrollment("나래", 10000, false, true, true),
                        new Enrollment("다온", 10000, true, true, false),
                        new Enrollment("가람", 10000, false, true, false),
                        new Enrollment("나래", 10000, false, true, true)),
                        new Report(List.of(
                                "나래:9000", "가람:10000", "나래:9000"), 28000))
        );

        LegacyCalculator legacy = new LegacyCalculator();
        ExtractedCalculator extracted = new ExtractedCalculator();
        FinalCalculator finished = new FinalCalculator(new FeePolicy());

        // B: 기준 결과와 단계별 동등성을 모두 확인한다.
        for (Scenario scenario : scenarios) {
            Report before = legacy.settle(scenario.input());
            Report middle = extracted.settle(scenario.input());
            Report after = finished.settle(scenario.input());

            check(before.equals(scenario.expected()),
                    scenario.name() + " / 기존-기대");
            check(before.equals(middle),
                    scenario.name() + " / 기존-추출");
            check(middle.equals(after),
                    scenario.name() + " / 추출-완성");
        }

        Scenario mixed = scenarios.get(scenarios.size() - 1);
        Report result = finished.settle(mixed.input());
        System.out.println("완성 항목: " + result.lines());
        System.out.println("완성 합계: " + result.total());
    }

    private static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            throw new AssertionError(name);
        }
    }
}

record Enrollment(
        String studentName,
        int tuition,
        boolean cancelled,
        boolean paid,
        boolean repeat) {
}

record Report(List<String> lines, int total) {
    // C: 계산 중의 목록과 확정된 결과를 분리한다.
    Report {
        lines = List.copyOf(lines);
    }
}

record Scenario(String name, List<Enrollment> input, Report expected) {
}

final class LegacyCalculator {
    // D: 비교 기준으로 보존한 기존 구현이다.
    Report settle(List<Enrollment> items) {
        int t = 0;
        List<String> r = new ArrayList<>();
        for (Enrollment x : items) {
            if (!x.cancelled()) {
                if (x.paid()) {
                    int a;
                    if (x.repeat()) {
                        a = x.tuition() - x.tuition() * 10 / 100;
                    } else {
                        a = x.tuition() - x.tuition() * 0 / 100;
                    }
                    r.add(x.studentName() + ":" + a);
                    t += a;
                }
            }
        }
        return new Report(r, t);
    }
}

final class ExtractedCalculator {
    // E: 이름과 계산 위치만 먼저 정리한다.
    Report settle(List<Enrollment> enrollments) {
        int total = 0;
        List<String> lines = new ArrayList<>();
        for (Enrollment enrollment : enrollments) {
            if (!enrollment.cancelled()) {
                if (enrollment.paid()) {
                    int fee = feeFor(enrollment);
                    lines.add(enrollment.studentName() + ":" + fee);
                    total += fee;
                }
            }
        }
        return new Report(lines, total);
    }

    private int feeFor(Enrollment enrollment) {
        if (enrollment.repeat()) {
            return enrollment.tuition() - enrollment.tuition() * 10 / 100;
        } else {
            return enrollment.tuition() - enrollment.tuition() * 0 / 100;
        }
    }
}

final class FinalCalculator {
    private final FeePolicy feePolicy;

    FinalCalculator(FeePolicy feePolicy) {
        this.feePolicy = feePolicy;
    }

    // F: 제외 대상을 먼저 거르고 정상 처리를 나란히 둔다.
    Report settle(List<Enrollment> enrollments) {
        int total = 0;
        List<String> lines = new ArrayList<>();
        for (Enrollment enrollment : enrollments) {
            if (shouldSkip(enrollment)) {
                continue;
            }
            int fee = feePolicy.feeFor(enrollment);
            lines.add(enrollment.studentName() + ":" + fee);
            total += fee;
        }
        return new Report(lines, total);
    }

    private boolean shouldSkip(Enrollment enrollment) {
        return enrollment.cancelled() || !enrollment.paid();
    }
}

final class FeePolicy {
    int feeFor(Enrollment enrollment) {
        int discountPercent = enrollment.repeat() ? 10 : 0;
        return enrollment.tuition()
                - enrollment.tuition() * discountPercent / 100;
    }
}

줄별 해설

A의 List.of는 실행에 사용할 사례를 순서대로 만든다. 각 Scenario에는 입력과 예상 결과가 한 쌍으로 들어간다. 일반·재수강 사례는 기본 계산을 분리해서 보여 주고, 제외 사례는 취소한 항목과 미결제 항목을 각각 제공한다. 절사 사례의 101원은 계산 순서를 바꾸는 실수를 찾기 위한 값이다. 실제로 자주 나오는 금액인지보다 두 구현의 차이를 드러내는지가 중요하다.

혼합 사례에는 나래가 두 번 들어 있다. 두 항목은 입력의 첫째와 마지막에 있고, 중간의 취소 항목만 빠진다. 예상 목록을 직접 적어 두었으므로 정렬이나 중복 제거가 추가되면 비교가 실패한다. 예상 결과를 새 계산기로 만들어 넣으면 계산기의 오류를 그대로 예상값으로 복제할 수 있으므로 그렇게 작성하지 않는다.

B에서는 같은 scenario.input()을 세 구현에 전달한다. 어느 구현도 입력을 변경하지 않고 새 결과 목록을 만든다. 첫 check는 기존 결과를 검산값과 비교한다. 둘째는 함수 추출 전후를, 셋째는 조건과 책임을 바꾼 전후를 비교한다. 세 비교가 모두 참이면 완성 결과도 해당 예상 결과와 같다.

check는 조건과 이름만 받는다. 실패하면 FAIL을 출력하고 AssertionError를 던져 실행을 중단한다. 실패 이후의 성공 메시지에 문제가 묻히지 않도록 한 것이다. Java의 assert 문을 사용하지 않았으므로 별도의 활성화 옵션 없이도 검사가 실행된다. 출력에서 사례 이름과 비교 단계가 함께 보이므로 어느 경계를 살펴봐야 할지 바로 알 수 있다.

C의 Report 생성자에서 lines 매개변수는 복사된 목록으로 바뀌고, 그 값이 record의 필드에 저장된다. 바깥에서 전달한 ArrayList와 결과가 같은 목록 객체를 공유하지 않는다. 요소는 변경할 수 없는 String이므로 이 예제에서는 요소를 다시 복사할 필요가 없다. 이 처리는 세 계산기가 공통으로 사용하는 결과 경계다.

D의 settle에서 최대 중첩 4는 for, 취소 검사, 결제 검사, 재수강 검사 안으로 차례로 들어갈 때 만들어진다. 선언과 계산, 결과 추가가 한 메서드에 모여 있다. 재수강과 일반 수강의 계산식은 할인율만 다르다. 이 구현은 읽기 어려운 부분을 남겨 놓았지만 비교 기준이므로 실습 중 내용을 고치지 않는다.

E에서는 settle의 안쪽 재수강 조건이 feeFor로 이동한다. settle은 9문장이고 feeFor는 if와 두 return을 합쳐 3문장이다. 새 이름은 결과를 모으는 total과 lines, 처리 중인 enrollment의 역할을 드러낸다. 계산식의 중복은 아직 두 곳이다. 함수 추출만으로 중복까지 사라졌다고 평가하지 않아야 한다.

F의 shouldSkip은 제외 조건을 이름으로 설명한다. 반복문의 본문에서는 먼저 제외하고, 이어서 금액 계산·항목 추가·합계 누적을 수행한다. FeePolicy의 discountPercent는 숫자의 단위를 이름에 담는다. 최종 계산식은 여전히 할인액을 먼저 정수로 구한 다음 원래 수강료에서 뺀다. 식의 줄바꿈은 바뀌었지만 연산 순서는 유지된다.

실행 결과

터미널에서 Main.java가 있는 디렉터리로 이동한 뒤 실행한다. 첫 명령은 요청한 기본 컴파일 명령이다. 마지막 명령은 같은 소스를 모든 경고 검사와 함께 다시 컴파일하며, 이 코드에서는 추가 출력 없이 끝난다.

javac -d out *.java
java -cp out Main
javac -Xlint:all -Werror -d out *.java

프로그램의 예상 출력은 다음과 같다.

PASS 빈 목록 / 기존-기대
PASS 빈 목록 / 기존-추출
PASS 빈 목록 / 추출-완성
PASS 일반 / 기존-기대
PASS 일반 / 기존-추출
PASS 일반 / 추출-완성
PASS 재수강 / 기존-기대
PASS 재수강 / 기존-추출
PASS 재수강 / 추출-완성
PASS 제외 / 기존-기대
PASS 제외 / 기존-추출
PASS 제외 / 추출-완성
PASS 절사 / 기존-기대
PASS 절사 / 기존-추출
PASS 절사 / 추출-완성
PASS 혼합 / 기존-기대
PASS 혼합 / 기존-추출
PASS 혼합 / 추출-완성
완성 항목: [나래:9000, 가람:10000, 나래:9000]
완성 합계: 28000

각 사례에서 기존-추출과 추출-완성이 모두 PASS이면 해당 입력의 목록과 합계가 단계 사이에 유지되었다는 뜻이다. 마지막 두 줄에서는 같은 이름의 항목 두 개와 입력 순서도 확인할 수 있다. 결과가 같다는 검사는 변경의 안전성을 뒷받침하고, 앞의 지표는 읽기 부담이 어떻게 달라졌는지 설명한다. 둘 가운데 하나로 다른 하나를 대신하지 않는다.

실무에서 자주 틀리는 것

계산식을 정리하면서 절사 위치를 바꾼다

다음 코드는 재수강 청구액을 한 번에 계산하려는 수정이다. 101원 입력에서 결과가 90원이 되어 기존 동작과 달라진다.

return enrollment.tuition() * (100 - discountPercent) / 100;

기존 규칙이 할인액의 소수 부분을 버리는 방식이라면 그 순서를 그대로 둔다. 요금 규칙을 바꾸려면 별도의 요구와 예상 결과를 먼저 정한다.

return enrollment.tuition()
        - enrollment.tuition() * discountPercent / 100;

합계만 비교하고 항목의 차이를 놓친다

총액 비교만으로는 항목 순서가 바뀌거나 한 항목의 금액이 다른 항목으로 옮겨 간 문제를 찾을 수 없다.

check(before.total() == after.total(), "정산 결과");

외부에서 관찰하는 결과 전체를 비교한다. 이 예제에서는 Report가 항목 목록과 합계를 모두 담으므로 다음 한 줄로 두 정보를 확인한다.

check(before.equals(after), "정산 결과");

가독성을 이유로 중복 항목을 없앤다

출력을 깔끔하게 보이게 하려고 distinct를 넣으면 같은 이름과 금액을 가진 별개의 수강 신청이 사라진다. 정산 목록의 중복은 코드 중복과 다른 문제다.

return new Report(lines.stream().distinct().toList(), total);

입력 한 항목에 대해 결과 한 항목을 남기는 기존 규칙을 유지한다. 중복 신청의 허용 여부는 신청을 받는 쪽의 요구와 함께 따로 판단한다.

return new Report(lines, total);

모든 오류를 빈 정산으로 바꾼다

정산 중 예외가 생겼다는 이유로 빈 결과를 반환하면 “대상이 없다”와 “계산하지 못했다”를 구분할 수 없다. 비교 코드의 오류까지 빈 정산으로 가려질 수 있다.

try {
    return calculator.settle(enrollments);
} catch (RuntimeException exception) {
    return new Report(List.of(), 0);
}

이번 코드에는 복구할 수 있는 실패 규칙이 없으므로 호출 결과를 그대로 반환하고 예외도 호출자에게 전달한다. 입력 검증이나 복구 정책이 필요해지면 그 경계에서 실패의 의미를 정한다.

return calculator.settle(enrollments);

한눈에 보기

수정 순서와 각 단계에서 확인할 근거
순서수정 내용결과 확인읽기 관점의 변화
기준 확보기존 구현과 검산값을 둔다기존-기대 비교보존할 동작을 말할 수 있다
이름·함수역할을 이름에 담고 계산을 추출한다기존-추출 비교정산 흐름과 계산을 나누어 읽는다
조건·책임제외 조건을 앞에 두고 정책을 분리한다추출-완성 비교정상 흐름의 중첩과 계산 중복이 줄어든다
마무리경고와 지표를 확인한다전체 사례 재실행변경 효과와 남은 제약을 설명한다

리뷰 설명에는 “코드가 짧아졌다”보다 구체적인 근거를 남긴다. 예를 들어 “정산 대상의 제외 조건을 먼저 처리해 최대 중첩이 4에서 2로 줄었고, 할인 계산식은 한 곳에 모았다. 여섯 입력에서 항목 순서·중복·합계가 유지된다”라고 쓸 수 있다. int 범위와 입력 검증을 이번에 바꾸지 않았다는 범위도 함께 적으면 다음 변경을 맡는 사람이 출발점을 이해하기 쉽다.

연습 문제

  1. 절사 사례에서 FeePolicy의 계산식을 수강료 곱하기 할인 후 비율 나누기 100으로 바꾸었다고 하자. 어떤 check가 처음 실패하며, 기존 결과와 수정 결과는 각각 얼마인지 설명하라.
  2. 혼합 사례의 같은 이름 두 항목을 제거하도록 완성 구현을 바꾸었다고 하자. 합계가 그대로 28,000원이어도 현재 비교가 실패하는 이유를 설명하라.
  3. 완성 단계의 최대 메서드 길이가 중간 단계보다 늘어났는데도 변경을 받아들일 수 있는 이유를 지표와 코드 흐름으로 설명하라.
  4. 수강료가 0원인 재수강 항목 하나를 새 사례로 추가하라. 입력과 예상 Report를 작성하고, 기존의 세 비교를 그대로 적용할 수 있는 이유를 설명하라.

정답과 해설

  1. 앞선 네 사례는 그대로 통과하고 “절사 / 추출-완성”에서 처음 실패한다. 기존-기대와 기존-추출은 FeePolicy를 사용하지 않으므로 통과한다. 기존과 추출 구현은 할인액 10원을 빼서 91원을 만들고, 수정한 완성 구현은 101 곱하기 90 나누기 100으로 90원을 만든다. 실패 이후에는 AssertionError 때문에 혼합 사례를 실행하지 않는다.

  2. Report의 동등성 비교는 total뿐 아니라 lines도 비교한다. 예상 목록에는 나래:9000이 두 번 있고 완성 목록에는 한 번만 있으므로 같지 않다. 합계와 항목 목록이 서로 맞지 않는 결과도 이 비교에서 발견된다. 합계만 검사했다면 이 변경을 놓쳤을 수 있다.

  3. 정한 측정법에서는 continue도 한 문장으로 세므로 최대 길이가 9에서 10으로 늘어난다. 대신 최대 중첩은 3에서 2로 줄고, 계산식 위치는 2에서 1로 줄어든다. 제외 여부를 확인한 뒤에는 금액 계산과 결과 누적을 같은 깊이에서 읽는다. 문장 수 하나만 줄이는 것보다 판단해야 할 조건을 적게 유지하는 편이 이번 흐름을 이해하는 데 도움이 된다.

  4. 사례 목록에 다음 요소를 추가할 수 있다. 할인액과 청구액이 모두 0이지만, 정산 대상이므로 항목은 남아야 한다. 사례마다 동일한 형태의 입력과 예상값을 가지므로 main의 반복문이나 check를 고칠 필요가 없다.

    new Scenario("0원", List.of(
            new Enrollment("새봄", 0, false, true, true)),
            new Report(List.of("새봄:0"), 0))
    

이번 정산 모듈에서 중요한 결과는 클래스를 몇 개 만들었는지가 아니다. 기존 동작을 설명할 입력을 고르고, 한 번에 바꾸는 판단을 줄이며, 매 단계의 결과를 확인한 과정이다. 앞으로 다른 오래된 코드를 만나도 보존할 출력과 수정할 구조를 먼저 구분하면 된다. 읽히는 코드는 그 구분을 작은 변경과 확인으로 이어 간 결과다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.