이 글은 누구를 위한 것인가
- 여러 출처에서 들어온 장소·업체·상품 데이터를 하나로 합쳐야 하는 팀
- "같은 것 같은데 확신이 없는" 레코드 쌍을 어떻게 처리할지 고민 중인 개발자
- 중복 제거를 한 번 돌렸다가 잘못 병합해서 되돌리느라 고생한 경험이 있는 경우
들어가며
여러 출처에서 장소 데이터를 모으면 같은 대상이 이렇게 들어온다.
① 강남역 스타벅스 서울 강남구 강남대로 396 02-1234-5678
② 스타벅스 강남역점 서울특별시 강남구 강남대로396 0212345678
③ STARBUCKS 강남역 강남대로 396, 강남구 02) 1234-5678
사람 눈에는 하나다. 문자열 비교로는 셋 다 다르다. 데이터가 수만 건이 되면 이 문제는 눈으로 처리할 수 없고, 방치하면 검색 결과에 같은 장소가 세 번 나온다.
**엔티티 해석(entity resolution)**은 "서로 다른 레코드가 같은 실세계 대상을 가리키는가"를 판정하는 문제다. 이 글은 수만 건 규모에서 실제로 돌아가는 파이프라인을 3단계로 나눠 정리한다.
1. 전량 비교는 불가능하다 — 블로킹
가장 먼저 부딪히는 벽은 조합 폭발이다.
[비교 횟수]
n건을 서로 비교하면 n(n-1)/2
1,000건 → 499,500 쌍
10,000건 → 49,995,000 쌍
40,000건 → 799,980,000 쌍 ← 8억 쌍
쌍당 유사도 계산에 0.1ms만 걸려도 40,000건은 22시간
**블로킹(blocking)**은 "애초에 비교할 필요가 없는 쌍"을 걸러내는 단계다. 같은 블록에 들어간 레코드끼리만 비교한다.
from collections import defaultdict
import re
def blocking_keys(record: dict) -> list[str]:
"""한 레코드에 여러 블로킹 키를 부여한다 (하나만 쓰면 놓치는 쌍이 생김)"""
keys = []
# 키 1: 행정구역 + 상호 첫 2글자
gu = extract_gu(record["address"]) # "강남구"
name = normalize_name(record["name"]) # "강남역스타벅스"
if gu and len(name) >= 2:
keys.append(f"gu:{gu}|n2:{name[:2]}")
# 키 2: 정규화된 전화번호 뒤 8자리
phone = re.sub(r"\D", "", record.get("phone") or "")
if len(phone) >= 8:
keys.append(f"tel:{phone[-8:]}")
# 키 3: 좌표 격자 (약 100m 단위)
if record.get("lat") and record.get("lng"):
keys.append(f"geo:{round(record['lat'], 3)},{round(record['lng'], 3)}")
return keys
def build_blocks(records: list[dict]) -> dict[str, list[int]]:
blocks = defaultdict(list)
for i, r in enumerate(records):
for k in blocking_keys(r):
blocks[k].append(i)
# 지나치게 큰 블록은 블로킹 의미가 없으므로 제외
return {k: v for k, v in blocks.items() if 2 <= len(v) <= 200}
키를 여러 개 부여하는 것이 핵심이다. 상호명만으로 블록을 만들면 표기가 크게 다른 쌍을 놓치고, 좌표만 쓰면 같은 건물의 다른 업체가 전부 한 블록에 들어온다. 서로 다른 축의 키를 3개쯤 붙이면 재현율과 계산량이 균형을 잡는다.
[블로킹 효과 — 4만 건 실측 예]
전량 비교 799,980,000 쌍
블로킹 적용 1,240,000 쌍 (0.16%)
→ 유사도 계산 시간 22시간 → 약 2분
블록 크기 상한(<= 200)을 두는 이유는, "서울특별시" 같은 값이 키에 섞이면 블록 하나에 수천 건이 몰려 블로킹이 무의미해지기 때문이다. 이런 블록은 버리는 편이 낫다 — 어차피 그 안에서 진짜 중복을 찾을 신호가 약하다.
2. 쌍 단위 유사도 — 필드마다 다른 척도를 쓴다
블록 안의 쌍에 대해 유사도를 계산한다. 필드마다 적합한 비교 방식이 다르다는 점이 중요하다.
from rapidfuzz import fuzz
import math
def name_sim(a: str, b: str) -> float:
a, b = normalize_name(a), normalize_name(b)
# 토큰 순서가 뒤바뀐 경우("강남역 스타벅스" vs "스타벅스 강남역점")
return fuzz.token_sort_ratio(a, b) / 100.0
def addr_sim(a: str, b: str) -> float:
# 주소는 접두 일치가 중요 (시/구/도로명 순서가 고정)
a, b = normalize_addr(a), normalize_addr(b)
return fuzz.partial_ratio(a, b) / 100.0
def phone_sim(a: str | None, b: str | None) -> float | None:
if not a or not b:
return None # 결측은 0이 아니라 None
da = re.sub(r"\D", "", a)[-8:]
db = re.sub(r"\D", "", b)[-8:]
return 1.0 if da == db else 0.0 # 전화번호는 이진 판정
def geo_sim(a: dict, b: dict) -> float | None:
if not all(k in a and k in b for k in ("lat", "lng")):
return None
d = haversine_m(a["lat"], a["lng"], b["lat"], b["lng"])
# 0m→1.0, 300m→0.0 으로 감쇠
return max(0.0, 1.0 - d / 300.0)
결측값을 0으로 처리하면 안 된다. 전화번호가 한쪽에 없는 것은 "다르다"가 아니라 "모른다"이다. 이걸 0으로 넣으면 정보가 부족한 레코드일수록 병합되지 않는 편향이 생긴다. None으로 두고 가중 평균에서 제외한다.
WEIGHTS = {"name": 0.4, "addr": 0.3, "phone": 0.2, "geo": 0.1}
def pair_score(a: dict, b: dict) -> float:
parts = {
"name": name_sim(a["name"], b["name"]),
"addr": addr_sim(a["address"], b["address"]),
"phone": phone_sim(a.get("phone"), b.get("phone")),
"geo": geo_sim(a, b),
}
num = sum(WEIGHTS[k] * v for k, v in parts.items() if v is not None)
den = sum(WEIGHTS[k] for k, v in parts.items() if v is not None)
return num / den if den else 0.0
한국어 상호명은 띄어쓰기와 지점 표기가 제각각이라 정규화 단계에서 승부가 갈린다. 점, 지점, 본점 같은 접미사 처리, 영문·한글 혼용, 괄호 안 부가 정보 제거를 어디까지 할지 미리 정해두는 편이 좋다. DB에서 후보를 좁힐 때는 PostgreSQL의 pg_trgm으로 트라이그램 인덱스를 걸어두면 블로킹 자체를 SQL로 넘길 수도 있다.
3. 임계값은 감이 아니라 라벨로 정한다
pair_score >= 0.85의 0.85는 어디서 나온 숫자인가? 대부분 감으로 정하고, 그 결과 병합 사고가 난다.
300~500쌍만 직접 라벨링하면 임계값을 데이터로 정할 수 있다. 정밀도-재현율 곡선은 scikit-learn의 관련 문서에 계산 방식이 나와 있다.
import numpy as np
from sklearn.metrics import precision_recall_curve
# labeled: [(score, is_same), ...] — 사람이 판정한 쌍
scores = np.array([s for s, _ in labeled])
truth = np.array([y for _, y in labeled])
precision, recall, thresholds = precision_recall_curve(truth, scores)
for p, r, t in zip(precision, recall, thresholds):
if p >= 0.99: # 정밀도 99% 이상인 첫 지점
print(f"자동 병합 임계값 {t:.3f} (정밀도 {p:.3f}, 재현율 {r:.3f})")
break
여기서 정밀도를 재현율보다 우선하는 이유가 있다. 잘못 병합하면 서로 다른 두 장소가 하나로 합쳐지고, 이건 사용자에게 즉시 보이는 데이터 오류다. 반면 병합을 놓치면 중복이 남을 뿐이고, 다음 배치에서 다시 잡을 수 있다. 되돌리기 비용이 비대칭이므로 임계값도 비대칭으로 잡는다.
[3구간 정책]
score >= 0.93 자동 병합 (정밀도 99% 지점)
0.75 ~ 0.93 검토 큐로 (사람이 판정)
score < 0.75 별개로 유지
→ 검토 큐 규모를 먼저 계산할 것.
4만 건에서 중간 구간이 5,000쌍이면 사람이 감당 못 한다.
그럴 땐 상위 스코어부터 N건만 큐에 넣고 나머지는 보류.
지표 정의와 곡선 해석은 scikit-learn의 모델 평가 문서를 기준으로 삼으면 팀 내 용어가 흔들리지 않는다.
4. 쌍 판정을 군집으로 — 이행성 함정
쌍 단위 판정을 그대로 병합하면 문제가 생긴다.
A ≈ B (0.95)
B ≈ C (0.94)
A ≈ C (0.62) ← A와 C는 다른 장소인데?
쌍 관계는 이행적이지 않다. A-B와 B-C를 각각 병합하면 A와 C도 같은 군집에 들어간다. 이걸 방치하면 연쇄 병합으로 거대한 군집이 생긴다 — 이른바 체이닝 문제다.
판정 결과를 그래프로 보고 연결 요소를 뽑는 방식은 NetworkX의 연결 요소 문서에 기본형이 정리돼 있다.
import networkx as nx
def resolve_clusters(pairs: list[tuple[int, int, float]], t_auto: float):
g = nx.Graph()
g.add_weighted_edges_from([(a, b, s) for a, b, s in pairs if s >= t_auto])
clusters = []
for comp in nx.connected_components(g):
sub = g.subgraph(comp)
n = len(comp)
# 완전 그래프 대비 밀도가 낮으면 체이닝 의심 → 분할
density = sub.number_of_edges() / (n * (n - 1) / 2) if n > 1 else 1.0
if n <= 3 or density >= 0.6:
clusters.append(set(comp))
else:
# 약한 간선을 끊어 하위 군집으로 분리
for part in nx.community.greedy_modularity_communities(sub):
clusters.append(set(part))
return clusters
군집 크기에 상한을 두는 것도 실무에서 유용하다. 한 군집에 20건이 넘게 묶였다면 거의 항상 체이닝이다. 자동 병합에서 빼고 검토 큐로 보낸다.
5. 병합은 반드시 되돌릴 수 있어야 한다
원본을 덮어쓰는 병합은 하지 않는다. 원본은 남기고, 대표 레코드를 따로 만든다.
-- 원본은 그대로 유지
CREATE TABLE places (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL, -- 출처
source_id TEXT NOT NULL,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
UNIQUE (source, source_id)
);
-- 병합 결과는 별도 매핑으로
CREATE TABLE place_clusters (
cluster_id BIGSERIAL PRIMARY KEY,
canonical_id BIGINT NOT NULL REFERENCES places(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE place_cluster_members (
cluster_id BIGINT NOT NULL REFERENCES place_clusters(cluster_id),
place_id BIGINT NOT NULL REFERENCES places(id),
score REAL,
decided_by TEXT NOT NULL, -- 'auto' | 'human'
decided_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (cluster_id, place_id)
);
이 구조의 이점은 명확하다.
- 임계값을 바꿔서 다시 돌려도 원본 데이터가 손상되지 않는다
- 잘못된 병합을 발견하면 해당 멤버 행만 지우면 된다
decided_by로 자동 판정과 사람 판정을 구분해, 나중에 자동 판정만 재계산할 수 있다
대표 레코드(canonical)의 필드는 군집 안에서 필드별로 따로 고른다. 이름은 가장 많이 등장한 표기, 전화번호는 최신 갱신본, 좌표는 출처 신뢰도가 높은 쪽 — 이런 식이다. 한 레코드를 통째로 대표로 삼으면 다른 레코드에만 있던 정보가 버려진다.
지역·업종 축으로 정리된 플림의 장소 목록처럼 출처가 여럿인 디렉터리는 이 문제를 정면으로 마주하게 되는데, 결국 "무엇을 하나로 볼 것인가"의 기준을 문서로 명시해두는 것이 파이프라인 코드보다 오래 간다.
6. 운영 — 한 번 돌리고 끝나지 않는다
[배치 주기]
신규 유입분 → 매일. 기존 군집과의 매칭만 수행 (증분)
전체 재계산 → 월 1회. 임계값이나 정규화 규칙이 바뀐 뒤
[관측 지표]
자동 병합률 전체 쌍 중 자동 병합 비율
검토 큐 적체량 늘어나면 임계값 재조정 신호
병합 취소율 사람이 되돌린 비율 — 0.5% 넘으면 임계값이 낮음
군집 최대 크기 갑자기 커지면 체이닝 발생
이 중 병합 취소율이 가장 직접적인 신호다. 이 값이 오르면 임계값을 올리거나 가중치를 재조정해야 한다. 지표 없이 파이프라인만 돌리면 품질이 언제 나빠졌는지 알 수 없다.
7. 정리
1. 블로킹으로 비교 대상을 0.1% 수준까지 줄인다 (키는 여러 축으로)
2. 필드별로 다른 유사도 척도를 쓰고, 결측은 0이 아니라 제외
3. 300~500쌍 라벨링으로 임계값을 데이터로 정한다 (정밀도 우선)
4. 쌍 판정을 군집으로 바꿀 때 체이닝을 점검한다
5. 원본을 덮어쓰지 않고 매핑 테이블로 병합한다
6. 병합 취소율을 관측한다
가장 자주 생략되는 단계는 3번이다. 라벨링 300쌍은 반나절이면 끝나고, 그 반나절이 병합 사고 하나를 막는다.