Map 계약과 구현
배열 병렬 저장의 불일치를 Map으로 고치고 HashMap·LinkedHashMap·TreeMap의 키 순서와 조회 비용을 구분합니다.
Set이 값의 존재 여부를 묻는다면 Map은 고유 키와 연결된 값을 찾습니다.
회원 id로 Member를 조회하거나 제목별 조회 수를 누적할 때 키를 전체 목록에서 반복 검색하지 않고 관계 자체를 저장합니다.
같은 키에 put하면 원소가 하나 더 생기는 것이 아니라 기존 값이 교체됩니다.
개별 배열 정렬에 따른 키·값 불일치
키와 값을 같은 index의 병렬 배열에 저장하면 모든 변경을 함께 수행해야 합니다.
다음 코드는 제목만 정렬해 조회수와의 관계를 깨뜨립니다.
출력 타입은 맞아서 컴파일러가 발견하지 못하는 데이터 오류입니다.
import java.util.Arrays;
public final class ParallelArrayMapBug {
public static void main(String[] args) {
String[] titles = {"thread", "array", "hash"};
int[] viewCount = {70, 30, 50};
Arrays.sort(titles);
for (int i = 0; i < titles.length; i++) {
System.out.println(titles[i] + "=" + viewCount[i]);
}
}
}array=70이라는 잘못된 관계가 출력됩니다.
제목만 정렬하면 세 조회 수의 주인이 바뀐다
제목만 정렬해 같은 인덱스의 조회 수 대응이 틀어지는 전후 상태입니다. 세 쌍을 고정해 정렬 전·제목 변경·잘못된 출력으로 추적하며 잘못된 대응 관계를 확인할 수 있습니다.
ParallelArrayMapBug의 같은 인덱스를 정렬 전과 후로 비교합니다. 제목은 array, hash, thread로 바뀌지만 조회수 배열 70, 30, 50은 그대로라 세 출력 관계가 모두 잘못됩니다.
MAP · KEY–VALUE
제목만 정렬하면 세 조회수의 주인이 바뀐다
같은 index를 유지해도 제목만 정렬하면 키와 값의 관계가 모두 바뀝니다.
index
0
- 정렬 전 관계
thread=70- 제목만 정렬한 실제 출력
array=70
index
1
- 정렬 전 관계
array=30- 제목만 정렬한 실제 출력
hash=30
index
2
- 정렬 전 관계
hash=50- 제목만 정렬한 실제 출력
thread=50
이 예의 조건
- 제목 배열만 정렬하는 원 오류이며 컴파일 성공이 관계 정확성을 보장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 영역별 같은 index의 제목과 조회 수 전후를 연결합니다. 제목만 정렬되어 array 70/hash 30/thread 50이 되고 원래 array 30/hash 50/thread 70과 다릅니다. 실선은 전후 관계이며 원래 출력은 그대로입니다. |
| 5 | thread |
| 6 | 조회 수 70 |
| 8 | array |
| 9 | 조회 수 70 |
| 10 | array의 원래 값은 30 |
| 13 | array |
| 14 | 조회 수 30 |
| 16 | hash |
| 17 | 조회 수 30 |
| 18 | hash의 원래 값은 50 |
| 21 | hash |
| 22 | 조회 수 50 |
| 24 | thread |
| 25 | 조회 수 50 |
| 26 | thread의 원래 값은 70 |
| 구분 | 내용 |
|---|---|
| 설명 | 영역별 같은 index의 제목과 조회 수 전후를 연결합니다. 제목만 정렬되어 array 70/hash 30/thread 50이 되고 원래 array 30/hash 50/thread 70과 다릅니다. 실선은 전후 관계이며 원래 출력은 그대로입니다. |
| 전체 읽기 정보 | 제목만 정렬하면 세 조회 수의 주인이 바뀐다 제목만 정렬하면 세 조회 수의 주인이 바뀐다 ParallelArrayMapBug의 같은 인덱스를 정렬 전과 후로 비교합니다. 제목은 array, hash, thread로 바뀌지만 조회 수 배열 70, 30, 50은 그대로라 세 출력 관계가 모두 잘못됩니다. MAP · KEY–VALUE 제목만 정렬하면 세 조회 수의 주인이 바뀐다 같은 index를 유지해도 제목만 정렬하면 키와 값의 관계가 모두 바뀝니다. index 0 정렬 전 관계 thread=70 제목만 정렬한 실제 출력 array=70 index 1 정렬 전 관계 array=30 제목만 정렬한 실제 출력 hash=30 index 2 정렬 전 관계 hash=50 제목만 정렬한 실제 출력 thread=50 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원 대응 동일 index의 제목과 조회 수를 읽습니다. |
|
|
| 제목만 정렬 제목 순서를 array/hash/thread로 바꿉니다. |
|
|
| 잘못된 결과 원 관계와 다른 짝이 출력됩니다. |
|
|
제목만 정렬하면 세 조회 수의 주인이 바뀐다
제목만 정렬해 같은 인덱스의 조회 수 대응이 틀어지는 전후 상태입니다. 세 쌍을 고정해 정렬 전·제목 변경·잘못된 출력으로 추적하며 잘못된 대응 관계를 확인할 수 있습니다.
ParallelArrayMapBug의 같은 인덱스를 정렬 전과 후로 비교합니다. 제목은 array, hash, thread로 바뀌지만 조회수 배열 70, 30, 50은 그대로라 세 출력 관계가 모두 잘못됩니다.
MAP · KEY–VALUE
제목만 정렬하면 세 조회수의 주인이 바뀐다
같은 index를 유지해도 제목만 정렬하면 키와 값의 관계가 모두 바뀝니다.
index
0
- 정렬 전 관계
thread=70- 제목만 정렬한 실제 출력
array=70
index
1
- 정렬 전 관계
array=30- 제목만 정렬한 실제 출력
hash=30
index
2
- 정렬 전 관계
hash=50- 제목만 정렬한 실제 출력
thread=50
이 예의 조건
- 제목 배열만 정렬하는 원 오류이며 컴파일 성공이 관계 정확성을 보장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 영역별 같은 index의 제목과 조회 수 전후를 연결합니다. 제목만 정렬되어 array 70/hash 30/thread 50이 되고 원래 array 30/hash 50/thread 70과 다릅니다. 실선은 전후 관계이며 원래 출력은 그대로입니다. |
| 5 | thread |
| 6 | 조회 수 70 |
| 8 | array |
| 9 | 조회 수 70 |
| 10 | array의 원래 값은 30 |
| 13 | array |
| 14 | 조회 수 30 |
| 16 | hash |
| 17 | 조회 수 30 |
| 18 | hash의 원래 값은 50 |
| 21 | hash |
| 22 | 조회 수 50 |
| 24 | thread |
| 25 | 조회 수 50 |
| 26 | thread의 원래 값은 70 |
| 구분 | 내용 |
|---|---|
| 설명 | 영역별 같은 index의 제목과 조회 수 전후를 연결합니다. 제목만 정렬되어 array 70/hash 30/thread 50이 되고 원래 array 30/hash 50/thread 70과 다릅니다. 실선은 전후 관계이며 원래 출력은 그대로입니다. |
| 전체 읽기 정보 | 제목만 정렬하면 세 조회 수의 주인이 바뀐다 제목만 정렬하면 세 조회 수의 주인이 바뀐다 ParallelArrayMapBug의 같은 인덱스를 정렬 전과 후로 비교합니다. 제목은 array, hash, thread로 바뀌지만 조회 수 배열 70, 30, 50은 그대로라 세 출력 관계가 모두 잘못됩니다. MAP · KEY–VALUE 제목만 정렬하면 세 조회 수의 주인이 바뀐다 같은 index를 유지해도 제목만 정렬하면 키와 값의 관계가 모두 바뀝니다. index 0 정렬 전 관계 thread=70 제목만 정렬한 실제 출력 array=70 index 1 정렬 전 관계 array=30 제목만 정렬한 실제 출력 hash=30 index 2 정렬 전 관계 hash=50 제목만 정렬한 실제 출력 thread=50 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원 대응 동일 index의 제목과 조회 수를 읽습니다. |
|
|
| 제목만 정렬 제목 순서를 array/hash/thread로 바꿉니다. |
|
|
| 잘못된 결과 원 관계와 다른 짝이 출력됩니다. |
|
|
Entry 하나로 키와 값을 묶거나 Map에 저장하면 정렬과 조회가 관계 단위로 움직입니다.
Map의 entrySet은 두 값을 함께 순회하기 위한 뷰입니다.
put의 반환값과 중복 키 정책
put(key, value)는 이전 값을 반환하고 없었다면 null을 반환합니다.
null 값을 허용하는 HashMap에서는 반환 null만으로 “없었음”과 “이전 값 null”을 구분할 수 없으므로 containsKey가 필요합니다.
null 값을 저장하지 않는 정책에서는 putIfAbsent의 반환값으로 중복 입력을 거부할 수 있습니다. null 값도 허용한다면 containsKey로 키 존재를 별도로 확인해야 합니다.
get은 없는 키에서 null을 반환합니다.
null을 정상 값으로 허용하지 않는 도메인이라면 get 결과 null이 부재를 뜻합니다.
더 명시적으로 Optional로 감싸거나 getOrDefault, computeIfAbsent, merge를 목적에 맞게 사용합니다.
특히 getOrDefault는 키의 매핑이 없을 때만 기본값을 반환합니다. HashMap에 queue=null이 저장되어 있다면 getOrDefault("queue", 0)도 null이며, 정수 변수로 받으면 언박싱 때 예외가 발생합니다. 반면 같은 상태의 putIfAbsent("queue", 20)은 20을 저장하고, computeIfAbsent도 저장된 null을 계산 대상으로 봅니다. null을 “아직 집계하지 않음” 같은 실제 값으로 쓰는 정책이라면 containsKey로 부재를 따로 판정해야 합니다.
import java.util.HashMap;
import java.util.Map;
public final class TitleViewCountMap {
public static void main(String[] args) {
Map<String, Integer> viewCount = new HashMap<>();
add(viewCount, "array", 30);
add(viewCount, "hash", 50);
add(viewCount, "array", 20);
System.out.println("array=" + viewCount.get("array"));
System.out.println("missing=" + viewCount.getOrDefault("queue", 0));
for (Map.Entry<String, Integer> entry : viewCount.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
}
private static void add(Map<String, Integer> viewCount, String title, int amount) {
if (title == null || title.isBlank() || amount <= 0) throw new IllegalArgumentException();
viewCount.merge(title, amount, Integer::sum);
}
}merge는 키가 없거나 값이 null이면 amount를 저장하고, 기존 값이 있으면 합칩니다. 이 예제는 null 값을 저장하지 않습니다.
containsKey와 get·put을 나누어 호출하는 코드보다 누적 의도가 직접 보입니다.
단일 스레드 예제이며 동시 갱신에는 ConcurrentHashMap 같은 별도 규칙이 필요합니다.
이 add는 amount가 양수인지 검사하지만, 누적 결과가 int 범위 안인지는 검사하지 않습니다. 기존 값이 2,147,483,647이고 1을 더하면 Integer::sum은 −2,147,483,648이 됩니다. 양수 입력 검증과 누적 합의 범위 검증은 다른 조건입니다.
예시
import java.util.HashMap;
import java.util.Map;
public final class MapMergeOverflowBoundary {
public static void main(String[] args) {
Map<String, Integer> raw = new HashMap<>();
raw.put("array", Integer.MAX_VALUE);
raw.merge("array", 1, Integer::sum);
System.out.println("raw=" + raw.get("array"));
Map<String, Integer> checked = new HashMap<>();
checked.put("array", Integer.MAX_VALUE);
try {
checked.merge("array", 1, Math::addExact);
} catch (ArithmeticException expected) {
System.out.println("overflow rejected");
}
System.out.println("kept=" + checked.get("array"));
}
}출력은 raw=-2147483648, overflow rejected, kept=2147483647입니다. 두 Map은 각각 같은 시작값을 가진 별도 실험입니다. 두 번째 merge는 합을 구하는 함수에서 예외가 나므로 기존 매핑을 교체하지 않습니다. 조회수를 int로 제한하는 정책이라면 원 add의 Integer::sum 자리에 Math::addExact를 사용하고, 상한 거부를 호출자에게 어떻게 알릴지도 정합니다. 더 큰 수를 저장해야 한다면 값 타입을 Long으로 바꾸는 설계가 필요합니다. 단일 호출의 실패 보존은 여러 키를 함께 갱신하는 트랜잭션 보장이 아닙니다.
세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다
두 새 키와 기존 array 누적 뒤 기본값 조회가 insert하지 않는 실제 호출 순서입니다. Map·size의 변화/유지를 따라가기 좋습니다.
TitleViewCountMap의 세 add 호출과 두 조회를 순서대로 비교합니다. array는 30에 20을 더해 50이 되고 hash는 50입니다. queue의 기본값 0을 읽어도 Map에는 두 키만 남습니다. 중간 Map 상태는 코드 추적입니다.
MAP · UPDATE TRACE
세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다
세 add 호출만 저장 상태를 바꿉니다. getOrDefault의 0은 부재를 읽는 반환값입니다.
실선: 원 add·조회 순서기본값 조회는 insert가 아님
실선: 원 add·조회 순서기본값 조회는insert가 아님
| 호출 순서 | 연산·조회 결과 | Map의 관계 | size |
|---|---|---|---|
1 · add("array", 30) | 새 키에 30 저장 | array=30hash 없음 | 1 |
2 · add("hash", 50) | 새 키에 50 저장 | array=30hash=50 | 2 |
3 · add("array", 20) | 30 + 20 = 50 | array=50hash=50 | 2 |
4 · get("array") | 반환 50 | array=50hash=50 | 2 |
5 · getOrDefault("queue", 0) | 반환 0 · 저장 없음 | array=50hash=50 | 2 |
- 1 ·
add("array", 30) - 연산·조회 결과
- 새 키에
30저장 - Map의 관계
array=30hash 없음- size
1
- 2 ·
add("hash", 50) - 연산·조회 결과
- 새 키에
50저장 - Map의 관계
array=30hash=50- size
2
- 3 ·
add("array", 20) - 연산·조회 결과
30 + 20 = 50- Map의 관계
array=50hash=50- size
2
- 4 ·
get("array") - 연산·조회 결과
- 반환
50 - Map의 관계
array=50hash=50- size
2
- 5 ·
getOrDefault("queue", 0) - 연산·조회 결과
- 반환
0· 저장 없음 - Map의 관계
array=50hash=50- size
2
이 예의 조건
- getOrDefault는 새 queue key를 저장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | array 30/hash 50/array+20 순서로 최종 array 50/hash 50입니다. array 조회 결과 50과 queue 기본값 0은 저장을 추가하지 않아 size는 2입니다. 실선은 원래 main의 실제 연산 순서입니다. |
| 4 | 새 키 |
| 19 | queue는 저장 안 함 |
| 구분 | 내용 |
|---|---|
| 설명 | array 30/hash 50/array+20 순서로 최종 array 50/hash 50입니다. array 조회 결과 50과 queue 기본값 0은 저장을 추가하지 않아 size는 2입니다. 실선은 원래 main의 실제 연산 순서입니다. |
| 전체 읽기 정보 | 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 TitleViewCountMap의 세 add 호출과 두 조회를 순서대로 비교합니다. array는 30에 20을 더해 50이 되고 hash는 50입니다. queue의 기본값 0을 읽어도 Map에는 두 키만 남습니다. 중간 Map 상태는 코드 추적입니다. MAP · UPDATE TRACE 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 세 add 호출만 저장 상태를 바꿉니다. getOrDefault의 0은 부재를 읽는 반환값입니다. 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 호출 순서 연산·조회 결과 Map의 관계 size 1 · add("array", 30) 새 키에 30 저장 array=30 hash 없음 1 2 · add("hash", 50) 새 키에 50 저장 array=30 hash=50 2 3 · add("array", 20) 30 + 20 = 50 array=50 hash=50 2 4 · get("array") 반환 50 array=50 hash=50 2 5 · getOrDefault ("queue", 0) 반환 0 · 저장 없음 array=50 hash=50 2 1 · add("array", 30) 연산·조회 결과 새 키에 30 저장 Map의 관계 array=30 hash 없음 size 1 2 · add("hash", 50) 연산·조회 결과 새 키에 50 저장 Map의 관계 array=30 hash=50 size 2 3 · add("array", 20) 연산·조회 결과 30 + 20 = 50 Map의 관계 array=50 hash=50 size 2 4 · get("array") 연산·조회 결과 반환 50 Map의 관계 array=50 hash=50 size 2 5 · getOrDefault ("queue", 0) 연산·조회 결과 반환 0 · 저장 없음 Map의 관계 array=50 hash=50 size 2 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 새 키 array 30과 hash 50을 차례로 저장합니다. |
|
|
| 기존 누적 array에 20을 더합니다. |
|
|
| 두 조회 array와 미등록 queue를 읽습니다. |
|
|
세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다
두 새 키와 기존 array 누적 뒤 기본값 조회가 insert하지 않는 실제 호출 순서입니다. Map·size의 변화/유지를 따라가기 좋습니다.
TitleViewCountMap의 세 add 호출과 두 조회를 순서대로 비교합니다. array는 30에 20을 더해 50이 되고 hash는 50입니다. queue의 기본값 0을 읽어도 Map에는 두 키만 남습니다. 중간 Map 상태는 코드 추적입니다.
MAP · UPDATE TRACE
세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다
세 add 호출만 저장 상태를 바꿉니다. getOrDefault의 0은 부재를 읽는 반환값입니다.
실선: 원 add·조회 순서기본값 조회는 insert가 아님
실선: 원 add·조회 순서기본값 조회는insert가 아님
| 호출 순서 | 연산·조회 결과 | Map의 관계 | size |
|---|---|---|---|
1 · add("array", 30) | 새 키에 30 저장 | array=30hash 없음 | 1 |
2 · add("hash", 50) | 새 키에 50 저장 | array=30hash=50 | 2 |
3 · add("array", 20) | 30 + 20 = 50 | array=50hash=50 | 2 |
4 · get("array") | 반환 50 | array=50hash=50 | 2 |
5 · getOrDefault("queue", 0) | 반환 0 · 저장 없음 | array=50hash=50 | 2 |
- 1 ·
add("array", 30) - 연산·조회 결과
- 새 키에
30저장 - Map의 관계
array=30hash 없음- size
1
- 2 ·
add("hash", 50) - 연산·조회 결과
- 새 키에
50저장 - Map의 관계
array=30hash=50- size
2
- 3 ·
add("array", 20) - 연산·조회 결과
30 + 20 = 50- Map의 관계
array=50hash=50- size
2
- 4 ·
get("array") - 연산·조회 결과
- 반환
50 - Map의 관계
array=50hash=50- size
2
- 5 ·
getOrDefault("queue", 0) - 연산·조회 결과
- 반환
0· 저장 없음 - Map의 관계
array=50hash=50- size
2
이 예의 조건
- getOrDefault는 새 queue key를 저장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | array 30/hash 50/array+20 순서로 최종 array 50/hash 50입니다. array 조회 결과 50과 queue 기본값 0은 저장을 추가하지 않아 size는 2입니다. 실선은 원래 main의 실제 연산 순서입니다. |
| 4 | 새 키 |
| 19 | queue는 저장 안 함 |
| 구분 | 내용 |
|---|---|
| 설명 | array 30/hash 50/array+20 순서로 최종 array 50/hash 50입니다. array 조회 결과 50과 queue 기본값 0은 저장을 추가하지 않아 size는 2입니다. 실선은 원래 main의 실제 연산 순서입니다. |
| 전체 읽기 정보 | 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 TitleViewCountMap의 세 add 호출과 두 조회를 순서대로 비교합니다. array는 30에 20을 더해 50이 되고 hash는 50입니다. queue의 기본값 0을 읽어도 Map에는 두 키만 남습니다. 중간 Map 상태는 코드 추적입니다. MAP · UPDATE TRACE 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 세 add 호출만 저장 상태를 바꿉니다. getOrDefault의 0은 부재를 읽는 반환값입니다. 세 번 누적해 array는 50이 되고 부재 조회는 저장하지 않는다 호출 순서 연산·조회 결과 Map의 관계 size 1 · add("array", 30) 새 키에 30 저장 array=30 hash 없음 1 2 · add("hash", 50) 새 키에 50 저장 array=30 hash=50 2 3 · add("array", 20) 30 + 20 = 50 array=50 hash=50 2 4 · get("array") 반환 50 array=50 hash=50 2 5 · getOrDefault ("queue", 0) 반환 0 · 저장 없음 array=50 hash=50 2 1 · add("array", 30) 연산·조회 결과 새 키에 30 저장 Map의 관계 array=30 hash 없음 size 1 2 · add("hash", 50) 연산·조회 결과 새 키에 50 저장 Map의 관계 array=30 hash=50 size 2 3 · add("array", 20) 연산·조회 결과 30 + 20 = 50 Map의 관계 array=50 hash=50 size 2 4 · get("array") 연산·조회 결과 반환 50 Map의 관계 array=50 hash=50 size 2 5 · getOrDefault ("queue", 0) 연산·조회 결과 반환 0 · 저장 없음 Map의 관계 array=50 hash=50 size 2 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 새 키 array 30과 hash 50을 차례로 저장합니다. |
|
|
| 기존 누적 array에 20을 더합니다. |
|
|
| 두 조회 array와 미등록 queue를 읽습니다. |
|
|
Map 뷰 선택
키만 검사하면 keySet, 값만 합산하면 values, 관계를 함께 출력하면 entrySet이 적합합니다.
뷰는 독립적인 결과 복사본이 아니라 원 Map과 연결된 창입니다. values에는 서로 다른 키의 같은 값이 여러 번 나타날 수 있습니다. 다음 예에서 40 두 개는 중복 키가 아니라 array와 hash의 값입니다.
예시
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
public final class BackedMapViewAndSnapshot {
public static void main(String[] args) {
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("array", 40);
counts.put("hash", 40);
Set<String> keys = counts.keySet();
Collection<Integer> values = counts.values();
System.out.println("values-before=" + values.size());
System.out.println("removed=" + keys.remove("array"));
System.out.println("map=" + counts.size() + ", values=" + values.size());
Map<String, Integer> snapshot = Map.copyOf(counts);
counts.put("queue", 55);
System.out.println("live=" + keys.size() + ", snapshot=" + snapshot.size());
try {
snapshot.put("new", 10);
} catch (UnsupportedOperationException expected) {
System.out.println("snapshot write rejected");
}
}
}출력 순서는 values-before=2, removed=true, map=1, values=1, live=2, snapshot=1, snapshot write rejected입니다. keySet에서 array를 제거하면 원 Map의 그 매핑도 지워집니다. 이후 queue를 추가한 결과는 이미 얻어 둔 keys에도 보이지만, 별도로 만든 snapshot의 매핑 수는 1입니다. 이 예의 값은 불변 Integer이고, 복사본이 원소 객체까지 깊게 복사한다는 뜻은 아닙니다. Map.copyOf는 null 키나 값을 허용하지 않으므로, null을 허용하는 Map의 스냅샷으로 무조건 사용할 수는 없습니다.
entrySet 순회 중 entry.setValue는 Map 값을 바꿀 수 있지만 구조 변경은 반복자 규칙을 따라야 합니다.
키를 가져와 다시 get하는 이중 조회보다 entrySet이 단순합니다.
Map의 키는 내부적으로 Set처럼 고유합니다.
HashMap은 hashCode로 그룹을 고르고 equals로 키를 확인하므로 15장의 불변 키 규칙이 그대로 적용됩니다.
가변 키를 저장 뒤 바꾸면 조회에서 원소를 잃을 수 있습니다.
Map 구현별 키 순서
HashMap은 일반 조회의 기본 선택이며 반복 순서를 약속하지 않습니다.
LinkedHashMap은 삽입 순서를 유지하고 accessOrder 옵션으로 LRU 같은 접근 순서를 만들 수 있습니다.
TreeMap은 자연 순서 또는 Comparator로 키를 정렬하고 범위 질의를 제공합니다.
세 구현의 get·put 결과 의미는 같지만 복잡도와 반복 순서가 다릅니다.
HashMap과 LinkedHashMap은 좋은 분산에서 평균 O(1), TreeMap은 O(log n)입니다.
보고서 한 번 정렬하려고 TreeMap을 쓰지 않고 출력에서 스트림 정렬할 수도 있습니다.
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public final class MapImplementationOrder {
public static void main(String[] args) {
compare("hash", new HashMap<>());
compare("linked", new LinkedHashMap<>());
compare("tree", new TreeMap<>());
}
private static void compare(String name, Map<String, Integer> map) {
map.put("thread", 70);
map.put("array", 30);
map.put("hash", 50);
map.put("array", 40);
System.out.println(name + " size=" + map.size() + " keys=" + map.keySet());
System.out.println(name + " array=" + map.get("array"));
}
}모두 size=3이고 array=40입니다.
키 반복 순서만 구현 요구사항에 따라 달라집니다.
HashMap의 현재 출력 모양은 테스트 기대값으로 고정하지 않습니다.
MapImplementationOrder가 thread, array, hash를 넣고 array만 40으로 교체합니다. 세 구현의 크기는 3입니다. HashMap의 키 순서는 보장되지 않고, 기본 LinkedHashMap은 최초 삽입 순서, TreeMap은 문자열 자연 순서를 유지합니다.
MAP · ORDER CONTRACT
array 값은 모두 40이고 키의 반복 순서만 다르다
같은 키의 값 교체는 size를 늘리지 않습니다. 세 구현의 차이는 키를 반복하는 순서입니다.
HashMap
- 키 반복 순서
- 순서 보장 없음키 집합:
array · hash · thread - size
3get("array")40
LinkedHashMap
- 키 반복 순서
- 최초 삽입 순서
thread → array → hash - size
3get("array")40
TreeMap
- 키 반복 순서
- 문자열 자연 순서
array → hash → thread - size
3get("array")40
게시글 저장소의 Map 캡슐화
저장소가 내부 Map을 그대로 반환하면 호출자가 검증 없이 put·remove할 수 있습니다.
find, save, snapshot 같은 도메인 메서드로 내부 변경을 제한하고 스냅샷은 Map.copyOf로 반환합니다. 복사본의 매핑은 수정할 수 없지만 원소를 깊게 복사하거나 삽입 순서를 보장하지는 않습니다.
import java.util.LinkedHashMap;
import java.util.Map;
public final class PostRepository {
public static void main(String[] args) {
Repository repository = new Repository();
repository.save(new Entry(1, "map", 45));
repository.save(new Entry(2, "queue", 55));
System.out.println(repository.find(2));
System.out.println("snapshot=" + repository.snapshot());
}
private static final class Repository {
private final Map<Integer, Entry> entries = new LinkedHashMap<>();
void save(Entry entry) {
if (entries.putIfAbsent(entry.id(), entry) != null)
throw new IllegalArgumentException("duplicate id");
}
Entry find(int id) {
Entry found = entries.get(id);
if (found == null) throw new java.util.NoSuchElementException("id=" + id);
return found;
}
Map<Integer, Entry> snapshot() {
return Map.copyOf(entries);
}
}
private record Entry(int id, String title, int viewCount) {}
}이 저장소가 검사하는 조건은 중복 ID와 조회할 ID의 존재입니다. 위 Entry의 빈 record 본문은 ID의 양수 여부, 제목의 공백, 조회수의 음수 여부를 검사하지 않습니다. 그런 도메인 조건까지 요구한다면 record의 생성자나 save 경계에 검증을 추가해야 합니다.
연습 문제
두 Map에서 공통 제목만 찾아 조회수 차이를 출력하세요.
한쪽에만 있는 키는 결과에 넣지 않고 입력 Map은 바꾸지 않습니다.
연습 입력은 left={array=40, hash=60, set=30}, right={hash=45, set=50, queue=20}입니다. 차이는 left 값에서 right 값을 빼는 방향으로 계산합니다. 해설을 열기 전에 공통 키, 두 차이값, 연산 뒤 입력 크기를 각각 적으세요. 출력 줄에서 키가 나오는 순서는 정답 조건이 아닙니다.
정답과 해설
더 작은 keySet을 순회하면 containsKey 검사 횟수를 줄일 수 있습니다.
예제는 의미를 선명하게 하기 위해 left entrySet을 순회합니다.
import java.util.LinkedHashMap;
import java.util.Map;
public final class CommonMapDifferenceSolution {
public static void main(String[] args) {
Map<String, Integer> left = Map.of("array", 40, "hash", 60, "set", 30);
Map<String, Integer> right = Map.of("hash", 45, "set", 50, "queue", 20);
Map<String, Integer> difference = new LinkedHashMap<>();
for (var entry : left.entrySet())
if (right.containsKey(entry.getKey())) {
difference.put(entry.getKey(), entry.getValue() - right.get(entry.getKey()));
}
System.out.println(difference);
System.out.println("inputs=" + left.size() + "/" + right.size());
}
}결과에는 hash=15와 set=-20만 있고 입력 크기는 3/3으로 유지됩니다. 결과 Map의 반복 순서는 보장되지 않은 left의 Map.of 반복 순서에 따릅니다.
값이 null일 수 있는 Map이라면 containsKey와 get의 의미를 더 세밀하게 나눠야 합니다.
Map 선택은 키 고유성·부재 표현·순서 요구·범위 질의에서 시작합니다.
keySet과 Set이 닮았다는 내부 원리를 이해하되 값 교체와 Entry 순회라는 Map 고유 규칙을 놓치지 않습니다.