Comet5 블로그
개발·운영

Python 목록 검색을 dict로 바꾸기 전에 확인할 조건

Comet5 · 최초 발행 · 수정 · 약 5분

중복 키와 순서의 의미를 보존하며 반복 검색을 개선하는 예제와 측정 방법입니다.

이 글의 목차

ID로 항목을 여러 번 찾는 코드가 느리다면 반복문 자체보다 데이터를 찾는 방식을 먼저 살펴볼 수 있습니다. Python의 list는 순서를 가진 항목 모음이고 dict는 키로 값을 조회하는 매핑입니다. 같은 데이터를 담더라도 중복과 순서의 의미가 달라질 수 있습니다.

먼저 중복 ID의 의미를 정하기

다음은 설명용 데이터입니다. 실제 서비스에서 id가 고유하다는 보장이 없으면 단순한 dict 변환이 데이터를 잃게 만들 수 있습니다.

rows = [
    {"id": 7, "name": "초안"},
    {"id": 9, "name": "검토"},
    {"id": 7, "name": "수정본"},
]
by_id = {row["id"]: row for row in rows}
assert by_id[7]["name"] == "수정본"
assert len(by_id) == 2

같은 키가 다시 나오면 앞의 값이 대체됩니다. 중복이 오류라면 변환 전에 검사하고, 여러 이력을 보존해야 한다면 키마다 리스트를 둡니다.

from collections import defaultdict
history = defaultdict(list)
for row in rows:
    history[row["id"]].append(row)
assert len(history[7]) == 2

반복 조회가 있을 때 비용 비교하기

목록에서 ID 하나를 찾는 코드는 찾을 때까지 항목을 확인합니다. 이를 많은 ID에 반복하면 같은 목록을 여러 번 훑습니다. dict를 한 번 만든 뒤 조회하면 초기 생성 비용과 추가 메모리를 들이는 대신 반복 탐색을 줄일 수 있습니다.

단 한 번만 찾고 끝나는 작은 목록이라면 변환 비용이 이득보다 클 수 있습니다. 정렬 순서대로 범위를 찾는 작업이나 동일 키의 모든 항목을 반환하는 작업도 단순 조회와 다릅니다.

측정할 때 변환 시간을 빼먹지 않기

비교할 작업을 ‘매 요청마다 변환 후 한 번 조회’인지 ‘한 번 변환 후 여러 번 조회’인지 먼저 정합니다. 두 경우를 섞으면 결과를 잘못 해석합니다. timeit으로 같은 데이터 크기와 같은 조회 목록을 사용하고, 실행 환경과 반복 횟수를 함께 남깁니다. 여기서는 측정하지 않은 속도 배수를 제시하지 않습니다.

검증 항목은 속도만이 아닙니다. 없는 ID의 처리, 입력 순서 보존, 중복 ID, 값 변경 후 인덱스의 신선도를 테스트합니다. dict.get()이 반환한 None이 ‘값이 없음’과 실제 값 None을 구분해야 하는 경우에는 별도 표식 객체를 사용합니다.

DB 조회라면 다른 문제일 수 있다

애플리케이션 자료구조를 바꿔도 매 요청마다 전체 테이블을 가져온다면 병목은 남습니다. 데이터가 이미 DB에 있다면 필요한 행만 조회하고 실행 계획과 인덱스를 먼저 살펴보세요. 자료구조 변경은 데이터의 위치와 조회 패턴을 함께 고려해야 효과를 판단할 수 있습니다.

같은 결과를 내는 두 구현부터 비교하기

다음 코드는 Python 3.11 이상에서 앞의 예제와 독립적으로 실행할 수 있습니다. 중복이 없는 ID만 사용하고, 찾지 못한 경우에는 None을 반환하도록 두 구현의 계약을 맞춥니다. lookup_dict 안에서 dict를 만들기 때문에 전처리 비용도 포함됩니다.

from timeit import repeat

items = [{"id": i, "value": i * 3} for i in range(2000)]
queries = [10, 1999, 2500] * 20

def lookup_list():
    return [next((r for r in items if r["id"] == q), None)
            for q in queries]

def lookup_dict():
    index = {r["id"]: r for r in items}
    return [index.get(q) for q in queries]

assert lookup_list() == lookup_dict()
for name, operation in [("list", lookup_list), ("dict_with_build", lookup_dict)]:
    samples = repeat(operation, number=10, repeat=3)
    print(name, [round(seconds / 10 * 1000, 3) for seconds in samples])

표시되는 세 숫자는 각각 열 번 실행한 시간을 회당 밀리초로 환산한 값입니다. 특정 배수를 기대값으로 고정하지 않습니다. queries를 하나로 줄이거나 items를 작게 만들면 전처리 비용의 비중이 달라집니다. 이미 구축한 인덱스를 재사용하는 상황을 비교하려면 dict 생성 위치를 바꾸고 그 조건을 결과 이름에도 적어야 합니다.

또한 결과가 같은지 확인하는 assert를 타이밍 측정보다 먼저 실행합니다. 빠르게 잘못된 데이터를 반환하는 구현은 개선이 아닙니다. 이 예제는 객체 참조를 반환하므로 반환한 dict를 수정하면 원본 데이터도 바뀐다는 점에 유의하세요.

참고 자료