Devin.KR

리스트와 맵 선택

70분 안팎

학습 목표

순회·ID 조회 비용과 equals·hashCode의 관계를 비교합니다.

개념

왜 도서를 여러 권 관리하나요

앞 모듈에서는 Book 한 권의 제목과 대여 상태를 검증했습니다. 이제 담당자는 전체 목록을 보고 특정 도서를 수정해야 합니다. 같은 제목의 책이 두 권일 수 있으므로 제목만으로 실물을 구별하지 않습니다. 이번 저장소의 ID는 양수 정수이며 같은 ID를 두 번 등록하면 거부합니다. 기존 Book 생성자와 rename, 대여 규칙은 유지합니다. ID는 저장소의 키로 두며 제목이 바뀌어도 키는 바뀌지 않습니다. 이 선택으로 도서 객체에 새 생성자를 추가하지 않고 앞 모듈 테스트를 그대로 실행할 수 있습니다.

리스트의 위치와 업무 ID는 다릅니다

List는 원소를 순서대로 보관하는 인터페이스입니다. ArrayList는 전체 순회와 위치 접근에 사용할 수 있습니다. list.get(0)은 첫 위치의 값을 얻지만 ID가 0인 도서를 찾는 의미는 아닙니다. 앞의 책을 삭제하면 뒤 책의 위치가 바뀌므로 인덱스를 영구 ID로 쓰면 다른 책을 수정할 수 있습니다. 별도 ID를 가진 행을 리스트로 저장한다면 ID 조회에서는 각 행의 ID를 비교해야 합니다. 끝에 있거나 없는 ID를 찾는 경우 원소 수만큼 확인할 수 있습니다.

목록을 읽는 작업은 결과에 모든 원소가 필요하므로 맵을 써도 순회를 피하지 못합니다. 리스트는 순서와 중복을 그대로 표현하기 좋습니다. 반대로 중복이 업무상 허용되지 않는다는 요구는 List 자체가 해결하지 않습니다. add를 호출하기 전에 별도 검증이 필요합니다. 어떤 구조를 쓰든 정상 입력만 넣어 보고 중복을 막았다고 판단하지 않습니다. 같은 ID와 다른 제목을 가진 두 번의 요청을 테스트해야 기존 값 보존까지 관찰할 수 있습니다.

맵은 키에서 도서를 찾습니다

Map<Integer, Book>은 정수 키 하나와 도서 값 하나를 연결합니다. HashMap의 키 조회는 해시가 잘 분산되는 조건에서 평균적으로 빠른 조회를 기대합니다. 모든 입력에서 일정한 시간이라는 보장은 아닙니다. 이번 실습은 작은 목록의 예측 가능한 출력도 필요해 LinkedHashMap을 사용합니다. 기본 삽입 순서를 유지하지만 ID 오름차순을 자동으로 만들어 주지는 않습니다. ID 2를 먼저 등록하고 1을 다음에 등록하면 목록의 키 순서는 2, 1입니다. 정렬은 다음 레슨에서 명시합니다.

books.put(id, book)은 같은 키가 있으면 값을 교체합니다. 중복 등록을 거부하려면 containsKey를 먼저 검사하고 예외를 발생시킨 뒤 put에 도달하지 않게 합니다. 이 실습은 한 스레드가 사용하는 저장소이며 검사와 등록을 여러 스레드에서 동시에 실행하는 안전성까지 제공하지 않습니다. putIfAbsent를 안다고 저장소 전체가 동시 요청에 안전해지는 것도 아닙니다. 동시성 요구는 뒤 모듈에서 따로 다룹니다. 지금은 중복 요청 실패 후 원본 제목이 유지되는지 확인합니다.

equals와 hashCode가 조회를 연결합니다

해시 구조는 hashCode로 후보를 좁히고 equals로 같은 키인지 확인합니다. equals가 true인 두 키는 같은 hashCode를 반환해야 합니다. 반대로 hashCode가 같아도 서로 다른 키일 수 있으므로 해시값 자체를 ID로 쓰지 않습니다. Integer 키는 이미 값 동등성 계약을 제공합니다. 숫자 래퍼를 ==로 비교하면 참조 비교가 섞일 수 있으므로 사용자 정의 키를 만들 때는 equals 계약을 생각합니다. 이번 저장소에서는 정수 키를 그대로 쓰고 Book의 동등성을 임의로 바꾸지 않습니다.

앞 모듈 Book에는 equals 재정의가 없습니다. new Book("A")를 두 번 만들면 제목은 같아도 서로 다른 객체입니다. HashSet<Book>에 넣는다고 제목이나 ID 중복 등록이 사라지지 않습니다. 제목을 키에 포함한 사용자 정의 객체는 rename 이후 해시가 바뀌면 조회가 어려워질 수 있습니다. 저장된 동안 바뀌지 않는 ID를 키로 고른 이유가 여기 있습니다. 동등성과 불변 키의 더 넓은 설명은 연결한 서재에서 읽고, 이 실습에서는 같은 제목의 다른 ID를 허용하는 테스트로 선택을 확인합니다.

외부 참조가 규칙을 우회하지 않게 합니다

Map을 private으로 두어도 find가 내부 Book을 그대로 반환하면 호출자가 rename이나 returnBook을 직접 호출할 수 있습니다. 파일 저장소로 확장한 뒤에는 이런 변경이 파일에 남지 않는 문제가 생깁니다. MemoryRepository.copy는 제목과 대여 상태를 새 Book에 옮깁니다. add에도 복사본을 넣고 find와 all에도 복사본을 반환합니다. all은 새 맵을 반환하므로 호출자가 clear해도 내부 목록은 유지됩니다. 얕은 맵 복사는 내부 도서 참조를 공유하기 때문에 이 요구를 충족하지 못합니다.

복사는 메모리와 시간을 사용합니다. 많은 데이터에서는 반환 전용 불변 객체나 페이지 단위 조회를 검토할 수 있습니다. 여기서는 앞 모듈의 가변 Book을 유지하면서 저장소 변경 경로를 제한하는 단순한 선택입니다. 도서 대여 상태도 복사해야 하므로 새 Book만 만들고 끝내지 않습니다. isBorrowed가 true이면 복사본에도 borrow를 호출합니다. ID를 찾았다는 확인과 객체 상태를 정확히 옮겼다는 확인을 다른 테스트로 나누면 실패 원인을 읽기 쉽습니다.

부재와 잘못된 요청을 구별합니다

find는 없는 ID에 Optional.empty를 반환합니다. Optional은 값이 있을 수도 없을 수도 있다는 계약을 타입으로 드러냅니다. 화면에서는 부재를 안내할 수 있고 테스트에서는 isEmpty로 관찰합니다. 없는 도서를 수정하거나 삭제하면 NoSuchElementException으로 거부합니다. null 도서를 등록하거나 양수가 아닌 ID를 등록하면 IllegalArgumentException입니다. 빈 목록은 정상 상태이므로 오류로 취급하지 않습니다. 조회 결과를 무턱대고 orElseThrow로 꺼내는 코드는 부재를 처리할 수 있는 위치인지 먼저 생각합니다.

실패 보고서에서 계약을 찾습니다

starter의 TODO는 중복 ID 검사입니다. 테스트가 Expected IllegalArgumentException to be thrown, but nothing was thrown이라고 보고하면 등록을 계속 진행한 분기를 봅니다. expected A but was B라면 기존 도서를 덮었는지 확인합니다. 맵의 크기가 1이라는 사실만으로 중복 거부를 입증할 수 없습니다. 덮어써도 크기는 그대로이기 때문입니다. RepositoryTest의 이름과 실패 줄을 읽고 입력, 예외, 기존 제목을 함께 확인합니다. 컴파일 오류는 규칙 판단 이전 문제이므로 테스트 기대값을 바꾸어 숨기지 않습니다.

완료를 설명할 수 있는 기준

실습은 앞 모듈의 누적 테스트 33개와 저장소 테스트 6개를 실행합니다. 수정 후 39개가 실행되고 실패·오류·건너뜀이 0인지 보고서에서 확인합니다. 전체 순회가 필요한 목록에는 순서가 의미 있고, 특정 도서 조회에는 키가 의미 있다는 두 문장으로 선택을 설명합니다. 리스트와 맵 중 하나가 모든 상황에서 우월하다는 설명 대신 이번 요구의 연산을 적습니다. ID 조회, 중복 등록 거부, 목록 순서, 외부 참조 차단을 각각 어떤 코드와 테스트가 보장하는지 README에 기록합니다.

API 확인: Java 17 LinkedHashMap 공식 문서의 기본 삽입 순서 계약을 기준으로 목록 테스트를 작성했습니다.

따라하기

실습 경로와 실패 테스트 읽기

이 모듈의 로컬 실습은 JDK 17과 Maven Wrapper를 사용합니다. starter를 풀고 pom.xml과 mvnw가 있는 폴더에서 실행합니다. 의존성 캐시가 없다면 온라인으로 먼저 준비합니다. 제공 테스트와 앞 모듈의 Book 규칙을 유지하고, 보고서에서 실패 이름과 expected·actual을 확인합니다. 이번에는 중복 ID 등록이 거부되는지 RepositoryTest를 읽습니다.

chmod +x mvnw
./mvnw test

TODO를 계약에 맞게 구현

README와 src/test/java/lab의 테스트를 읽습니다. MemoryRepository.add에서 containsKey로 중복을 거부한 뒤 put합니다. 기존 Book과 테스트를 유지하고 전체 테스트를 다시 실행합니다.

./mvnw test

독립 관찰 예제 실행

수정 후 빌드된 target/classes에서 제공 관찰 프로그램을 실행합니다. 삽입 순서, 중복 등록 오류, 기존 제목이 차례로 출력되는지 확인합니다.

java -cp target/classes lab.CollectionDemo

실행 결과

[2, 1]
중복 ID: 2
Java 노트

누적 테스트 결과 확인

전체 테스트를 실행하고 바로 종료 상태를 확인합니다. target/surefire-reports에서 제공 테스트 39개와 Failures·Errors·Skipped가 모두 0인지 확인합니다. 새 테스트를 추가했다면 개수가 늘어납니다.

./mvnw -q test
echo $?

실행 결과

0

확인 문제

실습

같은 ID의 도서를 중복 추가하지 않는 목록을 구현합니다. starter의 TODO를 완성하고 README에 정상·경계·실패 경로와 실패 뒤 원본이 보존되는 이유를 작성합니다. 제공 테스트와 앞 모듈의 도서 규칙을 유지합니다.

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

실행 명령

./mvnw test

기대 결과

39개 테스트 실행, 실패·오류·건너뜀 0, 종료 코드 0입니다.

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

더 읽기

면접 질문

  • 리스트의 인덱스와 도서 ID를 구분해야 하는 이유는 무엇인가요?
  • equals와 hashCode는 어떤 관계를 지켜야 하나요?