"해시맵은 해시 함수만큼만 좋아. 나쁜 해시도 여전히 '정답'이긴 해. 다만 O(1)이라는 꿈을 조용히 O(n) 연결 리스트로 되돌려놓을 뿐이지."
좋은 해시의 세 속성
해시 함수는 이런 조건을 갖춰야 해.
- 결정론적 — 같은 키는 언제나 같은 해시를 내놔야 해. 이게 안 되면 저장한 걸 다시 못 찾아. 타협의 여지가 없어.
- 균일 — 서로 다른 키를 테이블 전체에 고르게 흩어야 해. 그래야 슬롯이 비슷한 속도로 차고 충돌이 드물지. O(1)을 지켜주는 게 이 성질이야.
- 빠름 — 입력 길이에 비추어 충분히 빨라야 해. 정수처럼 크기가 고정된 키의 해시는 O(1)로 봐도 되지만, 긴 문자열이나 튜플의 첫 해시는 일반적으로 키 길이만큼 읽어야 한다는 걸 기억해.
좋은 해시에는 눈사태(avalanche) 성질이 있어. 키를 아주 조금만 바꿔도("cat"과 "car"처럼) 해시가 전혀 다른 값으로 튀어. 비슷하게 생긴 키들이 같은 슬롯에 뭉치는 걸 막아주는 게 정확히 이 성질이고.
나쁜 해시가 왜 조용한 살인자인가
합법이면서 최악인 해시 함수를 상상해 봐. 어떤 키가 오든 return 0. 결정론적이고 빠르지. 그리고 재앙이야. 모든 키가 슬롯 0에 떨어지니까 "해시맵"이 선형으로 훑어야 하는 거대한 체인 하나로 주저앉아. 조회는 O(n)이 되는데, 어디에도 고장 난 티가 안 나. 코드는 여전히 '정답'이고 그냥 느릴 뿐이거든. 진짜 키 여럿을 적은 슬롯에 몰아넣는 해시는 이렇게 에러 한 줄 없이 성능만 조용히 부숴. 해시 함수의 균일성이 곧 O(1)과 O(n)의 갈림길이야.
반드시 지켜야 하는 계약
객체를 dict 키로 쓰려면 같은 객체는 같은 해시를 가져야 하고, 저장되어 있는 동안 해시가 바뀌면 안 돼. Python에서 값 기반 __eq__를 정의하면서 __hash__를 같이 정의하지 않으면 보통 __hash__ = None이 되고, 그 인스턴스는 해시할 수 없게 돼. str, int, 그리고 해시 가능한 값만 담은 tuple이 대표적인 키야. list, dict, set은 가변이라 기본적으로 해시가 안 되고. 가변 상태를 값 비교와 해시 양쪽에 쓰는 객체라면 아예 키로 안 쓰는 게 안전해.
여담으로, Python은 문자열 해시에 프로세스마다 다른 무작위 시드를 섞어. 그래서 hash("x")가 실행할 때마다 달라지지. 공격자가 전부 충돌하는 키를 미리 만들어 오는 걸 막으려는 의도적인 보안 조치고, 이 트랙 마지막에서 다시 볼 거야.
피파의 고백
__eq__를 붙여놓고 dict 키로 쓰려다가 Python한테 "해시 불가"라며 바로 문전박대당한 적이 있어. 예전 같았으면 왜 막냐고 투덜댔겠지만, 그건 거짓말하는 딕셔너리가 생기는 걸 막아준 안전장치였어. 그때 아빠 규칙이 그대로 박혔지. "동등성과 해시는 한 쌍으로 설계해. 그리고 키가 들어간 뒤엔 해시에 쓰는 상태를 건드리지 마."