JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화

2026.09.10·수정 2026.09.13·약 19분·작성: 해비·블로그 소개

해시 테이블: 이름표를 칸으로 바꾸고 같은 칸에서 다시 찾습니다

네 칸의 사물함 예제로 해시 충돌과 원래 키 비교를 배웁니다. 작은 코드부터 재배치와 문자열 정규화까지 단계적으로 연결합니다.

먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

해시 테이블의 핵심 구조를 표현한 개념 표지
구조의 특징을 표현한 개념 이미지입니다. 정확한 동작은 아래 코드와 추적 표를 함께 확인하세요.

사물함 이름표를 숫자 칸으로 바꿔 봅니다

학생 이름으로 사물함을 찾는다고 해보겠습니다. 사물함이 네 칸뿐이라면 이름을 숫자로 바꾸어 0번부터 3번 중 한 칸을 고를 수 있습니다. 이 숫자를 만드는 규칙이 해시 함수입니다.

처음에는 글자의 복잡한 숫자값 대신 이름 길이를 사용하겠습니다. 이름 길이 % 4로 칸을 정합니다.

이름 길이 4로 나눈 나머지 선택한 칸
민수 2 2 2번
지수 2 2 2번
하늘 2 2 2번
1 1 1번

민수와 지수는 다른 이름인데도 같은 2번 칸을 골랐습니다. 이것이 충돌입니다. 충돌은 해시 함수가 고장 났다는 뜻이 아닙니다. 가능한 이름은 아주 많고 칸은 네 개뿐이므로 언젠가는 같은 칸을 고르게 됩니다.

작은 해시 함수의 결과를 직접 봅니다

function smallHash(key) {
  return key.length % 4;
}

console.log(smallHash('민수'));
console.log(smallHash('지수'));
2
2

함수는 문자열 길이를 네 칸 안의 번호로 줄입니다. 두 이름이 모두 2를 내므로 해시 번호만 보고 “같은 이름”이라고 결론 내리면 안 됩니다. 해시는 찾을 후보 칸을 알려줄 뿐이고, 마지막에는 원래 이름을 다시 비교해야 합니다.

충돌한 이름은 같은 칸의 목록에 둡니다

const buckets = [[], [], [], []];
buckets[2].push(['민수', '파란 가방']);
buckets[2].push(['지수', '빨간 가방']);

console.log(buckets[2].map(pair => pair[0]).join(', '));
민수, 지수

2번 칸 자체를 작은 목록으로 만들었습니다. 각 항목에는 원래 이름과 값이 함께 있습니다. 찾을 때는 2번 칸까지만 바로 간 뒤, 그 안에서 원래 이름이 같은 항목을 확인합니다. 이름을 저장하지 않고 가방만 넣으면 어느 가방이 민수 것인지 구분할 수 없습니다.

조회는 칸 선택과 원래 키 비교 두 단계입니다

const bucket = [['민수', '파란 가방'], ['지수', '빨간 가방']];
const entry = bucket.find(pair => pair[0] === '지수');

console.log(entry[1]);
빨간 가방

find는 같은 칸 안에서 이름을 하나씩 비교합니다. 여기서는 민수가 아니므로 다음 항목으로 가고, 지수를 만나 빨간 가방을 읽습니다. 해시 번호가 같다는 이유만으로 첫 항목을 반환했다면 지수에게 민수의 가방을 주는 오류가 생깁니다.

확인 문제: 민수와 지수의 해시 번호가 같으면 같은 키일까요?

아닙니다. 같은 칸의 후보라는 뜻일 뿐입니다. 문자열 '민수' === '지수'는 거짓이므로 서로 다른 항목으로 보관해야 합니다.

칸이 붐비면 더 큰 표로 다시 배치합니다

네 칸에 이름이 계속 늘면 한 칸의 목록이 길어져 다시 많은 이름을 비교하게 됩니다. 그래서 전체 칸 수를 늘리고 각 이름의 새 칸을 다시 계산합니다.

이름 4칸일 때 7칸일 때
민수 2번 2번
지수 2번 2번
1번 1번
가나다라마 1번 5번

길이만 쓰는 장난감 함수는 칸을 늘려도 두 글자 이름이 계속 충돌합니다. 실제 전체 구현이 문자열의 각 글자를 섞어 해시를 만드는 이유입니다. 또한 칸 수가 달라지면 나머지 결과도 달라질 수 있으므로 기존 배열을 그대로 복사하지 않고 모든 키의 위치를 다시 계산해야 합니다.

더 알아보기: 같은 글자로 보이는 문자열 정리

화면에서 같은 글자로 보여도 컴퓨터 안의 조합 방식이 다른 문자열이 있습니다. NFC 정규화는 이런 표현을 한 형태로 맞춘 뒤 저장하고 찾게 합니다. 아래 전체 구현은 모든 공개 동작에서 이 정리를 적용합니다. 참고로 위의 length는 JavaScript의 UTF-16 코드 단위 개수이며, 표는 차이를 피하려고 완성된 한국어 음절만 사용했습니다.

선택 실습: 같은 글자로 보이는 문자열 비교하기
const first = 'é';
const second = 'e\u0301';

console.log(first === second);
console.log(first.normalize('NFC') === second.normalize('NFC'));
false
true

화면에서는 둘 다 é처럼 보이지만 컴퓨터 안의 글자 조합이 다를 수 있습니다. 저장할 때만 정리하고 찾을 때는 정리하지 않으면 같은 이름을 못 찾습니다. 그래서 이어지는 구현은 저장·조회·삭제에서 같은 정규화 과정을 거칩니다.

이제 기존 전체 구현을 읽는 순서

먼저 “키를 칸 번호로 바꾸는 함수”를 찾고, 다음으로 한 칸 안에서 원래 키를 비교하는 부분을 찾으세요. 그 뒤 같은 키를 다시 저장할 때 값을 바꾸는 흐름, 항목이 많아질 때 더 큰 표로 옮기는 흐름, 마지막으로 모든 공개 동작에서 NFC 정규화를 적용하는지 확인하면 됩니다.

위 길이 기반 함수는 충돌 원리를 눈으로 보기 위한 축소 예제라 실제 저장소에 적합하지 않습니다. 아래 정밀 구현은 더 나은 문자열 해시, 충돌 목록, 크기 조절과 문자열 정책을 함께 다룹니다.

관련 코딩테스트 문제로 이어서 연습합니다

이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.

전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기

여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.

키를 빨리 찾으려면 무엇을 생략할 수 있는가

문자열 배열에서 키 하나를 찾으면 최악에는 모든 키를 비교합니다. 해시 테이블은 키로부터 버킷 번호를 계산해 조사 범위를 줄입니다. 다만 가능한 문자열 수는 버킷 수보다 훨씬 많으므로 서로 다른 키가 같은 버킷으로 가는 충돌은 정상적인 현상입니다.

이 구현은 각 버킷에 작은 배열을 두는 분리 체이닝을 사용합니다. 해시는 후보 버킷을 선택하고, 그 안에서 실제 문자열 동등성 비교로 키를 확정합니다. 해시 값이 같다는 이유만으로 키까지 같다고 처리하면 서로 다른 데이터가 덮어써집니다.

문자열 계약과 유니코드 정책

키는 문자열만 허용합니다. 숫자 1이나 객체를 자동 문자열화하지 않아 의도하지 않은 키 합침을 피합니다. 키는 모든 공개 조회·저장·삭제에서 NFC로 정규화하므로 조합형 e + 결합 악센트와 완성형 é는 같은 키입니다. 대소문자, 공백, 호환 문자는 별도로 합치지 않습니다.

빈 문자열과 이모지도 유효한 키입니다. for…of는 UTF-16 코드 단위를 낱개로 쪼개지 않고 코드 포인트 단위로 진행합니다. 단, 코드 포인트는 사용자가 보는 글자 묶음인 자소와 같지 않습니다. NFC 역시 철자가 비슷한 모든 문자열을 하나로 바꾸는 검색용 유사도 처리가 아닙니다.

값에는 undefined도 허용하기 때문에 get은 {found:true,value} 또는 {found:false}를 반환합니다. 같은 정규화 키를 set하면 값만 교체하고 size는 늘리지 않습니다. entries의 순서는 버킷 배치에 따라 달라지며 삽입 순서를 보장하지 않습니다. 내부 buckets나 resize는 학습을 위한 공개 상태이므로 호출자가 직접 바꾸지 않는 계약입니다.

전체 코드와 실행

아래를 hash-table.cjs로 저장해 node ./hash-table.cjs로 실행합니다. Node.js CommonJS 전체 파일입니다. 예제는 NFC로 합쳐지는 키를 갱신하고 초기 버킷 2개를 4개로 늘린 후 조회 결과를 출력합니다.

'use strict';

class StringHashTable {
  constructor(capacity = 4) {
    if (!Number.isInteger(capacity) || capacity < 1) {
      throw new RangeError('capacity must be a positive integer');
    }
    this.buckets = Array.from({ length: capacity }, () => []);
    this.size = 0;
  }

  normalize(key) {
    if (typeof key !== 'string') throw new TypeError('key must be a string');
    return key.normalize('NFC');
  }

  index(key, capacity = this.buckets.length) {
    let hash = 2166136261;
    for (const character of key) {
      hash = Math.imul(hash ^ character.codePointAt(0), 16777619) >>> 0;
    }
    return hash % capacity;
  }

  resize(capacity) {
    const next = Array.from({ length: capacity }, () => []);
    for (const bucket of this.buckets) {
      for (const entry of bucket) {
        next[this.index(entry.key, capacity)].push(entry);
      }
    }
    this.buckets = next;
  }

  set(rawKey, value) {
    const key = this.normalize(rawKey);
    let bucket = this.buckets[this.index(key)];
    for (const entry of bucket) {
      if (entry.key === key) {
        entry.value = value;
        return this;
      }
    }
    if ((this.size + 1) / this.buckets.length > 0.75) {
      this.resize(this.buckets.length * 2);
      bucket = this.buckets[this.index(key)];
    }
    bucket.push({ key, value });
    this.size += 1;
    return this;
  }

  get(rawKey) {
    const key = this.normalize(rawKey);
    for (const entry of this.buckets[this.index(key)]) {
      if (entry.key === key) return { found: true, value: entry.value };
    }
    return { found: false };
  }

  delete(rawKey) {
    const key = this.normalize(rawKey);
    const bucket = this.buckets[this.index(key)];
    const index = bucket.findIndex(entry => entry.key === key);
    if (index === -1) return false;
    bucket.splice(index, 1);
    this.size -= 1;
    return true;
  }

  entries() {
    const result = [];
    for (const bucket of this.buckets) {
      for (const entry of bucket) result.push([entry.key, entry.value]);
    }
    return result;
  }
}

module.exports = { StringHashTable };

if (require.main === module) {
  const table = new StringHashTable(2);
  table.set('e\u0301', 1);
  table.set('é', 2);
  table.set('가', 3);
  table.set('🙂', 4);
  console.log(JSON.stringify({
    size: table.size,
    capacity: table.buckets.length,
    accent: table.get('e\u0301'),
    missing: table.get('없음')
  }));
}

실행 출력

{"size":3,"capacity":4,"accent":{"found":true,"value":2},"missing":{"found":false}}

해시 계산과 충돌 탐색

index는 코드 포인트를 누적하면서 Math.imul로 32비트 정수 곱셈을 수행하고 >>> 0으로 부호 없는 값으로 해석합니다. 마지막 나머지 연산이 버킷 범위를 만듭니다. FNV 계열의 혼합 상수를 활용한 교육용 함수이며 표준 바이트 기반 FNV 구현이나 암호학적 해시라고 주장하지 않습니다.

set은 먼저 같은 키를 찾습니다. 기존 키의 갱신이라면 새 저장 공간이 필요 없으므로 바로 반환합니다. 없을 때에만 새 원소를 포함한 적재율을 계산합니다. size가 실제 서로 다른 정규화 키 수와 일치한다는 불변식이 리사이즈 시점을 정합니다.

get은 버킷의 모든 후보 중 정확히 같은 key를 찾습니다. delete 역시 일치 항목만 지우며, splice가 뒤의 버킷 원소들을 이동시킬 수 있으므로 충돌 체인의 길이를 비용에 포함해야 합니다. 찾지 못한 삭제는 false이며 size를 바꾸지 않습니다.

왜 리사이즈는 복사가 아니라 재배치인가

적재율 (size + 1) / 버킷 수가 0.75를 넘으면 버킷 수를 두 배로 늘립니다. 같은 해시 정수라도 나누는 수가 바뀌면 나머지가 달라집니다. 기존 버킷 배열만 길게 만드는 것으로는 조회 위치가 맞지 않기 때문에 모든 항목을 새 용량으로 다시 분류합니다.

resize는 이미 중복이 제거된 항목을 직접 새 버킷에 넣습니다. set을 다시 호출하지 않으므로 size를 중복 증가시키거나 불필요하게 키를 재정규화하지 않습니다. 재배치 중에도 key와 value는 유지되며, 끝에 buckets 참조만 바꿉니다. 삭제 후에는 축소하지 않는 정책이므로 최고 사용량에 가까운 버킷 메모리가 남을 수 있습니다.

작업 서로 다른 키 수 버킷 수 핵심 변화
초기 용량 2 0 2 각 버킷은 서로 다른 빈 배열
set(e + 결합 악센트, 1) 1 2 NFC 키 é 생성
set(é, 2) 1 2 같은 키 갱신, 리사이즈 없음
set(가, 3) 2 4 예상 적재율 1이므로 재배치
set(🙂, 4) 3 4 적재율 0.75 유지
delete(é) 2 4 해당 체인에서만 제거, 축소 없음

충돌을 눈으로 확인하려면 같은 용량에서 index 결과가 같은 서로 다른 두 문자열을 찾아 set해 보세요. 두 항목은 같은 배열에 나란히 있어야 합니다. 테스트에서는 index를 항상 0을 반환하도록 바꾼 별도 인스턴스로 충돌이 극단적인 경우의 정확성도 검사할 수 있습니다. 이런 조작은 일반 사용 API가 아닙니다.

평균 O(1)을 말하기 위한 조건

작업 비용 모델
정규화·해시 길이 L에 비례한다고 가정하는 통상 모델
리사이즈 없는 조회·갱신·삭제 체인 길이 b, 최대 비교 길이 L일 때 O((b+1)L)
리사이즈 1회 버킷 B개 초기화 + 저장 키 총길이 C 재해시: O(B+C)
고른 분포와 길이 상한 Lmax를 가정한 연산열 기대 상환 O(Lmax), 고정 길이면 O(1)
충돌이 집중된 한 연산 최악 O(nL), 길이 상한이 있으면 O(nLmax)
테이블 공간 O(B+n+C), 값이 참조하는 객체 본체는 별도

해시 분포가 고르다는 가정은 공격자가 고른 키에도 보장되는 사실이 아닙니다. 버킷 수가 늘어도 의도적 충돌은 남을 수 있으며, 리사이즈하는 단일 set은 상수 시간이 아닙니다. JavaScript 문자열 정규화의 구체적인 실행 시간 역시 언어 명세가 Big-O로 약속하지 않으므로 표의 문자열 처리 가정을 구분해 읽어야 합니다.

실무에서는 보통 내장 Map을 먼저 사용합니다. 이 구현의 목적은 충돌 처리와 비용 조건을 이해하는 것이며 해시 서비스의 보안성이나 Map보다 빠른 성능을 보장하지 않습니다. 외부 입력에 노출되는 서버라면 입력량 제한과 검증을 별도로 설계해야 합니다.

경계 확인과 연습

NFC 등가 키의 갱신, 대소문자가 다른 키, 빈 문자열, 이모지, 저장된 undefined, 없는 키 삭제를 확인하세요. 리사이즈 직전과 직후에 모든 entries를 다시 조회하면 나머지 계산을 옛 용량으로 수행하는 오류를 찾기 쉽습니다.

JavaScript 해시 테이블 연습: 정규화 문자열 빈도와 등장 순서에서 직접 구현을 적용해 보세요.

함께 연습하고 확인할 자료

Design HashMap: 정수 키 사전을 구현하는 외부 연습입니다. 이 글의 NFC 문자열 키 정책과는 입력 계약이 다릅니다.

관련 개념 참고: Princeton Algorithms — Hash Tables, MDN — String.prototype.normalize() 공식 자료와 문제 페이지는 2026년 9월 10일 확인했습니다. 외부 문제의 지문·예시를 복제하지 않고 이 글의 예제와 창작 문제를 별도로 구성했습니다.

직접 실습: 연산 비용으로 구조를 설명합니다

실습 주제: JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화

  1. 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
  2. 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
  3. 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
  4. 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과

메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.

테스트 체크리스트

  • 빈 구조에 대한 조회·삭제 처리
  • 첫 원소와 마지막 원소 변경
  • 중복값 또는 동일 우선순위 처리
  • 입력 크기가 커졌을 때 예상 복잡도 유지

이 글이 도움이 되었나요?

조회 중

자료구조 학습 순서

필수 18개 · 전체 18개

읽음 기록 관리

전체 과정 목차 (18개)
  1. 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
  2. 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
  3. 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
  4. 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
  5. 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
  6. 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
  7. 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
  8. 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화 현재 글
  9. 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
  10. 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
  11. 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수
  12. 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS
  13. 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
  14. 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
  15. 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
  16. 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
  17. 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
  18. 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기