콘텐츠로 이동

Ch.10 자료구조 선택의 기준

< Hash Table과 시간 복잡도 | 유사 사례와 키워드 정리 >


앞에서 Hash Table이 왜 빠른지, 시간 복잡도가 뭔지 확인했다. 이제 실무 질문이다. "그래서 뭘 쓰라는 거냐?"

그래서 뭘 기준으로 고르나

Set이 빠르다고 다 Set으로 바꾸면 될 것 같지만, 그렇지 않다. 무엇이 빠른지가 아니라 "이 데이터로 무엇을 자주 하는가"가 기준이다.

자료구조 선택의 핵심은 "이 데이터로 무엇을 하는가"다.

주요 연산 추천 자료구조 이유
"이 값이 있는가?" (존재 여부) Set O(1) 검색, 중복 제거
"이 키에 해당하는 값은?" (키-값 매핑) Dict O(1) 검색 + 값 접근
"순서대로 순회해야 한다" List 인덱스 접근 O(1), 순서 보장
"앞에 추가/삭제가 잦다" deque (collections.deque) 양쪽 끝 O(1)
"항상 최소/최대를 꺼내야 한다" heapq 삽입 O(log n), 최소 추출 O(log n)
"정렬된 상태를 유지해야 한다" bisect + List 또는 SortedList Binary Search O(log n)

(Java라면 HashSet, HashMap, ArrayList, ArrayDeque, PriorityQueue, TreeMap 등이 대응된다.)

그 기준을 어기면 어떤 코드가 나오나

기준을 표로 정리했지만, 실무에서 틀리는 건 늘 비슷한 자리다. 같은 실수가 반복되는 패턴부터 보자.

패턴 1: List에서 in 검색

가장 흔한 실수다. 앞에서 본 블랙리스트 사례.

# 나쁜 코드
blacklist = [10234, 20456, 30789, ...]  # List
if user_id in blacklist:  # O(n)

# 좋은 코드
blacklist = {10234, 20456, 30789, ...}  # Set
if user_id in blacklist:  # O(1)

List를 Set으로 바꾸는 건 한 줄이다. []{}로 바꾸거나, set(list_data)로 변환하면 된다.

패턴 2: List에서 중복 제거 후 검색

# 나쁜 코드
seen = []
for item in data:
    if item not in seen:  # O(n) x n번 = O(n^2)
        seen.append(item)

# 좋은 코드
seen = set()
for item in data:
    if item not in seen:  # O(1) x n번 = O(n)
        seen.add(item)

이 패턴은 n이 작을 때는 차이가 안 느껴진다. n이 1만만 넘어도 체감된다.

패턴 3: 두 List의 교집합

# 나쁜 코드
common = []
for item in list_a:
    if item in list_b:  # O(m) x n번 = O(n*m)
        common.append(item)

# 좋은 코드
common = set(list_a) & set(list_b)  # O(n+m)

Set의 교집합(&), 합집합(|), 차집합(-) 연산은 내부적으로 Hash Table을 활용하기 때문에 훨씬 빠르다.

패턴 4: Dict를 안 쓰고 두 List를 사용

# 나쁜 코드
user_ids = [1, 2, 3, 4, 5]
user_names = ["Alice", "Bob", "Charlie", "Dave", "Eve"]

# user_id = 3의 이름을 찾으려면
idx = user_ids.index(3)  # O(n)
name = user_names[idx]

# 좋은 코드
users = {1: "Alice", 2: "Bob", 3: "Charlie", 4: "Dave", 5: "Eve"}
name = users[3]  # O(1)

"키로 값을 찾는" 연산이면 Dict가 정답이다.

이 원리가 애플리케이션 안에서만 통하나

지금까지는 메모리 위 자료구조 이야기였다. 그런데 같은 원리가 한 층 아래, DB에서도 그대로 반복된다.

여기서 잠깐 앞으로 나올 이야기를 맛보자.

DB에서 SELECT * FROM users WHERE email = 'test@test.com'을 실행할 때:

  • 인덱스가 없으면: 테이블 전체를 순회한다 (Full Table Scan, Linear Search와 같은 원리)
  • 인덱스가 있으면: B-Tree 인덱스로 바로 찾는다 (O(log n), Hash Table처럼 바로는 아니지만 매우 빠름)

자료구조 선택이 애플리케이션 레벨에서만 중요한 게 아니다. DB 쿼리 성능도 결국 자료구조 이야기다. Ch.11에서 B-Tree와 인덱스를 다루고, Ch.14에서 실무 인덱스 설계를 본다.

그럼 Set으로 다 바꾸면 끝인가

빠르고 원리도 일관되니 전부 Set으로 가고 싶지만, Set은 아무 값이나 받지 않는다. 받을 수 있는 값에 조건이 있다.

Set과 Dict가 만능은 아니다. Set에 넣으려면 그 값이 Hashable이어야 한다. 즉, hash() 함수가 동작해야 한다.

# 가능
s = {1, 2, 3}           # int → hashable
s = {"a", "b", "c"}     # str → hashable
s = {(1, 2), (3, 4)}    # tuple → hashable

# 불가능
s = {[1, 2], [3, 4]}    # list → unhashable! TypeError
s = {{1: 2}}             # dict → unhashable! TypeError

Mutable(변경 가능한) 객체는 Hash 값이 바뀔 수 있어서 Hash Table에 넣을 수 없다. List, Dict, Set 자체는 Set의 원소가 될 수 없다. Tuple은 Immutable이니까 가능하다.

(이 제약은 Java에서도 비슷하다. HashMap의 키로 사용하려면 hashCode()equals()를 올바르게 구현해야 한다. 커스텀 객체를 Map의 키로 쓸 때 hashCode()를 오버라이드하지 않으면 버그가 생긴다.)

그 속도는 공짜인가

받을 수 있는 값이라 해도, 빠름에는 대가가 있다. Hash Table이 충돌을 줄이려고 배열을 여유 있게 잡기 때문이다.

Set과 Dict가 빠른 대신 메모리를 더 쓴다. Hash Table은 내부 배열을 여유 있게 잡아야 충돌을 줄일 수 있기 때문이다.

import sys

data = list(range(10000))
print(f"List: {sys.getsizeof(data):,} bytes")     # List: 87,624 bytes
print(f"Set:  {sys.getsizeof(set(data)):,} bytes") # Set:  524,504 bytes
print(f"Dict: {sys.getsizeof({v:True for v in data}):,} bytes")  # Dict: 295,000 bytes 정도

같은 데이터를 담았을 때 Set은 List보다 6배 정도 메모리를 더 쓴다. 이건 Hash Table의 구조적 비용이다.

하지만 이건 보통 걱정할 수준이 아니다. 10만 개 정수의 Set이 약 4MB 정도다. 검색 성능 4,000배 개선의 대가로 4MB를 쓰는 건 거의 모든 서버에서 합리적인 트레이드오프다.

(진짜 메모리가 부족한 상황이라면 Bloom Filter 같은 확률적 자료구조를 고려할 수 있다. "없는 건 확실하고, 있는 건 거의 확실하다" 수준의 판별을 매우 적은 메모리로 한다.)

자료구조 선택은 시간-공간 트레이드오프의 전형적인 예다.

다음 파일에서는 지금까지 본 사례와 키워드를 한자리에 정리한다.


< Hash Table과 시간 복잡도 | 유사 사례와 키워드 정리 >