서열 DB 인덱싱 — 1억 건에서 즉시 찾는 도구 만들기
이 토픽을 마치면
교과서에서 배운 DB 인덱스, 해시 테이블, 이진 탐색을 엮어서, 수백만~수억 건의 변이/서열 테이블에서 원하는 항목을 선형 스캔 없이 즉시 찾는 인덱스 도구를 직접 만들 수 있습니다. "DB에 인덱스를 걸었더니 쿼리가 100배 빨라졌다"는 말이 내부에서 무슨 일이었는지 코드로 이해하게 됩니다.
이 글은 교육용 일반 예제입니다. 유전체 변이 테이블 조회는 바이오인포매틱스에서 매일 하는 일이라 소재로 삼았습니다.
"그 위치 변이 좀 찾아줘" — 매번 전체를 훑는 문제
유전체 변이 데이터는 보통 이런 표입니다. 염색체, 위치, 참조 염기, 변이 염기, 그리고 부가 정보들.
chrom pos ref alt gene
chr1 12345 A G GENE_A
chr1 67890 C T GENE_B
chr7 55211 G A GENE_C
... (수백만~수억 줄)여기서 흔한 질문 두 가지가 있습니다.
- 정확 조회: "chr1의 12345 위치에 변이 있어?" — 딱 한 지점.
- 범위 조회: "chr1의 10000~20000 사이 변이 전부 줘" — 한 구간.
순진하게 하면 매번 처음부터 끝까지 전부 훑습니다(선형 스캔).
def find_linear(records, chrom, pos): hits = [] for r in records: if r["chrom"] == chrom and r["pos"] == pos: hits.append(r) return hits변이가 1만 건이면 순식간이죠. 그런데 전장유전체(whole genome)면 변이가 수백만~수억 건입니다. 조회 한 번에 전체를 훑으면, 질문 100개를 하려면 전체를 100번 훑습니다. 여기서 "쿼리가 왜 이렇게 느려요?"가 시작됩니다.
DB를 써 본 사람은 압니다. 이럴 때 인덱스를 겁니다. 그런데 인덱스가 대체 뭘 하길래 빨라질까요? 이 글은 그 인덱스를 밑바닥부터 만듭니다.
먼저 완성품을 봅시다 (블랙박스 먼저 돌리기)
우리가 만들 도구는 테이블을 한 번 훑어 인덱스를 만들어 두고, 그 다음부터는 조회를 즉시 처리합니다.
db = VariantIndex(records) # 인덱스 1회 구축
db.get("chr1", 12345) # 정확 조회 → 즉시db.range("chr1", 10000, 20000) # 범위 조회 → 즉시=== 조회 성능 (변이 500,000건) ===
정확 조회 (선형 스캔) : 0.0180 초/건
정확 조회 (해시 인덱스): 0.0000009 초/건 ← 약 2만 배
범위 조회 (선형 스캔) : 0.0210 초/건
범위 조회 (이진 탐색) : 0.0000254 초/건 ← 약 800 배
인덱스 구축 (1회) : 0.32 초핵심은 "구축은 한 번, 조회는 무한히 빠르게"입니다. 인덱스를 만드는 데 잠깐 시간을 쓰지만, 그 다음부터 조회가 폭발적으로 빨라집니다. 지금부터 두 종류의 인덱스를 만들어 이 속도에 도달합니다.
이 도구는 어떤 부품으로 조립되는가 (부품 분해도)
변이 인덱스 (VariantIndex)
┌──────────────────────────────────────────────┐
│ [입력] 테이블 로딩 ────────── 부품: 파일 입출력 │ ← 완성 제공 (도구)
│ │ │
│ ┌───────┴────────┐ │
│ ▼ ▼ │
│ [정확 조회] [범위 조회] │
│ 해시 인덱스 정렬 + 이진 탐색 │
│ 부품: 해시 테이블 부품: 이진 탐색 │ ← 둘 다 직접 만든다 ★
│ │ │ │
│ └───────┬────────┘ │
│ ▼ │
│ [의미] 이게 바로 DB 인덱스 ── 부품: DB 인덱스 │ ← 직접 만든다 ★
└──────────────────────────────────────────────┘| 부품 | 어디서 배웠나 | 이 도구에서 하는 일 |
|---|---|---|
| 파일 입출력 | string-and-file-io | 변이 테이블 읽기 |
| 해시 테이블 | hash-table | 정확 위치 O(1) 조회 |
| 이진 탐색 | binary-search | 정렬된 위치에서 범위 O(log n) 조회 |
| DB 인덱스 | db-index | 위 둘이 "DB 인덱스"의 실체임을 잇기 |
📌 이 개념들 처음 본다면 (상단 진입 링크)
직접 만드는 새 개념은 해시 인덱스·이진 탐색·DB 인덱스 3개. 파일 읽기는 도구라 완성본으로 드립니다. 딱 3개죠 — 인지 한계선 안입니다.
만들기 1단계 — 데이터 준비 (완성 제공)
조회 대상 데이터를 만드는 부분은 도구라 드립니다. 실제로는 파일에서 읽지만, 브라우저 실습을 위해 리스트로 생성합니다.
import random
random.seed(1)chroms = ["chr1", "chr2", "chr7"]
def make_records(n): recs = [] for i in range(n): recs.append({ "chrom": random.choice(chroms), "pos": random.randint(1, 1_000_000), "ref": random.choice("ACGT"), "alt": random.choice("ACGT"), "gene": f"GENE_{i % 500}", }) return recs
records = make_records(50000)assert len(records) == 50000assert set(r["chrom"] for r in records) <= {"chr1", "chr2", "chr7"}이 records가 우리의 "테이블"입니다. 이제 이걸 두 가지 방식으로 인덱싱합니다.
만들기 2단계 — 정확 조회용 해시 인덱스 ★ (해시 테이블)
✍️ 직접 채우는 구간. 부품 = 해시 테이블. 목표: "(염색체, 위치) → 변이들"을 O(1)로 찾게 만든다.
정확 조회의 열쇠는 앞의 프라이머 편에서 본 것과 같습니다 — 딕셔너리(=해시 테이블)에 미리 넣어 두면 즉시 꺼낼 수 있다. 여기선 키를 (염색체, 위치) 튜플로 삼습니다.
🔎 해시 인덱스란 (드로어 — hash-table) 딕셔너리에
키 → 값을 넣어 두면, 나중에 그 키로 한 번에 값을 꺼냅니다(O(1)). 전체를 훑을 필요가 없죠. "정확히 이 키"를 찾는 조회엔 이보다 빠른 게 없습니다. 이게 DB의 해시 인덱스입니다.
from collections import defaultdict
def build_hash_index(records): """(chrom, pos) → 그 위치의 변이 리스트.""" index = defaultdict(list) for r in records: index[(r["chrom"], r["pos"])].append(r) return index
hash_index = build_hash_index(records)
def get_exact(hash_index, chrom, pos): return hash_index.get((chrom, pos), [])
# 검증: 해시 조회 결과가 선형 스캔 결과와 정확히 같아야 한다sample = records[12345]linear = find_linear(records, sample["chrom"], sample["pos"])hashed = get_exact(hash_index, sample["chrom"], sample["pos"])assert hashed == linearassert len(get_exact(hash_index, "chr1", -999)) == 0 # 없는 위치는 빈 리스트find_linear(전체 훑기)와 get_exact(해시)의 결과가 완전히 같다는 걸 assert로 확인했습니다. 답은 같고 속도만 다릅니다. 해시 인덱스는 위치가 몇 개든 조회가 일정 시간입니다.
🤔 자기설명 프롬프트 키를
(chrom, pos)튜플로 잡았습니다. 만약pos만 키로 쓰면 무슨 문제가 생길까요? (힌트: 서로 다른 염색체가 같은 pos를 가질 수 있습니다. chr1:12345와 chr7:12345는 다른 변이죠.)
만들기 3단계 — 범위 조회용 이진 탐색 인덱스 ★ (이진 탐색)
✍️ 직접 채우는 구간. 부품 = 이진 탐색. 목표: "이 구간의 변이 전부"를 O(log n)으로 찾는다.
해시 인덱스는 "정확히 이 위치"엔 최강이지만, "10000~20000 사이 전부"엔 무력합니다. 해시는 순서가 없어서 범위를 모으지 못하거든요. 범위 조회엔 다른 무기 — 정렬 + 이진 탐색이 필요합니다.
발상은 이렇습니다. 염색체별로 위치를 정렬해 두면, 범위의 시작점과 끝점을 이진 탐색으로 즉시 찾고, 그 사이를 잘라내면 됩니다.
🔎 이진 탐색이란 (드로어 — binary-search) 정렬된 데이터에서 반씩 잘라가며 찾습니다. 100만 개라도 약 20번이면 끝(log₂ 1,000,000 ≈ 20). 사전에서 단어를 찾을 때 가운데를 펼쳐 앞/뒤를 판단하는 것과 같죠. 파이썬 표준 라이브러리
bisect가 이걸 해 줍니다.
import bisect
def build_range_index(records): """chrom별로 위치를 정렬. (정렬된 pos 배열, 그에 대응하는 레코드 배열)""" by_chrom = defaultdict(list) for r in records: by_chrom[r["chrom"]].append(r) index = {} for chrom, recs in by_chrom.items(): recs.sort(key=lambda r: r["pos"]) # 위치 순 정렬 (구축 시 1회) positions = [r["pos"] for r in recs] index[chrom] = (positions, recs) return index
range_index = build_range_index(records)
def get_range(range_index, chrom, start, end): if chrom not in range_index: return [] positions, recs = range_index[chrom] lo = bisect.bisect_left(positions, start) # start가 들어갈 자리 (O(log n)) hi = bisect.bisect_right(positions, end) # end 다음 자리 return recs[lo:hi] # 그 사이를 한 번에 잘라낸다
# 검증: 이진 탐색 범위 결과가 선형 필터 결과와 같아야 한다 (개수·내용)def range_linear(records, chrom, start, end): return [r for r in records if r["chrom"] == chrom and start <= r["pos"] <= end]
bs = get_range(range_index, "chr1", 100000, 200000)lin = range_linear(records, "chr1", 100000, 200000)assert len(bs) == len(lin)assert sorted(r["pos"] for r in bs) == sorted(r["pos"] for r in lin)# 경계 포함 확인: start와 end 자신도 포함되어야 한다 (bisect_left/right 조합)assert all(100000 <= r["pos"] <= 200000 for r in bs)bisect_left/bisect_right 조합이 핵심입니다. 이 둘로 범위의 양 끝 인덱스를 O(log n)에 찾고, recs[lo:hi]로 그 사이를 통째로 가져옵니다. 선형 필터가 전체를 훑는 동안, 이진 탐색은 사전 펼치듯 몇 번 만에 구간을 집어냅니다.
🤔 자기설명 프롬프트 왜 시작점엔
bisect_left, 끝점엔bisect_right를 썼을까요? 만약 둘 다bisect_left를 쓰면,end와 정확히 같은 위치의 변이가 결과에 포함될까요 빠질까요? (경계 조건 하나가 결과를 바꿉니다.)
부품을 하나로 — 완성된 인덱스 클래스
두 인덱스를 하나의 도구로 묶습니다. 이게 바로 축소판 DB 인덱스입니다.
class VariantIndex: def __init__(self, records): self.records = records self.hash_index = build_hash_index(records) # 정확 조회용 self.range_index = build_range_index(records) # 범위 조회용
def get(self, chrom, pos): return get_exact(self.hash_index, chrom, pos)
def range(self, chrom, start, end): return get_range(self.range_index, chrom, start, end)
db = VariantIndex(records)
# 두 조회 모두 선형 스캔과 결과가 일치해야 한다s = records[999]assert db.get(s["chrom"], s["pos"]) == find_linear(records, s["chrom"], s["pos"])assert len(db.range("chr2", 0, 1_000_000)) == len(range_linear(records, "chr2", 0, 1_000_000))🔎 그래서 DB 인덱스가 뭐였나 (드로어 — db-index) DB에
CREATE INDEX를 걸면, DB가 뒤에서 우리가 방금 만든 것과 똑같은 일을 합니다. 정확 조회엔 해시 인덱스를, 범위 조회엔 정렬 구조(B-tree, 이진 탐색의 형제)를 미리 만들어 두죠. "인덱스를 걸었더니 100배 빨라졌다"는 건, 전체 스캔이 해시/이진 탐색으로 바뀌었다는 뜻입니다. 여러분은 방금 그 마법의 내부를 직접 구현했습니다.
성능 딥다이브 — 왜 이렇게 빨라지나
import time
records = make_records(200000)db = VariantIndex(records)targets = [(r["chrom"], r["pos"]) for r in random.sample(records, 200)]
# 정확 조회: 선형 vs 해시t0 = time.time()for c, p in targets: find_linear(records, c, p)linear_time = time.time() - t0
t0 = time.time()for c, p in targets: db.get(c, p)hash_time = time.time() - t0
print(f"정확 조회 200건 — 선형 : {linear_time:.4f} 초")print(f"정확 조회 200건 — 해시 : {hash_time:.6f} 초")assert hash_time < linear_time숫자는 기계마다 다르지만 방향은 언제나 같습니다. 선형 조회는 데이터가 커질수록 느려지지만(O(n)), 해시 조회는 그대로입니다(O(1)). 인덱스 구축에 든 시간(O(n) 한 번)은, 조회를 여러 번 할수록 순식간에 본전을 뽑습니다. 조회가 많은 시스템일수록 인덱스가 이깁니다.
다른 길도 있다 (멀티패스 성찰)
- 진짜 DB(SQLite/PostgreSQL): 실무에선 우리가 손으로 만든 걸 DB가 해 줍니다.
CREATE INDEX ... ON variants(chrom, pos)한 줄이면 끝. 우리 방식이 나은 때: 인덱스가 정확히 무슨 일을 하는지 이해하고 디버깅해야 할 때. DB를 블랙박스로만 쓰면 왜 느린지 못 잡습니다. - 구간 트리(Interval Tree): 변이가 "점"이 아니라 "구간"(예: CNV, 유전자 영역)이면, 이진 탐색보다 구간 트리가 낫습니다. 트레이드오프: 구현 복잡도 ↔ 구간 겹침 질의 성능.
- 메모리 vs 디스크: 우리 인덱스는 전부 램에 있습니다. 데이터가 램보다 크면 디스크 기반 인덱스(B-tree on disk)가 필요합니다. 진짜 DB가 이걸 해 줍니다.
핵심: "정확 조회엔 해시, 범위 조회엔 정렬+이진 탐색." 이 대응은 손으로 만들든 DB를 쓰든 변하지 않는 원리입니다. 조회의 모양을 보고 인덱스를 고르는 게 실력입니다.
다음 단계로 (하단 출구 링크)
- 왜 해시가 O(1)인지, 충돌은 어떻게 푸는지 → 해시 테이블
- 이진 탐색의 형제인 트리 구조 → 이진 탐색 트리
- 앞서 만든 중복 제거기와 연결 → 응용편 프라이머 중복 제거기
직접 해보기 (독립 문제)
- 유전자명 인덱스:
pos가 아니라gene으로 정확 조회하는 해시 인덱스를 추가하세요. ("GENE_42의 모든 변이 줘") - 다중 조건: "chr1의 10000~20000 사이이면서 alt가 'G'인 변이"를 범위 인덱스 + 필터로 구하세요.
- 인덱스 vs 구축 비용: 조회를 몇 번 이상 해야 인덱스 구축 비용이 본전을 넘는지, 선형 스캔과 손익분기점을 실험으로 찾으세요.
- 도전: 새 변이가 실시간으로 추가될 때, 정렬된 범위 인덱스를 매번 다시 정렬하지 않고
bisect.insort로 유지하는 방법을 구현하세요.
정리
우리는 "대용량 테이블에서 원하는 걸 찾는" 문제를, 두 종류의 인덱스로 정복했습니다.
- 해시 테이블은 정확 조회를 O(1)로 만들었습니다. (점 조회)
- 이진 탐색은 정렬된 위치에서 범위 조회를 O(log n)으로 만들었습니다. (구간 조회)
- DB 인덱스는, 이 둘이 바로 DB가 뒤에서 하는 일임을 알려줬습니다. (원리의 통합)
CREATE INDEX 한 줄이 마법처럼 느껴졌다면, 이제 그 안이 보일 겁니다. 마법이 아니라, 교과서에서 배운 해시와 이진 탐색이 데이터 옆에 미리 만들어 둔 지도일 뿐입니다.
이 글은 일반 교육 예제입니다. 실제로는 다중 컬럼 인덱스, 디스크 기반 저장, 동시성 등이 더해집니다. 그 상세한 버전은 이 뼈대 위에 여러분이 붙이거나, 검증된 DB에 맡기면 됩니다.