Devin.KR

리팩터링 순서 - 안전하게 조금씩

개발자KR 조회 5

이 장에서 배우는 것

수강료 계산 코드를 읽기 쉽게 고치려다가 할인 금액까지 달라진 적이 있다고 가정한다. 이름을 바꾸고, 함수를 나누고, 계산 위치를 옮긴 뒤에야 결과가 달라졌다는 사실을 알았다면 어느 수정부터 확인해야 할지 막막하다. 수정한 내용이 많을수록 원인을 찾는 범위도 넓어진다.

리팩터링(refactoring)은 관찰할 수 있는 동작을 유지하면서 코드의 내부 구조를 바꾸는 작업이다. 안전하게 진행하려면 바꿀 구조뿐 아니라 유지할 동작과 확인할 시점도 정해야 한다. 이 장에서는 학원 수강료 결과표를 기준으로, 변경 하나를 끝낼 때마다 같은 결과가 나오는지 실행한다. 앞 장에서 다룬 읽히는 테스트를 변경 순서를 지키는 도구로 사용한다.

  • 기존 동작을 테스트로 감싼 뒤 이름, 추출, 이동, 구조 변경 순서로 진행한다.
  • 각 단계를 독립적으로 실행하고, 실패하면 바로 앞 변경부터 확인한다.
  • 동작을 바꾸는 수정과 구조를 바꾸는 수정을 구분한다.
  • 기존 코드와 각 단계의 결과표 전체를 자동으로 비교하는 실행기를 만든다.

문제 상황

학원 수강 관리 프로그램에는 수강 신청 목록을 받아 수강생별 청구 금액을 만드는 메서드가 있다. 입력에는 수강생 식별자, 월 수강료, 신청 개월 수, 재수강 여부가 들어간다. 강좌를 선택한 다음 단계에서 전달되는 자료이므로 여기서는 강좌 정보 대신 확정된 월 수강료만 받는다.

현재 규칙은 단순하다. 월 수강료에 개월 수를 곱하고, 재수강이면 그 합계의 10분의 1을 할인한다. 금액은 원 단위 정수이며 할인액을 구할 때 정수 나눗셈을 사용한다. 이 비율과 금액은 이 장의 가상 업무 규칙이다.

static List<Row> x(List<Registration> a) {
    List<Row> b = new ArrayList<>();
    for (Registration c : a) {
        long d = c.monthlyFee() * c.months();
        if (c.returningStudent()) {
            d = d - d / 10;
        }
        b.add(new Row(c.studentId(), d));
    }
    return b;
}

코드는 짧지만 변수 이름만 읽어서는 입력, 합계, 결과 목록을 구분하기 어렵다. 다른 보고서에서도 같은 계산이 필요해지자 담당자는 이름을 고치면서 계산을 별도 클래스로 옮기려 한다. 그 과정에서 “개월마다 할인하고 합산하는 편이 자연스럽다”는 생각까지 들었다.

여기서 작업이 갈린다. 계산 위치를 옮기는 일은 구조 변경이지만 할인 시점을 바꾸는 일은 동작 변경이다. 월 수강료가 10,001원이고 3개월 재수강이면 기존 계산은 30,003원에서 3,000원을 빼므로 27,003원이다. 매월 할인한 뒤 합치면 9,001원에 3을 곱한 27,003원이 된다. 이 입력만 보면 차이가 드러나지 않는다.

월 수강료가 10,006원이면 다르다. 기존 계산은 30,018원에서 3,001원을 뺀 27,017원이다. 매월 할인하면 9,006원에 3을 곱한 27,018원이다. 이름을 바꾸는 작업에 계산식 수정까지 섞으면 이런 차이를 구조 변경의 실수인지 새 규칙의 결과인지 구분하기 어려워진다.

먼저 유지할 동작을 고정한다

테스트로 감싼다는 말은 기존 코드를 곧바로 옳다고 선언한다는 뜻이 아니다. 이번 작업에서 보존할 동작을 입력과 예상 결과로 남긴다는 뜻이다. 기존 동작에 의문이 있으면 그 의문을 따로 기록하고, 구조를 고치는 동안에는 합의한 기준을 유지한다.

이 장에서 비교할 대상은 반환된 결과표다. 행에는 수강생 식별자와 청구 금액이 들어간다. 행의 개수, 순서, 식별자, 금액을 모두 비교한다. 금액의 합계만 같다고 통과시키면 서로 다른 수강생의 금액이 뒤바뀌어도 놓칠 수 있다.

기준 결과표에 넣을 입력과 확인 목적
입력기준 청구액확인 목적
S1, 10,000원, 1개월, 일반10,000원할인 없는 기본 계산
S2, 10,000원, 3개월, 재수강27,000원할인 분기
S3, 10,006원, 3개월, 재수강27,017원할인액의 정수 나눗셈
S4, 0원, 2개월, 재수강0원청구액이 없는 행 유지
S5, 7,000원, 2개월, 일반14,000원여러 개월의 일반 계산

예상값을 기존 메서드로 생성하면 기존 메서드의 계산 실수를 그대로 받아들이게 된다. 따라서 기준 결과표는 위 규칙으로 직접 계산해 코드에 적는다. 먼저 기존 구현이 이 표와 같은지 확인하고, 그다음 각 변경 단계가 기존 구현의 결과와 같은지 비교한다. 빈 목록도 별도 입력으로 확인한다.

이 비교가 보장하는 범위도 정한다. 예제 입력은 음수가 아닌 월 수강료와 양의 개월 수를 사용하며 곱셈 결과는 long 범위 안에 있다. null 입력, 잘못된 개월 수의 처리, 범위를 넘는 곱셈은 이번 계약에 포함하지 않는다. 실제 운영 코드가 그런 입력이나 예외 동작을 계약으로 제공한다면 해당 사례부터 기준에 추가해야 한다.

직접 정한 예상 결과로 기존 코드를 확인하고 각 변경 단계의 결과표를 기존 결과와 비교한다

같은 입력에 같은 결과가 나온다는 확인은 입력 사례가 다루는 범위에서만 성립한다. 다섯 행이 모든 가능한 계산을 증명하지는 않는다. 다만 할인 여부와 나눗셈 위치처럼 이번 수정에서 흔들릴 수 있는 조건을 의도적으로 포함하면, 단순히 실행이 끝났다는 확인보다 유용하다.

한 번에 하나씩 바꾸고 바로 실행한다

이 장에서 사용할 순서는 테스트로 감싸기, 이름 바꾸기, 함수 추출, 계산 이동, 구조 변경이다. 모든 코드에 강제할 순서는 아니지만, 짧은 수강료 메서드를 정리할 때는 변경 이유를 나누기 좋다. 뒤 단계에서 문제가 나도 앞 단계까지 통과했다는 사실을 출발점으로 삼을 수 있다.

각 단계에서 바꾸는 것과 확인할 것
단계이번에 바꾸는 것실행으로 확인할 것
테스트로 감싸기입력과 기준 결과표 추가기존 구현이 기준과 같은가
이름메서드와 지역 변수 이름행과 금액이 유지되는가
추출금액 계산을 별도 메서드로 분리계산식과 호출 인자가 유지되는가
이동계산 메서드를 계산 클래스로 이동호출 위치가 바뀌어도 결과가 같은가
구조 변경보고서가 계산기를 생성자에서 받도록 변경새 연결 방식에서도 결과가 같은가

이름 단계에서는 계산식을 건드리지 않는다. a를 registrations로, d를 amount로 바꿔도 할인 방식은 그대로 둔다. 이름을 바꾸는 동안 계산식을 더 짧게 만들고 싶어져도 다음 작업으로 남긴다. 지금 변경을 한 문장으로 설명할 수 있을 정도로 범위를 좁힌다.

추출 단계에서는 반복문 안의 금액 계산만 calculateAmount로 꺼낸다. 반복문이 행을 만드는 흐름과 금액을 계산하는 흐름을 구분하게 된다. 이때 입력 자료형을 바꾸거나 새로운 검증을 추가하지 않는다. 추출한 계산이 원래와 같은 값을 반환하는지 먼저 실행한다.

이동 단계에서는 이미 추출한 계산을 FeeCalculator로 옮긴다. 추출과 이동을 나누면 계산 범위를 정하는 작업과 호출 대상을 바꾸는 작업을 각각 확인할 수 있다. 마지막에는 보고서가 내부에서 계산기를 만드는 대신 생성자에서 받게 한다. 이 예제의 구조 변경은 이 작은 연결 변경까지다. 인터페이스나 여러 계산 정책을 추가할 필요는 없다.

구조 변경마다 실행을 통과한 뒤 다음 단계로 가고 실패하면 마지막 변경을 확인한다

작은 단계는 수정한 줄 수만으로 판단하지 않는다. 한 줄짜리 계산식 수정도 금액을 바꿀 수 있고, 여러 지역 변수 이름을 바꾸는 수정은 계산과 무관할 수 있다. 변경 의도가 하나이고 실패했을 때 확인할 범위가 좁은지가 더 중요하다.

실무에서는 이름 단계가 통과한 뒤 그 상태를 저장하고 추출로 넘어간다. 실패하면 마지막 수정의 차이를 살펴보고, 이해하기 어렵다면 그 수정만 되돌린다. 되돌릴 때 다른 사람이 작성한 변경이나 아직 저장하지 않은 별도 작업까지 없애지 않도록 변경 범위를 확인한다.

완성 코드는 학습을 위해 각 단계의 구현을 함께 보관한다. 실제 프로젝트에서는 보통 하나의 운영 구현을 단계적으로 바꾸고 테스트를 계속 실행한다. 여기서는 전후 모습을 한 번에 실행해 비교할 수 있도록 복사본을 남긴 것이다. 구현을 여러 벌 유지하는 방식 자체가 목표는 아니다.

완성 코드

아래 코드를 같은 디렉터리의 Main.java로 저장한다. JDK 17 표준 라이브러리만 사용한다. record는 입력 한 건과 결과 한 행을 담으며, 결과 행의 값 비교에도 사용한다. 프로그램은 파일을 쓰지 않고 현재 시각이나 난수를 사용하지 않는다.

Main.java

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

public class Main {
    record Registration(
            String studentId,
            long monthlyFee,
            int months,
            boolean returningStudent) {
    }

    record Row(String studentId, long amount) {
    }

    interface Report {
        List<Row> create(List<Registration> registrations);
    }

    record Stage(String name, Report report) {
    }

    static final class LegacyReport implements Report {
        @Override
        public List<Row> create(List<Registration> a) {
            return x(a);
        }

        private List<Row> x(List<Registration> a) {
            List<Row> b = new ArrayList<>();
            for (Registration c : a) {
                long d = c.monthlyFee() * c.months();
                if (c.returningStudent()) {
                    d = d - d / 10;
                }
                b.add(new Row(c.studentId(), d));
            }
            return b;
        }
    }

    static final class NamedReport implements Report {
        @Override
        public List<Row> create(
                List<Registration> registrations) {
            return createRows(registrations);
        }

        private List<Row> createRows(
                List<Registration> registrations) {
            List<Row> rows = new ArrayList<>();
            for (Registration registration : registrations) {
                long amount =
                        registration.monthlyFee() * registration.months();
                if (registration.returningStudent()) {
                    amount = amount - amount / 10;
                }
                rows.add(new Row(registration.studentId(), amount));
            }
            return rows;
        }
    }

    static final class ExtractedReport implements Report {
        @Override
        public List<Row> create(
                List<Registration> registrations) {
            List<Row> rows = new ArrayList<>();
            for (Registration registration : registrations) {
                long amount = calculateAmount(registration);
                rows.add(new Row(registration.studentId(), amount));
            }
            return rows;
        }

        private long calculateAmount(Registration registration) {
            long amount =
                    registration.monthlyFee() * registration.months();
            if (registration.returningStudent()) {
                amount = amount - amount / 10;
            }
            return amount;
        }
    }

    static final class FeeCalculator {
        long calculateAmount(Registration registration) {
            long amount =
                    registration.monthlyFee() * registration.months();
            if (registration.returningStudent()) {
                amount = amount - amount / 10;
            }
            return amount;
        }
    }

    static final class MovedReport implements Report {
        private final FeeCalculator calculator = new FeeCalculator();

        @Override
        public List<Row> create(
                List<Registration> registrations) {
            List<Row> rows = new ArrayList<>();
            for (Registration registration : registrations) {
                long amount = calculator.calculateAmount(registration);
                rows.add(new Row(registration.studentId(), amount));
            }
            return rows;
        }
    }

    static final class StructuredReport implements Report {
        private final FeeCalculator calculator;

        StructuredReport(FeeCalculator calculator) {
            this.calculator = calculator;
        }

        @Override
        public List<Row> create(
                List<Registration> registrations) {
            List<Row> rows = new ArrayList<>();
            for (Registration registration : registrations) {
                long amount = calculator.calculateAmount(registration);
                rows.add(new Row(registration.studentId(), amount));
            }
            return rows;
        }
    }

    public static void main(String[] args) {
        List<Registration> registrations = List.of(
                new Registration("S1", 10_000L, 1, false),
                new Registration("S2", 10_000L, 3, true),
                new Registration("S3", 10_006L, 3, true),
                new Registration("S4", 0L, 2, true),
                new Registration("S5", 7_000L, 2, false));

        List<Row> expected = List.of(
                new Row("S1", 10_000L),
                new Row("S2", 27_000L),
                new Row("S3", 27_017L),
                new Row("S4", 0L),
                new Row("S5", 14_000L));

        Report legacy = new LegacyReport();
        List<Row> baseline = legacy.create(registrations);
        check(expected.equals(baseline), "기준 결과표");
        check(legacy.create(List.of()).isEmpty(), "기존 빈 입력");
        printTable("기존", baseline);

        List<Stage> stages = List.of(
                new Stage("이름", new NamedReport()),
                new Stage("추출", new ExtractedReport()),
                new Stage("이동", new MovedReport()),
                new Stage(
                        "구조",
                        new StructuredReport(new FeeCalculator())));

        for (Stage stage : stages) {
            List<Row> actual = stage.report().create(registrations);
            check(baseline.equals(actual), stage.name() + " 결과표");
            check(
                    stage.report().create(List.of()).isEmpty(),
                    stage.name() + " 빈 입력");
            printTable(stage.name(), actual);
        }
        System.out.println("모든 단계 확인 완료");
    }

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

    private static void printTable(String name, List<Row> rows) {
        System.out.println("[" + name + "]");
        System.out.println("수강생|청구액(원)");
        for (Row row : rows) {
            System.out.println(row.studentId() + "|" + row.amount());
        }
    }
}

줄별 해설

입력과 출력 선언. Registration의 네 구성 요소는 계산에 필요한 입력이다. Row에는 결과표에서 관찰할 두 값만 넣는다. 두 record의 구성 요소는 String과 기본형이므로 이번 예제에서는 내부 목록 같은 변경 가능한 값을 따로 관리할 필요가 없다.

Report와 Stage. Report는 모든 구현을 같은 방식으로 호출하기 위한 실행기용 인터페이스다. Stage는 단계 이름과 실행할 구현을 묶는다. 이 두 선언 덕분에 실행기는 구현별 계산식을 알 필요 없이 create를 호출할 수 있다. 실행기 연결을 위해 기존 구현을 감싸되, x의 계산문은 그대로 둔다.

LegacyReport의 x. d를 만드는 줄은 월 수강료와 개월 수를 곱한다. 다음 조건문은 재수강일 때만 합계의 10분의 1을 뺀다. 마지막 add는 청구액이 0이어도 행을 추가한다. 나중에 0원 행을 생략하면 계산 합계는 같을 수 있어도 결과표는 달라진다.

NamedReport의 createRows. x, a, b, c, d의 이름을 의도가 드러나는 이름으로 바꾼 단계다. Report와 연결하는 create는 유지한다. 곱셈, 조건문, 뺄셈, 행 추가의 순서는 이전 구현과 같다. 이름을 고쳤다는 설명과 실제 변경 내용이 일치한다.

ExtractedReport의 calculateAmount. 기존 반복문에서 합계를 계산하고 할인을 적용하던 부분을 가져온다. 반복문에는 입력 한 건을 계산에 전달하고 결과 행을 추가하는 코드가 남는다. 반환형은 금액과 같은 long이다. 반환 전에 추가 반올림이나 자료형 변환을 넣지 않는다.

FeeCalculator와 MovedReport. 추출한 메서드 본문을 계산 클래스로 옮긴다. 보고서가 calculator.calculateAmount를 호출하도록 바뀌지만 인자는 같은 registration이다. 이 단계에서는 보고서 내부에서 계산기를 만든다. 계산 위치를 옮기는 변경부터 확인하려는 것이다.

StructuredReport의 생성자. 계산기를 만드는 위치를 호출부로 옮긴다. 보고서는 전달받은 계산기를 필드에 보관하고 사용한다. 행을 만드는 반복문은 이동 단계와 같다. 새로운 할인 규칙이나 대체 구현을 넣지 않으므로, 이번 단계의 확인 대상은 객체 연결 방식이 바뀐 뒤에도 결과가 유지되는지다.

main의 expected와 baseline. expected는 규칙으로 직접 계산한 기준이며 baseline은 기존 구현의 실행 결과다. 먼저 둘을 비교하므로 기존 코드가 예상 표와 다르면 다음 단계로 진행하지 않는다. 결과 목록의 equals는 행 순서까지 비교하고, 각 Row의 equals는 식별자와 금액을 비교한다.

단계 반복문. 각 단계의 actual을 baseline과 비교한다. expected와 baseline이 같다는 첫 검사가 통과했으므로 단계 결과도 기준 표와 같아야 한다. 결과표 검사와 빈 입력 검사를 통과한 뒤에만 해당 표를 출력한다. 이전 단계의 실제 결과를 다음 단계의 예상값으로 덮어쓰지 않는다.

check와 printTable. check는 성공하면 PASS를 출력한다. 실패하면 FAIL을 출력하고 AssertionError로 실행을 끝낸다. 뒤 단계가 계속 출력되어 실패가 묻히지 않게 한다. printTable은 정해진 구분자와 정수 문자열만 사용하므로 지역별 숫자 형식에 영향을 받지 않는다.

실행 결과

다음 명령으로 컴파일하고 실행한다. 경고 확인이 필요하면 첫 명령에 -Xlint:all을 추가해도 된다. 예제에는 경고 억제 선언이나 외부 의존성이 없다.

javac -d out *.java
java -cp out Main

정상 실행의 예상 출력은 다음과 같다. 각 단계의 결과표를 사람이 읽을 수 있게 출력하지만, 통과 여부는 출력 문자열을 눈으로 비교해서 정하지 않는다. 출력 전에 목록 전체를 자동으로 비교한다.

PASS 기준 결과표
PASS 기존 빈 입력
[기존]
수강생|청구액(원)
S1|10000
S2|27000
S3|27017
S4|0
S5|14000
PASS 이름 결과표
PASS 이름 빈 입력
[이름]
수강생|청구액(원)
S1|10000
S2|27000
S3|27017
S4|0
S5|14000
PASS 추출 결과표
PASS 추출 빈 입력
[추출]
수강생|청구액(원)
S1|10000
S2|27000
S3|27017
S4|0
S5|14000
PASS 이동 결과표
PASS 이동 빈 입력
[이동]
수강생|청구액(원)
S1|10000
S2|27000
S3|27017
S4|0
S5|14000
PASS 구조 결과표
PASS 구조 빈 입력
[구조]
수강생|청구액(원)
S1|10000
S2|27000
S3|27017
S4|0
S5|14000
모든 단계 확인 완료

예를 들어 이름 단계에만 S3의 청구액을 바꾸는 실수를 넣으면 FAIL 이름 결과표에서 멈춘다. 완성본에서는 모든 구현이 함께 있지만, 실제 작업에서는 이름 단계가 통과해야 추출을 시작한다. 실행기의 단계 순서가 작업 순서가 되는 셈이다.

실무에서 자주 틀리는 것

계산을 옮기면서 할인 시점도 바꾼다

다음은 재수강 계산을 간결하게 만든다며 월별 할인으로 바꾼 코드다. 틀렸다는 의미는 새 규칙으로도 사용할 수 없다는 뜻이 아니라, 기존 동작을 유지하는 이번 작업에 맞지 않는다는 뜻이다.

long monthlyFee = registration.monthlyFee();
long amount =
        (monthlyFee - monthlyFee / 10) * registration.months();

고친 코드는 합계를 구한 다음 할인액을 계산한다. 일반 수강 분기도 유지한다.

long amount = registration.monthlyFee() * registration.months();
if (registration.returningStudent()) {
    amount = amount - amount / 10;
}

월별 할인으로 정책을 바꿔야 한다면 구조 변경을 마친 뒤 별도 작업으로 진행한다. 그 작업에서는 새 예상 금액과 변경 이유를 명시한다. 기존 예상값이 실패했다는 이유만으로 숫자를 새 출력에 맞춰 고치지 않는다.

행 개수만 비교하고 같은 결과라고 판단한다

아래 검사는 수강생별 금액이 달라도 행 수만 같으면 통과한다.

check(baseline.size() == actual.size(), "결과표");

이번 계약에는 순서, 식별자, 금액이 포함되므로 결과 목록 전체를 비교한다.

check(baseline.equals(actual), "결과표");

순서가 중요하지 않은 다른 업무라면 그 계약에 맞는 비교를 설계해야 한다. 하지만 기존 출력 순서를 유지하기로 한 작업에서 비교 전에 양쪽을 정렬하면 순서가 바뀐 실수를 숨기게 된다.

관찰한 결과를 그대로 예상값으로 삼는다

다음 코드는 같은 결과 객체를 자신과 비교한다. 계산이 잘못되어도 통과한다.

List<Row> expected = report.create(registrations);
List<Row> actual = expected;
check(expected.equals(actual), "기준 결과표");

예상값은 업무 규칙으로 별도로 정한다. 아래 코드는 한 건만 확인하는 예이며, 완성 코드에서는 다섯 행으로 넓혔다.

List<Registration> input = List.of(
        new Registration("S3", 10_006L, 3, true));
List<Row> expected = List.of(new Row("S3", 27_017L));
check(expected.equals(report.create(input)), "나눗셈 위치");

기존 구현과 변경 구현을 비교하는 검사도 필요하지만, 그것만으로 기준 계산의 타당성까지 확인되지는 않는다. 직접 정한 예상값과 기존 구현을 연결하는 첫 검사가 필요한 이유다.

실패를 출력한 뒤 다음 단계로 넘어간다

다음 코드는 실패를 기록해도 실행을 계속한다. 로그가 길면 마지막 완료 문구만 보고 작업이 끝났다고 오해할 수 있다.

if (!condition) {
    System.out.println("FAIL " + name);
}

이 장의 실행기는 첫 실패에서 멈춘다. 성공 출력도 분리해 실패한 검사가 PASS까지 출력하지 않게 한다.

if (!condition) {
    System.out.println("FAIL " + name);
    throw new AssertionError(name);
}
System.out.println("PASS " + name);

중단된 뒤에는 마지막 변경부터 살핀다. 예상값을 바꾸거나 다음 구조 변경으로 넘어가면 실패 원인을 좁힐 기회를 잃는다.

한눈에 보기

안전하게 작은 변경을 이어 가는 판단 기준
작업 시점확인 질문다음 행동
수정 전유지할 입력과 결과가 정해졌는가기준 결과표와 빈 입력 검사를 만든다
수정 중변경 의도를 하나로 설명할 수 있는가이름, 추출, 이동을 나눈다
단계 완료기준과 행 전체가 같은가컴파일하고 실행한 뒤 상태를 저장한다
검사 실패마지막 변경이 무엇인가해당 차이를 확인하거나 그 변경만 되돌린다
규칙 변경 필요입력에 대한 결과를 바꾸려는가구조 변경과 분리해 예상값을 논의한다

최종 구조의 크기보다 변경 경로를 설명할 수 있는지가 중요하다. 이번 예제는 같은 표를 반복해서 확인할 수 있도록 단계를 드러냈다. 다음 장에서는 이렇게 확인한 작업 방식을 팀이 함께 사용할 포매터, 정적 검사, 리뷰 기준으로 연결한다.

연습 문제

  1. 실행기의 입력 끝에 같은 수강생 식별자 S1을 가진 다른 신청을 추가한다. 월 수강료는 4,000원, 개월 수는 2, 재수강 여부는 false다. 예상 결과표를 수정하고 모든 단계를 실행한다. 행이 합쳐져야 하는지 설명한다.
  2. 이동 단계의 보고서에서 청구액이 0인 행을 추가하지 않도록 바꾼다. 어떤 검사가 먼저 실패하고 왜 실패하는지 설명한다. 확인 후 기존 동작으로 되돌린다.
  3. 모든 재수강 계산을 월별 할인 후 합산으로 바꾼다고 가정한다. 기존 S3의 예상값과 새 예상값을 구하고, 이 수정을 어느 작업에 포함해야 하는지 설명한다.
  4. 구조 단계의 검증 사례로 10,006원, 1개월, 재수강 입력을 추가했다. 이 사례만으로 할인 시점 변경을 찾아낼 수 있는지 계산으로 설명한다.

정답과 해설

  1. 예상 결과표 끝에 new Row("S1", 8_000L)을 추가한다. 기존 코드는 신청 한 건마다 행 하나를 만들며 수강생별로 합산하지 않는다. 따라서 S1 행은 두 개가 되고 총 여섯 행을 순서대로 비교해야 한다. 식별자가 같다는 이유로 행을 합치는 수정은 별도 동작 변경이다.
  2. 기준, 이름, 추출 검사가 통과한 뒤 FAIL 이동 결과표가 출력된다. S4의 0원 행이 사라져 목록의 크기와 내용이 달라지기 때문이다. 이동 빈 입력 검사까지는 도달하지 않는다. 청구액 합계만 비교했다면 이 차이를 놓칠 수 있다.
  3. 기존 S3은 30,018원에서 3,001원을 뺀 27,017원이다. 새 계산은 월 10,006원에서 1,000원을 뺀 9,006원에 3을 곱하므로 27,018원이다. 같은 입력의 출력이 바뀌므로 리팩터링과 분리한 규칙 변경 작업으로 다룬다. 새 규칙의 예상값은 승인된 업무 의미를 기준으로 정한다.
  4. 찾아낼 수 없다. 1개월이면 합계가 월 수강료와 같아 두 계산 모두 10,006원에서 1,000원을 뺀 9,006원이다. 계산 순서 차이를 드러내려면 여러 개월과 나눗셈의 나머지가 함께 작용하는 입력이 필요하다. S3의 3개월 입력을 유지하는 이유다.

댓글 0

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

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