Devin.KR

정확성 리뷰 - 경계값·null·오프바이원

개발자KR 조회 4

이 장에서 배우는 것

앞 장에서 변경의 목적을 먼저 확인하고, 그 목적이 코드의 어느 부분에 반영됐는지 따라 읽었다. 이번에는 그 흐름을 경계 입력까지 밀고 간다. 대출 목록에 책이 한 권만 있으면 어떻게 되는지, 반납 예정일 바로 다음 날에는 얼마를 부과하는지, 입력 자체가 없으면 어떤 결과를 내야 하는지 확인한다. 일반적인 입력 몇 개에서 결과가 맞았다는 사실만으로는 이런 질문에 답할 수 없다.

정확성 리뷰는 코드가 정해진 규칙대로 결과를 만드는지 확인하는 작업이다. 여기서 중요한 것은 복잡한 계산식을 빨리 읽는 능력보다, 결과가 바뀌는 지점을 찾아 작은 입력으로 실행하는 습관이다. 이 장에서는 도서관 연체료 계산 변경을 검토하며 그 습관을 익힌다.

  • 반복문의 시작값과 종료 조건을 원소 개수에 대입해 누락을 찾는다.
  • 빈 컬렉션과 null을 구분하고, 각각의 처리 규칙을 확인한다.
  • 정수 나눗셈에서 버려지는 나머지를 찾아 올림 계산에 반영한다.
  • 날짜 경계를 고정한 뒤 변경 전후의 결과를 같은 입력으로 비교한다.
  • 기존 결과와의 일치 여부와 요구사항 충족 여부를 따로 판단한다.

문제 상황

도서관 대출 서비스에서 연체료 합계가 적게 표시된다는 제보가 들어왔다. 담당자는 계산을 수정하고 리뷰를 요청했다. 변경 설명에는 “연체료 계산 보정”이라고만 적혀 있다. 리뷰어가 코드를 보니 반복문의 종료 조건과 나눗셈이 함께 바뀌어 있다. 어느 수정이 어떤 입력에 영향을 주는지 확인해야 한다.

팀은 먼저 이 예제에서 사용할 규칙을 다음과 같이 정리했다. 금액과 기간은 설명을 위한 가상 규칙이며 실제 서비스의 요금이 아니다. 도서 한 권의 반납 예정일까지는 연체료가 없다. 그 다음 날부터 연체일을 센다. 연체일을 3일씩 묶고, 묶음에 하루라도 포함되면 200원을 부과한다. 한 권의 연체료 상한은 1,000원이며, 여러 권의 연체료를 더한 전체 금액에는 별도 상한을 두지 않는다.

대출 목록이 비어 있으면 합계는 0원이다. 이 계산기에 전달되는 null 목록은 대출 자료가 없는 상태로 취급한다. 목록 안의 null 원소도 계산에서 제외한다. 이는 이 예제의 입력 계약이다. 모든 서비스에서 null을 이렇게 처리해야 한다는 뜻은 아니다. 유효한 대출 객체의 반납 예정일과 계산 기준일은 null이 아니라고 정한다.

“보정”이라는 설명만으로는 변경을 판단하기 어렵다. 예정일 다음 날부터 200원을 부과하는 규칙과, null을 자료 없음으로 처리한다는 계약을 함께 적어 달라. 그 규칙을 기준으로 전후 결과를 비교하겠다.

리뷰어는 서로 다른 질문을 분리한다. 한 권의 연체료가 맞는지, 목록의 모든 원소를 처리하는지, 입력이 없는 상태를 약속대로 다루는지는 별개의 질문이다. 합계 하나만 확인하면 서로 다른 결함이 같은 결과 뒤에 숨어 버릴 수 있다.

반복 경계는 원소 개수로 읽는다

반복 조건의 부등호가 한 칸 어긋나 원소 하나를 빠뜨리거나 범위를 넘는 문제를 오프바이원(off-by-one) 오류라고 부른다. 인덱스가 0부터 시작하는 목록에서 크기가 3이면 유효한 인덱스는 0, 1, 2다. 마지막 인덱스와 원소 개수를 같은 값처럼 읽으면 종료 조건을 잘못 작성하기 쉽다.

기존 합계 코드는 인덱스가 목록 크기에서 1을 뺀 값보다 작은 동안 반복한다. 크기가 3이면 인덱스 0과 1만 처리하고 2를 빠뜨린다. 크기가 1이면 처음부터 조건이 거짓이어서 아무것도 계산하지 않는다. 그런데 크기가 0일 때도 결과는 0이므로, 빈 목록 검사만 통과했다고 반복 경계가 맞는 것은 아니다.

크기가 3인 목록에서 종료 조건을 size보다 작게 두어야 마지막 원소까지 처리한다

리뷰에서는 머릿속으로 긴 목록을 따라가기보다 크기 0, 1, 2를 먼저 대입한다. 크기 0은 반복 진입 여부를 보여 준다. 크기 1은 첫 원소가 곧 마지막 원소인 상황을 만든다. 크기 2는 첫 원소 처리 뒤에 다음 원소로 넘어가는지를 보여 준다. 이 작은 입력들은 서로 다른 경계를 확인한다.

목록 크기를 대입하면 기존 종료 조건의 누락이 드러난다
목록 크기기존 조건이 방문하는 인덱스방문해야 할 인덱스판정
0없음없음결과만 보면 정상이다
1없음0유일한 원소를 빠뜨린다
200, 1마지막 원소를 빠뜨린다

원소의 위치가 계산에 필요하지 않으면 향상된 for문으로 바꿀 수 있다. 그러면 종료 조건을 직접 유지할 필요가 줄어든다. 다만 반복문 형태를 바꾼 사실만으로 정확성이 증명되지는 않는다. 반복 안에서 원소를 잘못 건너뛰는 조건이 있는지도 함께 읽어야 한다.

빈 컬렉션과 null도 구분한다. 빈 컬렉션은 사용할 수 있는 목록 객체가 있고 원소 수가 0인 상태다. null은 목록 객체를 참조하지 않는 상태다. 빈 목록은 반복을 시작하지 않고 끝나지만, null에 크기를 물으면 계산 결과를 얻기 전에 실행이 중단된다. 따라서 두 입력을 모두 받는 계약이라면 각각을 확인해야 한다.

빈 목록에서 0원이 나오는 것은 확인했다. 한 권만 담은 목록도 추가해 달라. 기존 종료 조건에서는 한 권일 때 반복을 한 번도 실행하지 않으므로 마지막 원소 누락을 바로 확인할 수 있다.

나눗셈과 날짜에서 결과가 바뀌는 지점

Java의 정수 나눗셈은 소수 부분을 남기지 않는다. 이 예제의 양수 연체일에 대해 1을 3으로 나눈 몫은 0이고, 4를 3으로 나눈 몫은 1이다. 몫에 200을 곱하면 연체일이 1일이나 2일인 책의 연체료가 0원이 된다. “하루라도 포함되면 한 묶음”이라는 규칙과 다르다.

정확성 리뷰에서 “반올림한다”는 표현도 그대로 넘기지 않는다. 가까운 정수로 반올림할지, 소수 부분을 버릴지, 나머지가 있으면 올릴지에 따라 결과가 달라진다. 이번 규칙은 올림이다. 양수 연체일의 몫을 구하고 나머지가 0이 아니면 묶음 수에 1을 더한다. 금액을 구한 다음 반올림하는 것이 아니라, 부과할 묶음 수를 먼저 확정한다.

연체일이 3의 배수를 넘어서는 순간 올림한 묶음 수와 연체료가 증가한다

경계 입력은 구간의 한가운데보다 양끝에서 고른다. 3일 단위라면 1, 2, 3, 4일을 살펴본다. 3일은 첫 묶음의 끝이고 4일은 두 번째 묶음의 시작이다. 상한도 같은 방식으로 읽는다. 14일은 올림 때문에 이미 다섯 묶음이며, 15일도 다섯 묶음이다. 16일은 여섯 묶음이지만 상한 때문에 최종 금액은 그대로다.

계산 자료형도 확인한다. 날짜 차이는 long으로 받고, 묶음 수와 중간 금액도 long으로 계산한다. 상한을 적용한 후 int로 바꾸면 반환값이 0부터 1,000 사이임을 읽을 수 있다. 여러 권의 합계는 한 권의 상한보다 커질 수 있으므로 long으로 유지한다. 좁은 자료형으로 먼저 바꾼 뒤 상한을 적용하면, 이미 값이 손실된 상태에서 비교할 수 있다.

날짜 경계는 업무 문장으로 먼저 확정한다. “예정일까지 무료”라면 예정일과 기준일이 같은 경우 연체일은 0이다. 다음 날은 1일이다. 기준일이 예정일보다 앞서면 날짜 차이가 음수이므로 이 역시 0원으로 처리한다. 날짜 차이에 관성적으로 1을 더하면 예정일 당일도 연체로 계산할 수 있다.

이 예제는 시각 없이 날짜만 다루므로 LocalDate를 사용한다. 계산 도중 현재 날짜를 읽지 않고 고정된 기준일을 인자로 전달한다. 그래야 같은 프로그램을 내일 실행해도 경계 입력의 의미와 출력이 유지된다. 월말과 연말을 지나는 차이도 일자 숫자를 직접 빼지 않고 날짜 사이의 일수로 구한다.

전후 일치와 규칙 충족을 따로 확인한다

코드를 고칠 때 같은 입력으로 전후 결과를 비교하는 이유는 변경 범위를 확인하기 위해서다. 모든 행의 값이 같아야 한다는 뜻은 아니다. 버그를 수정했다면 일부 행은 달라져야 한다. 리뷰어는 각 행에서 결과가 같아야 하는지, 달라져야 하는지부터 정한다.

예정일 당일의 0원은 유지돼야 한다. 연체 1일의 0원은 200원으로 바뀌어야 한다. 연체 3일의 200원은 그대로여야 한다. 결과가 우연히 같은 행도 있어서 한두 사례만으로 변경을 판단하기 어렵다. 상한에 도달한 뒤에는 잘못된 중간 계산도 최종 금액에서 가려질 수 있다.

한 권의 경계 입력에서 유지할 결과와 바꿀 결과를 구분한다
연체일변경 전변경 후 기대값확인 목적
-1, 00원0원무료 구간 유지
1, 20원200원첫 묶음 올림
3200원200원묶음 끝 유지
4200원400원다음 묶음 진입
14800원1,000원올림으로 상한 도달
15, 161,000원1,000원상한 유지

완성 프로그램은 이 표를 먼저 출력한다. 이어서 한 권만 있는 목록으로 반복 경계를 확인하고, 빈 목록과 null 목록을 별도로 검사한다. 기존 코드가 null에서 중단되는 현상은 main에서 포착해 확인 결과로 남긴다. 기존 동작을 관찰하기 위한 장치이며, 변경 후 계산은 앞서 정한 입력 계약에 맞춰 0원을 반환한다.

연체 3일의 한 권만 담은 목록을 사용하자. 이 날짜에서는 전후의 한 권 계산이 모두 200원이므로, 합계 차이가 나면 반복 경계 때문임을 구분할 수 있다.

기대값은 계산 코드와 별도로 적는다. 수정한 공식을 그대로 복사해 기대값을 구하면 같은 실수를 두 번 작성할 수 있다. 이 예제에서는 업무 규칙을 손으로 적용한 금액을 배열에 넣는다. 검사 통과는 준비한 입력에 대한 증거이며, 모든 입력에 대한 증명은 아니다.

완성 코드

다음 파일 하나를 저장한다. 변경 전 구현은 beforeFee와 beforeTotal에, 변경 후 구현은 afterFee와 afterTotal에 담았다. 기존 구현의 결함을 그대로 남겨 같은 입력에서 차이를 관찰한다. 실행 중 파일을 만들거나 외부 자원에 접근하지 않는다.

Main.java

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.List;

public class Main {
    private static final long UNIT_DAYS = 3;
    private static final long UNIT_FEE = 200;
    private static final long MAX_FEE = 1_000;
    private static int failures;

    record Loan(LocalDate dueDate) {
    }

    static int beforeFee(Loan loan, LocalDate today) {
        long days = ChronoUnit.DAYS.between(loan.dueDate(), today);
        if (days <= 0) {
            return 0;
        }
        long amount = (days / UNIT_DAYS) * UNIT_FEE;
        return (int) Math.min(amount, MAX_FEE);
    }

    static long beforeTotal(List<Loan> loans, LocalDate today) {
        long total = 0;
        for (int i = 0; i < loans.size() - 1; i++) {
            total += beforeFee(loans.get(i), today);
        }
        return total;
    }

    static int afterFee(Loan loan, LocalDate today) {
        if (loan == null) {
            return 0;
        }
        long days = ChronoUnit.DAYS.between(loan.dueDate(), today);
        if (days <= 0) {
            return 0;
        }
        long units = days / UNIT_DAYS;
        if (days % UNIT_DAYS != 0) {
            units++;
        }
        long amount = units * UNIT_FEE;
        return (int) Math.min(amount, MAX_FEE);
    }

    static long afterTotal(List<Loan> loans, LocalDate today) {
        if (loans == null) {
            return 0;
        }
        long total = 0;
        for (Loan loan : loans) {
            total += afterFee(loan, today);
        }
        return total;
    }

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

    public static void main(String[] args) {
        LocalDate today = LocalDate.of(2030, 1, 2);
        int[] days = {-1, 0, 1, 2, 3, 4, 14, 15, 16};
        int[] expectedBefore = {0, 0, 0, 0, 200, 200, 800, 1000, 1000};
        int[] expectedAfter = {0, 0, 200, 200, 200, 400, 1000, 1000, 1000};

        System.out.println("연체일 | 변경 전 | 변경 후 | 기대값 | 비교");
        for (int i = 0; i < days.length; i++) {
            Loan loan = new Loan(today.minusDays(days[i]));
            int before = beforeFee(loan, today);
            int after = afterFee(loan, today);
            String comparison = before == after ? "같음" : "다름";
            System.out.println(days[i] + " | " + before + " | " + after
                    + " | " + expectedAfter[i] + " | " + comparison);
        }

        for (int i = 0; i < days.length; i++) {
            Loan loan = new Loan(today.minusDays(days[i]));
            check(beforeFee(loan, today) == expectedBefore[i]
                    && afterFee(loan, today) == expectedAfter[i],
                    "경계 " + days[i] + "일");
        }

        Loan threeDays = new Loan(today.minusDays(3));
        List<Loan> single = List.of(threeDays);
        System.out.println("한 권 합계 | " + beforeTotal(single, today)
                + " | " + afterTotal(single, today));
        check(beforeTotal(single, today) == 0
                && afterTotal(single, today) == 200, "마지막 원소 포함");

        List<Loan> empty = List.of();
        check(beforeTotal(empty, today) == 0
                && afterTotal(empty, today) == 0, "빈 목록 결과 유지");

        boolean beforeNullStopped = false;
        try {
            beforeTotal(null, today);
        } catch (NullPointerException e) {
            beforeNullStopped = true;
        }
        check(beforeNullStopped, "변경 전 null 목록 중단 관찰");
        check(afterTotal(null, today) == 0, "변경 후 null 목록은 0원");

        List<Loan> withNull = Arrays.asList(null, threeDays);
        boolean beforeElementStopped = false;
        try {
            beforeTotal(withNull, today);
        } catch (NullPointerException e) {
            beforeElementStopped = true;
        }
        check(beforeElementStopped, "변경 전 null 원소 중단 관찰");
        check(afterTotal(withNull, today) == 200, "null 원소 제외 후 합산");

        List<Loan> mixed = List.of(
                new Loan(today.minusDays(1)),
                new Loan(today.minusDays(4)));
        System.out.println("두 권 합계 | " + beforeTotal(mixed, today)
                + " | " + afterTotal(mixed, today));
        check(beforeTotal(mixed, today) == 0
                && afterTotal(mixed, today) == 600, "두 권 계산 변경 확인");

        Loan yearBoundary = new Loan(LocalDate.of(2029, 12, 31));
        check(afterFee(yearBoundary, today) == 200, "연말 경계는 연체 2일");

        System.out.println("실패 수 | " + failures);
        if (failures != 0) {
            throw new AssertionError("검사 실패: " + failures);
        }
    }
}

줄별 해설

상수 선언 부분은 묶음 기간, 묶음당 금액, 한 권의 상한을 드러낸다. 계산식 안에 숫자를 반복해서 적는 것보다 규칙과 구현을 대조하기 쉽다. 세 상수는 long이므로 나눗셈과 곱셈의 중간 결과도 long으로 유지된다. Loan은 반납 예정일 하나를 가진 레코드(record)다.

beforeFee의 날짜 차이 계산은 반납 예정일부터 기준일까지 지난 일수를 구한다. 0 이하일 때 먼저 반환하므로 예정일 당일과 예정일 이전은 무료다. 이 부분은 변경 후에도 유지한다. 그 아래 나눗셈은 나머지를 버리므로, 아직 3일을 채우지 못한 연체에는 요금을 부과하지 못한다.

beforeTotal의 반복 조건은 마지막 인덱스를 종료 기준에 잘못 사용한다. 마지막 인덱스까지 방문하려면 그 값과 같은 경우도 포함해야 하는데, 현재 조건은 그 전에 끝난다. 완성 코드에서는 이 구현을 남겨 두어 한 권 목록의 0원 결과를 재현한다.

afterFee의 첫 조건은 null 원소를 0원으로 처리한다. 이 조건은 객체의 날짜를 읽기 전에 있어야 한다. 객체를 먼저 사용한 뒤 null인지 검사하는 순서로는 입력 계약을 지킬 수 없다. 다만 Loan 안의 날짜까지 null일 수 있다는 계약은 아니므로, 서로 다른 두 종류의 null을 혼동하지 않는다.

units를 계산하는 부분은 양수 연체일에 대해서만 실행된다. 몫을 구한 다음 나머지가 있으면 1을 더한다. 3일이면 나머지가 없어 한 묶음이고, 4일이면 몫 1에 한 묶음을 더한다. 음수와 0을 앞에서 처리했으므로 이 구간에서는 양수에 대한 올림만 생각하면 된다.

Math.min을 호출하는 반환문은 한 권의 상한을 적용한다. 캐스팅은 그 뒤에 있다. LocalDate로 표현 가능한 날짜 사이의 일수와 이 예제의 단가에서는 중간 곱셈도 long 범위 안에 있다. 단가나 입력 범위를 바꾼다면 중간 계산의 범위도 다시 확인해야 한다.

afterTotal의 향상된 for문은 목록의 모든 원소를 계산한다. null 목록이면 앞에서 반환하며, 빈 목록이면 반복을 한 번도 실행하지 않고 total의 초깃값 0을 반환한다. null 원소의 정책은 afterFee가 적용한다. 한 권 상한은 이미 각 호출에서 적용됐으므로 합계에 다시 적용하지 않는다.

main의 세 배열은 입력 연체일, 기존 결과, 수정 후 기대값을 같은 위치에 둔다. 첫 반복은 전후 표를 출력하고, 두 번째 반복은 그 값들을 검사한다. 날짜는 고정된 today에서 연체일만큼 거슬러 만든다. 연체일이 -1이면 예정일이 기준일보다 하루 뒤가 된다.

한 권 목록 검사는 나눗셈 결함과 반복 결함을 분리한다. 연체 3일은 두 계산식 모두 200원이므로 합계가 0원에서 200원으로 바뀌는 이유는 마지막 원소 포함 여부다. 반면 두 권 합계 검사는 두 수정이 함께 적용된 최종 결과를 확인한다.

null 관찰 부분에서는 기존 구현이 계산을 마치지 못했다는 사실을 boolean 값으로 남긴다. 예외 메시지나 실행 경로 전체를 출력하지 않아 결과가 실행 환경에 따라 달라지는 것을 피한다. 마지막 실패 수가 0이 아니면 프로그램도 실패로 끝나므로, 출력만 보고 오류를 놓칠 가능성을 줄인다.

실행 결과

Main.java를 저장한 디렉터리에서 다음 명령을 실행한다. JDK 17의 표준 라이브러리만 사용하며 패키지 선언은 없다. 명령을 실행하면 컴파일 결과를 담는 out 디렉터리가 만들어진다.

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

예상 출력은 다음과 같다. “비교”는 전후 값의 일치 여부이며, 기대값 충족 여부는 이어지는 PASS 또는 FAIL로 확인한다.

연체일 | 변경 전 | 변경 후 | 기대값 | 비교
-1 | 0 | 0 | 0 | 같음
0 | 0 | 0 | 0 | 같음
1 | 0 | 200 | 200 | 다름
2 | 0 | 200 | 200 | 다름
3 | 200 | 200 | 200 | 같음
4 | 200 | 400 | 400 | 다름
14 | 800 | 1000 | 1000 | 다름
15 | 1000 | 1000 | 1000 | 같음
16 | 1000 | 1000 | 1000 | 같음
PASS | 경계 -1일
PASS | 경계 0일
PASS | 경계 1일
PASS | 경계 2일
PASS | 경계 3일
PASS | 경계 4일
PASS | 경계 14일
PASS | 경계 15일
PASS | 경계 16일
한 권 합계 | 0 | 200
PASS | 마지막 원소 포함
PASS | 빈 목록 결과 유지
PASS | 변경 전 null 목록 중단 관찰
PASS | 변경 후 null 목록은 0원
PASS | 변경 전 null 원소 중단 관찰
PASS | null 원소 제외 후 합산
두 권 합계 | 0 | 600
PASS | 두 권 계산 변경 확인
PASS | 연말 경계는 연체 2일
실패 수 | 0

무료 구간과 3일, 15일, 16일은 전후 결과가 같다. 이 행들은 유지해야 할 동작을 확인한다. 1일, 2일, 4일, 14일은 결과가 바뀌며 업무 규칙에 맞는 방향인지 확인한다. “다름” 자체가 실패가 아니라 의도한 차이가 어디에서 나타나는지를 보여 주는 정보다.

한 권 합계가 0원에서 200원으로 바뀌는 부분은 표의 한 권 계산과 구분해서 읽는다. 한 권 계산이 맞아도 합계 반복문에서 그 원소를 방문하지 않으면 최종 결과는 틀릴 수 있다. 따라서 개별 계산과 집계 경계를 모두 확인해야 한다.

실무에서 자주 틀리는 것

마지막 인덱스를 반복 횟수처럼 사용한다

다음 예제의 before가 틀린 코드이고 after가 고친 코드다. 두 메서드는 같은 배열을 입력받는다. 합계는 각각 200과 600이다. 빈 배열에서는 둘 다 0이어서 차이를 놓칠 수 있다.

class LoopExample {
    static int before(int[] fees) {
        int total = 0;
        for (int i = 0; i < fees.length - 1; i++) {
            total += fees[i];
        }
        return total;
    }

    static int after(int[] fees) {
        int total = 0;
        for (int i = 0; i < fees.length; i++) {
            total += fees[i];
        }
        return total;
    }

    public static void main(String[] args) {
        int[] fees = {200, 400};
        System.out.println(before(fees));
        System.out.println(after(fees));
    }
}

마지막 인덱스가 길이보다 1 작다는 사실은 맞다. 그러나 반복 조건은 방문할 인덱스가 길이보다 작은지를 물어야 한다. 리뷰 의견에는 “부등호 수정”보다 “마지막 원소 400이 합계에서 빠진다”처럼 입력과 결과를 연결해 적는 편이 판단에 도움이 된다.

빈 목록 검사로 null 검사까지 했다고 생각한다

isEmpty는 목록 객체가 있을 때 호출할 수 있다. 다음 before는 빈 목록에는 맞게 동작하지만 null에서는 결과를 반환하지 못한다. 이 예제의 계약에 맞는 after는 객체를 사용하기 전에 null을 확인한다. 두 메서드를 같은 null 입력으로 실행해 차이를 관찰한다.

import java.util.List;

class NullExample {
    static int before(List<Integer> fees) {
        return fees.isEmpty() ? 0 : fees.size();
    }

    static int after(List<Integer> fees) {
        if (fees == null || fees.isEmpty()) {
            return 0;
        }
        return fees.size();
    }

    public static void main(String[] args) {
        List<Integer> fees = null;
        try {
            System.out.println(before(fees));
        } catch (NullPointerException e) {
            System.out.println("변경 전: 계산 중단");
        }
        System.out.println("변경 후: " + after(fees));
    }
}

조건식의 왼쪽에서 null이 확인되면 오른쪽은 평가하지 않는다. 순서를 바꾸면 같은 보호 효과를 얻지 못한다. null을 0으로 바꾸는 것이 적절한지는 코드만으로 결정하지 않는다. 자료 누락을 알려야 하는 업무라면 계약부터 달라져야 한다.

정수 나눗셈 뒤에 반올림을 붙인다

나눗셈을 끝낸 뒤 Math.round를 호출해도 이미 버린 나머지는 돌아오지 않는다. 다음 before는 연체 4일을 한 묶음으로 계산한다. after는 나머지를 확인해 두 묶음으로 계산한다. 두 메서드의 입력은 양수 연체일이라고 정한다.

class RoundingExample {
    static long before(long days) {
        return Math.round((double) (days / 3));
    }

    static long after(long days) {
        long units = days / 3;
        return days % 3 == 0 ? units : units + 1;
    }

    public static void main(String[] args) {
        System.out.println(before(4));
        System.out.println(after(4));
    }
}

출력은 1과 2다. 괄호 안의 정수 나눗셈이 먼저 수행되므로 double로 바꾸는 시점에는 이미 값이 1이다. 나눗셈 전에 실수로 변환해도 가까운 정수로 반올림하는 규칙은 이번 올림 규칙과 다르다. 필요한 계산 방향을 먼저 확정해야 한다.

포함 여부를 정하지 않고 날짜 차이에 1을 더한다

기간을 세는 코드에서 시작일을 포함하려고 1을 더하는 경우가 있다. 그러나 예정일까지 무료인 연체일 계산에는 맞지 않는다. 같은 날짜를 전달하면 before는 1일, after는 0일을 출력한다.

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

class DateExample {
    static long before(LocalDate due, LocalDate today) {
        return Math.max(0L, ChronoUnit.DAYS.between(due, today) + 1);
    }

    static long after(LocalDate due, LocalDate today) {
        return Math.max(0L, ChronoUnit.DAYS.between(due, today));
    }

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2030, 1, 2);
        System.out.println(before(day, day));
        System.out.println(after(day, day));
    }
}

날짜 계산식을 보고 포함 여부를 추측하기보다 “예정일 당일은 무료, 다음 날부터 부과”라는 문장에 대입한다. 월이 바뀌거나 연도가 바뀌어도 같은 규칙이어야 한다. 날짜의 일자 부분만 빼는 방식은 이런 경계를 건너지 못하므로 날짜 사이의 일수를 사용한다.

한눈에 보기

정확성 리뷰에서는 결과가 바뀌는 경계와 입력 계약을 함께 확인한다
검토 대상선택할 입력확인할 질문이번 변경의 기준
반복 경계원소 수 0, 1, 2마지막 원소도 처리하는가모든 대출을 합산한다
빈 컬렉션빈 목록초깃값 그대로 끝나도 맞는가합계는 0원이다
nullnull 목록과 null 원소객체 사용 전에 계약을 적용하는가자료 없음으로 처리한다
정수 나눗셈연체 1, 2, 3, 4일남은 하루도 한 묶음인가묶음 수를 올림한다
금액 상한연체 14, 15, 16일올림 후 상한을 적용하는가한 권당 최대 1,000원이다
날짜 경계예정일 전날, 당일, 다음 날무료 구간의 끝이 어디인가다음 날부터 연체다
전후 비교동일한 고정 입력유지와 수정의 근거가 있는가기대값으로 각각 판단한다

리뷰를 마칠 때는 확인한 경계를 구체적으로 남긴다. “계산 확인”보다 “예정일 당일 0원 유지, 다음 날 200원으로 수정, 한 권 목록 합계 누락 해소”가 검토 범위를 잘 보여 준다. 아직 정해지지 않은 입력 계약이 있다면 그 부분도 드러내야 한다. 입력이 없을 때 계산을 계속할지 사용자에게 알릴지는 다음 장에서 오류 처리 관점으로 이어서 살펴본다.

연습 문제

  1. 완성 코드의 경계 표에 연체 5일과 6일을 추가한다. 변경 전 금액과 변경 후 기대값을 먼저 적은 뒤 실행한다. 어느 행에서 값이 같고 어느 행에서 달라지는지 설명한다.
  2. 연체 3일인 대출 두 권을 담은 목록과, 같은 두 권 사이에 null을 넣은 목록을 만든다. 각 목록의 변경 전후 합계를 예측한다. 기존 구현이 두 목록에서 다른 결과를 내는 이유를 설명한다.
  3. 기준일을 2032년 3월 1일로, 반납 예정일을 2032년 2월 28일로 정한다. 연체일과 변경 후 연체료를 구하고, 기준일에서 2일을 뺀 날짜도 확인한다.
  4. 연체 16일인 대출 두 권의 변경 후 합계를 예상한다. 합계에도 1,000원 상한을 적용하는 수정안이 들어왔다면, 요구사항과 입력을 근거로 리뷰 의견을 작성한다.

정답과 해설

  1. 연체 5일은 변경 전 200원, 변경 후 400원이다. 연체 6일은 전후 모두 400원이다. 5일에는 나머지가 있어 묶음 수를 올리지만, 6일에는 나머지가 없다. days 배열에 5와 6을 추가하면서 두 기대값 배열의 같은 위치에도 각각 값을 추가해야 한다. 입력 배열만 늘리면 검사용 배열의 길이가 맞지 않는다.

  2. 두 권 목록의 변경 전 합계는 200원이고 변경 후 합계는 400원이다. 기존 반복문은 첫 번째 대출만 처리한다. 가운데 null이 있는 세 원소 목록은 기존 구현에서 첫 대출을 처리한 뒤 null을 사용하다가 중단된다. 변경 후에는 null을 0원으로 처리하므로 합계가 400원이다. 원소 수가 늘어 반복 범위가 달라지면서 이전에는 방문하지 않던 위치의 문제도 드러난다.

  3. 2032년에는 2월 29일이 있으므로 연체일은 2일이다. 변경 후 연체료는 200원이다. 기준일에서 2일을 빼면 2032년 2월 28일이 된다. 날짜의 일자 숫자인 1과 28을 직접 빼서는 이 결과를 얻을 수 없다. 이 입력은 월 경계와 윤일을 함께 지나므로 날짜 연산을 사용했는지 확인하는 데 도움이 된다.

  4. 각 대출에 1,000원 상한을 적용하므로 합계는 2,000원이다. 리뷰 의견은 다음과 같이 쓸 수 있다.

    요구사항의 상한은 한 권 기준이다. 연체 16일인 두 권을 입력하면 각각 1,000원으로 합계가 2,000원이어야 한다. 합계에 상한을 적용하면 1,000원이 되므로, 상한은 개별 계산에만 두고 이 입력을 확인 항목에 추가해 달라.

    코드의 위치만 지적하지 않고 규칙과 재현 입력을 함께 제시하면 수정 방향을 판단하기 쉽다.

댓글 0

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

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