Devin.KR

소프트웨어 공학 · 심화

클린 코드 노하우 - 읽히는 코드로 고치는 순서

불변성과 값 객체

값 객체(record)로 금액·기간 표현, 방어적 복사, 불변 컬렉션, 공유 상태 버그 줄이기, 완성 코드는 금액을 int 에서 Money 값 객체로 바꾼 전/후의 결과와 반올림 차이를 보여 준다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 수강 등록과 수강료 계산의 책임을 나누었다. 그런데 책임을 나눈 뒤에도 문제가 남는다. 등록할 때 받은 목록을 다른 코드가 바꾸면 수강 내역도 바뀐다. 금액을 나타내는 정수에는 할인율이나 수강생 번호가 들어갈 수 있다. 시작일과 종료일을 따로 전달하면 두 날짜의 순서를 매번 확인해야 한다. 클래스의 역할만 나누어서는 이런 실수를 충분히 줄이기 어렵다.

이 장에서는 값의 의미와 변경 가능성을 함께 다룬다. 값 객체(value object)는 금액이나 기간처럼 하나의 의미를 이루는 값을 묶고, 그 값이 지켜야 할 조건을 생성 시점에 확인한다. 불변성(immutability)은 한 번 만든 객체의 관찰 가능한 상태가 바뀌지 않는 성질이다. 두 개념을 함께 적용하면 값을 읽는 곳에서 확인해야 할 일이 줄어든다.

  • JDK 17의 record로 금액과 수강 기간을 표현한다.
  • record의 필드 고정과 내부 객체의 불변성을 구분한다.
  • 방어적 복사와 수정할 수 없는 컬렉션으로 공유 상태의 변경을 막는다.
  • 기존 정수 계산의 결과를 유지한 뒤, 반올림 정책의 차이를 별도로 확인한다.

문제 상황

학원 수강 관리 프로그램은 상담 화면에서 선택한 수업 목록을 등록 객체에 전달한다. 오래된 등록 객체는 전달받은 목록을 필드에 그대로 저장한다. 상담 화면은 등록을 마친 뒤에도 그 목록을 사용한다. 다음 상담을 준비하며 목록에 수업을 추가했더니, 이미 등록한 수강생의 내역에도 그 수업이 나타난다.

List<String> selectedLessons = new ArrayList<>();
selectedLessons.add("Java 기초");

LegacyEnrollment enrollment =
        new LegacyEnrollment(12_000, selectedLessons);

selectedLessons.add("웹 실습");
// enrollment.lessons에도 "웹 실습"이 들어 있다.

등록 객체에 수업을 추가하는 메서드가 없어도 문제가 생긴다. 두 객체가 같은 목록을 가리키기 때문이다. 등록 객체의 코드를 읽는 사람은 외부의 목록 변경까지 찾아야 상태가 달라진 이유를 설명할 수 있다. 변경 지점이 객체 바깥으로 퍼진 셈이다.

수강료 계산에도 다른 문제가 있다. 기존 코드는 할인액을 정수 나눗셈으로 계산한다. 수강료가 10,005원이고 할인율이 15%라면 정확한 할인액은 1,500.75원이다. 정수 계산은 소수 부분을 버려 1,500원을 할인하고, 최종 수강료는 8,505원이 된다. 이를 금액 객체로 옮기면서 할인액을 반올림하면 최종 수강료가 8,504원으로 바뀐다.

둘 다 계산 가능한 결과지만 같은 규칙은 아니다. 구조를 고치는 작업과 계산 규칙을 바꾸는 작업을 한 번에 섞으면 차이의 원인을 찾기 어려워진다. 따라서 먼저 기존의 소수 부분 버림을 유지한 금액 객체를 만들고, 같은 입력의 결과를 비교한다. 그다음 반올림을 선택했을 때의 차이를 따로 출력한다. 예제의 수강료와 할인율은 동작을 설명하기 위한 고정값이다.

값의 의미를 record에 담는다

금액은 숫자와 계산 규칙을 함께 가진다

int는 수강료와 할인율을 구분하지 않는다. 둘 다 정수이므로 인수의 순서를 잘못 넣어도 컴파일될 수 있다. Money를 사용하면 적어도 금액이 들어갈 자리에 평범한 정수를 바로 전달할 수는 없다. 또한 음수 금액을 거부하는 검사를 생성자에 모으면, 사용하는 곳마다 같은 검사를 반복할 필요가 줄어든다.

이 장의 Money는 원 단위 금액만 표현하며 통화 변환은 다루지 않는다. 내부 표현에는 BigDecimal을 사용한다. 금액 자체는 정수 원 단위로 유지하되, 할인액 계산 중에 생기는 소수 부분을 명시적인 정책으로 처리하기 위해서다. double을 거쳐 만들지 않고 정수로부터 생성한다.

불변 객체의 연산은 원래 값을 수정하는 대신 결과를 담은 새 값을 돌려준다. 수강료에서 할인을 적용해도 원래 Money는 그대로다. 따라서 할인 전 금액을 다른 화면이나 계산에서 공유하더라도, 어느 한 계산이 원본을 바꾸어 다른 계산에 영향을 주지 않는다.

Money original = Money.of(12_000);
Money discounted =
        original.discountPercent(10, RoundingMode.DOWN);
// original은 12000원, discounted는 10800원이다.

record는 구성 요소를 받는 생성자, 접근자, equals, hashCode, toString 등을 제공한다. 구성 요소에 대응하는 필드는 final이므로 생성 후 다른 참조로 바꿀 수 없다. 다만 record라는 선언만으로 구성 요소가 가리키는 객체까지 불변이 되지는 않는다. Money에서는 내부의 BigDecimal도 불변이므로 값을 안전하게 공유할 수 있다.

금액의 동등성에는 표현도 영향을 준다. BigDecimal의 equals는 숫자뿐 아니라 소수 자릿수도 비교한다. 12000과 12000.0을 그대로 저장하면 같은 원 금액이 서로 다른 값처럼 비교될 수 있다. 완성 코드에서는 생성할 때 소수 자릿수를 0으로 통일한다. 이때 UNNECESSARY를 사용하므로 12000.0처럼 값의 손실 없이 정수로 바꿀 수 있는 입력은 허용하고, 12000.5처럼 소수 원이 있는 입력은 거부한다.

기간은 두 날짜 사이의 조건을 가진다

시작일과 종료일을 서로 다른 인수로 전달하면 순서에 관한 규칙이 흩어지기 쉽다. StudyPeriod는 두 날짜를 함께 보관하고, 종료일이 시작일보다 앞설 수 없다는 조건을 생성 시점에 확인한다. 사용하는 쪽에서는 이미 생성된 기간이 올바른 순서라는 전제로 읽을 수 있다.

완성 코드의 수강 기간은 시작일과 종료일을 모두 포함한다. contains 메서드는 이 경계 규칙을 한 곳에 담는다. 당일 등록도 허용하므로 시작일과 종료일이 같아도 유효하다. LocalDate는 날짜만 나타내는 불변 객체이므로 방어적 복사가 필요하지 않다. 현재 날짜를 직접 읽지 않고 고정된 날짜를 전달해 실행 결과도 일정하게 유지한다.

값 객체가 생성과 사용 시점에 보장하는 조건
값 객체생성 조건사용할 때의 의미
Money0원 이상, 정수 원 단위음수나 소수 원을 다시 검사하지 않는다.
StudyPeriod시작일이 종료일보다 늦지 않음양 끝을 포함하는 기간으로 조회한다.
Enrollment필수 값과 수업 목록이 존재함생성 후 등록 내용을 고정해서 읽는다.

값 객체의 조건은 업무 범위에 맞아야 한다. 이 장에서는 음수 수강료를 허용하지 않지만, 환급 내역까지 같은 Money로 표현할지는 별도의 설계 문제다. 여기서는 등록할 수강료라는 범위를 정하고 그 안에서 조건을 일관되게 지킨다.

참조를 고정하는 것과 내용을 고정하는 것은 다르다

final List라고 선언해도 add 호출까지 금지되지는 않는다. final은 필드가 다른 목록을 가리키지 못하게 할 뿐이다. 외부 코드가 같은 목록을 가지고 있으면 계속 내용을 바꿀 수 있다. record에 List를 그대로 넣었을 때도 이 문제가 남는다.

외부와 등록 객체가 같은 수정 가능한 목록을 공유하면 외부의 변경이 등록 내용에 반영된다

방어적 복사(defensive copy)는 외부에서 받은 변경 가능한 데이터를 객체 내부 상태와 분리하기 위한 복사다. 생성자에서 새 ArrayList를 만들면 원본 목록과의 연결은 끊어진다. 하지만 접근자가 내부 ArrayList를 그대로 돌려주면 호출자가 다시 내부 상태를 바꿀 수 있다. 입력 경계만 막고 출력 경계를 열어 둔 셈이다.

완성 코드에서는 List.copyOf로 목록을 저장한다. 반환된 목록은 추가·삭제·교체를 허용하지 않으며, 이후 원본 목록의 변경도 반영하지 않는다. 원본이 이미 적절한 수정 불가 목록이면 구현이 이를 재사용할 수 있으므로, 항상 새 객체를 만든다고 이해할 필요는 없다. 중요한 것은 객체의 개수가 아니라 관찰 가능한 변경의 차단이다.

List.copyOf는 요소 자체를 복제하지 않는 얕은 복사(shallow copy)다. 목록에 변경 가능한 수업 객체가 들어 있다면 그 객체의 상태는 여전히 바뀔 수 있다. 이 예제는 불변인 String을 요소로 사용한다. Enrollment의 나머지 구성 요소인 Money와 StudyPeriod도 불변이므로, 반환된 등록 객체를 통해 내부 상태를 바꿀 경로가 남지 않는다.

수정할 수 없는 목록과 수정할 수 없는 뷰도 구분해야 한다. Collections.unmodifiableList는 원본 목록을 감싸는 뷰를 만든다. 그 뷰를 통한 add는 막지만 원본의 변경은 보인다. 등록 시점의 내용을 보관하려는 목적에는 원본과 분리되는 List.copyOf가 어울린다. 단, 이 메서드는 null 요소를 허용하지 않으므로 기존 코드가 null을 사용했다면 입력 조건부터 확인해야 한다.

계산 결과를 유지하면서 표현을 바꾼다

불변성은 계산 규칙의 올바름을 대신 보장하지 않는다. Money를 사용해도 잘못된 할인 정책을 넣으면 결과가 달라진다. 표현을 바꾸는 동안에는 기존 계산을 비교 기준으로 남겨 두고, 차이가 드러나는 입력을 함께 확인해야 한다.

예제의 기존 계산은 수강료와 할인율이 모두 음수가 아니며, 곱셈이 int 범위를 넘지 않는 입력에 한정한다. 이 범위에서 정수 나눗셈의 소수 부분 버림은 BigDecimal의 RoundingMode.DOWN과 같은 방향으로 동작한다. DOWN은 0을 향해 버리는 규칙이다. 음수까지 포함하는 다른 도메인에서는 이를 단순히 내림이라고 부르면 혼동할 수 있다.

새 Money는 할인액을 먼저 정수 원 단위로 정한 뒤 원래 수강료에서 뺀다. 최종 청구액을 먼저 구하고 그 금액을 반올림하는 규칙과는 구분해야 한다. 같은 할인율이라도 반올림하는 대상과 시점에 따라 결과가 달라질 수 있기 때문이다.

같은 수강료에서도 할인액의 소수 부분을 버리는지 반올림하는지에 따라 최종 금액이 1원 달라진다

HALF_UP은 가장 가까운 정수로 반올림하고 정확히 중간이면 0에서 멀어지는 쪽을 선택한다. 완성 코드에서는 이 정책을 메서드 인수로 드러낸다. 실제 업무에서 정책이 하나로 정해져 있다면 해당 정책을 사용하는 업무 메서드 안에 고정해도 된다. 여기서는 기존 결과 유지와 정책 변경을 비교하기 위해 두 선택을 모두 노출한다.

두 사례의 결과가 같다고 모든 입력에서 같음이 증명되는 것은 아니다. 그래도 소수 할인액이 생기는 사례를 포함하면 반올림 정책이 의도치 않게 달라지는 실수를 빠르게 발견할 수 있다. 곱셈이 int 범위를 넘는 입력이나 기존 코드가 허용한 잘못된 입력은 별도로 조사해야 한다. 완성 코드는 지정한 사례에서의 결과 유지와 새 객체의 조건을 작은 검사로 확인한다.

완성 코드

다음 네 파일을 같은 디렉터리에 저장한다. 패키지 선언과 외부 라이브러리는 사용하지 않는다. 프로그램은 파일을 쓰지 않고, 고정된 입력으로 실행한 뒤 종료한다. 실패한 검사는 FAIL을 출력하고 마지막에 예외를 발생시켜 실행 실패를 알린다.

Money.java

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public record Money(BigDecimal won) {
    public Money {
        Objects.requireNonNull(won, "금액");
        if (won.signum() < 0) {
            throw new IllegalArgumentException("금액은 음수일 수 없다.");
        }
        won = won.setScale(0, RoundingMode.UNNECESSARY);
    }

    public static Money of(int won) {
        return new Money(BigDecimal.valueOf(won));
    }

    public Money discountPercent(int percent, RoundingMode rounding) {
        if (percent < 0 || percent > 100) {
            throw new IllegalArgumentException("할인율은 0부터 100까지다.");
        }
        Objects.requireNonNull(rounding, "반올림 정책");

        BigDecimal discount = won
                .multiply(BigDecimal.valueOf(percent))
                .divide(BigDecimal.valueOf(100), 0, rounding);

        return new Money(won.subtract(discount));
    }

    @Override
    public String toString() {
        return won.toPlainString() + "원";
    }
}

StudyPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public record StudyPeriod(LocalDate start, LocalDate end) {
    public StudyPeriod {
        Objects.requireNonNull(start, "시작일");
        Objects.requireNonNull(end, "종료일");
        if (end.isBefore(start)) {
            throw new IllegalArgumentException("종료일이 시작일보다 앞선다.");
        }
    }

    public boolean contains(LocalDate date) {
        Objects.requireNonNull(date, "조회일");
        return !date.isBefore(start) && !date.isAfter(end);
    }
}

Enrollment.java

import java.util.List;
import java.util.Objects;

public record Enrollment(
        String student,
        Money tuition,
        StudyPeriod period,
        List<String> lessons) {

    public Enrollment {
        Objects.requireNonNull(student, "수강생");
        Objects.requireNonNull(tuition, "수강료");
        Objects.requireNonNull(period, "수강 기간");
        lessons = List.copyOf(lessons);
    }
}

Main.java

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class Main {
    private static int failures;

    private static final class LegacyEnrollment {
        private final int tuition;
        private final List<String> lessons;

        private LegacyEnrollment(int tuition, List<String> lessons) {
            this.tuition = tuition;
            this.lessons = lessons;
        }

        private int discountedTuition(int percent) {
            return tuition - tuition * percent / 100;
        }
    }

    public static void main(String[] args) {
        compare(12_000, 10);
        compare(10_005, 15);

        Money original = Money.of(10_005);
        Money truncated =
                original.discountPercent(15, RoundingMode.DOWN);
        Money rounded =
                original.discountPercent(15, RoundingMode.HALF_UP);

        System.out.println(
                "할인액 버림=" + truncated + ", 할인액 반올림=" + rounded);
        check(truncated.equals(Money.of(8_505))
                        && rounded.equals(Money.of(8_504)),
                "반올림 정책 차이");
        check(original.equals(Money.of(10_005)), "원본 금액 유지");

        StudyPeriod period = new StudyPeriod(
                LocalDate.of(2030, 3, 1),
                LocalDate.of(2030, 3, 31));

        List<String> selected = new ArrayList<>();
        selected.add("Java 기초");

        LegacyEnrollment before =
                new LegacyEnrollment(12_000, selected);
        Enrollment after = new Enrollment(
                "수강생 가", Money.of(12_000), period, selected);

        selected.add("웹 실습");

        System.out.println("변경 후 기존 목록=" + before.lessons);
        System.out.println("변경 후 새 목록=" + after.lessons());
        check(before.lessons.size() == 2
                        && after.lessons().equals(List.of("Java 기초")),
                "입력 목록 분리");

        boolean modificationBlocked = false;
        try {
            after.lessons().add("추가 수업");
        } catch (UnsupportedOperationException expected) {
            modificationBlocked = true;
        }
        check(modificationBlocked, "반환 목록 수정 차단");

        check(period.contains(LocalDate.of(2030, 3, 1))
                        && period.contains(LocalDate.of(2030, 3, 31))
                        && !period.contains(LocalDate.of(2030, 4, 1)),
                "기간 경계");

        boolean reversedPeriodRejected = false;
        try {
            new StudyPeriod(
                    LocalDate.of(2030, 3, 31),
                    LocalDate.of(2030, 3, 1));
        } catch (IllegalArgumentException expected) {
            reversedPeriodRejected = true;
        }
        check(reversedPeriodRejected, "역전 기간 거부");

        check(new Money(new BigDecimal("12000.0"))
                        .equals(Money.of(12_000)),
                "금액 표현 통일");

        boolean fractionalWonRejected = false;
        try {
            new Money(new BigDecimal("12000.5"));
        } catch (ArithmeticException expected) {
            fractionalWonRejected = true;
        }
        check(fractionalWonRejected, "소수 원 거부");

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

    private static void compare(int tuition, int percent) {
        LegacyEnrollment before =
                new LegacyEnrollment(tuition, List.of("Java 기초"));
        int oldResult = before.discountedTuition(percent);
        Money newResult =
                Money.of(tuition).discountPercent(percent, RoundingMode.DOWN);

        System.out.println(
                "입력=" + tuition + "원, 할인율=" + percent
                        + "%, 변경 전=" + oldResult
                        + "원, 변경 후=" + newResult);
        check(Money.of(oldResult).equals(newResult),
                "기존 결과 유지 " + tuition);
    }

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

줄별 해설

Money의 생성자와 계산

Money 선언의 won은 구성 요소다. public Money 블록은 구성 요소 목록을 다시 적지 않는 간결한 생성자다. 이 블록에서 입력을 검사하고 won에 정규화한 값을 대입하면, 생성자 본문이 끝난 뒤 그 값이 필드에 저장된다. 이미 만들어진 객체의 필드를 변경하는 코드가 아니다.

requireNonNull은 값이 없는 입력을 생성 시점에 거부한다. 이어지는 signum 검사는 음수를 거부한다. setScale의 UNNECESSARY는 반올림해서 입력을 받아들이지 않겠다는 뜻이다. 소수 원이 들어오면 ArithmeticException이 발생한다. 이 예제에서는 입력 조건을 관찰할 수 있도록 해당 예외를 그대로 둔다.

of는 int로부터 Money를 만드는 진입점이다. 기존 호출부의 정수를 옮기기 편하도록 제공한다. 내부 표현이 BigDecimal이라고 해서 기존 정수 곱셈의 범위까지 그대로 흉내 내는 것은 아니다. 따라서 compare에서 결과 유지를 확인하는 입력은 기존 계산도 정상적으로 처리하는 범위로 정했다.

discountPercent는 할인율을 확인한 뒤 할인액을 계산한다. divide의 두 번째 인수 0은 할인액을 정수 원 단위로 만들겠다는 뜻이며, 세 번째 인수는 그때 적용할 정책이다. 마지막 줄은 원래 금액에서 할인액을 빼고 새 Money를 반환한다. won 필드에 값을 다시 넣는 코드는 없다.

toString은 이 프로그램의 출력을 원 단위로 일정하게 보여 준다. record의 기본 문자열 표현 대신 금액 자체를 출력하도록 바꾼 것이다. 금액의 비교는 이 문자열을 사용하지 않고 equals로 수행한다.

기간과 등록 정보의 경계

StudyPeriod 생성자는 두 날짜의 존재와 순서를 확인한다. contains는 시작일보다 이전이 아니면서 종료일보다 이후가 아닌 날짜를 포함한다. 조건이 한 곳에 있으므로 호출자가 날짜 경계 규칙을 다시 조합하지 않아도 된다.

Enrollment 생성자의 List.copyOf가 공유 상태 문제를 막는 핵심이다. selected에 나중에 수업을 추가해도 저장된 목록에는 반영되지 않는다. lessons 접근자가 내부 목록을 그대로 반환해도 그 목록의 구조를 수정할 수 없다. String 요소도 불변이므로 요소를 통해 등록 내용을 바꾸는 경로도 없다.

record의 자동 equals는 구성 요소를 비교한다. Money와 StudyPeriod에서는 의미상 같은 값을 비교하는 데 어울린다. 반면 수강생이나 등록처럼 별도 식별자가 있는 업무 개체는 모든 필드가 같다는 이유만으로 같은 개체라고 볼지 검토해야 한다. 여기의 Enrollment는 등록 시점의 내용을 고정해서 보여 주는 자료로 사용한다.

비교 실행과 의도한 차이

Main의 LegacyEnrollment는 고치기 전 상태를 비교하기 위한 작은 구현이다. tuition과 lessons를 final로 선언했어도 lessons가 가리키는 목록은 바뀔 수 있다. 이 대비를 통해 final의 범위를 확인한다.

compare는 한 입력으로 기존 int 계산과 새 Money 계산을 실행한다. 출력 형식까지 같은지 비교하는 대신, 출력할 금액을 같은 Money 형태로 맞추어 비교한다. 두 결과를 나란히 출력하므로 사람이 읽어서도 확인할 수 있다.

반올림 비교는 compare 밖에 둔다. 결과 유지 검사가 PASS인 이유와 결과가 1원 달라지는 이유를 구분하기 위해서다. 이어지는 목록 검사는 입력 목록의 변경 차단과 반환 목록의 수정 차단을 각각 확인한다. 둘 중 하나만 확인하면 한쪽 경계가 열린 구현을 놓칠 수 있다.

마지막 검사들은 기간의 경계와 생성 조건을 확인한다. check는 성공과 실패를 모두 출력하고 실패 횟수를 기록한다. 마지막의 AssertionError는 FAIL이 있었는데도 프로그램이 정상 종료되는 상황을 막는다. Java의 assert 문에 의존하지 않으므로 별도 실행 옵션도 필요하지 않다.

실행 결과

네 파일이 있는 디렉터리에서 다음 명령을 실행한다. 첫 명령은 경고 검사도 함께 수행하며, 컴파일에 성공하면 별도 메시지를 출력하지 않는다.

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

기본 컴파일 명령인 javac -d out *.java로도 실행할 수 있다. 예상 출력은 다음과 같다.

입력=12000원, 할인율=10%, 변경 전=10800원, 변경 후=10800원
PASS 기존 결과 유지 12000
입력=10005원, 할인율=15%, 변경 전=8505원, 변경 후=8505원
PASS 기존 결과 유지 10005
할인액 버림=8505원, 할인액 반올림=8504원
PASS 반올림 정책 차이
PASS 원본 금액 유지
변경 후 기존 목록=[Java 기초, 웹 실습]
변경 후 새 목록=[Java 기초]
PASS 입력 목록 분리
PASS 반환 목록 수정 차단
PASS 기간 경계
PASS 역전 기간 거부
PASS 금액 표현 통일
PASS 소수 원 거부
실패 수=0

앞의 두 입력에서 변경 전과 변경 후의 금액은 같다. 할인액 반올림을 선택한 결과만 의도적으로 다르다. 목록에서는 기존 객체가 외부 변경을 그대로 관찰하지만 새 객체는 등록할 때의 내용을 유지한다. 이 차이는 계산 규칙의 변경이 아니라, 외부에서 등록 상태가 바뀌던 경로를 막은 결과다.

실무에서 자주 틀리는 것

record에 넣었으니 목록도 불변이라고 생각한다

다음 코드는 생성자 인수와 필드가 같은 목록을 가리킨다. 자동 생성되는 접근자로도 그 목록을 수정할 수 있다.

// 틀린 코드
record LessonSelection(List<String> lessons) {
}

입력 목록을 분리하고 수정 불가 목록을 저장한다. 요소가 변경 가능한 객체라면 그 요소의 설계도 함께 확인해야 한다.

// 고친 코드
record LessonSelection(List<String> lessons) {
    LessonSelection {
        lessons = List.copyOf(lessons);
    }
}

수정 불가 뷰를 독립된 사본으로 착각한다

다음 대입은 saved를 통한 수정을 막지만 selected의 변경까지 막지는 못한다. 보관할 당시의 내용을 유지하려는 목적에는 맞지 않는다.

// 틀린 코드: 등록 당시 목록을 보관하려는 경우
List<String> saved = Collections.unmodifiableList(selected);
selected.add("웹 실습");
// saved에서도 추가한 수업이 보인다.

원본 목록의 이후 변경을 반영하지 않는 목록을 만든다. 원본 변경을 계속 보여 주는 뷰가 필요한 경우와 구분해서 선택한다.

// 고친 코드
List<String> saved = List.copyOf(selected);
selected.add("웹 실습");
// saved는 복사 시점의 내용을 유지한다.

할인액과 최종 금액을 같은 대상으로 취급한다

할인액을 반올림한다는 요구인데, 다음처럼 할인 후 금액을 먼저 계산하면 다른 규칙이 된다. 10,005원의 15% 할인에서 아래 계산은 8,504.25원을 버려 8,504원으로 만든다. 기존 코드의 8,505원과 다르다.

// 틀린 코드: 기존 할인액 버림 규칙을 유지하려는 경우
BigDecimal charged = amount
        .multiply(BigDecimal.valueOf(85))
        .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN);

할인액의 소수 처리부터 수행한 뒤 원래 금액에서 뺀다. 계산 순서도 업무 규칙의 일부다.

// 고친 코드
BigDecimal discount = amount
        .multiply(BigDecimal.valueOf(15))
        .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN);
BigDecimal charged = amount.subtract(discount);

같은 숫자면 record의 equals도 같다고 생각한다

정규화하지 않은 BigDecimal을 구성 요소로 사용하면 소수 자릿수 차이가 record의 동등성에도 전달된다. HashSet이나 HashMap에서 예상하지 못한 구분으로 나타날 수 있다.

// 틀린 코드: 원 단위 금액의 동등성을 표현하려는 경우
record RawMoney(BigDecimal won) {
}
// new RawMoney(new BigDecimal("12000"))과
// new RawMoney(new BigDecimal("12000.0"))은 equals가 false다.

허용할 표현을 생성 시점에 통일한다. 다음 코드는 소수 자릿수에 관한 수정 부분이며, 완성 코드의 null 검사와 음수 검사도 함께 유지해야 한다.

// 고친 코드의 핵심
won = won.setScale(0, RoundingMode.UNNECESSARY);

한눈에 보기

불변 상태를 만들기 위해 확인할 경계
대상놓치기 쉬운 점이 장의 선택확인 방법
금액정수만으로 의미가 드러나지 않음Money로 묶고 원 단위로 통일기존 계산 결과와 비교
기간역전된 날짜와 경계 규칙StudyPeriod에서 생성 조건 검사양 끝과 범위 밖 날짜 확인
목록 입력원본 목록의 변경이 전파됨List.copyOf로 저장원본을 바꾼 뒤 등록 내용 확인
목록 반환접근자로 내부 상태를 수정함수정 불가 목록 반환add가 거부되는지 확인
목록 요소목록만 복사하고 요소는 공유함불변인 String 사용변경 가능한 요소의 존재 확인
할인 계산반올림 대상과 시점이 바뀜할인액을 먼저 정수로 확정소수 할인액 사례 비교

불변 객체를 공유하면 다른 코드가 값을 바꾸는지 추적할 부담이 줄어든다. 다만 공유 변수에 새 객체를 대입하는 작업이나 여러 객체를 함께 갱신하는 작업까지 자동으로 안전해지는 것은 아니다. 이 장에서 막은 범위는 생성된 값의 내부 변경과 목록을 통한 외부 변경이다.

다음 장에서는 여기의 검사처럼 동작을 확인하는 코드도 읽기 쉬워야 한다는 문제를 다룬다. 검사 이름만 읽고도 어떤 입력과 규칙을 확인하는지 알 수 있도록 정리하는 일이 이어진다.

연습 문제

  1. compare에 수강료 10,010원, 할인율 5%인 사례를 추가하라. 기존 계산과 DOWN의 최종 금액을 구하고, HALF_UP을 사용했을 때도 같은지 설명하라.
  2. StudyPeriod에서 시작일과 종료일이 모두 2030년 3월 15일인 기간을 생성하라. 해당 날짜를 포함하고 전날과 다음 날을 포함하지 않는지 check로 확인하라.
  3. Enrollment의 수업 이름을 변경 가능한 Lesson 객체로 바꾸었다고 가정하라. List.copyOf만 적용했을 때 남는 변경 경로를 설명하고, 이름만 가진 불변 Lesson을 record로 작성하라.
  4. Money.of(12_000).discountPercent(10, RoundingMode.DOWN)을 호출한 뒤 반환값을 저장하지 않았다. 원래 Money가 어떻게 되는지 설명하고, 할인 전후 금액을 모두 보관하는 코드를 작성하라.

정답과 해설

  1. 정확한 할인액은 500.5원이다. 기존 정수 계산과 DOWN은 500원을 할인하므로 최종 금액은 9,510원이다. HALF_UP은 할인액을 501원으로 만들어 최종 금액이 9,509원이 된다. 정확히 중간인 입력이므로 반올림 정책의 차이를 확인하기에 적합하다.

    compare(10_010, 5);
    check(Money.of(10_010)
                    .discountPercent(5, RoundingMode.HALF_UP)
                    .equals(Money.of(9_509)),
            "중간 할인액 반올림");
  2. 생성 조건은 종료일이 시작일보다 앞서는 경우만 거부한다. 같은 날짜는 허용되며 contains는 양 끝을 포함한다.

    LocalDate day = LocalDate.of(2030, 3, 15);
    StudyPeriod oneDay = new StudyPeriod(day, day);
    check(oneDay.contains(day)
                    && !oneDay.contains(day.minusDays(1))
                    && !oneDay.contains(day.plusDays(1)),
            "하루짜리 기간");
  3. 목록 요소의 참조는 공유된다. 외부에서 Lesson의 이름을 바꾸면 등록 객체에서도 바뀐 이름을 읽게 된다. 요소도 불변으로 만들면 이 변경 경로를 막을 수 있다. 다음 코드는 별도의 Lesson.java에 둘 수 있다.

    import java.util.Objects;
    
    public record Lesson(String name) {
        public Lesson {
            Objects.requireNonNull(name, "수업 이름");
        }
    }

    이 Lesson만 요소로 사용하는 List.copyOf의 결과는 목록 구조와 요소 상태를 모두 변경할 수 없다. 요소에 다시 변경 가능한 객체를 추가한다면 그 구성 요소도 확인해야 한다.

  4. 원래 Money는 바뀌지 않는다. 반환값을 보관하지 않으면 계산 결과를 이후에 사용할 수 없을 뿐이다. 두 값을 각각 저장하면 할인 전 금액과 할인 후 금액을 혼동하지 않고 사용할 수 있다.

    Money originalTuition = Money.of(12_000);
    Money discountedTuition =
            originalTuition.discountPercent(10, RoundingMode.DOWN);
    
    check(originalTuition.equals(Money.of(12_000))
                    && discountedTuition.equals(Money.of(10_800)),
            "할인 전후 금액 보관");
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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