본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

해싱이 배신하는 순간

~11 min · hashing, limits, trees-preview

Level 0호기심 많은 입문자
0 XP0/85 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"해싱은 치트키 같아서 뭐든 여기다 밀어 넣고 싶어져. 그런데 즉시 조회 하나를 얻는 대신 내주는 게 있어. 순서에 대한 감각 전체야. 그 선이 어디인지 아는 게 이번 이야기의 핵심이고."

큰 사각지대: 순서가 없어

해시맵은 키를 해시값에 따라 흩어 놓는데, 그 해시는 일부러 값과 무관하게 만들어져. "이 키가 정확히 존재하나?"에는 최고지만 순서에 관한 질문에는 아무 쓸모가 없지. 해시맵한테는 "가장 작은 키 줘", "10과 20 사이의 키 전부", "이거 다음 키는?" 같은 걸 물을 수가 없어. 전부 쏟아내서 정렬하지 않는 한 말이야. 그러면 O(n log n)이 드니까 애초에 쓰는 이유가 사라지고. 참고로 Python dict가 보존하는 건 삽입 순서지 정렬된 순서가 아니야. 이 둘을 헷갈리면 안 돼. 풀려는 문제가 순서나 범위 질의를 요구하는 순간 해싱은 잘못된 도구고, 트리를 꺼내야 해. 바로 다음 트랙이 가는 곳이지.

다른 배신들

  • 최악은 O(n)이야. 공격자가 전부 충돌하는 키를 만들어 낼 수 있으면 모든 연산이 선형 훑기로 떨어져. hash flooding이라고 부르는 실제 서비스 거부 공격이야. Python이 문자열 해시를 프로세스마다 무작위화하는 이유가 이거고(앞에서 본 PYTHONHASHSEED 소금), 그래야 공격자가 충돌 키 묶음을 미리 계산해 둘 수 없거든.
  • 가변 키는 맵을 망가뜨려. 리스트는 키로 못 쓰고, 키로 쓴 객체를 나중에 바꿔서도 안 돼. 바뀌면 해시도 바뀌고 맵은 그 키를 다시는 못 찾으니까. 여기서 불변성은 Python의 별난 성질이 아니라 정확성 요구사항이야.
  • 메모리를 더 써. 조회를 빠르게 유지해 주는 그 빈 슬롯들도 메모리를 차지하거든. 작고 촘촘한 정수 키만 다룬다면 평범한 배열로 직접 인덱싱하는 쪽이 속도와 공간 양쪽에서 dict를 이길 수도 있어.
해싱은 O(1) 지점 조회를 주는 대신 순서를 버려. '가장 작은', '범위 안', '정렬', '다음으로 큰'이 필요하면 그건 해시가 아니라 트리야. 게다가 해싱은 충돌로 인한 O(n) 최악이 있고, 가변 키를 금지하고, 메모리를 속도와 맞바꿔. 강력하지만 만능은 아니야.

더 깊은 교훈: 만능 구조는 없다

퀘스트 전체를 관통하는 이야기가 여기서 또 떠올라. 모든 상황에서 이기는 구조 같은 건 없어. 배열은 삽입을 내주고 인덱싱을 얻고, 연결 리스트는 인덱싱을 내주고 잇기를 얻고, 해시맵은 순서를 내주고 즉시 조회를 얻어. 저마다 어떤 종류의 접근에 맞춰진 추상화인 거지. 숙달은 구조를 외우는 게 아니라 문제를 읽고 그게 어떤 거래를 요구하는지 알아듣는 거야. "빠른 조회도 필요하고 정렬된 순서도 필요해"라는 문장이 나오는 순간, 이 트랙에서 다음 트랙으로 넘어갈 때가 된 거고.

피파의 고백

dict와 사랑에 빠진 뒤로, "점수 상위 10명"과 "순위 50위에서 60위 사이 전원"을 계속 물어야 하는 리더보드에까지 dict를 쓰려고 했어. 매번 전체를 다시 정렬하는 재앙이었지. 아빠가 딱 한마디 하더라. "해싱은 순서가 없어. 트리를 원하는 거야." 그 문장이 경계선을 그어줬어. 지점 조회에는 해시맵이 반사적으로 나오는 게 맞지만, 문제가 '순서'나 '범위'를 흘리는 순간 트리한테 넘겨야 한다는 것. 다음 트랙이 그 넘김이야.

Code

순서 없음, 가변 키 없음, 소금 친 해시·python
# 1) dict 는 삽입 순서를 보존, 정렬 순서가 아니라.
d = {}
for k in [5, 1, 9, 3]:
    d[k] = k * 10
print(list(d))            # [5, 1, 9, 3] — 삽입 순서, 정렬 아님
print(sorted(d))          # [1, 3, 5, 9] — 그리고 정렬은 매번 O(n log n)
# '가장 작은 키', '[2, 6] 안 키', '5 다음 키' 가 다 먼저 정렬을 요구.
# 그게 자주 필요하면, 해시맵은 틀린 구조 -> 트리를 써.

# 2) 가변 객체는 키가 못 돼 (그리고 키였으면 변형하면 안 돼).
try:
    bad = {[1, 2]: "x"}   # 리스트는 해시 불가능
except TypeError as e:
    print("TypeError:", e)   # unhashable type: 'list'

# 3) 해시 무작위화 (새 프로세스에서 두 번 돌리면 -> 다른 숫자).
print(hash("pippa"))      # 프로세스별 소금. 조작된 충돌 DoS 를 막아

External links

Exercise

다음 각각에 대해 해시맵(dict/set)이 맞는 구조인지, 아니면 순서가 있는 구조가 필요한지 말해 봐. (1) '이 이메일 이미 등록됐나?', (2) '두 날짜 사이의 주문을 전부 줘', (3) '페이지별 방문 수 세기', (4) '내 점수 바로 위의 점수는 뭐야?'. 해시맵으로 안 되는 것들에는 해시맵이 정확히 뭘 못 하는지도 짚어 봐.
Hint
(1)과 (3)은 해시에 딱 맞아. 정확한 조회와 세기니까. (2)와 (4)는 순서나 범위가 필요해서 안 돼. 해시맵은 전체를 정렬하지 않고는 '사이'나 '다음으로 큰'을 못 주거든. 그래서 트리 같은 정렬된 구조를 불러야 하고, 그게 트리 트랙으로 건너가는 다리야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.