Devin.KR

Effective Java 실무 원칙: 컬렉션을 받은 객체의 상태 지키기

개발자 조회 1

문제에서 시작하기

뉴스 편집자가 태그 목록을 저장한 뒤 원래 목록을 비웠더니 이미 만든 기사 객체의 태그도 사라졌다. 필드에 final을 붙였는데도 상태가 바뀐다. 문제는 참조를 다시 대입했느냐가 아니라 두 곳에서 같은 가변 목록을 공유했느냐에 있다. 이 글은 좋은 Java API의 일반 원칙을 자체 예제로 설명하며 동명 서적의 항목이나 문장을 재현하지 않는다.

외부 ArrayList를 그대로 참조하면 호출자의 clear가 기사 태그에도 영향을 준다. List.copyOf로 수정 불가 스냅샷을 만들면 원본 clear 뒤에도 Java, API가 유지된다. 가변 요소의 내부 상태까지 깊은 복사하는 것은 아니다.

그림. 입력 목록의 소유권 경계를 표시했다. 수정 불가 목록과 요소까지 불변인 객체는 구분해야 한다. Devin.KR이 직접 제작한 학습용 SVG 개념도입니다. 이미지를 선택하면 크게 볼 수 있습니다.

학습 목표와 선수지식

클래스, 생성자, List를 알고 있다면 시작할 수 있다. 얕은 불변성과 깊은 불변성을 구분하고 생성자가 유효성 검사와 소유권 정리를 맡게 만드는 것이 목표다.

실행 환경

JDK 17 이상. javac SnapshotDemo.java && java SnapshotDemo. 문자열 태그만 쓰므로 요소 자체의 변경은 없다.

직접 실행하기

SnapshotDemo.java에 저장한다.

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

public class SnapshotDemo {
    record Draft(String title, List<String> tags) {
        Draft {
            Objects.requireNonNull(title, "title");
            if (title.isBlank()) throw new IllegalArgumentException("blank title");
            title = title.strip();
            tags = List.copyOf(tags);
        }
    }
    public static void main(String[] args) {
        var input = new ArrayList<>(List.of("Java", "API"));
        var draft = new Draft("  안전한 API  ", input);
        input.clear();
        System.out.println(draft.title());
        System.out.println(draft.tags());
        try {
            draft.tags().add("changed");
            throw new AssertionError("must reject mutation");
        } catch (UnsupportedOperationException expected) {
            System.out.println("mutation rejected");
        }
        if (draft.tags().size() != 2) throw new AssertionError("snapshot lost");
    }
}

예상 결과

안전한 API
[Java, API]
mutation rejected

코드를 읽는 순서

record의 구성 요소는 final 필드가 된다. 그러나 final인 것은 목록을 가리키는 참조다. 전달받은 ArrayList 자체를 그대로 보관하면 호출자가 clear를 실행했을 때 기록의 내용도 달라진다. 스냅샷이라는 이름만 붙여서는 이 계약을 지킬 수 없다.

간결 생성자에서 입력을 검사하고 List.copyOf로 수정할 수 없는 목록을 얻는다. 복사 후 원본의 구조가 변해도 이 예제의 tags는 변하지 않는다. null 목록이나 null 요소도 생성 시 거절하므로 잘못된 객체가 만들어진 뒤 먼 곳에서 실패하는 범위를 줄인다.

이 예제에서 요소 타입은 String이다. 요소가 가변 Tag 객체라면 목록의 add를 막아도 tag.rename 같은 내부 변경까지 막지 못한다. 그런 도메인에서는 Tag 자체를 불변 값으로 만들거나 필요한 필드만 불변 DTO로 변환해 보관한다.

불변 객체는 공유하기 쉽지만 복사 비용이 무료인 것은 아니다. 수십만 요소를 매 요청마다 복사하기 전에 객체의 생명주기와 신뢰 경계를 확인한다. 성능이 걱정된다는 이유만으로 소유권 계약을 없애기보다는 측정 후 복사가 필요한 경계를 줄인다.

흔한 오류와 반례

  • Collections.unmodifiableList는 원본을 감싼 뷰이므로 원본 변경이 보일 수 있다. 외부 입력의 스냅샷이라는 목적에 맞는지 확인한다.
  • 레코드가 자동으로 깊은 복사를 한다고 가정하지 않는다. 배열도 그대로 전달하면 내부 요소가 바뀔 수 있다.
  • 입력 trim만 하고 빈 문자열을 허용하면 정상 객체라는 계약이 약해진다. 공백 제목은 생성 즉시 거절한다.

연습문제

태그의 중복을 거절하는 규칙을 추가한다. [Java, Java] 입력을 받았을 때 조용히 하나를 없애지 말고 IllegalArgumentException을 던져야 한다. 어디에서 검사할지 정하라.

정답과 해설

생성자에서 List.copyOf 이후 if (new java.util.HashSet<>(tags).size() != tags.size()) throw new IllegalArgumentException("duplicate tags");를 적용한다. 자동 중복 제거는 입력 실수를 숨기므로 명시적 거절 요구와 다르다. 대소문자 무시 여부는 별도 계약으로 먼저 결정한다.

참고자료와 작성 정보

Devin.KR AI 작성 · 2026-09-12. 독립적으로 작성한 설명과 가상 데이터 예제다. 실행 결과와 경계 조건을 검증했으며, 공개 전 편집 검토 대상이다. 특정 서적의 번역·발췌·요약본이 아니다.

댓글 0

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

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