본문 바로가기

해시 코드와 배열 인덱스

직접 인덱싱의 범위 실패를 출발점으로 해시 함수·그룹·충돌 탐색의 역할을 분리합니다.

정수 키 1을 배열 1번 칸에 저장하면 조회는 한 번에 끝납니다.

문제는 키가 -20이거나 10억일 때입니다.

키 범위만큼 배열을 만들 수 없고 음수 칸도 존재하지 않습니다.

해시 자료 구조는 원본 키를 버리는 대신 제한된 그룹 인덱스로 변환하고, 같은 인덱스에 모인 후보 안에서 원래 키를 다시 비교합니다.

여기서 그룹은 버킷(bucket)이라고도 부르는 저장 후보 목록입니다. 배열 인덱스가 0부터 length - 1까지라는 규칙과 List의 순회·검색을 알고 있다면, “한 그룹을 고르기”와 “그 안에서 키를 찾기”를 나누어 추적할 수 있습니다.


키의 직접 배열 인덱스 사용 실패

다음 코드는 작은 양수에서는 성공해 결함이 숨어 있습니다.

두 번째 입력 -7이 들어오면 배열 경계 밖에 접근합니다.

배열 길이를 100만으로 키워도 음수 문제는 남고, 드문 큰 키 때문에 대부분의 칸이 비게 됩니다.

lab/DirectIndexFailure.java
public final class DirectIndexFailure {
    public static void main(String[] args) {
        String[] values = new String[10];
        values[3] = "hash";
        values[-7] = "collision";
        System.out.println(values[3]);
    }
}
실행 실패
java.lang.ArrayIndexOutOfBoundsException: Index -7 out of bounds for length 10

원본 키와 저장 위치를 같은 값으로 본 것이 원인입니다.

해시 코드는 넓은 키 공간을 정수로 요약하고, 인덱스 함수는 그 정수를 그룹 배열 범위로 줄입니다.

이 두 변환 뒤에도 원본 키는 그룹 안에 보관해야 합니다.

음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다

index 3 정상 쓰기 뒤 −7 쓰기는 범위를 벗어나 println에 미도달합니다. 배열의 실제 쓰기와 최초 실패 위치를 추적할 가치가 있습니다.

음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다

DirectIndexFailure는 길이 10 배열의 3번 칸에 hash를 저장한 뒤 -7번 칸에 쓰려고 하다가 예외로 멈춥니다. 마지막 println은 실행되지 않아 표준 출력은 비어 있습니다.

실패 위치 · 음수 배열 인덱스

음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다

길이 10 배열의 유효 인덱스는 0부터 9까지입니다. -7번 칸에 쓰는 단계에서 예외가 나므로 마지막 println은 실행되지 않습니다.

음수 직접 인덱스에서 멈추는 실행DirectIndexFailure는3번을정상저장하고−7번쓰기를시도하다배열범위예외로종료합니다. 실선은실제진행순서이며마지막println은실행하지않고stdout은없습니다.
3번에 정상 저장길이10 / values[3]=hash
연결 관계3번에 정상 저장 · 길이10 / values[3]=hash → −7번 쓰기 시도 · 0 ≤ −7 < 10은 false
−7번 쓰기 시도0 ≤ −7 < 10은 false
연결 관계−7번 쓰기 시도 · 0 ≤ −7 < 10은 false → 범위 예외에서 종료 · ArrayIndexOutOfBounds · Exception / stdout 없음
범위 예외에서 종료ArrayIndexOutOfBoundsException / stdout 없음

실선: 실행이 진행한 단계뒤 println은 실행하지 않음

  1. 정상 인덱스

    수행할 동작
    values[3] = "hash"
    결과
    3번 칸에 hash 저장
  2. 음수 인덱스

    수행할 동작
    values[-7] = "collision"
    결과
    ArrayIndexOutOfBoundsException
    유효 인덱스는 0..9 · 쓰기 실패
  3. 출력문

    수행할 동작
    println(values[3])
    결과
    도달하지 않음
    표준 출력 없음

이 예의 조건

  • 길이 10 배열의 직접 인덱스 예제이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2DirectIndexFailure는 3번에 정상 저장하고 −7번 쓰기를 시도하다 배열 범위 예외로 종료합니다. 실선은 실제 진행 순서이며 마지막 println은 실행하지 않고 stdout은 없습니다.
4길이 10
5values[3]=hash
10Exception
11stdout 없음
원 도표의 설명과 주석
구분내용
설명DirectIndexFailure는 3번에 정상 저장하고 −7번 쓰기를 시도하다 배열 범위 예외로 종료합니다. 실선은 실제 진행 순서이며 마지막 println은 실행하지 않고 stdout은 없습니다.
전체 읽기 정보음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다 음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다 DirectIndexFailure는 길이 10 배열의 3번 칸에 hash를 저장한 뒤 -7번 칸에 쓰려고 하다가 예외로 멈춥니다. 마지막 println은 실행되지 않아 표준 출력은 비어 있습니다. 실패 위치 · 음수 배열 인덱스 음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다 길이 10 배열의 유효 인덱스는 0부터 9까지입니다. -7번 칸에 쓰는 단계에서 예외가 나므로 마지막 println은 실행되지 않습니다. 정상 인덱스 수행할 동작 values[3] = "hash" 결과 3번 칸에 hash 저장 음수 인덱스 수행할 동작 values[-7] = "collision" 결과 ArrayIndexOutOfBoundsException 유효 인덱스는 0..9 · 쓰기 실패 출력문 수행할 동작 println(values[3]) 결과 도달하지 않음 표준 출력 없음
예의 값과 변화
관찰·상황값·보충 정보변화·이유
정상 쓰기

3번 칸에 hash를 저장합니다.

  • values[3]: hash
  • 길이: 10
  • 유효 범위의 첫 대입은 완료된다.
음수 쓰기 시도

−7번에 접근합니다.

  • 조건: 0≤-7<10 false
  • 직접 key를 인덱스로 쓰면 음수 입력을 보호하지 못한다.
실행 중단

범위 오류가 이후 println을 막습니다.

  • 예외: ArrayIndexOutOfBoundsException
  • stdout: 없음
  • 앞선 쓰기 완료와 뒤 출력 미실행을 구분한다.

음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다

index 3 정상 쓰기 뒤 −7 쓰기는 범위를 벗어나 println에 미도달합니다. 배열의 실제 쓰기와 최초 실패 위치를 추적할 가치가 있습니다.

음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다

DirectIndexFailure는 길이 10 배열의 3번 칸에 hash를 저장한 뒤 -7번 칸에 쓰려고 하다가 예외로 멈춥니다. 마지막 println은 실행되지 않아 표준 출력은 비어 있습니다.

실패 위치 · 음수 배열 인덱스

음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다

길이 10 배열의 유효 인덱스는 0부터 9까지입니다. -7번 칸에 쓰는 단계에서 예외가 나므로 마지막 println은 실행되지 않습니다.

음수 직접 인덱스에서 멈추는 실행DirectIndexFailure는3번을정상저장하고−7번쓰기를시도하다배열범위예외로종료합니다. 실선은실제진행순서이며마지막println은실행하지않고stdout은없습니다.
3번에 정상 저장길이10 / values[3]=hash
연결 관계3번에 정상 저장 · 길이10 / values[3]=hash → −7번 쓰기 시도 · 0 ≤ −7 < 10은 false
−7번 쓰기 시도0 ≤ −7 < 10은 false
연결 관계−7번 쓰기 시도 · 0 ≤ −7 < 10은 false → 범위 예외에서 종료 · ArrayIndexOutOfBounds · Exception / stdout 없음
범위 예외에서 종료ArrayIndexOutOfBoundsException / stdout 없음

실선: 실행이 진행한 단계뒤 println은 실행하지 않음

  1. 정상 인덱스

    수행할 동작
    values[3] = "hash"
    결과
    3번 칸에 hash 저장
  2. 음수 인덱스

    수행할 동작
    values[-7] = "collision"
    결과
    ArrayIndexOutOfBoundsException
    유효 인덱스는 0..9 · 쓰기 실패
  3. 출력문

    수행할 동작
    println(values[3])
    결과
    도달하지 않음
    표준 출력 없음

이 예의 조건

  • 길이 10 배열의 직접 인덱스 예제이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2DirectIndexFailure는 3번에 정상 저장하고 −7번 쓰기를 시도하다 배열 범위 예외로 종료합니다. 실선은 실제 진행 순서이며 마지막 println은 실행하지 않고 stdout은 없습니다.
4길이 10
5values[3]=hash
10Exception
11stdout 없음
원 도표의 설명과 주석
구분내용
설명DirectIndexFailure는 3번에 정상 저장하고 −7번 쓰기를 시도하다 배열 범위 예외로 종료합니다. 실선은 실제 진행 순서이며 마지막 println은 실행하지 않고 stdout은 없습니다.
전체 읽기 정보음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다 음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다 DirectIndexFailure는 길이 10 배열의 3번 칸에 hash를 저장한 뒤 -7번 칸에 쓰려고 하다가 예외로 멈춥니다. 마지막 println은 실행되지 않아 표준 출력은 비어 있습니다. 실패 위치 · 음수 배열 인덱스 음수 인덱스 쓰기에서 멈춰 출력에 도달하지 못한다 길이 10 배열의 유효 인덱스는 0부터 9까지입니다. -7번 칸에 쓰는 단계에서 예외가 나므로 마지막 println은 실행되지 않습니다. 정상 인덱스 수행할 동작 values[3] = "hash" 결과 3번 칸에 hash 저장 음수 인덱스 수행할 동작 values[-7] = "collision" 결과 ArrayIndexOutOfBoundsException 유효 인덱스는 0..9 · 쓰기 실패 출력문 수행할 동작 println(values[3]) 결과 도달하지 않음 표준 출력 없음
예의 값과 변화
관찰·상황값·보충 정보변화·이유
정상 쓰기

3번 칸에 hash를 저장합니다.

  • values[3]: hash
  • 길이: 10
  • 유효 범위의 첫 대입은 완료된다.
음수 쓰기 시도

−7번에 접근합니다.

  • 조건: 0≤-7<10 false
  • 직접 key를 인덱스로 쓰면 음수 입력을 보호하지 못한다.
실행 중단

범위 오류가 이후 println을 막습니다.

  • 예외: ArrayIndexOutOfBoundsException
  • stdout: 없음
  • 앞선 쓰기 완료와 뒤 출력 미실행을 구분한다.

나머지 연산과 음수 처리

그룹 수가 8이면 양수 hashCode에 hashCode % 8을 적용해 0부터 7까지를 얻습니다.

하지만 Java의 %는 왼쪽 피연산자가 음수일 때 음수 결과를 낼 수 있습니다.

양수인 bucketCount에 대해 Math.floorMod(hashCode, bucketCount)는 0 이상 bucketCount 미만의 인덱스를 반환합니다.

예를 들어 -7 % 8은 -7이지만 Math.floorMod(-7, 8)은 1입니다. -7 = (-1) × 8 + 1로 계산하면 음수 키가 왜 1번 그룹으로 가는지 확인할 수 있습니다. 양수 그룹 수라는 조건이 중요하며, 두 연산의 차이는 Math.floorMod 공식 문서에서도 확인할 수 있습니다.

음수를 없애려고 Math.abs(hashCode)를 먼저 쓰는 방법도 항상 안전하지는 않습니다. Math.abs(Integer.MIN_VALUE)는 양수 int 범위로 표현할 수 없어 여전히 -2147483648입니다. 그룹 수가 13이면 이 값의 % 13은 -11이고 floorMod는 2이므로, 절댓값에 기대지 않고 인덱스 변환 자체가 범위를 보장해야 합니다.

bucketCount가 0이면 정수 %와 floorMod 모두 ArithmeticException을 던집니다.

생성자에서 양수인지 검사하고 이후에는 바뀌지 않게 둡니다.

이 검증은 add마다 반복할 일이 아니라 자료 구조 생성 불변식입니다.


충돌은 오류가 아니라 후보가 늘어난 상태

서로 다른 키도 같은 나머지를 가질 수 있습니다.

그룹이 8개라면 1, 9, -7은 모두 1번에 도착합니다.

하나의 배열 칸에 값 하나만 두고 새 입력으로 덮어쓰면 기존 키를 잃습니다.

각 칸을 작은 목록으로 만들고 충돌한 Entry를 함께 저장해야 합니다.

조회는 전체 저장소를 훑지 않습니다.

먼저 해시 인덱스로 한 그룹을 고른 뒤 그 목록에서 원본 키의 동등성을 확인합니다.

분산이 좋으면 그룹이 짧아 평균 탐색이 빠르고, 모든 키가 한 칸에 모이면 선형 검색으로 퇴화합니다.

src/IntegerBucketTable.java
import java.util.ArrayList;
import java.util.List;

public final class IntegerBucketTable {
    public static void main(String[] args) {
        IntTable table = new IntTable(8);
        table.put(1, "array");
        table.put(9, "hash");
        table.put(-7, "negative");
        System.out.println(table.get(1));
        System.out.println(table.get(9));
        System.out.println(table.get(-7));
        System.out.println("sizes=" + table.bucketSizes());
    }

    private static final class IntTable {
        private final List<List<Entry>> buckets = new ArrayList<>();

        IntTable(int bucketCount) {
            if (bucketCount <= 0) throw new IllegalArgumentException("bucketCount");
            for (int i = 0; i < bucketCount; i++) {
                buckets.add(new ArrayList<>());
            }
        }

        void put(int key, String value) {
            List<Entry> bucket = buckets.get(index(key));
            for (int i = 0; i < bucket.size(); i++) {
                if (bucket.get(i).key() == key) {
                    bucket.set(i, new Entry(key, value));
                    return;
                }
            }
            bucket.add(new Entry(key, value));
        }

        String get(int key) {
            for (Entry entry : buckets.get(index(key))) {
                if (entry.key() == key) {
                    return entry.value();
                }
            }
            return null;
        }

        private int index(int key) {
            return Math.floorMod(Integer.hashCode(key), buckets.size());
        }

        String bucketSizes() {
            StringBuilder out = new StringBuilder();
            for (List<Entry> bucket : buckets) {
                out.append(bucket.size());
            }
            return out.toString();
        }
    }

    private static final class Entry {
        private final int key;
        private final String value;

        private Entry(int key, String value) {
            this.key = key;
            this.value = value;
        }

        private int key() { return key; }
        private String value() { return value; }
    }
}

1, 9, -7은 모두 같은 1번 그룹에 들어가지만 get은 세 값을 구분합니다.

이 예제에서 충돌은 데이터 손실이 아니라 그룹 내부 비교 횟수 증가로 나타납니다.

bucketSizes 출력은 분산을 눈으로 확인하기 위한 진단값입니다.

이 구현의 Integer.hashCode(key)는 정수 key 자체와 같은 값입니다. 따라서 1, 9, -7은 해시 코드는 다르지만 인덱스가 같은 경우입니다. 키의 범위를 그룹 수로 줄이는 단계에서도 충돌이 생긴다는 뜻입니다. 또한 Entry.key()는 기본형 int여서 ==로 값을 비교합니다. 객체 키를 다룰 때의 동등성 비교와 구분하세요. Integer.hashCode 공식 문서

이 실행은 세 값을 모두 1번 그룹에 넣으므로 그룹 크기 출력은 03000000입니다. 평균 적재 계수는 3/8인데 가장 긴 그룹은 3입니다. 평균값이 작아도 한 그룹이 길어질 수 있다는 예입니다. get(9)는 키 1을 한 번 비교하고 키 9에서 두 번째 비교로 반환하므로 -7은 더 비교하지 않습니다.

그룹 안에서 다시 확인하기

같은 table에 put(9, "updated")를 한 번 더 한 뒤 get(9), get(17), bucketSizes를 예상하세요. 키 17도 1번 그룹에 도착하지만 아직 넣지 않았습니다.

교체와 없는 키 조회 확인

get(9)는 updated입니다. 기존 키 9의 Entry를 교체했으므로 그룹 길이는 3, bucketSizes는 03000000 그대로입니다. get(17)은 세 후보의 원래 키가 모두 다르므로 null을 반환합니다. 같은 그룹으로 도착했다는 사실이 같은 키의 존재를 뜻하지 않으며, 이 get의 null은 값 없음의 표시입니다. 이 예제의 put은 null 값도 금지하지 않으므로 실제 API에서 저장된 null과 값 없음까지 구별하려면 별도 정책이 필요합니다.

제네릭 배열 형변환이 핵심을 흐리지 않도록 그룹 자체도 List로 표현했습니다.

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다

세 put의 1/9/−7 충돌을 보존한 bucket에서 get(9)가 1 불일치·9 일치에서 멈춥니다. hash 위치와 key 비교의 역할을 탐색 단계로 구분합니다.

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다

IntegerBucketTable의 세 put 이후 1번 그룹에는 키 1의 array, 키 9의 hash, 키 -7의 negative가 순서대로 있습니다. get(9)는 floorMod로 1번 그룹을 선택하고 첫 키 1은 건너뛴 뒤 둘째 키 9에서 hash를 반환합니다. 비교 순서는 코드 추적입니다.

해시 충돌 · 그룹과 원본 키

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다

같은 그룹에 들어가도 키 1·9·-7은 별도 항목입니다. get(9)는 원본 키를 비교하고 두 번째 후보에서 hash를 반환합니다.

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다IntegerBucketTable의 세 put 이후 1번 그룹에는 키 1의 array, 키 9의 hash, 키 -7의 negative가 순서대로 있습니다. get(9)는 floorMod로 1번 그룹을 선택하고 첫 키 1은 건너뛴 뒤 둘째 키 9에서 hash를 반환합니다. 비교 순서는 코드 추적입니다. 비교 코드 추적: 세 put 이후 저장 상태와 get(9)의 후보 비교 Integer.hashCode(key) = key · key 1, 9, -7 → index 1 buckets인덱스 / 원소 수 00개13개2–7각 0개 bucket[1] : List<Entry> get(9)floorMod(9, 8) = 1 key = 1"array"key = 9"hash"key = -7"negative" ① 1 ≠ 9 · 불일치 ② 9 = 9 · 일치 "hash" 반환 비교하지 않음
모든 버킷과 그룹 1 안의 후보 키 비교 버킷 0은 0개, 버킷 1은 3개, 버킷 2–7은 각각 0개입니다. 버킷 1에서 List<Entry> 영역으로 향하는 화살표는 세 항목의 소속을 나타냅니다. get(9)에서 첫 후보 key 1로 향하는 비교 화살표가 있습니다. key 1은 불일치, 다음 key 9는 일치해 hash를 반환하고 key -7은 비교하지 않습니다. 데스크톱과 같은 버킷 집계 노드, 요청 노드, 세 키 노드 및 두 화살표를 세로로 재배치했습니다. buckets · 인덱스 / 원소 수 capacity = 8 0 0개 1 3개 2–7 각 0개 get(9) floorMod(9, 8) = 1 비교 bucket[1] List<Entry> key = 1 "array" ① 1 ≠ 9 · 불일치 key = 9 "hash" ② 9 = 9 · 일치 "hash" 반환 key = -7 "negative" 비교하지 않음 Integer.hashCode(key) = key key 1, 9, -7 → index 1 둘째에서 반환해 셋째 미비교
인덱스와 그룹 크기
capacity = 8
인덱스 0, 2, 3, 4, 5, 6, 7은 0개, 인덱스 1은 3개입니다.
세 put 이후 bucket[1]
  1. key 1 → "array"
  2. key 9 → "hash"
  3. key -7 → "negative"
각 항목은 List<Entry>의 저장 순서입니다.
조회할 그룹
get(9)는 Integer.hashCode(9) = 9, floorMod(9, 8) = 1로 1번 그룹을 고릅니다.
첫째 후보
1 == 9는 false라 다음 후보를 확인합니다.
둘째 후보
9 == 9는 true라 "hash"를 반환합니다.
셋째 후보
반환으로 메서드가 끝나므로 -7은 비교하지 않습니다.

이 예의 조건

  • 세 put 뒤 get(9)의 코드 비교 순서이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
8원소 수
원 도표의 설명과 주석
구분내용
전체 읽기 정보같은 그룹의 세 키에서 두 번째 후보 9를 찾는다 같은 그룹의 세 키에서 두 번째 후보 9를 찾는다 IntegerBucketTable의 세 put 이후 1번 그룹에는 키 1의 array, 키 9의 hash, 키 -7의 negative가 순서대로 있습니다. get(9)는 floorMod로 1번 그룹을 선택하고 첫 키 1은 건너뛴 뒤 둘째 키 9에서 hash를 반환합니다. 비교 순서는 코드 추적입니다. 해시 충돌 · 그룹과 원본 키 같은 그룹의 세 키에서 두 번째 후보 9를 찾는다 같은 그룹에 들어가도 키 1·9·-7은 별도 항목입니다. get(9)는 원본 키를 비교하고 두 번째 후보에서 hash를 반환합니다. 인덱스와 그룹 크기 capacity = 8 인덱스 0, 2, 3, 4, 5, 6, 7 은 0개, 인덱스 1 은 3개입니다. 세 put 이후 bucket[1] key 1 → "array" key 9 → "hash" key -7 → "negative" 각 항목은 List<Entry> 의 저장 순서입니다. 조회할 그룹 get(9) 는 Integer.hashCode(9) = 9 , floorMod(9, 8) = 1 로 1번 그룹을 고릅니다. 첫째 후보 1 == 9 는 false 라 다음 후보를 확인합니다. 둘째 후보 9 == 9 는 true 라 "hash" 를 반환합니다. 셋째 후보 반환으로 메서드가 끝나므로 -7 은 비교하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
bucket 선택

key 9로 floorMod를 계산합니다.

  • index: floorMod(9,8)=1
  • bucket1: 1/array,9/hash,-7/negative
  • 같은 hash 위치의 모든 후보를 보존한다.
첫 후보 비교

key 1은 요청 9와 다릅니다.

  • 비교: 1≠9
  • 현재 후보: array
  • bucket 일치만으로 key 일치를 판단하지 않는다.
둘째 후보 반환

key 9가 일치하여 hash를 반환합니다.

  • 비교: 9=9
  • 반환: hash
  • -7 비교: 미실행
  • 첫 일치에서 중단하여 뒤 후보를 소비하지 않는다.

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다

세 put의 1/9/−7 충돌을 보존한 bucket에서 get(9)가 1 불일치·9 일치에서 멈춥니다. hash 위치와 key 비교의 역할을 탐색 단계로 구분합니다.

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다

IntegerBucketTable의 세 put 이후 1번 그룹에는 키 1의 array, 키 9의 hash, 키 -7의 negative가 순서대로 있습니다. get(9)는 floorMod로 1번 그룹을 선택하고 첫 키 1은 건너뛴 뒤 둘째 키 9에서 hash를 반환합니다. 비교 순서는 코드 추적입니다.

해시 충돌 · 그룹과 원본 키

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다

같은 그룹에 들어가도 키 1·9·-7은 별도 항목입니다. get(9)는 원본 키를 비교하고 두 번째 후보에서 hash를 반환합니다.

같은 그룹의 세 키에서 두 번째 후보 9를 찾는다IntegerBucketTable의 세 put 이후 1번 그룹에는 키 1의 array, 키 9의 hash, 키 -7의 negative가 순서대로 있습니다. get(9)는 floorMod로 1번 그룹을 선택하고 첫 키 1은 건너뛴 뒤 둘째 키 9에서 hash를 반환합니다. 비교 순서는 코드 추적입니다. 비교 코드 추적: 세 put 이후 저장 상태와 get(9)의 후보 비교 Integer.hashCode(key) = key · key 1, 9, -7 → index 1 buckets인덱스 / 원소 수 00개13개2–7각 0개 bucket[1] : List<Entry> get(9)floorMod(9, 8) = 1 key = 1"array"key = 9"hash"key = -7"negative" ① 1 ≠ 9 · 불일치 ② 9 = 9 · 일치 "hash" 반환 비교하지 않음
모든 버킷과 그룹 1 안의 후보 키 비교 버킷 0은 0개, 버킷 1은 3개, 버킷 2–7은 각각 0개입니다. 버킷 1에서 List<Entry> 영역으로 향하는 화살표는 세 항목의 소속을 나타냅니다. get(9)에서 첫 후보 key 1로 향하는 비교 화살표가 있습니다. key 1은 불일치, 다음 key 9는 일치해 hash를 반환하고 key -7은 비교하지 않습니다. 데스크톱과 같은 버킷 집계 노드, 요청 노드, 세 키 노드 및 두 화살표를 세로로 재배치했습니다. buckets · 인덱스 / 원소 수 capacity = 8 0 0개 1 3개 2–7 각 0개 get(9) floorMod(9, 8) = 1 비교 bucket[1] List<Entry> key = 1 "array" ① 1 ≠ 9 · 불일치 key = 9 "hash" ② 9 = 9 · 일치 "hash" 반환 key = -7 "negative" 비교하지 않음 Integer.hashCode(key) = key key 1, 9, -7 → index 1 둘째에서 반환해 셋째 미비교
인덱스와 그룹 크기
capacity = 8
인덱스 0, 2, 3, 4, 5, 6, 7은 0개, 인덱스 1은 3개입니다.
세 put 이후 bucket[1]
  1. key 1 → "array"
  2. key 9 → "hash"
  3. key -7 → "negative"
각 항목은 List<Entry>의 저장 순서입니다.
조회할 그룹
get(9)는 Integer.hashCode(9) = 9, floorMod(9, 8) = 1로 1번 그룹을 고릅니다.
첫째 후보
1 == 9는 false라 다음 후보를 확인합니다.
둘째 후보
9 == 9는 true라 "hash"를 반환합니다.
셋째 후보
반환으로 메서드가 끝나므로 -7은 비교하지 않습니다.

이 예의 조건

  • 세 put 뒤 get(9)의 코드 비교 순서이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
8원소 수
원 도표의 설명과 주석
구분내용
전체 읽기 정보같은 그룹의 세 키에서 두 번째 후보 9를 찾는다 같은 그룹의 세 키에서 두 번째 후보 9를 찾는다 IntegerBucketTable의 세 put 이후 1번 그룹에는 키 1의 array, 키 9의 hash, 키 -7의 negative가 순서대로 있습니다. get(9)는 floorMod로 1번 그룹을 선택하고 첫 키 1은 건너뛴 뒤 둘째 키 9에서 hash를 반환합니다. 비교 순서는 코드 추적입니다. 해시 충돌 · 그룹과 원본 키 같은 그룹의 세 키에서 두 번째 후보 9를 찾는다 같은 그룹에 들어가도 키 1·9·-7은 별도 항목입니다. get(9)는 원본 키를 비교하고 두 번째 후보에서 hash를 반환합니다. 인덱스와 그룹 크기 capacity = 8 인덱스 0, 2, 3, 4, 5, 6, 7 은 0개, 인덱스 1 은 3개입니다. 세 put 이후 bucket[1] key 1 → "array" key 9 → "hash" key -7 → "negative" 각 항목은 List<Entry> 의 저장 순서입니다. 조회할 그룹 get(9) 는 Integer.hashCode(9) = 9 , floorMod(9, 8) = 1 로 1번 그룹을 고릅니다. 첫째 후보 1 == 9 는 false 라 다음 후보를 확인합니다. 둘째 후보 9 == 9 는 true 라 "hash" 를 반환합니다. 셋째 후보 반환으로 메서드가 끝나므로 -7 은 비교하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
bucket 선택

key 9로 floorMod를 계산합니다.

  • index: floorMod(9,8)=1
  • bucket1: 1/array,9/hash,-7/negative
  • 같은 hash 위치의 모든 후보를 보존한다.
첫 후보 비교

key 1은 요청 9와 다릅니다.

  • 비교: 1≠9
  • 현재 후보: array
  • bucket 일치만으로 key 일치를 판단하지 않는다.
둘째 후보 반환

key 9가 일치하여 hash를 반환합니다.

  • 비교: 9=9
  • 반환: hash
  • -7 비교: 미실행
  • 첫 일치에서 중단하여 뒤 후보를 소비하지 않는다.

해시 성능을 설명할 때 확인할 세 숫자

첫째는 그룹 수입니다.

너무 적으면 충돌이 늘고 너무 많으면 빈 목록과 배열 공간이 늘어납니다.

둘째는 저장 원소 수를 그룹 수로 나눈 적재 계수입니다.

셋째는 평균값만 보면 드러나지 않을 수 있는 가장 긴 그룹 길이입니다.

적재 계수를 일정 범위로 유지하려면 원소 수가 늘 때 더 큰 그룹 배열을 만들고 모든 키의 새 index를 계산하는 rehash가 필요합니다. 위의 IntTable에는 아직 이 확장 기능이 없습니다. 그룹 수가 8로 고정된 채 원소만 늘면 같은 그룹 안에서 비교할 후보도 많아질 수 있습니다.

기존 인덱스를 그대로 복사할 수 없는 이유는 나누는 그룹 수가 달라지기 때문입니다.

그룹을 8개에서 16개로 늘리면 키 1은 계속 1번이지만 키 9는 1번에서 9번으로 이동합니다. 재해시는 모든 키의 위치를 새 규칙으로 정하는 과정이며, 모든 키의 인덱스가 반드시 바뀐다는 뜻은 아닙니다.

확장 한 번은 비싸지만 고르게 분산되는 입력에서는 이후 탐색 길이를 다시 줄일 수 있습니다. 같은 해시 코드를 가진 값들의 충돌까지 사라지는 것은 아닙니다.

해시 함수가 같은 입력에 매번 다른 값을 반환하면 저장 당시 그룹을 조회에서 찾을 수 없습니다.

키가 저장된 뒤 hashCode 계산에 쓰는 필드가 바뀌어도 같은 문제가 생깁니다.

해시 키는 저장 중에 동등성 판단과 해시 계산 결과가 안정적으로 유지되어야 합니다. 불변 값 객체를 키로 쓰면 이 조건을 지키기 쉽습니다.


게시글 저장소의 숫자 식별자를 해시로 탐색

게시글 저장소 CLI가 게시글 ID로 기록을 찾는다고 가정합니다.

사용자는 그룹을 모르고 id만 전달합니다.

Map<Integer, Post>는 ID를 키로, 게시글을 값으로 저장합니다. 조회는 요청한 ID와 저장된 Integer 키의 동등성을 비교해 Post를 반환한 뒤 그 게시글의 제목을 읽습니다. HashMap이 값인 Post의 필드를 검색하는 것은 아닙니다.

app/HashedBoardPostCli.java
import java.util.HashMap;
import java.util.Map;

public final class HashedBoardPostCli {
    public static void main(String[] args) {
        Map<Integer, Post> posts = new HashMap<>();
        add(posts, new Post(101, "hash-index", 45));
        add(posts, new Post(205, "collision", 55));
        int requested = 205;
        Post found = posts.get(requested);
        System.out.println("found=" + found.title());
        int total = 0;
        for (Post post : posts.values()) {
            total += post.viewCount();
        }
        System.out.println("total=" + total);
    }

    private static void add(Map<Integer, Post> posts, Post post) {
        if (posts.putIfAbsent(post.id(), post) != null) {
            throw new IllegalArgumentException("duplicate id=" + post.id());
        }
    }

    private static final class Post {
        private final int id;
        private final String title;
        private final int viewCount;

        private Post(int id, String title, int viewCount) {
            if (id <= 0 || title == null || title.isBlank() || viewCount <= 0) {
                throw new IllegalArgumentException("invalid post");
            }
            this.id = id;
            this.title = title;
            this.viewCount = viewCount;
        }

        private int id() { return id; }
        private String title() { return title; }
        private int viewCount() { return viewCount; }
    }
}

HashMap을 쓰는 애플리케이션 코드는 해시의 내부 동작을 재구현하지 않습니다.

직접 만든 IntTable은 원리 확인용이고, CLI는 표준 구현의 putIfAbsent로 중복 ID 정책을 표현합니다.

앞의 floorMod는 학습용 IntTable에서 선택한 인덱스 함수입니다. 표준 HashMap의 내부 인덱스 계산식으로 외우지 말고, 키가 같으면 연결된 값을 찾는 Map.get 계약을 기준으로 사용하세요.

이 CLI의 출력은 found=collision, total=100입니다. 넣지 않은 ID를 요청하면 get이 null을 반환하므로 실제 CLI에서는 found.title()을 호출하기 전에 없는 게시글을 처리해야 합니다. 여기서는 ID 205를 이미 저장했다는 조건으로 조회합니다.

조회 결과와 합계가 외부에 드러나는 계약입니다.


연습 문제

키 1, 9, 2, 10, -7을 그룹 8개에 배치합니다.

각 키와 floorMod로 얻은 index를 출력하고 어떤 키가 충돌하는지 설명하세요.

정답과 해설

1, 9, -7은 1번에 모이고 2와 10은 2번에 모입니다.

음수에도 % 대신 floorMod를 쓰므로 -7의 결과가 유효합니다.

exercise/CollisionGroupSolution.java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;

public final class CollisionGroupSolution {
    public static void main(String[] args) {
        Map<Integer, List<Integer>> groups = new LinkedHashMap<>();
        for (int key : new int[] {1, 9, 2, 10, -7}) {
            int index = Math.floorMod(Integer.hashCode(key), 8);
            groups.computeIfAbsent(index, ignored -> new ArrayList<>()).add(key);
            System.out.println(key + "->" + index);
        }
        System.out.println(groups);
    }
}

이 출력에서 같은 index는 같은 키라는 뜻이 아닙니다.

그룹 선택 뒤 원본 키 비교가 반드시 남아 있어야 충돌한 값이 공존합니다.

이 절의 마무리 기준은 세 문장입니다.

hashCode는 키를 정수로 요약하고, floorMod는 배열 범위를 보장하며, equals 또는 원본 키 비교는 충돌 후보를 구분합니다.

어느 하나라도 생략하면 범위 오류·데이터 손실·잘못된 조회 중 하나가 발생합니다.