컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 03

해시 테이블

자료구조해시충돌 해결

해시 테이블(Hash Table)은 키(Key)를 해시 함수로 변환해 배열 인덱스에 직접 매핑하는 자료구조입니다. 평균 O(1)의 삽입·탐색·삭제를 제공하며, 대부분의 언어에서 딕셔너리·맵의 내부 구현으로 사용됩니다.

핵심 개념

해시 함수

임의 크기의 키를 고정 범위의 정수(해시값)로 변환합니다. 좋은 해시 함수는 결정론적(같은 입력 → 같은 출력), 균등 분포, 빠른 계산 속도를 갖춥니다. 개념적으로는 hash(key) % capacity로 버킷 인덱스를 결정하고, 실제 구현은 비트 연산 등으로 조금씩 다릅니다.

핵심 개념

충돌(Collision)

서로 다른 두 키가 동일한 해시 인덱스로 매핑되는 현상입니다. 비둘기집 원리에 의해 키 공간이 배열 크기보다 크면 충돌은 반드시 발생합니다. 충돌 해결 방법으로 체이닝(Chaining)과 오픈 어드레싱(Open Addressing)이 있습니다.

핵심 개념

체이닝 (Chaining)

같은 버킷에 충돌한 키들을 연결 리스트로 연결합니다. 버킷이 연결 리스트의 헤드를 가리킵니다. 삽입은 O(1)이고 탐색은 버킷의 리스트 길이에 비례합니다. Java의 HashMap이 이 방식을 사용하며, OpenJDK 구현에서는 한 버킷이 길어지고 전체 용량이 64 이상이면 트리(Red-Black Tree)로 전환합니다.

핵심 개념

오픈 어드레싱 (Open Addressing)

충돌 시 다른 빈 버킷을 탐색합니다. Linear Probing은 index+1, +2, ...를 순서대로 탐색합니다. Quadratic Probing은 index+1², +2², ...으로 탐색합니다. Double Hashing은 두 번째 해시 함수로 보폭을 결정합니다. 일반적으로 메모리 지역성이 좋아 캐시 효율이 높은 편입니다.

핵심 개념

부하율 (Load Factor)

저장된 항목 수 / 버킷 수로 계산합니다. 부하율이 높을수록 충돌이 잦아져 성능이 O(n)에 가까워집니다. Java HashMap의 기본 부하율은 0.75로, 이를 초과하면 배열을 2배로 늘리고 전체를 재해싱(Rehashing)합니다.

핵심 개념

삭제와 Tombstone

오픈 어드레싱에서 단순 삭제(null 처리)는 탐색 체인을 끊어 이후 항목을 찾지 못하게 합니다. 이를 방지하기 위해 삭제된 슬롯에 Tombstone(묘비) 마커를 남겨 탐색은 계속하되 삽입 시에는 재사용합니다. 체이닝에서는 이 문제가 없습니다.

시간복잡도 — 평균 O(1) 삽입·탐색·삭제. 최악(모든 키가 같은 버킷)은 O(n). 좋은 해시 함수와 적절한 부하율 유지로 최악 케이스를 실질적으로 방지합니다. Java HashMap은 트리 전환으로 최악을 O(log n)으로 줄이려 합니다(키가 Comparable일 때 더 효과적입니다).
Java HashMap 내부 구조 — 기본 용량 16, 부하율 0.75, 임계값 초과 시 2배 리사이징. OpenJDK 소스 기준으로 버킷에 노드가 8개 이상 쌓인 상태에서 추가가 일어나고 테이블 용량이 64 이상이면 Red-Black Tree로 바꾸고(용량이 64 미만이면 트리 대신 테이블을 키웁니다), 리사이즈로 버킷이 쪼개져 노드가 6개 이하가 되면 다시 리스트로 돌립니다. hashCode()와 equals()를 반드시 함께 재정의해야 합니다.
해시 충돌 공격 — 악의적 입력으로 모든 키를 동일 버킷에 몰아 O(n) 성능을 유발하는 공격입니다. Java는 해시 무작위화 대신, 한 버킷의 체인이 길어지면 Red-Black Tree로 전환해 최악을 O(log n)으로 줄이려 합니다(JEP 180, Java 8+). 반면 파이썬은 3.3부터 해시 무작위화가 기본으로 켜져 있고, 3.4부터 문자열 해시에 SipHash 계열을 씁니다.
언제 해시 테이블을 쓰나 — 키-값 쌍 저장, 중복 제거, 빈도 카운팅, 캐시(LRU 구현), 두 컬렉션의 교집합·차집합 계산. 순서 보장이 필요하면 Java LinkedHashMap(삽입 순서) 또는 TreeMap(정렬 순서)을 사용합니다.

해시 테이블이 만들어진 이유

해시 테이블은 키를 해시 함수로 정수로 바꿔 배열 위치를 곧바로 계산하는 자료구조입니다. 정렬된 배열이나 트리가 키를 비교하며 찾는 것과 달리, 위치를 계산해 바로 접근해서 평균적으로 상수 시간에 넣고 찾을 수 있습니다. 대신 서로 다른 키가 같은 위치로 갈 수 있다는 충돌을 다뤄야 합니다.

숫자로 확인하기

Java HashMap의 리사이즈 시점 — 기본 용량 16, 부하율 0.75이면 임계값은 16 × 0.75 = 12입니다. 항목이 13번째로 들어갈 때 용량이 32로 늘어나고 모든 항목이 다시 배치됩니다.
충돌은 생각보다 빨리 생긴다 — 버킷이 365개일 때 키가 23개만 있어도 어딘가에서 충돌할 확률이 50%를 넘습니다(생일 문제, 해시값이 버킷에 고르게 퍼진다고 가정한 값). 키 수가 버킷 수보다 훨씬 적어도 충돌은 흔합니다.
비둘기집 원리 — 키가 될 수 있는 값이 버킷보다 많으면 충돌이 피할 수 없습니다. 예를 들어 문자열 키의 가능한 값은 사실상 무한이지만 버킷은 유한합니다.
체인 길이 — 부하율이 0.75이면 한 버킷에 평균 0.75개가 들어 있어 평균 탐색이 거의 상수 시간입니다. 부하율을 3으로 두면 평균 체인 길이가 3으로 늘어납니다.

자주 하는 오해

해시 테이블은 항상 O(1)이다 — 평균적인 경우입니다. 모든 키가 한 버킷에 몰리면 O(n)이 됩니다. 해시 함수와 부하율 관리가 평균을 지켜 줍니다.
키를 넣은 순서대로 나온다 — 해시 테이블 자체는 순서를 보장하지 않습니다. Java의 HashMap 문서는 순서가 시간이 지나도 일정하다는 보장이 없다고 설명합니다. 순서가 필요하면 LinkedHashMap이나 TreeMap을 씁니다. 파이썬 3.7부터 dict는 삽입 순서를 유지하는 것이 언어 명세입니다.
equals만 재정의하면 된다 — Java에서 equals가 같은 두 객체는 hashCode도 같아야 합니다. hashCode를 함께 재정의하지 않으면 같은 키를 다른 버킷에서 찾아 못 찾을 수 있습니다.
가변 객체를 키로 써도 된다 — Java 문서는 키를 바꾸면 맵의 동작이 보장되지 않는다고 경고합니다. HashMap에서는 보통 넣을 때 정해진 버킷과 달라져 get으로 찾지 못하는 일이 생기므로, 키는 변하지 않는 값(불변 객체)을 쓰는 것이 안전합니다.

면접에서 이렇게 물어요

면접 질문

해시 충돌은 어떻게 해결하나요?

한 버킷에 여러 키를 연결 리스트 같은 구조로 모으는 체이닝과, 충돌 시 다른 빈 버킷을 찾아가는 오픈 어드레싱이 있습니다. Java HashMap은 체이닝 방식을 씁니다.

면접 질문

해시 테이블이 이진 탐색 트리보다 항상 좋은가요?

평균 탐색은 해시 테이블이 빠르지만 키 순서나 범위 검색은 못 합니다. 정렬된 순회나 범위 조회가 필요하면 트리가 낫습니다.

면접 질문

리사이즈(rehash)는 왜 필요하고 비용은 어떤가요?

부하율이 높아지면 충돌이 늘어 성능이 떨어져서 버킷 수를 늘립니다. 모든 항목의 위치를 다시 계산하니 그 순간은 O(n)이지만 드물게 일어나서 분할 상환하면 삽입은 평균 상수 시간입니다.

면접 질문

해시 충돌 공격이란 무엇이고 어떻게 막나요?

공격자가 같은 버킷으로 가는 키를 일부러 만들어 연산 하나를 O(n)으로 느리게 만드는 공격입니다. 해시 함수에 무작위 시드를 넣거나, Java처럼 긴 체인을 트리로 바꿔 최악을 줄이는 방법이 있습니다.

실제 시스템에서는

Java HashMap — 문서는 기본 부하율 0.75가 시간과 공간 비용의 균형이라고 설명하고, 항목 수가 용량과 부하율의 곱을 넘으면 버킷 수를 대략 두 배로 늘려 다시 해시한다고 설명합니다. 한 버킷이 너무 커지면 트리 노드 구조로 바꾸는 최적화가 들어 있습니다(구현 노트, JEP 180).
Python dict — 삽입 순서를 유지하는 것이 3.7부터 언어 명세입니다. 문자열 해시는 서비스 거부 공격을 막으려고 무작위화가 3.3부터 기본으로 켜져 있습니다(PEP 456은 3.4에서 SipHash를 채택).
알고리즘적 복잡도 공격 — 해시 충돌로 서비스를 느리게 만드는 공격은 2003년 Crosby와 Wallach의 논문 등으로 알려졌습니다.

함께 보면 좋은 토픽

배열 vs 연결 리스트 — 버킷과 체인을 이루는 두 구조를 봅니다.
이진 탐색 트리 — 순서와 범위 검색이 필요할 때의 대안을 봅니다.
트라이 — 문자열 키를 다른 방식으로 다루는 구조를 봅니다.
인덱스 — 해시 인덱스와 B-Tree 인덱스의 차이를 봅니다.
시간·공간 복잡도 — 평균과 최악의 차이를 숫자로 봅니다.
LRU 캐시 구현 — 캐시가 가득 차면 가장 오래 쓰이지 않은 항목을 버리는 LRU를 구현하는 방법을 봅니다.

참고 자료

Java HashMap — HashMap — Java SE 21 API
논문 — Crosby, Wallach, "Denial of Service via Algorithmic Complexity Attacks", USENIX Security Symposium, 2003
교재 — Cormen, Leiserson, Rivest, Stein, "Introduction to Algorithms" 4판, "Hash Tables" 장

Java — HashMap 사용 및 내부 동작 이해

/* ── HashMap 기본 사용 ──────────────────────────────────── */
Map<String, Integer> map = new HashMap<>();

// 삽입 — 평균 O(1)
map.put("apple",  3);
map.put("banana", 5);
map.put("cherry", 2);

// 탐색 — 평균 O(1)
int count = map.getOrDefault("apple", 0); // 3

// 존재 확인
boolean has = map.containsKey("banana");  // true

// 삭제 — 평균 O(1)
map.remove("cherry");

// 순회 (순서 보장 없음)
for (Map.Entry<String, Integer> e : map.entrySet()) {
    System.out.println(e.getKey() + " → " + e.getValue());
}

/* ── 빈도 카운팅 패턴 ───────────────────────────────────── */
int[] nums = {1, 2, 2, 3, 3, 3};
Map<Integer, Integer> freq = new HashMap<>();
for (int n : nums) {
    freq.merge(n, 1, Integer::sum); // 없으면 1, 있으면 +1
}
// freq: {1=1, 2=2, 3=3}

/* ── HashMap 내부 구조 파라미터 ─────────────────────────── */
// 기본 용량 = 16 (항상 2의 거듭제곱)
// 기본 부하율 = 0.75
// 임계값 = 용량 * 부하율 = 12
// 13번째 삽입으로 size가 임계값 12를 초과 → 용량 32로 리사이징 + 전체 재해싱

// 초기 용량 예측 가능 시 직접 지정으로 리사이징 방지
Map<String, Integer> presized =
    new HashMap<>(1 << 7); // 128 (2의 거듭제곱)

/* ── 키로 사용할 클래스의 필수 규칙 ────────────────────── */
public class Point {
    private final int x, y;

    @Override
    public int hashCode() {
        // Objects.hash는 null 안전, 여러 필드 조합에 적합
        return Objects.hash(x, y);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return x == p.x && y == p.y;
    }
    // hashCode와 equals를 반드시 함께 재정의해야 합니다.
    // equals()가 true면 hashCode()도 같아야 하는 규약 때문입니다.
}

HashMap은 key.hashCode()로 버킷 인덱스를 계산하고 equals()로 동등성을 비교합니다.
hashCode()와 equals()를 함께 재정의하지 않으면 같은 논리적 키가 서로 다른 버킷에 들어가 탐색 실패가 발생합니다.
merge(key, 1, Integer::sum)은 key가 없으면 1로 초기화하고, 있으면 현재 값에 1을 더합니다. 빈도 카운팅의 관용구입니다.
초기 용량을 예상 항목 수 / 0.75보다 크게 설정하면 리사이징을 방지해 성능을 높일 수 있습니다.

Java — 체이닝 해시 테이블 직접 구현

/* ── 체이닝 해시 테이블 구현 ────────────────────────────── */
public class ChainingHashTable<K, V> {
    private static final int DEFAULT_CAP   = 16;
    private static final float LOAD_FACTOR = 0.75f;

    private LinkedList<Entry<K,V>>[] buckets;
    private int size;

    private static class Entry<K, V> {
        K key; V value;
        Entry(K k, V v) { key = k; value = v; }
    }

    @SuppressWarnings("unchecked")
    public ChainingHashTable() {
        buckets = new LinkedList[DEFAULT_CAP];
    }

    private int bucketIdx(K key) {
        // & 0x7FFFFFFF으로 음수 해시값을 양수로 변환
        return (key.hashCode() & 0x7FFFFFFF) % buckets.length;
    }

    public void put(K key, V value) {
        int idx = bucketIdx(key);
        if (buckets[idx] == null) buckets[idx] = new LinkedList<>();

        // 같은 키 존재 시 값 갱신
        for (Entry<K,V> e : buckets[idx]) {
            if (e.key.equals(key)) { e.value = value; return; }
        }
        buckets[idx].add(new Entry<>(key, value));
        size++;

        // 부하율 초과 시 리사이징
        if ((float) size / buckets.length > LOAD_FACTOR) resize();
    }

    public V get(K key) {
        int idx = bucketIdx(key);
        if (buckets[idx] == null) return null;
        for (Entry<K,V> e : buckets[idx]) {
            if (e.key.equals(key)) return e.value;
        }
        return null;
    }

    @SuppressWarnings("unchecked")
    private void resize() {
        LinkedList<Entry<K,V>>[] old = buckets;
        buckets = new LinkedList[old.length * 2]; // 2배 확장
        size = 0;
        for (LinkedList<Entry<K,V>> bucket : old) {
            if (bucket == null) continue;
            for (Entry<K,V> e : bucket) put(e.key, e.value); // 재해싱
        }
    }
}

버킷 인덱스 계산 시 hashCode()가 음수일 수 있으므로 & 0x7FFFFFFF로 부호 비트를 제거한 뒤 나머지를 구합니다.
삽입 시 같은 키가 이미 있으면 값을 갱신하고 size를 늘리지 않습니다.
부하율이 0.75를 초과하면 배열을 2배로 늘리고 모든 항목을 새 인덱스로 재해싱합니다.
리사이징 자체는 O(n)이지만 분할 상환 분석으로 삽입은 평균 O(1)입니다.
Java의 실제 HashMap은 보조 해시(secondary hashing)를 적용해 상위 비트도 인덱스 계산에 반영함으로써 충돌을 줄입니다.

Q1

해시 테이블에서 해시 함수의 역할은?

키를 어디에 저장할지 계산합니다.
해시 함수는 입력된 키를 특정 규칙에 따라 배열의 인덱스(버킷 위치)로 변환해, 평균 O(1)의 빠른 조회를 가능하게 합니다.

Q2

체이닝(Chaining) 방식이 해시 충돌을 해결하는 방법은?

한 버킷에 여러 개를 매달아 둡니다.
체이닝은 같은 버킷에 해시된 데이터들을 연결 리스트 형태로 이어붙여 저장해 충돌을 처리합니다.

Q3

오픈 어드레싱(Open Addressing) 방식과 체이닝의 차이는?

'주소를 연다'는 이름처럼 다른 자리를 찾아봅니다.
오픈 어드레싱은 충돌이 나면 정해진 규칙(선형 탐사 등)에 따라 테이블 내 다른 빈 슬롯을 찾아 저장하는 방식으로, 별도의 연결 리스트가 필요 없습니다.