Devin.KR

레거시 코드 고치기 - 특성 테스트로 감싸고 바꾸기

개발자KR 조회 5

이 장에서 배우는 것

앞 장에서 변경을 검사하는 자동화 관문을 만들었다. 그런데 검사가 통과했다는 사실만으로 오래된 코드의 동작까지 보존했다고 말할 수는 없다. 기존 검사에 배송 지역이나 할인 조합이 빠져 있다면, 배송비 계산을 간결하게 고치는 동안 고객에게 청구하는 금액이 달라질 수도 있다. 검사 장치를 갖추는 일과 그 장치에 무엇을 기록할지 정하는 일은 서로 연결되어 있다.

이 장에서는 읽기 어려운 배송비 계산 메서드를 바로 고치지 않는다. 먼저 같은 입력을 넣었을 때 현재 코드가 반환하는 값을 표로 남긴다. 이 표를 특성 테스트(characterization test)로 실행한 뒤, AI가 제안한 리팩터링(refactoring)이 표의 값을 보존하는지 확인한다. 현재 동작이 바람직한 정책인지 판단하는 일과, 구조를 바꾸면서 그 동작을 유지하는 일을 구분하는 것이 핵심이다.

  • 현재 구현에서 얻은 결과를 입력·출력 표와 실행 가능한 검사로 기록한다.
  • 경계값과 조건 조합을 골라 동작이 달라지는 지점을 드러낸다.
  • AI에게 변경 범위와 동작 보존의 근거를 함께 요구한다.
  • 작은 변경마다 기존 구현, 고정한 기대값, 새 구현을 비교한다.

문제 상황

팀이 관리하는 작은 쇼핑몰 저장소에는 주문, 재고, 결제 모의 객체가 있다. 이번 작업은 주문 확정 직전에 호출하는 배송비 계산 메서드를 읽기 쉽게 바꾸는 것이다. 재고 차감이나 결제 승인 흐름은 수정하지 않는다. 메서드의 입력은 상품 합계 금액, 도서 지역 여부, 회원 여부이며 출력은 원 단위 배송비다.

기존 코드에는 눈에 걸리는 동작이 있다. 상품 합계가 50,000원 이상이면 즉시 0원을 반환한다. 따라서 도서 지역 추가 요금도 붙지 않는다. 그보다 적으면 기본 배송비에 도서 지역 요금을 더하고, 회원에게는 1,000원을 빼 준다. 음수 상품 합계를 전달해도 예외가 발생하지 않는다. 이 금액들은 예제 저장소가 가진 가상의 정책 값이다.

담당자는 음수 입력이 상위 주문 검증에서 차단된다고 기억한다. 그러나 계산 메서드 자체의 계약을 설명하는 문서는 없다. 다른 호출 경로가 생겼는지도 확실하지 않다. AI에게 “배송비 계산을 정리해 달라”라고만 요청하면 음수 검증을 추가하거나 도서 지역에는 무료 배송을 적용하지 않도록 바꿀 수 있다. 모두 그럴듯한 수정이지만, 이번 작업의 동작 보존 조건과는 다르다.

배송비 메서드를 읽기 쉽게 정리하라. 먼저 현재 구현의 입력·출력 표를 제안하라. 상품 합계의 무료 배송 경계, 도서 지역과 회원의 조합, 음수 입력을 포함하라. 기존 구현의 결과를 확인하기 전에는 기대값을 확정하지 말라. 이번 변경에서는 입력 검증이나 배송 정책을 추가하지 말라.

이 요청은 AI의 제안을 곧바로 정답으로 채택하라는 뜻이 아니다. AI가 고른 사례가 중요한 분기를 지나가는지 사람이 확인해야 한다. 특히 “무료 배송이라면 도서 지역 요금도 없다”라는 관찰과 “도서 지역도 무료여야 한다”라는 정책 판단을 섞지 않아야 한다.

현재 동작을 기대값으로 고정하기

일반적인 요구사항 기반 테스트는 정해진 정책을 기대값으로 쓴다. 특성 테스트는 잘 모르는 코드의 현재 결과를 먼저 관찰하고, 선택한 사례에서 그 결과를 유지하는지 검사한다. 두 방식 모두 기대값과 실제값을 비교하지만 기대값을 얻는 출발점이 다르다. 특성 테스트가 통과했다고 해서 기존 정책이 옳다는 뜻은 아니다.

예를 들어 상품 합계가 50,000원이고 도서 지역이며 회원인 주문의 현재 배송비가 0원이라고 하자. 팀은 이 결과를 표에 기록한다. 나중에 정책 담당자가 도서 지역 요금은 별도로 받아야 한다고 결정하면, 그것은 요구사항을 바꾸는 작업이다. 구조 정리 작업에 그 결정을 끼워 넣으면 리뷰에서 어떤 차이가 의도된 것인지 추적하기 어려워진다.

선택한 입력에서 관찰하고 고정할 배송비
사례상품 합계도서 지역 / 회원현재 배송비
음수-1아니오 / 아니오3,000
합계 없음0아니오 / 아니오3,000
일반 주문10,000아니오 / 아니오3,000
회원 주문10,000아니오 / 예2,000
도서 주문10,000예 / 아니오8,000
도서 회원10,000예 / 예7,000
경계 직전49,999예 / 예7,000
무료 경계50,000아니오 / 아니오0
무료 도서 회원50,000예 / 예0
경계 직후50,001예 / 아니오0

표에는 경계 직전, 경계 자체, 경계 직후가 들어 있다. 조건식의 비교 연산자가 바뀌는 실수를 찾으려면 경계 자체가 필요하다. 무료 배송 미만에서는 두 불리언 입력의 네 조합을 모두 넣었다. 이를 통해 지역 추가 요금과 회원 할인이 각각 적용되는지, 함께 적용될 때 무엇이 되는지 볼 수 있다.

음수 사례는 음수 주문을 허용하자는 선언이 아니다. 이번 구조 변경에서 해당 입력의 동작까지 바꾸지 않겠다는 기록이다. 실제 서비스에서 그 입력이 허용되는지는 별도 확인이 필요하다. 현재 동작을 관찰하는 단계에서는 이상해 보이는 결과도 지우지 않는다. 그래야 나중에 의도적으로 바꿀 때 변경 전후를 설명할 수 있다.

기존 구현에서 관찰한 결과를 고정해야 새 구현의 동작 변화를 발견할 수 있다

표의 기대값은 새 구현을 만들기 전에 고정한다. 매번 실행할 때 기존 구현을 호출해서 기대값을 다시 계산하면, 기존 구현까지 함께 수정했을 때 기준도 움직인다. 완성 코드에서는 기대값을 숫자 리터럴로 보관하고, 기존 구현이 그 숫자와 일치하는지도 검사한다. 이렇게 하면 두 구현이 똑같이 잘못 바뀌어도 기록과의 차이를 발견할 수 있다.

AI에게 작은 변경과 보존 근거를 요구하기

첫 번째 요청은 현재 동작을 드러내는 데 집중한다. 사람이 표와 기존 구현의 결과를 확인한 다음에야 구조 변경을 요청한다. 이때 “깔끔하게 바꾸라”보다 무엇을 유지하고 무엇을 건드리지 않을지 구체적으로 적는 편이 낫다. 이번에는 메서드의 입력 형식과 반환형을 유지하고, 조기 반환의 위치도 유지한다.

확인한 입력·출력 표를 기대값으로 고정했다. 이제 배송비 계산만 리팩터링하라. 먼저 금액 리터럴을 이름 있는 상수로 바꾸고 표를 확인하라. 다음으로 무료 배송 미만의 계산을 보조 메서드로 옮기고 다시 확인하라. 기존 구현과 표의 기대값은 수정하지 말라. 각 단계에서 변경한 부분과 동작 보존의 근거를 설명하라.

이 예제에서는 상수 이름 붙이기와 보조 메서드 추출이라는 두 단계면 충분하다. 저장소 전체에 새 추상화를 도입하거나 배송 정책 객체 계층을 만들 필요는 없다. 변경 이유가 메서드의 가독성 개선이라면 그 목적에 맞는 크기로 끝내는 것이 리뷰하기 쉽다. 작은 변경은 문제가 생겼을 때 되돌릴 지점도 선명하게 만든다.

각 단계에서 요구할 변경과 확인 근거
단계변경확인할 근거
기록입력·출력 표 추가기존 구현과 고정 기대값이 일치한다
이름 부여숫자를 상수로 교체조건식과 계산 순서가 유지된다
메서드 추출유료 배송 계산을 분리무료 배송 조기 반환 뒤에서만 호출한다
최종 비교새 구현을 같은 입력으로 실행새 결과가 표 및 기존 결과와 일치한다

AI가 “모든 동작을 보존했다”라고 답하더라도 그 문장만으로 작업을 끝내지 않는다. 표는 선택한 입력에 대한 실행 근거다. 전체 입력에 대해서는 조건과 계산을 읽어 추가로 확인한다. 기존 구현과 새 구현 모두 50,000원 이상에서 먼저 0을 반환한다. 그 미만에서는 3,000원을 시작으로 도서 지역이면 5,000원을 더하고 회원이면 1,000원을 뺀다. 이 대응 관계가 코드에서도 보여야 한다.

이 예제는 입력 합계에 다른 값을 더하거나 곱하지 않는다. 따라서 경계 비교 외에는 합계 크기에 따라 계산식이 달라지지 않는다. 이런 구조를 확인하면 표의 사례가 어떤 분기를 대표하는지 설명할 수 있다. 반대로 합계 비율로 배송비를 계산하거나 여러 구간을 가진 코드라면, 같은 열 개의 사례만으로 충분하다고 판단해서는 안 된다.

상수 교체와 메서드 추출을 나누고 각 단계에서 같은 표를 확인한다

다음 완성 코드는 비교를 위해 오래된 구현과 최종 구현을 한 파일에 둔다. 실제 작업에서는 기록을 먼저 실행하고, 상수 교체 뒤 다시 실행하고, 메서드 추출 뒤 다시 실행한다. 최종 코드만 보고 중간 단계도 검사했다고 주장할 수는 없다. 여기서는 독자가 변경 전후를 한 번의 실행으로 직접 확인할 수 있도록 두 구현을 함께 제공한다.

완성 코드

다음 내용을 Main.java로 저장한다. JDK 17 표준 라이브러리만 사용한다. 파일이나 네트워크를 사용하지 않으며 입력 순서와 출력이 고정되어 있다. CASES의 마지막 숫자는 현재 구현에서 확인해 고정한 배송비다. 새 구현의 결과에 맞춰 이 숫자를 다시 계산하거나 수정하지 않는다.

Main.java

import java.util.List;

public class Main {
    private static int failures = 0;

    private record ShippingCase(
            String name,
            int subtotal,
            boolean island,
            boolean member,
            int expected) {
    }

    private static final List<ShippingCase> CASES = List.of(
            new ShippingCase("음수", -1, false, false, 3000),
            new ShippingCase("합계 없음", 0, false, false, 3000),
            new ShippingCase("일반 주문", 10000, false, false, 3000),
            new ShippingCase("회원 주문", 10000, false, true, 2000),
            new ShippingCase("도서 주문", 10000, true, false, 8000),
            new ShippingCase("도서 회원", 10000, true, true, 7000),
            new ShippingCase("경계 직전", 49999, true, true, 7000),
            new ShippingCase("무료 경계", 50000, false, false, 0),
            new ShippingCase("무료 도서 회원", 50000, true, true, 0),
            new ShippingCase("경계 직후", 50001, true, false, 0)
    );

    private static final class LegacyShipping {
        static int fee(int subtotal, boolean island, boolean member) {
            if (subtotal >= 50000) {
                return 0;
            }
            int result = 3000;
            if (island) {
                result = result + 5000;
            }
            if (member) {
                result = result - 1000;
            }
            return result;
        }
    }

    private static final class RefactoredShipping {
        private static final int FREE_SHIPPING_THRESHOLD = 50000;
        private static final int BASE_FEE = 3000;
        private static final int ISLAND_SURCHARGE = 5000;
        private static final int MEMBER_DISCOUNT = 1000;

        static int fee(int subtotal, boolean island, boolean member) {
            if (subtotal >= FREE_SHIPPING_THRESHOLD) {
                return 0;
            }
            return paidShippingFee(island, member);
        }

        private static int paidShippingFee(
                boolean island, boolean member) {
            int result = BASE_FEE;
            if (island) {
                result = result + ISLAND_SURCHARGE;
            }
            if (member) {
                result = result - MEMBER_DISCOUNT;
            }
            return result;
        }
    }

    private static void check(boolean condition, String name) {
        if (condition) {
            System.out.println("PASS " + name);
        } else {
            System.out.println("FAIL " + name);
            failures++;
        }
    }

    public static void main(String[] args) {
        boolean legacyMatches = true;
        boolean refactoredMatches = true;
        boolean implementationsMatch = true;

        System.out.println("사례 | 합계 | 도서 | 회원 | 기록 | 이전 | 이후");

        for (ShippingCase c : CASES) {
            int before = LegacyShipping.fee(
                    c.subtotal(), c.island(), c.member());
            int after = RefactoredShipping.fee(
                    c.subtotal(), c.island(), c.member());

            if (before != c.expected()) {
                legacyMatches = false;
                System.out.println("불일치: " + c.name() + " / 이전-기록");
            }
            if (after != c.expected()) {
                refactoredMatches = false;
                System.out.println("불일치: " + c.name() + " / 이후-기록");
            }
            if (before != after) {
                implementationsMatch = false;
                System.out.println("불일치: " + c.name() + " / 이전-이후");
            }

            System.out.println(
                    c.name() + " | " + c.subtotal()
                    + " | " + c.island() + " | " + c.member()
                    + " | " + c.expected()
                    + " | " + before + " | " + after);
        }

        check(legacyMatches, "기존 구현과 기록 일치");
        check(refactoredMatches, "새 구현과 기록 일치");
        check(implementationsMatch, "변경 전후 일치");

        System.out.println("검사 실패 수: " + failures);
        if (failures > 0) {
            throw new IllegalStateException("배송비 동작 보존 검사 실패");
        }
    }
}

줄별 해설

import 문은 표를 담을 List를 가져온다. ShippingCase는 사례 이름, 세 입력, 고정 기대값을 묶는 레코드(record)다. 각 행을 별도 객체로 표현하므로 여러 배열의 같은 인덱스를 맞추는 실수를 피할 수 있다.subtotal(), island(), member(), expected()는 각 구성 요소의 값을 반환한다.

CASES는 List.of로 만든 수정 불가능한 목록이다. 행의 순서는 출력 순서가 된다. expected에는 메서드 호출식이 아니라 관찰한 숫자를 넣었다. 기존 구현과 새 구현이 같은 결과를 내더라도 그 결과가 기록과 다르면 검사가 실패하도록 기준을 독립시킨 것이다. 목록의 수정 불가능성과 기대값의 독립성은 서로 다른 성질이며, 여기서는 둘 다 사용한다.

LegacyShipping.fee의 첫 조건은 상품 합계가 무료 배송 경계 이상인지 확인한다. 조건이 참이면 아래의 지역과 회원 분기로 내려가지 않는다. 이 조기 반환은 단순히 코드를 짧게 만드는 장치가 아니다. 도서 지역 추가 요금과 회원 할인보다 무료 배송 판단이 먼저라는 현재 동작을 표현한다.

그 아래의 result는 3,000원으로 시작한다. 도서 지역이면 5,000원을 더하고 회원이면 1,000원을 뺀다. 두 조건은 서로 독립적인 if 문이다. 이를 if와 else if로 바꾸면 도서 지역 회원에게 할인이 적용되지 않는다. 읽기 쉬운 모양으로 바꾸려다 조건의 관계를 바꾸는 대표적인 지점이다.

RefactoredShipping의 네 상수는 숫자의 역할을 이름으로 드러낸다. 값과 자료형은 기존 구현과 같다. fee는 기존과 같은 비교 연산자로 무료 배송을 먼저 판단한다. 유료 배송에 해당할 때만 paidShippingFee를 호출하므로, 추출한 메서드에는 상품 합계를 전달할 필요가 없다.

paidShippingFee는 기본 배송비에 추가 요금과 할인을 적용한다. 이 메서드는 음수 상품 합계를 검사하지 않는다. 음수 입력에 대한 새 정책이 이번 변경 범위에 없기 때문이다. 추출한 메서드의 이름을 보고 새로운 책임을 더하기보다, 옮겨 온 계산을 그대로 유지했는지 확인한다.

check는 조건과 검사 이름을 받는다. 참이면 PASS, 거짓이면 FAIL을 출력하고 실패 수를 늘린다. Java의 assert는 실행 옵션에 따라 비활성화될 수 있으므로 이 프로그램은 명시적인 조건 검사를 사용한다. 검사가 실패해도 나머지 비교를 계속 수행하고 마지막에 예외를 던진다.

main의 세 불리언 변수는 서로 다른 비교를 모은다. legacyMatches는 기존 구현과 기록, refactoredMatches는 새 구현과 기록, implementationsMatch는 변경 전후의 일치를 나타낸다. 한 사례라도 다르면 false가 되고 이후 사례가 맞아도 true로 돌아가지 않는다. 실패를 발견한 뒤 그 상태를 유지하는 방식이다.

반복문은 각 행에 같은 입력을 넣어 before와 after를 얻는다. 차이가 있으면 사례 이름과 비교 대상을 출력한다. 마지막 행만 보지 않아도 어느 입력에서 문제가 생겼는지 알 수 있다. 정상 실행에서는 불일치 메시지가 없고, 모든 입력과 세 결과가 차례로 출력된다.

마지막의 예외는 사람이 FAIL 문구를 놓쳐도 실행이 정상 종료로 처리되지 않게 한다. 이 프로그램은 선택한 표에 대한 보존 여부를 검사한다. 결제 모의 객체나 재고 변경은 호출하지 않으므로 주문 전체의 동작까지 검사했다고 해석해서는 안 된다. 이번 변경 대상인 배송비 계산에 근거를 집중한 것이다.

실행 결과

Main.java가 있는 디렉터리에서 다음 두 명령을 순서대로 실행한다. 첫 명령은 클래스를 out 디렉터리에 만들고, 둘째 명령은 비교 프로그램을 실행한다.

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

예상 출력은 다음과 같다. 기록, 이전, 이후의 값이 모든 행에서 같다. 컴파일 단계에서는 경고가 출력되지 않는다.

사례 | 합계 | 도서 | 회원 | 기록 | 이전 | 이후
음수 | -1 | false | false | 3000 | 3000 | 3000
합계 없음 | 0 | false | false | 3000 | 3000 | 3000
일반 주문 | 10000 | false | false | 3000 | 3000 | 3000
회원 주문 | 10000 | false | true | 2000 | 2000 | 2000
도서 주문 | 10000 | true | false | 8000 | 8000 | 8000
도서 회원 | 10000 | true | true | 7000 | 7000 | 7000
경계 직전 | 49999 | true | true | 7000 | 7000 | 7000
무료 경계 | 50000 | false | false | 0 | 0 | 0
무료 도서 회원 | 50000 | true | true | 0 | 0 | 0
경계 직후 | 50001 | true | false | 0 | 0 | 0
PASS 기존 구현과 기록 일치
PASS 새 구현과 기록 일치
PASS 변경 전후 일치
검사 실패 수: 0

이 결과를 근거로 “표의 열 개 사례에서 변경 전후 배송비가 같다”라고 말할 수 있다. 모든 입력을 실행했다고 말할 수는 없다. 전체 입력에 대한 판단에는 앞서 살펴본 분기와 계산 순서의 대응이 추가로 필요하다. 실행 결과와 코드 검토가 각각 어떤 근거를 제공하는지 구분해야 설명의 범위가 정확해진다.

검사가 정말 차이를 잡는지 확인하려면 별도 작업 사본에서 새 구현의 비교 연산자를 잠시 바꿔 볼 수 있다. 무료 경계 사례에서 불일치가 나와야 한다. 이 확인을 했다면 변경을 원래대로 되돌리고 다시 실행한다. 실패해야 할 변형에서도 계속 PASS가 나온다면 검사 입력이나 비교 대상부터 의심해야 한다.

실무에서 자주 틀리는 것

새 결과를 기대값으로 사용한다

다음은 실행은 되지만 동작 보존을 검사하지 못하는 코드다. actual을 그대로 expected에 넣으므로 어떤 결과가 나와도 두 값이 같다. 각 예시는 독립된 Java 클래스이며, 완성 코드에 추가할 필요는 없다.

class MovingExpectation {
    static boolean matches(int actual) {
        int expected = actual;
        return actual == expected;
    }
}

고친 코드는 기존 동작에서 확인한 값을 고정한다. 여기서는 무료 배송 미만의 도서 지역 회원 주문을 나타낸다. 해당 입력에서 새 결과가 7,000원이 아니라면 거짓을 반환한다.

class FixedExpectation {
    static boolean matches(int actual) {
        int expected = 7000;
        return actual == expected;
    }
}

AI가 바뀐 구현에 맞춰 표까지 수정하면 검사 기준도 함께 바뀐다. 따라서 구현 변경과 기대값 변경이 동시에 나타나면 이유를 확인한다. 정책 변경을 승인한 작업이 아니라면 기대값은 원래 관찰한 값으로 유지한다.

상식에 맞게 보이는 계산 순서로 바꾼다

다음 코드는 기본 배송비만 무료로 만들고 도서 지역 추가 요금은 나중에 붙인다. 자연스럽게 보일 수 있지만 기존 동작과 다르다. 상품 합계 50,000원, 도서 지역, 비회원 입력에서 5,000원을 반환한다.

class ChangedOrder {
    static int fee(int subtotal, boolean island, boolean member) {
        int result = subtotal >= 50000 ? 0 : 3000;
        if (island) {
            result += 5000;
        }
        if (member && result > 0) {
            result -= 1000;
        }
        return result;
    }
}

고친 코드는 조기 반환을 유지한다. 같은 입력에서 기존 구현과 같은 0원이 나온다. 완성 코드의 경계 직후 사례도 이 차이를 발견한다.

class PreservedOrder {
    static int fee(int subtotal, boolean island, boolean member) {
        if (subtotal >= 50000) {
            return 0;
        }
        int result = 3000;
        if (island) {
            result += 5000;
        }
        if (member) {
            result -= 1000;
        }
        return result;
    }
}

정책을 바꾸고 싶다는 의견은 기록할 수 있다. 다만 이번 구조 변경의 근거로 사용하지 않는다. 현재 표를 유지한 변경이 끝난 뒤, 새 정책의 기대값을 명시하는 별도 작업으로 다룬다.

검사 결과를 다음 사례로 덮어쓴다

다음 코드는 마지막 사례의 비교 결과만 반환한다. 앞에서 차이가 나더라도 뒤의 사례가 같으면 성공처럼 보인다.

class OverwrittenResult {
    static boolean allMatch(int[] expected, int[] actual) {
        if (expected.length != actual.length) {
            return false;
        }
        boolean matches = true;
        for (int i = 0; i < expected.length; i++) {
            matches = expected[i] == actual[i];
        }
        return matches;
    }
}

고친 코드는 불일치를 발견하면 false를 유지한다. 완성 코드도 이 원리로 세 비교의 실패 상태를 누적한다.

class AccumulatedResult {
    static boolean allMatch(int[] expected, int[] actual) {
        if (expected.length != actual.length) {
            return false;
        }
        boolean matches = true;
        for (int i = 0; i < expected.length; i++) {
            if (expected[i] != actual[i]) {
                matches = false;
            }
        }
        return matches;
    }
}

비교 함수도 검증 대상이다. 첫 사례만 다르고 마지막 사례는 같은 배열을 넣어 보면 이 실수를 드러낼 수 있다. 배송비 계산이 맞더라도 결과를 모으는 코드가 실패를 감추면 특성 테스트가 제 역할을 하지 못한다.

한눈에 보기

레거시 배송비 계산을 바꿀 때 유지할 기준
질문실행할 일해석의 한계
현재 무엇을 하는가선택한 입력을 기존 구현에 넣고 결과를 기록한다현재 결과가 올바른 정책이라는 뜻은 아니다
무엇을 사례로 고르는가경계와 조건 조합, 수상한 입력을 포함한다표에 없는 입력도 고려해야 한다
기준이 움직이지 않는가관찰한 기대값을 리터럴로 고정한다기대값 변경에는 별도 이유가 필요하다
AI가 얼마나 바꾸는가상수 교체와 메서드 추출을 나눈다최종 성공이 중간 실행을 증명하지는 않는다
보존 근거가 있는가표 비교와 분기·계산 순서 확인을 함께 한다배송비 검사가 주문 전체 검사를 대신하지는 않는다

오래된 구현을 비교용으로 계속 제품 코드에 남길 필요는 없다. 이번 예제는 학습을 위해 나란히 두었지만, 실제 병합에서는 저장소 이력에 이전 구현을 남기고 새 구현과 고정 기대값의 비교를 유지할 수 있다. 중요한 것은 비교용 코드를 얼마나 오래 두느냐보다 현재 동작을 기록한 근거가 변경 과정에서 사라지지 않는 것이다.

연습 문제

  1. 새 구현의 무료 배송 조건을 경계보다 클 때만 참이 되도록 바꾸었다고 가정한다. 완성 코드의 어떤 사례가 실패하며, 새 배송비는 각각 얼마가 되는지 설명하라.
  2. 상품 합계 49,999원, 도서 지역, 비회원 사례를 표에 추가하라. 기대값과 추가 이유를 설명하고 완성 프로그램을 실행하라.
  3. AI가 음수 상품 합계에서 예외를 던지도록 바꾸자고 제안했다. 이번 작업에서 받아들일지 판단하고, AI에게 보낼 후속 요청을 두 문장으로 작성하라.
  4. 새 구현이 무료 배송 미만에서 회원 할인을 빠뜨렸다. 기존 구현과 새 구현만 비교하는 검사와, 고정 기대값까지 비교하는 검사의 차이를 설명하라. 두 구현에서 동시에 회원 할인을 지운 경우도 고려하라.

정답과 해설

  1. 상품 합계가 정확히 50,000원인 무료 경계와 무료 도서 회원 사례가 실패한다. 새 구현은 무료 경계에서 3,000원, 무료 도서 회원에서 7,000원을 반환한다. 50,001원인 경계 직후 사례는 여전히 무료 배송이다. 경계 직전과 직후만 검사하면 비교 연산자 하나가 바뀐 문제를 놓칠 수 있다.
  2. 기대값은 8,000원이다. 무료 배송 미만이므로 기본 3,000원에 도서 지역 요금 5,000원을 더하고, 비회원이므로 할인하지 않는다. 이 사례는 무료 배송 경계 바로 아래에서 도서 지역 요금이 유지되는지 확인한다. 기존 도서 주문과 계산 경로는 같지만, 경계 근처의 비회원 동작을 명시적으로 남기는 가치가 있다. 정상 실행에서는 행이 하나 늘고 세 PASS와 실패 수 0은 유지된다.
  3. 이번 변경에는 받아들이지 않는다. 후속 요청의 예는 “음수 입력에서 기존 구현이 반환한 3,000원을 이번 리팩터링에서도 유지하라. 음수 검증 추가는 호출부 계약과 기대 동작을 확인하는 별도 변경으로 제안하라.”다. 제안을 버리는 것이 아니라 동작 보존 작업과 정책 변경 작업을 분리하는 판단이다.
  4. 새 구현만 할인을 빠뜨리면 변경 전후 비교로도 회원 주문, 도서 회원, 경계 직전의 차이를 찾을 수 있다. 두 구현에서 동시에 할인을 지우면 서로 같은 값을 반환하므로 변경 전후 비교는 통과한다. 그러나 고정 기대값과의 비교는 두 구현 모두에서 실패한다. 관찰한 값을 별도로 보관해야 비교 기준까지 함께 바뀌는 상황을 발견할 수 있다.

현재 동작을 모르는 코드에서는 설명보다 먼저 관찰할 수 있는 기준이 필요하다. 이 장의 표는 오래된 배송비 계산에 그 기준을 마련한다. 작은 변경마다 같은 입력을 실행하고 조건의 대응을 읽으면, 팀은 AI가 낸 구조 변경을 구체적인 근거로 검토할 수 있다. 다음 장에서는 기대한 동작과 실제 현상이 어긋난 상황에서 로그와 가설을 좁혀 최소 재현을 만드는 방법을 다룬다.

댓글 0

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

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