Devin.KR

diff 읽는 순서 - 목적에서 경계 조건까지

개발자KR 조회 5

이 장에서 배우는 것

앞 장에서 리뷰가 결함을 찾고, 지식을 나누고, 팀의 기준을 맞추는 활동임을 살펴봤다. 이제 실제 변경 내용을 어떤 순서로 읽을지 정한다. 같은 코드를 읽더라도 출발점에 따라 눈에 들어오는 정보가 달라진다. 목적을 모른 채 반복문부터 읽으면 문법은 이해해도 변경이 필요한 이유를 놓치기 쉽다.

이 장에서는 도서관 대출 서비스의 변경을 검토하는 상황을 따라간다. 변경 비교 화면(diff)에 나타난 파일 순서를 그대로 따르지 않고, 변경 요청(PR)의 설명에서 출발해 테스트, 공개 인터페이스, 핵심 로직, 경계 조건으로 이동한다. 마지막에는 이 순서를 변경 파일 목록에 적용하는 작은 Java 프로그램을 만든다.

  • PR 설명에서 변경 목적과 범위를 찾아 읽기 기준을 세운다.
  • 테스트와 공개 인터페이스를 통해 기대 동작과 호출자의 영향을 파악한다.
  • 핵심 로직과 경계 조건을 서로 다른 질문으로 나누어 살핀다.
  • 큰 변경에서 먼저 읽을 부분과 나중에 확인할 부분을 구분한다.
  • 변경 파일을 읽기 순서로 정렬하고 파일별 우선 확인 항목을 출력한다.

문제 상황

도서관 대출 서비스에 예약 대기자가 있는 책의 연장을 제한하는 변경이 올라왔다. PR 제목은 “예약 도서 연장 제한”이다. 설명에는 “예약 대기자가 있으면 연장을 거절하고, 예약 대기자가 없으면 기존 연장 동작을 유지한다”라고 적혀 있다. 변경 파일에는 대출 서비스, 연장 정책, 테스트, 경계 사례 자료가 섞여 있다.

리뷰어는 화면 맨 위에 있는 정책 파일부터 읽는다. 조건문 하나가 추가된 것은 보이지만, 거절 결과를 호출자가 어떻게 받는지는 알 수 없다. 다음 파일에서 결과를 표현하는 메서드를 발견하고 앞 파일로 돌아간다. 테스트를 읽은 뒤에는 예약이 없는 경우를 확인하려고 다시 정책 파일을 연다. 파일을 여러 번 여는 것 자체는 문제가 아니다. 다만 매번 무엇을 확인하려고 돌아갔는지 설명하지 못한다면 읽기의 기준이 부족한 상태다.

“먼저 설명에서 유지해야 할 동작을 확인하겠다. 다음으로 테스트에서 성공과 거절의 예를 보고, 호출자가 받는 결과를 확인한 뒤 정책 조건을 읽겠다.”

이 의견은 코드를 고치라는 요구가 아니다. 리뷰어가 검토 경로를 정하는 말이다. 경로가 있으면 읽다가 발견한 의문을 어느 단계에서 해결할지도 정할 수 있다. 반환 결과에 대한 의문은 공개 인터페이스에서, 조건 판단에 대한 의문은 핵심 로직에서 다룬다.

팀은 이런 경로를 공유하기 위해 변경 파일 목록을 정렬하는 도구를 만든다. 기존 도구는 단계별 숫자를 비교한다. 수정안은 단계의 선언 순서를 비교한다. 이번에 리뷰할 변경의 목적은 정렬 규칙을 한곳에 모으는 것이다. 같은 입력에 대한 출력과 입력 목록의 순서는 그대로 유지해야 한다. 도서관 서비스 자체의 구현은 파일명과 확인 항목으로 표현하고, 실행 프로그램은 읽기 계획을 만드는 일에 집중한다.

목적을 먼저 읽고 기대 동작을 찾는다

PR 설명은 읽기 기준을 만드는 출발점이다

설명에서 먼저 찾을 것은 변경 이유, 바뀌는 동작, 유지해야 할 동작이다. “연장 정책 수정”만으로는 읽기 기준을 만들기 어렵다. 예약 대기자가 있을 때 무엇이 달라지고, 없을 때 무엇이 유지되는지가 있어야 코드와 대조할 수 있다. 설명의 문장을 사실로 확정하지는 않는다. 설명은 뒤에서 테스트와 구현으로 확인할 주장이다.

리뷰를 시작할 때는 목적을 짧게 다시 써 본다. 이 사례라면 “예약 대기자가 있는 책의 연장을 거절한다”가 바뀌는 동작이고, “대기자가 없을 때 기존 연장 결과를 유지한다”가 보존할 동작이다. 정렬 도구의 수정이라면 “단계 순서의 중복을 줄이되 출력은 유지한다”가 읽기 기준이 된다. 이 한 문장이 있어야 불필요한 변경이 섞였는지도 판단할 수 있다.

테스트에서는 입력과 관찰 가능한 결과를 읽는다

다음으로 테스트를 읽는다. 테스트 이름만 훑지 말고 준비한 입력, 실행한 메서드, 비교한 결과를 연결한다. 예약 대기자가 있는 사례만 있다면 새 제한은 보이지만 기존 성공 동작이 유지되는지는 드러나지 않는다. 이 단계에서는 테스트 작성 기법을 넓게 논하기보다 설명에 적힌 동작이 어떤 예로 표현됐는지 파악한다.

테스트를 먼저 읽는 이유는 구현의 의도를 추측하는 시간을 줄이기 위해서다. 다만 테스트가 있다고 요구사항이 모두 확인된 것은 아니다. 설명에는 거절 결과가 필요한데 테스트가 반환값을 확인하지 않는다면 기대 동작이 아직 충분히 표현되지 않은 것이다. 그 의문을 기록한 뒤 공개 인터페이스에서 결과 표현을 확인한다.

읽는 단계마다 답할 질문이 달라진다
순서읽을 대상먼저 답할 질문
1PR 설명왜 바꾸며 무엇을 유지하는가
2테스트어떤 입력과 결과가 변경을 설명하는가
3공개 인터페이스호출자는 무엇을 주고 무엇을 받는가
4핵심 로직기대 결과에 이르는 판단 흐름은 무엇인가
5경계 조건판단이 갈리는 지점에서 빠진 경우는 없는가
설명에서 목적을 잡고 테스트와 공개 인터페이스를 거쳐 로직과 경계 조건을 읽는다

호출자의 시선에서 로직으로 들어간다

공개 인터페이스는 변경의 바깥쪽 경계다

공개 인터페이스는 다른 코드가 이용하는 진입점과 결과 형식이다. Java의 interface 선언만을 뜻하지 않는다. 외부에서 호출하는 public 메서드의 이름, 매개변수, 반환형도 포함한다. 대출 연장 메서드가 성공 여부만 돌려주는지, 거절 이유도 돌려주는지에 따라 호출자가 처리할 일이 달라진다.

여기서는 호출부까지 따라가며 변경의 영향을 읽는다. 메서드 이름은 그대로인데 반환값의 뜻이 바뀌었다면 호출자는 이전 해석을 계속 사용할 수 있다. 반대로 내부 계산만 바뀌고 입력과 결과의 약속이 유지된다면 그 사실을 읽기 기준에 추가한다. 공개 인터페이스를 먼저 확인하면 내부의 작은 변수명 변경에 집중하기 전에 바깥으로 드러나는 영향을 파악할 수 있다.

핵심 로직에서는 하나의 대표 경로를 따라간다

핵심 로직을 읽을 때는 테스트에서 본 대표 입력 하나를 선택한다. 예약 대기자가 있는 책을 연장하려는 요청이라면 입력이 들어온 뒤 어떤 조건을 거쳐 거절 결과가 만들어지는지 따라간다. 그다음 대기자가 없는 입력을 따라가며 기존 경로가 유지되는지 확인한다. 처음부터 모든 갈래를 동시에 머릿속에 올릴 필요는 없다.

이번 정렬 도구에서는 “섞여 들어온 파일 목록이 단계 순서로 나오는가”를 먼저 읽는다. 목록 복사, 비교 기준, 결과 반환이라는 흐름을 잡은 뒤 같은 단계의 파일끼리 어떤 순서로 놓이는지 확인한다. 코드 한 줄을 이해하는 일과 전체 경로를 이해하는 일을 구분하면 읽다가 길을 잃었을 때 돌아갈 지점도 분명해진다.

경계 조건은 마지막에 별도의 질문으로 확인한다

대표 경로가 보이면 판단이 바뀌는 지점을 살핀다. 도서관 사례에서는 예약 대기자가 없는 경우와 처음 생기는 경우가 서로 다른 경로로 갈린다. 정렬 도구에서는 입력이 비어 있거나 같은 단계의 파일이 여러 개일 때도 규칙이 유지되는지 확인한다. 구체적인 경계값을 고르는 방법은 다음 장에서 다룬다. 여기서는 경계 조건을 위한 읽기 시간을 따로 확보하는 데 집중한다.

이 순서는 파일을 한 번만 열라는 규칙이 아니다. 테스트를 처음 읽을 때는 기대 동작을 파악하고, 마지막에는 그 테스트가 판단의 경계를 보여 주는지 다시 읽을 수 있다. 파일의 첫 방문 순서와 모든 검토를 끝내는 순서는 다르다. 실행 예제도 파일의 첫 방문 순서를 정렬한 뒤, 마지막에 경계 조건 확인 문장을 별도로 출력한다.

큰 변경은 질문과 묶음으로 나누어 읽는다

변경량이 많으면 한 번에 모든 내용을 이해하려는 시도를 멈추고 질문별로 읽는다. 첫 읽기에서는 목적과 변경 지도를 만든다. 다음 읽기에서는 대표 경로를 확인한다. 마지막 읽기에서는 남겨 둔 경계 조건과 의문을 점검한다. 각 읽기가 끝났을 때 답한 질문과 아직 답하지 못한 질문이 구분되어야 한다.

파일 목록도 역할별로 묶는다. 설명, 테스트, 공개 인터페이스, 핵심 로직, 경계 자료라는 분류는 탐색을 돕는 출발점이다. 파일 이름만 보고 역할을 확정하지는 않는다. Service라는 이름의 파일에도 공개 메서드와 내부 판단이 함께 있을 수 있다. 이런 파일은 공개 인터페이스 단계에서 먼저 방문하고, 핵심 로직 단계에서 다시 읽는다고 메모한다.

큰 PR에서는 연장 제한과 관련된 파일을 먼저 연결한다. 별개의 이름 정리나 화면 문구 수정이 섞여 있다면 어떤 목적에 속하는지 표시한다. 독립적으로 설명하고 검증할 수 있는 변경은 분리를 제안할 수 있다. 다만 파일 수만으로 분리 여부를 정하지 않는다. 여러 파일이 하나의 동작을 함께 완성한다면 목적을 기준으로 묶어 읽는 편이 도움이 된다.

“연장 제한 경로를 먼저 확인했다. 대출 이력 표시 변경은 별도 목적처럼 보인다. 두 변경의 연결 이유를 설명에 추가하거나, 각각 검증할 수 있다면 PR을 나누면 읽기 범위가 분명해지겠다.”

시간이 부족하더라도 뒤쪽 파일을 조용히 생략하지 않는다. 어디까지 읽었는지, 어떤 질문이 남았는지 기록한다. “정책 확인 완료”보다 “예약 있음·없음의 대표 경로를 확인했고, 경계 사례 자료는 남아 있다”가 다음 행동을 정하기 쉽다. 정렬된 목록은 검토 완료 증거가 아니라 남은 작업을 드러내는 지도다.

큰 변경은 목적 지도와 대표 경로와 남은 경계 질문으로 나누어 검토한다

완성 코드

다음 프로그램은 정렬 도구의 수정 전후를 함께 담는다. before는 단계마다 숫자를 지정한 기존 구현이고, after는 Stage의 선언 순서를 사용하는 수정 구현이다. 두 메서드는 같은 입력을 받아 같은 읽기 계획을 만든다. 각 파일의 단계와 확인 항목은 사람이 지정한다. 프로그램이 파일 내용을 분석하거나 리뷰 판단을 대신하지는 않는다.

경계 자료인 loan-limit-cases.csv는 사례 입력을 모은 파일이라는 설정이다. 테스트 소스인 LoanServiceTest.java는 테스트 단계에서 먼저 읽는다. 경계 조건 단계에서는 사례 자료를 자세히 확인하고 앞서 읽은 테스트도 다시 대조한다. 같은 단계 안에서는 경로순으로 정렬해 입력 목록의 순서가 달라도 결과가 일정하게 나오도록 한다.

Main.java

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

public class Main {
    enum Stage {
        DESCRIPTION("PR 설명"),
        TEST("테스트"),
        PUBLIC_API("공개 인터페이스"),
        LOGIC("핵심 로직"),
        BOUNDARY("경계 조건");

        private final String label;

        Stage(String label) {
            this.label = label;
        }

        String label() {
            return label;
        }
    }

    record Change(String path, Stage stage, String focus) {
    }

    static int oldRank(Stage stage) {
        return switch (stage) {
            case DESCRIPTION -> 0;
            case TEST -> 1;
            case PUBLIC_API -> 2;
            case LOGIC -> 3;
            case BOUNDARY -> 4;
        };
    }

    static List<Change> before(List<Change> changes) {
        List<Change> result = new ArrayList<>(changes);
        result.sort(
                Comparator.comparingInt(
                        (Change change) -> oldRank(change.stage()))
                        .thenComparing(Change::path));
        return result;
    }

    static List<Change> after(List<Change> changes) {
        List<Change> result = new ArrayList<>(changes);
        result.sort(
                Comparator.comparing(Change::stage)
                        .thenComparing(Change::path));
        return result;
    }

    static List<String> render(List<Change> changes) {
        List<String> lines = new ArrayList<>();
        for (int i = 0; i < changes.size(); i++) {
            Change change = changes.get(i);
            lines.add((i + 1) + ". [" + change.stage().label()
                    + "] " + change.path()
                    + " | 優先確認: " + change.focus());
        }
        return lines;
    }

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

    public static void main(String[] args) {
        List<Change> changes = List.of(
                new Change("LoanPolicy.java", Stage.LOGIC,
                        "예약 유무에 따른 연장 판단"),
                new Change("loan-limit-cases.csv", Stage.BOUNDARY,
                        "예약 없음과 있음의 경계 사례"),
                new Change("LoanService.java", Stage.PUBLIC_API,
                        "연장 요청과 반환 결과의 약속"),
                new Change("ReservationPolicy.java", Stage.LOGIC,
                        "예약 대기 여부의 전달 경로"),
                new Change("PR.md", Stage.DESCRIPTION,
                        "연장 제한의 목적과 유지할 동작"),
                new Change("LoanServiceTest.java", Stage.TEST,
                        "연장 성공과 거절의 입력 및 결과"));

        List<Change> snapshot = new ArrayList<>(changes);
        List<Change> oldPlan = before(changes);
        List<Change> newPlan = after(changes);

        check(render(oldPlan).equals(render(newPlan)),
                "수정 전후 출력 동일");
        check(changes.equals(snapshot), "입력 목록 유지");

        List<String> expectedPaths = List.of(
                "PR.md",
                "LoanServiceTest.java",
                "LoanService.java",
                "LoanPolicy.java",
                "ReservationPolicy.java",
                "loan-limit-cases.csv");
        List<String> actualPaths = newPlan.stream()
                .map(Change::path)
                .toList();
        check(actualPaths.equals(expectedPaths), "읽기 순서 일치");
        check(after(List.of()).isEmpty(), "빈 변경 목록");

        List<Change> reversed = new ArrayList<>(changes);
        java.util.Collections.reverse(reversed);
        check(render(after(reversed)).equals(render(newPlan)),
                "입력 순서와 무관한 정렬");

        System.out.println("읽기 계획");
        for (String line : render(newPlan)) {
            System.out.println(line);
        }
        System.out.println(
                "마지막 확인: 경계 사례를 앞서 읽은 테스트와 다시 대조한다.");
    }
}

출력 표식의 “優先確認”은 파일별로 먼저 볼 내용을 구분하는 고정 문자열이다. 정렬 결과와 함께 이 표식 뒤의 문장을 읽으면 파일을 여는 목적을 확인할 수 있다. 실제 팀 도구에서는 팀이 사용하는 표현으로 통일하면 된다.

줄별 해설

import 세 줄은 목록 복사와 비교 기준 구성에 필요한 표준 라이브러리를 가져온다. 파일을 읽거나 쓰지 않으므로 예제 파일을 별도로 준비할 필요는 없다. 목록에 등장하는 경로는 리뷰 대상 파일을 표현하는 문자열이다.

Stage 선언의 다섯 상수는 읽기 순서를 나타낸다. 열거형 값의 자연 순서는 선언 순서이므로 DESCRIPTION부터 BOUNDARY까지의 배치가 수정 구현의 정렬 규칙이 된다. 화면에 표시할 한국어 이름은 label 필드에 따로 둔다. 표시 문구를 바꿔도 비교 기준은 달라지지 않는다.

Change 레코드는 경로, 첫 방문 단계, 우선 확인 항목을 묶는다. 이 프로그램은 각 경로가 한 번씩 들어오고 세 값이 모두 제공된 목록을 입력 계약으로 삼는다. 같은 파일을 여러 관점에서 다시 읽어야 한다면 확인 항목이나 마지막 점검 메모로 표현한다. 하나의 분류가 파일에 담긴 모든 책임을 설명한다고 해석하지 않는다.

oldRank는 수정 전의 규칙이다. 단계 선언과 숫자 대응이 따로 존재한다. before는 입력을 ArrayList로 복사한 다음 숫자와 경로를 차례로 비교한다. 입력을 복사하므로 호출자가 전달한 목록의 순서는 바뀌지 않는다. 읽기 계획을 얻기 위해 원래 변경 목록을 잃을 필요가 없기 때문이다.

after도 같은 방식으로 복사하지만 비교 기준에서 oldRank를 사용하지 않는다. comparing(Change::stage)가 단계 선언 순서로 비교하고, thenComparing(Change::path)가 같은 단계 안에서 경로를 비교한다. 이번 변경에서는 단계 선언이 읽기 순서라는 약속을 코드에 드러내고, 별도의 숫자 대응을 제거한다. 따라서 이후 단계의 선언 위치를 바꾸면 출력 순서도 바뀐다는 점을 리뷰에서 확인해야 한다.

render는 정렬 결과를 출력할 문자열 목록으로 바꾼다. 번호는 정렬된 위치에 붙인다. 수정 전후의 목록 크기만 비교하면 파일이 뒤바뀐 차이를 놓칠 수 있으므로, check에서는 render 결과 전체를 비교한다. 이 입력에 대해 출력이 같음을 실행으로 확인하는 것이다. 한 사례의 성공만으로 가능한 모든 입력의 동등성이 증명되는 것은 아니다.

check는 조건이 참이면 PASS를 출력한다. 거짓이면 FAIL을 출력한 뒤 AssertionError로 실행을 끝낸다. 실패한 검사 뒤에 정상 계획처럼 보이는 출력을 이어 가지 않도록 한 것이다. 별도의 테스트 도구 없이도 어떤 확인이 실패했는지 실행 결과에서 알 수 있다.

main의 입력 목록은 의도적으로 읽기 순서와 다르게 배치했다. 첫 검사는 수정 전후의 출력이 같은지 확인한다. 다음 검사는 입력 목록이 유지되는지 확인한다. expectedPaths는 요구한 순서를 직접 적어 수정 전 구현에도 순서 오류가 있을 가능성을 따로 점검한다. 두 구현이 같다는 사실과 요구사항에 맞다는 사실은 서로 다른 확인이다.

마지막 두 검사는 빈 목록과 입력 순서가 뒤집힌 목록을 다룬다. 뒤집힌 목록에는 같은 핵심 로직 단계의 두 파일도 포함되므로 경로 비교가 결과를 일정하게 만드는지 함께 확인한다. 검사가 끝나면 정렬된 파일과 우선 확인 항목을 출력한다. 마지막 문장은 경계 조건 점검 때 테스트로 돌아가야 함을 알려 준다.

실행 결과

Main.java를 저장한 디렉터리에서 다음 명령을 실행한다. JDK 17에서 외부 라이브러리 없이 컴파일되며, 일반 컴파일과 모든 컴파일 경고를 활성화한 확인 모두 경고 없이 통과하는 코드다. 출력 디렉터리 외에 프로그램이 생성하는 파일은 없다.

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

예상 출력은 다음과 같다. 시각이나 난수를 사용하지 않으므로 실행할 때마다 같다.

PASS: 수정 전후 출력 동일
PASS: 입력 목록 유지
PASS: 읽기 순서 일치
PASS: 빈 변경 목록
PASS: 입력 순서와 무관한 정렬
읽기 계획
1. [PR 설명] PR.md | 優先確認: 연장 제한의 목적과 유지할 동작
2. [테스트] LoanServiceTest.java | 優先確認: 연장 성공과 거절의 입력 및 결과
3. [공개 인터페이스] LoanService.java | 優先確認: 연장 요청과 반환 결과의 약속
4. [핵심 로직] LoanPolicy.java | 優先確認: 예약 유무에 따른 연장 판단
5. [핵심 로직] ReservationPolicy.java | 優先確認: 예약 대기 여부의 전달 경로
6. [경계 조건] loan-limit-cases.csv | 優先確認: 예약 없음과 있음의 경계 사례
마지막 확인: 경계 사례를 앞서 읽은 테스트와 다시 대조한다.

첫 PASS는 같은 입력에 대해 수정 전후의 출력 문자열 목록이 같다는 검사 결과다. 세 번째 PASS는 그 공통 결과가 의도한 경로 순서와도 일치한다는 뜻이다. 실제 리뷰에서는 이 출력과 함께 비교 기준의 변경을 읽는다. 실행 결과는 읽을 근거를 보태며, 변경 목적을 대신 설명하지는 않는다.

실무에서 자주 틀리는 것

파일명 정렬을 읽기 순서로 사용한다

다음 틀린 코드는 파일명의 문자 순서가 검토 순서라고 가정한다. 각 예시는 별도 파일로 저장해 실행할 수 있는 독립 프로그램이다.

class WrongOrder {
    public static void main(String[] args) {
        var paths = new java.util.ArrayList<>(
                java.util.List.of("PR.md", "LoanPolicy.java"));
        paths.sort(String::compareTo);
        System.out.println(paths);
    }
}

출력은 [LoanPolicy.java, PR.md]다. 읽기 목적을 반영하려면 역할을 비교 기준으로 삼아야 한다. 다음 수정 코드는 역할을 직접 부여한다.

class FixedOrder {
    record File(String path, int order) {}

    public static void main(String[] args) {
        var files = new java.util.ArrayList<>(java.util.List.of(
                new File("PR.md", 0),
                new File("LoanPolicy.java", 3)));
        files.sort(java.util.Comparator.comparingInt(File::order));
        System.out.println(files.stream().map(File::path).toList());
    }
}

수정 후 출력은 [PR.md, LoanPolicy.java]다. 이 사례는 순서 오류를 고치는 변경이므로 출력 차이가 의도된 결과다. 완성 코드의 수정 전후 비교처럼 동작을 보존하는 변경과 구분해 읽어야 한다.

파일을 열었다는 사실을 검토 완료로 기록한다

다음 코드는 파일을 열었다는 사실만으로 완료 문구를 출력한다. 파일을 방문한 것과 필요한 질문에 답한 것은 다르다.

class WrongProgress {
    public static void main(String[] args) {
        boolean opened = true;
        System.out.println(opened ? "검토 완료" : "검토 전");
    }
}

수정 코드에서는 남은 질문이 있으면 그 내용을 출력한다. 실제로 무엇이 남았는지를 적어야 다음 읽기를 이어 갈 수 있다.

class FixedProgress {
    public static void main(String[] args) {
        boolean opened = true;
        var pending = java.util.List.of("예약 경계 사례 대조");
        if (!pending.isEmpty()) {
            System.out.println("남은 확인: " + String.join(", ", pending));
        } else {
            System.out.println(opened ? "검토 완료" : "검토 전");
        }
    }
}

수정 전 출력은 “검토 완료”이고, 수정 후 출력은 “남은 확인: 예약 경계 사례 대조”다. 여기서는 완료 판단 기준을 바꾸므로 출력도 바뀐다. 남은 질문 목록이 실제 검토 상태와 맞는지는 사람이 관리해야 한다.

수정 전후의 목록 크기만 비교한다

동작 보존을 확인할 때 항목 수만 같으면 충분하다고 생각하기 쉽다. 다음 코드는 순서가 바뀌었는데도 true를 출력한다.

class WrongComparison {
    public static void main(String[] args) {
        var before = java.util.List.of("PR.md", "LoanPolicy.java");
        var after = java.util.List.of("LoanPolicy.java", "PR.md");
        System.out.println(before.size() == after.size());
    }
}

정렬 결과가 계약이라면 위치별 내용까지 비교해야 한다. 목록의 equals는 순서를 포함해 원소를 비교한다.

class FixedComparison {
    public static void main(String[] args) {
        var before = java.util.List.of("PR.md", "LoanPolicy.java");
        var after = java.util.List.of("LoanPolicy.java", "PR.md");
        System.out.println(before.equals(after));
    }
}

같은 비교 입력에 대해 수정 전은 true, 수정 후는 false를 출력한다. 잘못된 동등성 판단을 고쳤으므로 차이가 있어야 한다. 완성 프로그램에서는 출력할 문자열 목록 전체를 비교해 단계 이름과 확인 항목의 차이까지 확인한다.

한눈에 보기

읽기 순서를 유지하기 위한 행동과 남길 기록
상황할 행동남길 기록
리뷰 시작목적과 보존할 동작을 읽는다이번 변경이 답해야 할 질문
테스트 확인입력과 기대 결과를 연결한다설명과 연결된 대표 사례
공개 메서드 확인호출자의 입력과 결과 해석을 읽는다호출부에 미치는 영향
핵심 로직 확인대표 입력의 실행 경로를 따라간다판단 흐름과 남은 의문
마지막 점검경계 사례와 테스트를 다시 대조한다확인한 경계와 미확인 항목
큰 변경목적별 묶음과 읽기 차례를 만든다검토 범위와 남은 질문

순서는 집중할 질문을 정하는 장치다. 읽다가 필요한 파일로 이동해도 된다. 이동한 이유를 알고 원래 질문으로 돌아올 수 있으면 된다. 반대로 정렬된 목록을 끝까지 열었어도 목적과 결과의 연결을 설명하지 못한다면 읽기를 더 이어 가야 한다.

연습 문제

  1. PR 설명에는 “예약 대기자가 없으면 기존 연장 동작을 유지한다”라고 적혀 있다. 테스트에는 예약이 있을 때 거절하는 사례만 있다. 다음에 무엇을 확인할지 두 문장으로 적는다.
  2. LoanService.java에 공개 메서드와 핵심 판단 로직이 함께 있다. 완성 프로그램의 목록에 같은 경로를 두 번 추가하지 않고, 이 파일을 읽는 계획을 설명한다.
  3. after에서 thenComparing(Change::path)를 제거한다면 어떤 검사가 실패하는지 예상한다. 같은 단계의 두 파일과 뒤집힌 입력을 근거로 설명한다.
  4. 큰 PR에 연장 정책 변경과 대출 이력 표시 변경이 함께 들어 있다. 즉시 분리를 요구하기 전에 확인할 사항과, 검토 진행 상황에 남길 내용을 적는다.

정답과 해설

  1. 예약이 없는 성공 사례가 테스트에 표현되어 있는지 다른 관련 테스트까지 확인한다. 공개 인터페이스에서 기존 성공 결과의 약속을 읽고 핵심 로직에서 그 경로가 유지되는지 따라간다. 테스트 하나가 빠졌다는 관찰과 구현이 잘못됐다는 결론은 구분한다.
  2. 첫 방문 단계는 공개 인터페이스로 유지한다. 먼저 매개변수와 반환 결과를 읽고, 핵심 로직 차례에 같은 파일로 돌아와 대표 입력의 판단 경로를 읽는다고 확인 항목에 기록한다. 경계 조건을 확인할 때 필요한 부분을 다시 방문할 수도 있다.
  3. “입력 순서와 무관한 정렬” 검사가 실패한다. 원래 입력에서는 LoanPolicy.java가 ReservationPolicy.java보다 앞서지만 뒤집힌 입력에서는 반대다. 단계만 비교하면 두 파일이 같은 값으로 취급되고, List.sort는 비교 결과가 같은 원소의 기존 상대 순서를 유지한다. 경로 비교를 제거한 새 규칙이 의도된 것인지 판단한 뒤 구현과 검사를 함께 맞춰야 한다.
  4. 두 변경이 하나의 동작을 완성하는 데 필요한지, 각각 독립적으로 설명하고 검증할 수 있는지 확인한다. 기록에는 연장 정책의 대표 경로를 어디까지 읽었는지와 이력 표시 변경 및 경계 사례 중 무엇이 남았는지를 적는다. 분리 여부는 파일 수보다 목적과 검증 단위에 근거해 제안한다.

댓글 0

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

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