프라이머 라이브러리 중복 제거기 — 개념 3개를 엮어 도구 하나 만들기
이 토픽을 마치면
교과서(DryBench)에서 낱개로 배운 해시 테이블, 딕셔너리, 정규식 세 개념을 하나로 엮어서, 수만 개짜리 프라이머 라이브러리에서 중복을 거의 즉시 찾아내는 실전 도구를 직접 만들 수 있습니다. 단순히 "중복을 지운다"가 아니라, 왜 순진한 방법이 느린지, 각 개념이 도구의 어느 부품이 되는지까지 몸으로 이해하게 됩니다.
이 글은 교육용 일반 예제입니다. 특정 연구실의 실제 라이브러리가 아니라, 분자생물학 어디에서나 반복되는 "올리고 재고가 불어나면 생기는 문제"를 소재로 삼았습니다.
"어, 이거 저번에 주문했던 거 아니야?" — 우리가 겪는 문제
연구실에서 PCR, 클로닝, qPCR, 시퀀싱을 하다 보면 프라이머(짧은 DNA 올리고, 보통 18~25 nt)를 계속 주문하게 됩니다. 처음엔 엑셀 시트 한 장이면 충분했죠. 이름, 서열, 용도, 주문 날짜, Tm 정도.
그런데 1년, 2년이 지나면 이 시트가 수천, 수만 줄로 불어납니다. 사람이 여럿 쓰다 보면 이런 일이 벌어집니다.
- 김 선생이
hGAPDH_F로 등록한 서열을, 이 선생이 나중에GAPDH_forward라는 다른 이름으로 똑같이 등록한다. - 누구는
ATGCGT...대문자로, 누구는atgcgt...소문자로 넣는다. - 복사-붙여넣기 하다가 서열 앞뒤에 공백이나 눈에 안 보이는 탭이 붙는다.
- 어떤 서열은 역상보(reverse complement) 로 뒤집어 넣었는데, 실은 이미 있는 프라이머와 같은 자리에 붙는다.
- 오타로
ATGXGT처럼 ACGT가 아닌 문자가 섞인다.
결과가 뭘까요? 똑같은 올리고를 두 번, 세 번 주문해서 돈이 새고, 실험할 때 "이 프라이머가 그 프라이머 맞나?" 헷갈려서 사고가 납니다.
그래서 "중복 좀 정리하자" 하고 순진하게 코드를 짭니다. 모든 서열을 다른 모든 서열과 하나씩 비교하는 거죠.
# 순진한 방법 — 모든 쌍을 비교 (일부러 느린 버전)def find_duplicates_naive(sequences): duplicates = [] for i in range(len(sequences)): for j in range(i + 1, len(sequences)): if sequences[i] == sequences[j]: duplicates.append((i, j)) return duplicates시료가 100개면 순식간입니다. 그런데 1만 개가 되면? 비교 횟수가 대략 10000 × 10000 / 2 = 5천만 번. 노트북에서 몇십 초에서 몇 분이 걸립니다. 10만 개면 그 100배죠. 커피 한 잔 마시고 와도 안 끝납니다.
여기서 멈추지 말고 질문을 던져 봅시다. 왜 느릴까? 그리고 이걸 순식간으로 바꾸려면 어떤 도구가 필요할까? 이 글은 그 답을 코드로 만들어 갑니다.
먼저 완성품을 봅시다 (블랙박스 먼저 돌리기)
빈 에디터부터 시작하면 길을 잃습니다. 그러니 우리가 최종적으로 만들 도구를 먼저 돌려 보고, 그 다음에 뚜껑을 열어 내부를 뜯어보겠습니다. (이게 fast.ai 같은 곳에서 쓰는 "위에서 아래로(top-down)" 학습법입니다 — 완성된 게임을 먼저 한 판 하고, 그 다음 규칙을 배우는 거죠.)
완성된 dedup_primers.py를 이렇게 씁니다.
report = dedup_primer_library("primers.csv")
print(report.summary())그러면 이런 리포트가 나옵니다.
=== 프라이머 라이브러리 중복 리포트 ===
전체 항목 : 12,480
유효 항목 : 12,451 (형식 오류 29건 제외)
고유 서열 : 9,832
정확 중복 : 2,619 (같은 서열, 다른 이름)
역상보 중복 : 174 (뒤집으면 같은 서열)
처리 시간 : 0.31 초
─────────────────────────────────
가장 많이 중복된 서열 TOP 3
1) ATGGCACCACAGTCCATGCC ×7 (GAPDH_F, hGAPDH_forward, ...)
2) GTCGACCTGCAGGCATGCAA ×5
3) CCTGCAGGTCGACTCTAGAG ×4핵심은 두 가지입니다. 첫째, 0.31초. 앞의 순진한 버전이 몇 분 걸리던 걸 순식간에 끝냅니다. 둘째, 단순히 "중복 있음/없음"이 아니라 어떤 서열이 몇 번, 어떤 이름들로 중복됐는지까지 알려줍니다. 이 두 가지를 어떻게 만드는지가 이 글의 전부입니다.
이 도구는 어떤 부품으로 조립되는가 (부품 분해도)
우리가 조립할 "중복 제거 엔진"은 사실 교과서 개념 3개가 맞물린 기계입니다. 프라모델처럼 각 부품이 어디서 왔는지 붙여 보겠습니다.
프라이머 중복 제거 엔진
┌─────────────────────────────────────────────┐
│ │
│ [입력] CSV 파일 읽기 ──── 부품: 파일 입출력 │ ← 완성품 제공 (도구)
│ │ │
│ ▼ │
│ [정제] 서열 정규화·검증 ── 부품: 정규식 │ ← 직접 만든다 ★
│ │ │
│ ▼ │
│ [판정] 중복 즉시 판별 ──── 부품: 해시 세트 │ ← 직접 만든다 ★
│ │ │
│ ▼ │
│ [집계] 서열별 이름 묶기 ── 부품: 딕셔너리 │ ← 직접 만든다 ★
│ │ │
│ ▼ │
│ [출력] 리포트 │
└─────────────────────────────────────────────┘| 부품 | 어디서 배웠나 (DryBench) | 이 도구에서 하는 일 |
|---|---|---|
| 파일 입출력 | string-and-file-io | CSV에서 이름·서열 읽어오기 |
| 정규식 | regex-pattern-matching | 서열이 진짜 ACGT로만 됐는지 검증·정제 |
| 해시 세트 | hash-table | "이 서열 본 적 있나?"를 O(1)로 판정 |
| 딕셔너리 | list-and-dictionary | 서열마다 어떤 이름들이 딸렸는지 묶기 |
📌 이 개념들 처음 본다면 (상단 진입 링크) 아래 세 개는 이 튜토리얼의 핵심 부품입니다. 생소하면 옆 드로어(우측 미니 카드)로 30초씩 예열하고 오세요. 여기서 다시 필요할 때 짚어 줄 겁니다.
이제 위에서 아래로 하나씩 만들어 봅시다. 파일 읽기 부품은 이미 완성해서 드릴 테니(도구), 여러분은 정제·판정·집계 세 부품에만 집중하면 됩니다. 딱 세 개죠? 사람이 한 번에 붙잡을 수 있는 새 개념은 3개가 한계라서 일부러 그렇게 나눴습니다.
만들기 0단계 — 데이터가 어떻게 생겼는지부터
코드를 짜기 전에 재료를 봅시다. 프라이머 시트를 CSV로 내보내면 대략 이렇게 생겼습니다.
name,sequence,purpose,date
GAPDH_F,ATGGCACCACAGTCCATGCC,qPCR,2025-01-12
GAPDH_R,GGCATGGACTGTGGTCATGAG,qPCR,2025-01-12
hGAPDH_forward, atggcaccacagtccatgcc ,qPCR,2025-06-03
cloning_F,GTCGACCTGCAGGCATGCAA,cloning,2025-02-20
bad_entry,ATGXGTNN,cloning,2025-03-01눈으로만 봐도 문제가 보이죠.
GAPDH_F와hGAPDH_forward는 이름만 다르고 서열이 같습니다. 게다가 뒤엣놈은 소문자에 앞뒤 공백까지 붙어 있어요.bad_entry의 서열ATGXGTNN은X가 들어 있어 DNA 서열이 아닙니다 (오타이거나 축약 기호).
그러니까 "중복"을 제대로 세려면, 비교하기 전에 서열을 같은 모양으로 맞추고(정규화), 애초에 서열도 아닌 것들을 걸러내야(검증) 합니다. 안 그러면 ATGGCACCACAGTCCATGCC와 atggcaccacagtccatgcc 를 다른 서열로 착각해서 중복을 놓칩니다.
여기가 핵심 통찰입니다. 중복 제거의 절반은 "비교를 잘하는 것"이 아니라 "비교 전에 모양을 맞추는 것" 입니다.
만들기 1단계 — 파일 읽기 (이 부품은 완성해서 드립니다)
파일 읽기는 이 튜토리얼의 학습 목표가 아닙니다. 여러분의 머릿속 작업 공간은 소중하니까, 이런 "도구성" 코드는 완성본으로 드리고 넘어가겠습니다. (string-and-file-io 카드에서 배운 그대로입니다. 낯설면 드로어로 확인하세요.)
import csv
def load_primers(filepath): """CSV에서 (이름, 원본 서열) 튜플 리스트를 읽어온다.""" records = [] with open(filepath, "r", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: name = row["name"].strip() seq = row["sequence"] # 일부러 정제 안 함 — 다음 단계에서 처리 records.append((name, seq)) return records
# 실습에서는 파일 대신 리스트로 흉내 냅니다 (브라우저에서 바로 돌리려고).raw_records = [ ("GAPDH_F", "ATGGCACCACAGTCCATGCC"), ("GAPDH_R", "GGCATGGACTGTGGTCATGAG"), ("hGAPDH_forward", " atggcaccacagtccatgcc "), ("cloning_F", "GTCGACCTGCAGGCATGCAA"), ("bad_entry", "ATGXGTNN"),]
assert len(raw_records) == 5assert raw_records[2][1] == " atggcaccacagtccatgcc " # 공백·소문자 그대로 살아 있음raw_records는 아직 지저분한 날것입니다. 소문자, 공백, 엉터리 서열이 그대로 있죠. 이걸 다음 단계에서 정리합니다.
만들기 2단계 — 서열 정규화·검증 ★ (여기서부터 직접)
✍️ 여기는 여러분이 채우는 구간입니다. 부품 = 정규식. 목표: 서열을 "표준형"으로 바꾸고, DNA가 아닌 건 걸러낸다.
먼저 "표준형"을 정의합시다. 우리 규칙은 이렇게 하죠.
- 앞뒤 공백을 없앤다.
- 전부 대문자로 통일한다.
A,C,G,T로만 이루어졌는지 검사한다. 아니면 버린다.
3번이 정규식이 빛나는 자리입니다. "문자열 전체가 ACGT의 반복인가?"는 정규식 패턴 ^[ACGT]+$ 하나로 끝납니다.
🔎 왜 정규식인가? (드로어 — regex-pattern-matching)
^는 문자열 시작,$는 끝,[ACGT]는 "이 넷 중 하나",+는 "1개 이상 반복". 합치면 "처음부터 끝까지 ACGT만 있다"는 뜻입니다.if문으로 글자 하나하나 검사할 수도 있지만, 정규식은 그 의도를 한 줄로 표현합니다.
import re
# 서열 전체가 A/C/G/T로만 되어 있는지 검사하는 패턴 (미리 컴파일해서 재사용)DNA_PATTERN = re.compile(r"^[ACGT]+$")
def normalize(seq): """공백 제거 + 대문자화. (아직 검증 전)""" return seq.strip().upper()
def is_valid_dna(seq): """정규화된 서열이 순수 DNA인지 검사.""" return DNA_PATTERN.match(seq) is not None이제 확인해 봅시다.
assert normalize(" atggcaccacagtccatgcc ") == "ATGGCACCACAGTCCATGCC"assert is_valid_dna("ATGGCACCACAGTCCATGCC") is Trueassert is_valid_dna("ATGXGTNN") is False # X, N 포함 → 탈락assert is_valid_dna("") is False # 빈 문자열도 탈락 (+ 덕분에)마지막 assert를 눈여겨보세요. 빈 문자열이 걸러지는 건 패턴에 +(1개 이상)를 썼기 때문입니다. 만약 *(0개 이상)를 썼다면 빈 문자열이 "유효한 DNA"로 통과해 버립니다. 정규식 기호 하나가 데이터 품질을 가릅니다.
🤔 자기설명 프롬프트
normalize를 먼저 하고is_valid_dna를 나중에 하는 순서, 왜 이게 맞을까요? 만약 검증을 먼저 하고 정규화를 나중에 하면 무슨 일이 생길지atgc(소문자)를 예로 들어 스스로 설명해 보세요. (힌트: 소문자atgc는^[ACGT]+$에 걸릴까요?)
이제 날것 레코드를 "깨끗한 서열 + 이름" 목록으로 바꾸는 함수를 완성합니다. 유효하지 않은 항목은 따로 모아 두었다가 리포트에 "몇 건 제외됨"으로 보고할 겁니다.
def clean_records(raw_records): cleaned = [] # (이름, 표준 서열) rejected = [] # (이름, 원본) — 형식 오류 for name, seq in raw_records: norm = normalize(seq) if is_valid_dna(norm): cleaned.append((name, norm)) else: rejected.append((name, seq)) return cleaned, rejected
cleaned, rejected = clean_records(raw_records)
assert len(cleaned) == 4 # bad_entry만 빠짐assert len(rejected) == 1assert rejected[0][0] == "bad_entry"# 핵심: 이름은 달라도 서열은 이제 완전히 같아졌다assert cleaned[0][1] == cleaned[2][1] == "ATGGCACCACAGTCCATGCC"마지막 줄이 이번 단계의 승리입니다. GAPDH_F와 hGAPDH_forward가 이제 문자 그대로 같은 서열을 갖게 됐습니다. 정규화 전에는 컴퓨터 눈에 다른 문자열이었죠. 이제야 "중복"을 셀 준비가 됐습니다.
만들기 3단계 — 중복을 순식간에 판정하기 ★ (해시 세트)
✍️ 직접 채우는 구간. 부품 = 해시 세트. 목표: "이 서열 전에 본 적 있나?"를 O(1)로 답한다.
맨 앞의 순진한 버전이 왜 느렸는지 다시 떠올려 봅시다. 서열 하나를 확인하려고 앞서 나온 모든 서열과 일일이 비교했기 때문입니다. n개를 처리하면 비교가 n²에 비례하죠.
🔎 O(n²) vs O(n) (드로어 — big-o-notation)
리스트에 in으로 확인하면 리스트를 처음부터 훑습니다 — 항목 하나당 O(n), 전체 O(n²). 반면 세트에 in 은 해시로 위치를 바로 계산해 O(1)에 답합니다 — 전체 O(n). 데이터가 10배 커지면 리스트 방식은 100배 느려지지만, 세트 방식은 10배만 느려집니다.
해시 세트의 핵심은 이겁니다. 서열을 해시 함수에 넣으면 "칸 번호(주소)"가 나오고, 그 칸을 바로 들여다보면 되니 얼마나 데이터가 많든 확인이 거의 일정 시간입니다. 도서관에서 청구기호로 책을 바로 찾는 것과 같죠. 파이썬에서는 set이 바로 이 해시 테이블입니다.
hash-table 카드에서 배운 원리를, 여기서는 딱 세 줄로 씁니다.
def find_exact_duplicates(cleaned): """정확히 같은 서열의 중복 쌍(이름)을 찾는다. O(n).""" seen = set() # 지금까지 본 서열들 (해시 세트) unique = [] # 처음 보는 (이름, 서열) duplicates = [] # 중복인 (이름, 서열) for name, seq in cleaned: if seq in seen: # ← O(1) 판정! 여기가 심장 duplicates.append((name, seq)) else: seen.add(seq) unique.append((name, seq)) return unique, duplicates
unique, dups = find_exact_duplicates(cleaned)
assert len(unique) == 3 # GAPDH_F, GAPDH_R, cloning_Fassert len(dups) == 1 # hGAPDH_forward (GAPDH_F와 같은 서열)assert dups[0][0] == "hGAPDH_forward"assert dups[0][1] == "ATGGCACCACAGTCCATGCC"if seq in seen: — 이 한 줄이 몇 분을 0.3초로 만든 주인공입니다. 리스트였다면 seen이 커질수록 이 검사가 점점 느려졌겠지만, 세트라서 10만 개가 쌓여도 검사 속도는 그대로입니다.
🤔 자기설명 프롬프트
seen을set()이 아니라[](리스트)로 바꾸면 코드는 여전히 "돌아갑니다". 결과도 똑같이 나오죠. 그런데 왜 그렇게 하면 안 될까요? 1만 개, 10만 개일 때 무슨 일이 벌어질지 O(n²)와 연결해서 설명해 보세요.
만들기 4단계 — 몇 번, 어떤 이름으로 중복됐는지 묶기 ★ (딕셔너리)
✍️ 직접 채우는 구간. 부품 = 딕셔너리. 목표: 서열마다 "이 서열을 쓴 이름들"을 모아 준다.
3단계까지 하면 "중복이 있다/없다"는 알 수 있습니다. 그런데 실무에서 진짜 궁금한 건 "이 서열, 도대체 몇 개의 이름으로 몇 번 등록됐어?" 입니다. GAPDH_F, hGAPDH_forward, GAPDH_qF... 이렇게 흩어진 걸 한 서열 밑에 모아야 정리가 됩니다.
이건 딕셔너리가 하는 일 그 자체입니다. 키 = 서열, 값 = 그 서열을 쓴 이름들의 리스트.
🔎 왜 딕셔너리인가? (드로어 — list-and-dictionary) 딕셔너리도 내부는 해시 테이블입니다(3단계의 세트와 사촌지간). 세트가 "있다/없다"만 기억한다면, 딕셔너리는 "키에 딸린 값"까지 기억합니다. "서열 → 이름 목록" 같은 일대다 묶음에 딱 맞습니다.
from collections import defaultdict
def group_by_sequence(cleaned): """서열을 키로, 그 서열을 쓴 이름 리스트를 값으로 묶는다.""" groups = defaultdict(list) for name, seq in cleaned: groups[seq].append(name) # 없으면 빈 리스트가 자동 생성됨 return groups
groups = group_by_sequence(cleaned)
# GAPDH 서열에는 이름이 2개 딸려 있어야 한다assert groups["ATGGCACCACAGTCCATGCC"] == ["GAPDH_F", "hGAPDH_forward"]assert len(groups["GGCATGGACTGTGGTCATGAG"]) == 1 # GAPDH_R는 유일assert len(groups) == 3 # 고유 서열 3종defaultdict(list)를 쓴 덕분에 "키가 있으면 추가, 없으면 새로 만들기"를 if로 분기하지 않고 groups[seq].append(name) 한 줄로 끝냈습니다. 이제 이 groups에서 값이 2개 이상인 것만 뽑으면 "중복된 서열 목록"이 나옵니다.
def duplicated_groups(groups): """이름이 2개 이상 딸린(=중복된) 서열만 골라, 많이 중복된 순으로 정렬.""" dups = {seq: names for seq, names in groups.items() if len(names) > 1} # 중복 횟수 많은 순 정렬 return sorted(dups.items(), key=lambda kv: len(kv[1]), reverse=True)
ranked = duplicated_groups(groups)
assert len(ranked) == 1assert ranked[0][0] == "ATGGCACCACAGTCCATGCC"assert ranked[0][1] == ["GAPDH_F", "hGAPDH_forward"]리포트의 "가장 많이 중복된 서열 TOP 3"가 바로 이 ranked에서 나옵니다. 세 부품(정규식·해시 세트·딕셔너리)이 여기서 하나로 맞물렸습니다.
만들기 5단계 — 한 걸음 더: 역상보 중복 (심화)
지금까지는 "글자가 똑같은" 중복만 잡았습니다. 그런데 생물학에는 함정이 하나 더 있습니다. 역상보(reverse complement).
DNA는 이중 가닥이라, 5'-ATGC-3'의 반대 가닥은 3'-TACG-5', 즉 뒤집어 읽으면 5'-GCAT-3'입니다. 어떤 사람이 프라이머를 반대 가닥 기준으로 적어 넣으면, 글자는 완전히 달라 보여도 실은 같은 자리에 붙는 같은 올리고일 수 있습니다.
이걸 잡으려면 "서열과 그 역상보 중 사전순으로 앞서는 것"을 대표값(canonical form)으로 삼으면 됩니다. 그러면 원본이든 역상보든 같은 대표값으로 모입니다. — 이건 우리가 2단계에서 한 "정규화"의 확장판입니다. 정규화의 힘이 여기서 다시 나옵니다.
COMPLEMENT = str.maketrans("ACGT", "TGCA")
def reverse_complement(seq): return seq.translate(COMPLEMENT)[::-1]
def canonical(seq): """서열과 역상보 중 사전순으로 작은 쪽을 대표값으로.""" rc = reverse_complement(seq) return min(seq, rc)
# 검증: 서로 역상보인 두 서열은 같은 대표값을 가져야 한다assert reverse_complement("ATGC") == "GCAT"assert canonical("ATGC") == canonical("GCAT") # 둘 다 "ATGC"보다 작은 쪽으로 수렴assert canonical("AAAA") == "AAAA" # 역상보 TTTT보다 작으므로 자기 자신이제 3~4단계에서 seq 자리에 canonical(seq)를 넣기만 하면, 역상보 중복까지 한 번에 잡힙니다. 부품을 새로 만든 게 아니라, 이미 있는 판정·집계 부품에 "대표값"이라는 렌즈만 하나 끼운 겁니다. 좋은 도구는 이렇게 확장됩니다.
🤔 자기설명 프롬프트
canonical을 쓰면 정확 중복 개수와 역상보 중복 개수를 어떻게 구분해서 셀 수 있을까요? (힌트:seq == canonical(seq)인지 아닌지가 단서입니다.)
부품을 하나로 — 완성된 엔진
이제 다섯 단계를 조립하면 맨 앞에서 봤던 그 도구가 됩니다.
def dedup_primer_library(raw_records, use_canonical=False): cleaned, rejected = clean_records(raw_records)
key = canonical if use_canonical else (lambda s: s)
seen = set() groups = defaultdict(list) exact_dups = 0 for name, seq in cleaned: k = key(seq) if k in seen: exact_dups += 1 else: seen.add(k) groups[k].append(name)
ranked = sorted( ((k, names) for k, names in groups.items() if len(names) > 1), key=lambda kv: len(kv[1]), reverse=True, ) return { "total": len(raw_records), "valid": len(cleaned), "rejected": len(rejected), "unique": len(groups), "exact_duplicates": exact_dups, "top": ranked[:3], }
report = dedup_primer_library(raw_records)
assert report["total"] == 5assert report["valid"] == 4assert report["rejected"] == 1assert report["unique"] == 3assert report["exact_duplicates"] == 1assert report["top"][0][1] == ["GAPDH_F", "hGAPDH_forward"]이 assert들이 전부 통과하면, 여러분은 교과서 개념 3개를 엮어 실제로 돌아가는 도구를 만든 겁니다. 축하합니다.
그래서 얼마나 빨라졌나 (성능 딥다이브)
말로만 "빠르다" 하면 안 되죠. 직접 재 봅시다. 서열 1만 개(중복 섞어서)를 만들어 순진한 버전과 해시 버전을 겨루게 합니다.
import random, time
random.seed(42)bases = "ACGT"pool = ["".join(random.choice(bases) for _ in range(20)) for _ in range(2000)]# 2000종을 5번씩 반복 → 1만 개 (중복 잔뜩)big = [(f"p{i}", random.choice(pool)) for i in range(10000)]
# 순진한 버전 (O(n²)) — 앞 1500개만 재도 충분히 느립니다sample = [s for _, s in big[:1500]]t0 = time.time()naive_pairs = find_duplicates_naive(sample)naive_time = time.time() - t0
# 해시 버전 (O(n)) — 전체 1만 개t0 = time.time()report = dedup_primer_library(big)hash_time = time.time() - t0
print(f"순진한 버전(1,500개) : {naive_time:.3f} 초")print(f"해시 버전(10,000개) : {hash_time:.3f} 초")
# 데이터가 6배 이상 많은데도 해시 버전이 압도적으로 빠르다assert hash_time < naive_time숫자는 기계마다 다르지만 방향은 언제나 같습니다. 적은 데이터로도 순진한 버전이 더 느리고, 데이터를 키우면 격차가 폭발합니다. 이게 O(n²)와 O(n)의 차이를 손으로 만져 보는 순간입니다. big-o-notation 카드에서 그래프로만 보던 두 곡선이, 지금 여러분 화면의 초시계로 갈라지고 있습니다.
다른 길도 있다 (멀티패스 성찰)
여러분은 방금 해시 세트 + 딕셔너리로 문제를 풀었습니다. 훌륭합니다. 그런데 같은 문제를 푸는 길은 하나가 아닙니다. 진짜 실력은 "내 방법 말고 어떤 방법이 또 있고, 언제 그게 더 나은가"를 아는 데서 나옵니다.
- Pandas
drop_duplicates(): 데이터가 이미 DataFrame이라면df.drop_duplicates(subset="seq")한 줄로 끝납니다. 내부적으로 역시 해시를 쓰죠. 우리가 손으로 만든 걸 라이브러리가 대신 해 주는 겁니다. 언제 우리 방식이 나은가? 중복 횟수와 이름 목록까지 커스텀하게 뽑아야 할 때.drop_duplicates는 지워 버리지, 누가 몇 번 겹쳤는지 리포트해 주진 않습니다. - Biopython: 서열 파싱·역상보에 특화된 도구가 이미 있습니다. 역상보를
Seq(...).reverse_complement()로 안전하게 처리하죠. 실무에서는 이런 검증된 라이브러리를 쓰는 게 옳습니다. 다만 원리를 모르고 갖다 쓰면 왜 느린지, 왜 결과가 이상한지 디버깅을 못 합니다. 그래서 우리가 밑바닥부터 만들어 본 거예요. - 정렬 후 이웃 비교: 서열을 정렬(O(n log n))한 뒤 옆 항목만 비교하는 방법도 있습니다. 메모리를 세트보다 아낄 수 있어, 데이터가 램에 안 들어갈 만큼 거대할 때 유리합니다.
핵심: 우리 도구가 "정답"이 아니라, 각 방법의 트레이드오프를 이해한 사람이 정답입니다. 해시는 빠르지만 메모리를 쓰고, 정렬은 메모리를 아끼지만 조금 느리고, 라이브러리는 편하지만 속을 모릅니다.
다음 단계로 (하단 출구 링크)
이 도구를 더 키우고 싶다면, 다음 개념들이 자연스러운 다음 발걸음입니다.
- 지금은 서열을 메모리에 다 올렸습니다. 파일이 램보다 크면? → 이진 탐색과 DB 인덱스로 "디스크에 두고 빠르게 찾기"를 배우고, 응용편 서열 DB 인덱싱으로 이어집니다.
- 중복 판정을 웹에서 실시간으로? → WebSocket을 엮은 응용편으로.
- 왜 세트가 O(1)인지 더 깊이? → 해시 테이블 카드로 되돌아가 충돌 해결까지 마스터하세요.
직접 해보기 (독립 문제)
이번엔 스켈레톤 없이, 완전히 새로운 상황에 여러분의 도구를 적용해 봅시다. 구조는 똑같습니다 — 정규화 → 판정 → 집계.
- 길이 필터 추가: 프라이머는 보통 18~25 nt입니다. 이 범위를 벗어난 서열도
rejected로 보내도록clean_records를 확장하세요. (assert로 30 nt짜리가 걸러지는지 검증할 것) - GC 함량 경고: 각 고유 서열의 GC 함량을 계산해, 40~60% 밖이면 리포트에 경고를 붙이세요. (다음 응용편 GC content 계산기의 예고편입니다.)
- 역상보 리포트:
use_canonical=True로 돌렸을 때, "정확 중복"과 "역상보 중복"을 따로 세어 리포트에 두 줄로 나눠 보여주세요. (5단계 자기설명 프롬프트의 힌트를 씁니다.) - 도전: 서열에 IUPAC 축약 문자(
N,R,Y등)가 들어간 경우를 "무효"가 아니라 "축약 서열"로 따로 분류하려면, 2단계의 정규식을 어떻게 바꿔야 할까요?
각 과제마다 assert로 스스로 채점하세요. 통과하면, 여러분은 이제 이 도구의 주인입니다.
정리
우리는 "중복 좀 지우자"는 사소해 보이는 문제에서 출발해서, 교과서 개념 세 개가 어떻게 하나의 실전 도구로 맞물리는지 봤습니다.
- 정규식은 비교 전에 서열의 모양을 맞추고 엉터리를 걸렀습니다. (정제)
- 해시 세트는 "본 적 있나?"를 O(1)로 답해, 몇 분을 0.3초로 줄였습니다. (판정)
- 딕셔너리는 서열마다 이름들을 묶어, 누가 몇 번 겹쳤는지까지 알려줬습니다. (집계)
그리고 이 셋은 따로 배울 땐 "그래서 이걸 어디 써?" 싶던 것들이었죠. 엮이는 순간 실험실의 진짜 문제를 풉니다. 이게 바로 응용의 힘입니다 — 개념은 낱개로 배우고, 도구로 조립한다.
다시 강조하지만 이 글은 일반 교육 예제입니다. 실제 여러분의 라이브러리에는 컬럼도, 규칙도, 예외도 훨씬 많을 겁니다. 그 상세한 버전은 이 뼈대 위에 여러분이 직접 살을 붙이면 됩니다. 뼈대는 언제나 같습니다 — 정규화, 판정, 집계.