Devin.KR

객체와 컬렉션의 상태

70분 안팎

학습 목표

Task와 저장소를 분리하고 목록 반환이 내부 상태를 바꾸지 않게 구현합니다.

개념

저장소와 반환 목록의 주인을 정합니다

AI 코드에서 return tasks 한 줄은 간단해 보입니다. 그러나 호출자가 그 목록을 비우면 서비스 안의 할 일까지 사라지는 구조라면 조회가 저장소 수정 권한을 내준 셈입니다. 이번 레슨은 Task에 한 항목의 값을 담고 TaskRepository에 여러 항목의 저장을 맡깁니다. 목록을 읽는 동작과 내부 상태를 바꾸는 동작을 분리해, 반환값을 받은 사람이 무엇을 바꿀 수 있는지 직접 확인합니다.

상태는 메모리에 현재 저장된 모든 할 일의 값입니다. Python 모델은 상태를 인자로 받고 새 상태를 반환했습니다. Java 서비스는 객체 안의 저장소를 사용합니다. 저장 방식은 달라도 외부 계약은 유지합니다. 생성 후 조회하면 생성한 값이 보이고, 실패 후에는 이전 목록과 값이 같습니다. 상태를 감추었다는 이유로 불변 조건까지 생긴 것으로 생각하지 않습니다.

Task는 public record Task(int id, String title, boolean done)로 선언합니다. record는 생성자, 필드 읽기 메서드와 값 비교 등을 제공합니다. 읽기는 task.id(), task.title(), task.done()처럼 합니다. 이 record의 필드는 int, String, boolean이고 내용을 바꾸는 setter를 두지 않습니다. 따라서 한 Task를 두 목록에서 공유해도 그 항목의 done을 직접 바꾸는 경로가 없습니다.

record라는 문법 자체가 모든 중첩 객체를 불변으로 만드는 것은 아닙니다. 만약 Task에 수정 가능한 List 필드를 추가하면 그 리스트 내용은 별도 방어가 필요합니다. 이번 안전성의 근거는 실제 필드 세 개가 모두 불변 값이라는 점입니다. 학습자는 클래스 이름이 아니라 필드의 자료형과 공개된 수정 메서드를 확인하는 습관을 갖춥니다. 데이터 구조가 바뀌면 복사 정책도 다시 검토합니다.

객체의 교체와 목록의 복사를 구분합니다

완료 처리는 기존 Task의 done을 바꾸는 대신 old.completed()로 새 Task를 만듭니다. completed는 id와 title을 유지하고 done만 true인 값을 반환합니다. 저장소에 그 새 값을 넣으면 다음 조회는 완료를 보여 줍니다. 이전 조회에서 받은 Task는 계속 false입니다. 이것이 스냅샷의 의미입니다. 완료 취소나 제목 변경 메서드는 이번 범위에 없습니다.

List<Task>는 Task를 순서대로 담는 목록이라는 형식입니다. 꺾쇠 안의 Task는 목록 원소의 자료형을 제한합니다. Map<Integer, Task>는 정수 번호와 항목의 대응입니다. 제목이 같은 항목 두 개는 서로 다른 번호에 저장할 수 있습니다. title을 Map 키로 쓰면 두 번째 복습이 첫 번째를 덮어쓰므로 중복 제목을 허용한 명세를 어기게 됩니다.

이번 저장소는 TreeMap을 사용해 정수 키 오름차순으로 값을 꺼냅니다. HashMap에서 우연히 1, 2 순서로 보였다고 조회 순서가 보장되는 것은 아닙니다. 테스트는 2번을 먼저 넣고 1번을 뒤에 넣어도 목록이 1, 2가 되는지 검사합니다. 실제 미션 초기 상태는 번호가 1부터 연속인 정상 목록이며, 뒤섞인 삽입은 저장 구조의 순서를 따로 관찰하기 위한 부분 연습입니다.

new ArrayList<>(tasks.values())는 현재 값들을 새 목록에 담습니다. 반환 목록은 수정 가능하므로 clear와 set을 호출할 수 있습니다. 하지만 바깥 목록은 새 것이고 원소는 불변 Task여서 내부 저장소에는 영향이 없습니다. List.copyOf로 수정 불가 목록을 반환하는 설계도 가능하지만 이 실습은 반환 목록을 실제로 비워도 저장소가 살아 있는 것을 확인하도록 수정 가능한 복사를 선택합니다.

Collections.unmodifiableList처럼 기존 목록을 감싼 뷰는 외부 수정만 막을 수 있고 내부 변경이 뷰에 보일 수 있습니다. 수정 금지와 시점 고정은 다른 속성입니다. 이 레슨의 list는 호출 시점의 별도 목록을 만드는 스냅샷입니다. 아직 완료하지 않은 조회 결과를 보관한 뒤 완료하면 보관 값은 미완료로 남고 새 조회만 완료가 됩니다. 두 조회의 값을 나란히 비교합니다.

생성자와 필드의 역할을 읽습니다

TaskRepository의 private final 필드는 저장 구조를 가리킵니다. private은 클래스 밖에서 그 필드에 직접 접근하지 못하게 합니다. final은 필드 참조를 다른 Map으로 재대입하지 못하게 할 뿐 Map의 put까지 막지는 않습니다. final Map을 불변 저장소로 해석하면 save가 상태를 바꾸는 사실을 놓칩니다. 접근 제한, 참조 재대입 제한, 객체 내용 수정 제한을 구별합니다.

기본 생성자는 빈 저장소를 만듭니다. 초기 목록을 받는 생성자는 각 Task를 자체 Map에 넣습니다. 넘겨받은 List를 필드로 보관하지 않으므로 호출자가 초기 목록을 나중에 비워도 저장된 항목은 남습니다. Task 값은 불변이라 재사용할 수 있습니다. 정상 초기 상태라는 전제는 README에 적으며 깨진 id나 null 항목을 자동 복구하는 기능은 추가하지 않습니다.

save는 같은 id에 새 Task 값을 넣고 find는 id에 대응하는 값을 찾습니다. 없는 번호의 find는 null입니다. 저장소는 업무 오류를 결정하지 않고 서비스가 NOT_FOUND로 바꿉니다. size는 현재 항목 수이며 삭제가 없는 미션에서 다음 번호 계산에 쓰입니다. 조회 목록의 길이를 다음 번호로 삼는 코드가 있어도 반환 목록을 호출자가 바꿨다면 서비스의 실제 size와 혼동해서는 안 됩니다.

복사 검사를 실제로 실패시켜 봅니다

StateTest의 snapshotIsDetached는 첫 조회 목록을 비운 뒤 두 번째 조회에 원래 항목이 있는지 확인합니다. snapshotIsNotLive는 이전 조회를 보관하고 저장소에서 같은 id를 완료 값으로 교체한 뒤 두 목록의 done을 비교합니다. initialListIsDetached는 초기 목록을 비운 뒤 저장소의 크기를 확인합니다. 세 검사는 각각 다른 공유 경로를 찾으므로 하나가 통과했다고 나머지를 생략하지 않습니다.

starter의 list는 항상 빈 목록을 돌려줍니다. emptySnapshot은 통과하지만 항목이 있는 검사는 실패합니다. 이를 저장소 자체를 반환하는 코드로 고치면 다른 공유 문제가 생길 수 있으므로 번호순 값들을 독립된 ArrayList에 담습니다. 저장소의 Map을 비우거나 테스트 기대 목록을 빈 값으로 바꾸면 안 됩니다. list 한 메서드를 완성한 뒤 전체 다섯 검사를 다시 실행합니다.

IndexOutOfBoundsException이 get(0)에서 나오면 복사 문제보다 먼저 조회가 빈 목록이 되었는지 봅니다. 이 starter는 테스트가 그런 환경 오류로 중단되지 않도록 비교에 충분한 정보를 줍니다. UnsupportedOperationException은 수정 불가 목록을 비우려 했다는 뜻입니다. 그 설계를 택했다면 명시된 실습 계약과 맞는지 검토하며 예외를 catch해서 내부 상태 검사를 건너뛰지 않습니다.

목록 크기는 맞지만 순서 비교가 실패하면 저장 구조의 반복 순서와 계약의 id 오름차순을 대조합니다. 항목 값 비교가 같은데 같은 객체인지 궁금할 때는 equals와 참조 비교를 구분합니다. 이번 테스트는 주소가 같다는 이유로 결함이라고 하지 않습니다. 불변 원소 공유는 허용되며 요구사항은 외부 관찰 값과 수정 영향의 분리입니다. 필요 없는 깊은 복사로 의미를 흐리지 않습니다.

완료 판단은 반환 목록 변경, 초기 목록 변경, 저장소의 후속 교체가 서로 영향을 주지 않는다는 근거로 합니다. AI 설명에 방어적 복사라는 단어가 있어도 어느 층을 복사했는지 코드와 테스트로 확인합니다. 일반적인 List와 Map의 성능·구현 선택은 더 읽기로 보냅니다. 지금은 이 프로젝트의 상태 소유권을 값의 변화로 설명할 수 있는지가 기준입니다.

실습 코드에서 찾을 부분

아래 두 메서드는 서로 다른 클래스에 있습니다. TaskRepository.list는 목록을 복사하고 Task.completed는 새로운 완료 값을 만듭니다. 바깥 목록과 안쪽 값의 안전성을 각각 설명합니다.

public List<Task> list() {
    return new ArrayList<>(tasks.values());
}
public Task completed() {
    return new Task(id, title, true);
}

따라하기

관찰 프로젝트 준비

완료 객체 교체와 스냅샷을 관찰하기 전에 StateTest의 다섯 계약을 실행합니다. 이후 단계에서 반환 목록과 저장소를 따로 비교합니다.

./mvnw test

반환 목록을 비워 보기

case 1에서 copy만 clear합니다. COPY와 STORE 크기가 서로 다른 이유를 저장소 Map과 반환 ArrayList의 주인으로 설명합니다. 저장소까지 비우는 코드는 만들지 않습니다.

java -cp target/classes demos/Demo.java 1

실행 결과

COPY=0
STORE=1

이전 조회와 완료 후 조회 비교

case 2는 이전 목록을 보관하고 같은 id를 완료 값으로 교체합니다. BEFORE와 AFTER를 비교하고 불변 Task 공유가 현재 필드 구성에서 안전한 이유를 적습니다.

java -cp target/classes demos/Demo.java 2

실행 결과

BEFORE=false
AFTER=true

저장 순서와 번호순 조회 비교

case 3은 번호 2를 먼저 저장합니다. 그럼에도 1부터 조회되는 근거를 TreeMap 키 순서에서 찾습니다. 제목이 같아도 항목 둘이 남는 이유를 id와 연결합니다.

java -cp target/classes demos/Demo.java 3

실행 결과

1:복습
2:복습

확인 문제

실습

TaskRepository.list를 번호순의 수정 가능한 독립 스냅샷으로 완성합니다. 저장소 구조와 Task record를 유지하고 제공된 상태 테스트 5개로 반환 목록·초기 목록·후속 완료의 독립성을 검사합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

테스트 5개가 실패·오류·건너뜀 없이 통과하며 종료 코드는 0입니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • AI가 만든 코드가 실행될 때 추가로 확인할 내용을 설명해 주시면 됩니다.