백엔드 개발에서 성능은 매우 중요하며, 데이터를 빠르게 조회하기 위해 해시(Hash) 기반의 자료 구조(해시 테이블, 해시맵 등)를 빈번하게 사용합니다. 해시 자료 구조의 핵심과 성능 저하의 주범인 '해시 충돌'에 대해 알아보겠습니다.
해시 자료 구조는 키-값 쌍(Key-Value pair)으로 이루어진 데이터 구조입니다.
가장 큰 특징은 키(Key)를 이용해 값(Value)을 평균 의 매우 빠른 시간 복잡도로 찾을 수 있다는 것입니다.
이것이 가능한 이유는 내부적으로 키를 해시 함수(Hash Function)에 통과시켜 반환된 '해시 값'을 배열의 인덱스로 사용하여 값을 관리하기 때문입니다.
해시 자료 구조는 키를 해시 함수에 넣어서 나오는 결과를 기반으로 값을 관리합니다. 하지만 해시 함수는 종종 서로 다른 키를 사용해도 같은 해시 값을 반환하는 경우가 존재합니다.
이처럼 서로 다른 키가 같은 인덱스(버킷)로 매핑되는 상황을 해시 충돌(Hash Collision)이라고 합니다.
해시 충돌이 발생하면 의 시간 복잡도를 보장할 수 없게 되며, 충돌이 많아질수록 성능은 에 가까워질 수 있습니다. 따라서 이 충돌을 효율적으로 관리하고 완화하는 것이 핵심입니다.
해시 충돌을 완화하기 위한 접근 방법으로 크게 두 가지가 대표적입니다.
분리 연결법은 이름 그대로 충돌이 발생한 데이터를 기존 데이터에 '연결'시키는 방식입니다.
즉, 각 버킷을 단순한 값이 아닌 연결 리스트(Linked List)나 트리(Tree) 형태로 관리하여, 충돌이 발생하더라도 해당 버킷에 데이터를 계속해서 추가할 수 있도록 합니다.
하나의 버킷에 데이터가 계속 쌓여 연결 리스트가 길어지면, 해당 버킷을 탐색하는 시간은 이 되어 성능이 저하됩니다.
많은 현대 언어의 해시맵 구현체(예: Java의 HashMap)는 이 문제를 해결하기 위해, 특정 버킷의 연결 리스트 길이가 일정 임계값(예: 8개)을 초과하면, 해당 버킷의 자료 구조를 연결 리스트에서 레드-블랙 트리(Red-Black Tree)와 같은 균형 잡힌 이진 탐색 트리로 변환합니다.
이를 통해 최악의 경우에도 탐색 성능을 으로 보장할 수 있습니다.
개방 주소법은 충돌이 발생했을 때, 해당 버킷이 이미 사용 중이라면 다른 비어있는 해시 버킷을 찾아 데이터를 삽입하는 방식입니다.
개방 주소법에서 빈 버킷을 찾기 위한 탐사(Probing) 방법에는 여러 가지가 존재합니다.
임의의 고정된 크기(예: 1)만큼 한 칸씩 순차적으로 이동하며 빈 버킷을 찾는 가장 간단한 방법입니다.
선형 탐사법처럼 한 칸씩 찾는 것이 아닌, 만큼의 보폭으로 제곱으로 늘려가며 빈 버킷을 찾습니다.
해시 충돌이 발생하는 경우, 두 번째 보조 해시 함수(Auxiliary Hash Function)를 사용하는 방법입니다.
첫 번째 해시 값으로는 초기 위치를 정하고, 충돌 시 두 번째 해시 함수의 결과값만큼 일정하게 이동하며 빈 버킷을 찾습니다.
해시 충돌은 피할 수 없지만, 충돌이 '너무 자주' 일어나지 않도록 관리하는 것이 중요합니다. 이때 사용되는 개념이 적재율(Load Factor)입니다.
적재율 = (저장된 데이터 개수) / (전체 버킷 수)적재율이 너무 높아지면(예: 0.75 이상) 빈 공간이 줄어들어 해시 충돌이 급격하게 증가하고 성능이 저하됩니다.
이 문제를 해결하기 위해 해시 테이블은 적재율이 특정 임계값(예: Java HashMap의 기본값 0.75)을 초과하면 리해싱(Rehashing)을 수행합니다.
리해싱이란, 기존보다 더 큰 크기(보통 2배)의 새로운 버킷 배열을 생성한 뒤, 기존의 모든 데이터를 새로운 해시 함수(또는 변경된 인덱스 계산)에 따라 새 배열에 다시 삽입하는 과정을 말합니다.
리해싱은 비용이 많이 드는 작업이지만, 적재율을 낮춰 다시 의 평균 시간 복잡도를 유지할 수 있도록 해주는 필수적인 작업입니다.