- Published on
pandas merge 후 행이 폭증할 때 - 중복키 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 DataFrame을 merge했을 뿐인데 행이 갑자기 수십 배로 늘어나는 경험은 데이터 파이프라인에서 꽤 흔합니다. 특히 로그/이벤트 테이블과 마스터 테이블을 붙이거나, 집계 전후의 데이터를 합칠 때 자주 발생합니다. 이 현상은 pandas의 버그가 아니라 **조인 키가 유일하지 않아 발생한 조합 폭발(cartesian-like expansion)**인 경우가 대부분입니다.
이 글에서는 pandas.merge가 행을 늘리는 정확한 메커니즘을 이해하고, 어떤 키가 중복인지, 어느 쪽에서 중복이 발생했는지, 중복이 의도된 1:N인지 실수로 생긴 것인지를 진단하는 방법을 단계별로 정리합니다. 이벤트 데이터에서 중복/유실을 다루는 사고방식은 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략에서 다루는 “정합성 제약을 명시적으로 검증한다”는 원칙과도 닮아 있습니다.
1) 왜 merge 후 행이 폭증하나: N×M 매칭의 원리
merge는 SQL JOIN과 동일하게 동작합니다. 키가 유일하면(예: left의 키가 1개, right도 1개) 결과는 1:1로 깔끔합니다. 하지만 키가 중복되면 다음과 같은 일이 벌어집니다.
- left에 키
K가 N행 존재 - right에 키
K가 M행 존재 - 결과에는 키
K에 대해 N×M행이 생성
즉, 조인 자체가 “곱셈”을 수행합니다. 실무에서 자주 생기는 패턴은 아래와 같습니다.
- right가 마스터라고 믿었는데 사실은 동일 키가 여러 버전(이력)으로 존재
- left가 이벤트라고 생각했는데 중복 이벤트가 섞여 있음(재처리/중복 적재)
- 키 컬럼의 공백/대소문자/타입 불일치로 인해 의도치 않은 키 분화 또는 합쳐짐
2) 가장 빠른 1차 점검: merge 전후 행 수와 키 유일성
먼저 “폭증”이 어느 키에서 생겼는지 보기 전에, 조인 제약을 기계적으로 확인하는 습관이 중요합니다.
import pandas as pd
# 예시 데이터
left = pd.DataFrame({
"user_id": [1, 1, 2, 3],
"event": ["click", "view", "click", "buy"],
})
right = pd.DataFrame({
"user_id": [1, 1, 2],
"segment": ["A", "B", "A"],
})
print(len(left), len(right))
merged = left.merge(right, on="user_id", how="left")
print(len(merged))
위 예시에서 user_id=1은 left에 2행, right에 2행이므로 결과에서 4행이 됩니다.
다음으로 각 테이블에서 조인 키가 유일한지 즉시 확인합니다.
key = ["user_id"]
left_dups = left.duplicated(key).sum()
right_dups = right.duplicated(key).sum()
print("left duplicated keys:", left_dups)
print("right duplicated keys:", right_dups)
right_dups > 0인데 right를 “차원/마스터”로 가정했다면 거의 확정적으로 설계/데이터 품질 이슈입니다.
3) validate=로 조인 제약을 강제하라 (가장 강력한 예방책)
pandas는 merge(..., validate=...)로 조인 관계를 검증할 수 있습니다. 이 옵션을 켜면, 중복으로 인해 1:1이 깨지는 순간 즉시 예외를 던져 “조용한 폭증”을 막습니다.
# left: many, right: one 이라고 기대한다면
# (예: 이벤트 left + 유일한 유저 마스터 right)
merged = left.merge(
right,
on="user_id",
how="left",
validate="many_to_one" # 또는 'm:1'
)
가능한 값은 다음과 같습니다.
"one_to_one"("1:1")"one_to_many"("1:m")"many_to_one"("m:1")"many_to_many"("m:m")
실무 팁:
- 대부분의 “행 폭증” 사고는 사실상
m:m조인이 발생했는데도 눈치채지 못한 케이스입니다. - 파이프라인/노트북에서라도
validate를 기본값처럼 습관화하면, 문제를 가장 이른 지점에서 잡을 수 있습니다.
4) 어떤 키가 폭증을 만들었는지: 중복 키의 분포를 수치로 보기
키가 하나가 아니라 복합키인 경우(예: user_id, date)가 많습니다. 복합키의 중복 분포를 보면 “어느 값에서 N×M이 큰지”가 드러납니다.
keys = ["user_id"]
left_counts = left.groupby(keys).size().rename("left_n")
right_counts = right.groupby(keys).size().rename("right_m")
stats = pd.concat([left_counts, right_counts], axis=1).fillna(0).astype(int)
stats["expected_join_rows"] = stats["left_n"] * stats["right_m"]
# 폭증 기여도가 큰 키부터 보기
print(stats.sort_values("expected_join_rows", ascending=False).head(10))
expected_join_rows가 큰 키가 곧 폭증의 주범입니다.- left 또는 right 중 한쪽이 0이면 조인 결과에 기여하지 않습니다.
이 방식은 SQL로도 흔히 하는 “조인 전 cardinality 점검”을 pandas에서 재현한 것입니다.
5) 의도된 중복 vs 의도치 않은 중복을 구분하는 체크리스트
중복이 항상 나쁜 것은 아닙니다. 예를 들어 “주문(1) : 주문상품(N)” 같은 관계는 1:N이 정상입니다. 문제는 내가 기대한 제약과 실제 데이터가 다를 때입니다.
5.1 right가 마스터인데 중복이라면
원인 후보:
- SCD(변경이력) 테이블인데 최신 1건만 필터링하지 않음
- 유효기간 컬럼(
valid_from,valid_to)을 조인 조건에 포함하지 않음 - 소스가 중복 적재됨(재처리, 배치 재실행)
해결 패턴(최신 1건만 남기기):
# 예: updated_at 기준 최신 레코드만 남겨 마스터화
right_latest = (right
.sort_values(["user_id", "updated_at"])
.drop_duplicates(["user_id"], keep="last")
)
merged = left.merge(right_latest, on="user_id", how="left", validate="many_to_one")
5.2 left가 이벤트인데 중복이라면
원인 후보:
- 동일 이벤트가 여러 번 적재(Exactly-once 보장 실패)
- 이벤트 키(event_id)가 없거나 신뢰할 수 없음
해결 패턴(이벤트 고유키로 dedup):
left_dedup = left.drop_duplicates(["event_id"], keep="first")
merged = left_dedup.merge(right, on="user_id", how="left", validate="many_to_one")
이때 “중복 제거가 맞는가?”는 도메인 의사결정입니다. 이벤트 소싱/로그 기반 시스템에서 중복과 유실을 어떻게 복구하는지의 관점은 위에서 언급한 글(Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략)의 접근이 참고가 됩니다.
6) indicator=True로 매칭 상태를 시각화하라
행 폭증과 별개로, 조인 결과가 “어느 쪽에만 존재하는지”를 확인하면 키 품질 문제(누락, 타입 불일치)가 빨리 드러납니다.
merged = left.merge(
right,
on="user_id",
how="outer",
indicator=True
)
print(merged["_merge"].value_counts())
left_only가 많다면 right에 없는 키가 많음right_only가 많다면 left에 없는 키가 많음- 기대와 다르면 조인 키 정규화(공백 제거, 타입 통일)를 의심해야 합니다.
7) 타입/정규화 문제로 인한 “가짜 중복”도 흔하다
키가 문자열/정수로 섞이거나, 공백/대소문자 차이로 인해 같은 엔티티가 서로 다른 키로 취급되면 조인 결과가 비정상적으로 커지거나(혹은 매칭이 안 되거나) 합니다.
# 예: 문자열 키 정규화
for df in (left, right):
df["user_id"] = (df["user_id"]
.astype(str)
.str.strip()
.str.lower()
)
merged = left.merge(right, on="user_id", how="left", validate="many_to_one")
정규화는 조인 직전에 “양쪽 모두”에 적용해야 합니다. 한쪽만 바꾸면 매칭이 더 깨집니다.
8) 그래도 m:m이 필요하다면: 폭증을 통제하는 설계로 바꿔라
업무적으로 m:m 조인이 필요한 경우도 있습니다(예: 사용자-태그, 상품-카테고리). 이때는 폭증이 “정상”이므로, 다음을 고려해야 합니다.
- 결과 행 수가 커지는 것이 비용/메모리 측면에서 감당 가능한가?
- 조인 전에 필터링/집계로 cardinality를 줄일 수 있는가?
- 조인 결과를 바로 집계할 거라면, 조인 대신 키별 집계 후 결합이 가능한가?
예: right에서 키별로 먼저 집계해 1:1로 만든 뒤 조인
right_agg = (right
.groupby("user_id")
.agg(segment_cnt=("segment", "nunique"))
.reset_index()
)
merged = left.merge(right_agg, on="user_id", how="left", validate="many_to_one")
이 패턴은 “조인으로 행을 늘린 뒤 다시 줄이는” 비효율을 피합니다.
9) 실전 디버깅 루틴(요약)
다음 루틴을 그대로 체크리스트로 사용하면 재현/진단 속도가 크게 올라갑니다.
- merge 전후 행 수 출력:
len(left),len(right),len(merged) validate로 기대 제약 강제:many_to_one/one_to_one등- 중복 키 카운트:
duplicated(keys).sum() - 키별 N×M 기여도 계산:
left_n * right_m상위 키 확인 indicator=True로 매칭 상태 확인- 키 정규화/타입 통일
- 필요 시 최신 1건 필터링(SCD) 또는 사전 집계로 1:1화
데이터 조인의 폭증 문제는 본질적으로 “키의 정합성” 문제입니다. 인프라에서 인증/권한이 어긋나면 요청이 실패하듯, 데이터에서도 키 제약이 어긋나면 조인이 폭발합니다. 운영 환경에서 원인별로 체계적으로 점검하는 접근은 Kubernetes 401 Unauthorized 원인별 해결 가이드처럼 “증상→가능 원인→검증 방법”으로 분해하는 습관과도 연결됩니다.
10) 결론: merge는 결합이 아니라 ‘제약을 드러내는 테스트’다
pandas.merge 후 행이 폭증했다면, 그 자체가 중요한 신호입니다.
- 내가 기대한 관계가 1:1 또는 1:N인데 실제 데이터는 m:m이다
- 키가 정규화/타입 통일이 안 되어 있다
- 마스터라고 믿은 테이블이 사실은 이력/중복을 포함한다
가장 좋은 해결책은 “사후 dedup”이 아니라, validate로 제약을 강제하고, 중복 키의 분포를 수치로 진단한 뒤, **도메인 규칙(최신 1건, 유효기간, 고유 이벤트 키 등)**을 명시적으로 코드에 반영하는 것입니다. 이렇게 하면 조인이 폭증하는 순간을 ‘디버깅 지옥’이 아니라 ‘빠른 실패(fail fast)’로 바꿀 수 있습니다.