- Published on
Pandas merge에서 행 폭증? 중복키 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
데이터를 pandas.merge로 붙였는데, 결과 행 수가 원본보다 훨씬 커져서 당황한 적이 있다면 대부분 원인은 하나입니다. 조인 키가 유일하지 않아 many-to-many 조인이 발생했고, 그 결과가 카티전 곱처럼 불어나기 때문입니다.
특히 로그/이벤트 데이터, 주문-상품, 사용자-세션 같은 도메인에서는 키 중복이 흔합니다. 문제는 “중복이 있는지”를 모르고 조인을 해버리면, 결과가 조용히 폭증하면서 이후 집계/모델링/리포트가 전부 틀어질 수 있다는 점입니다.
이 글에서는 다음을 목표로 합니다.
- 행 폭증을 일으키는 대표 패턴 이해
- 중복키를 빠르게 찾아내는 진단 코드
validate로 조인을 강제 검증하는 방법- 폭증을 막는 설계(사전 집계, 중복 제거, 키 재정의)
관련해서 pandas의 대표 함정인 뷰/복사 문제도 함께 자주 등장합니다. 필요하면 pandas SettingWithCopyWarning 완전 해결 가이드도 같이 참고하면 좋습니다.
왜 merge 후 행이 폭증할까: 조인 유형과 곱셈 효과
merge는 SQL 조인과 동일한 개념입니다. 핵심은 조인 키의 유일성입니다.
- 왼쪽 키가 유일, 오른쪽 키가 유일:
one-to-one - 왼쪽 키 유일, 오른쪽 키 중복:
one-to-many - 왼쪽 키 중복, 오른쪽 키 유일:
many-to-one - 양쪽 키 중복:
many-to-many
이때 같은 키에 대해 왼쪽이m개, 오른쪽이n개면 결과는m * n개가 됩니다.
행 폭증은 거의 항상 마지막 경우입니다.
10초 재현 예시
import pandas as pd
left = pd.DataFrame({
"user_id": [1, 1, 2],
"event": ["click", "view", "click"],
})
right = pd.DataFrame({
"user_id": [1, 1, 1, 2],
"segment": ["A", "B", "C", "A"],
})
merged = left.merge(right, on="user_id", how="inner")
print(len(left), len(right), len(merged))
print(merged)
user_id=1에서 왼쪽은 2행, 오른쪽은 3행이므로 결과는 6행이 됩니다. 원본이 3행인데 조인 후 7행이 되는 식의 “이상 현상”이 아니라, 정의대로 동작한 결과입니다.
1단계: merge 전후 행 수부터 의심하기
가장 먼저 할 일은 “감”이 아니라 수치로 확인하는 것입니다.
before_left = len(left)
before_right = len(right)
merged = left.merge(right, on="user_id", how="left")
print({
"left": before_left,
"right": before_right,
"merged": len(merged),
"multiplier_vs_left": len(merged) / max(before_left, 1),
})
how="left"인데도merged가left보다 크면, 오른쪽에 중복키가 있을 가능성이 큽니다.how="inner"에서 폭증하면 양쪽 중복 또는 필터링 착시를 의심하세요.
2단계: 키 중복을 정확히 찾아내기
단일 키 중복 진단
key = ["user_id"]
left_dups = left[left.duplicated(key, keep=False)].sort_values(key)
right_dups = right[right.duplicated(key, keep=False)].sort_values(key)
print("left dup keys:", left_dups[key].drop_duplicates().head(10).to_dict("records"))
print("right dup keys:", right_dups[key].drop_duplicates().head(10).to_dict("records"))
duplicated(..., keep=False)는 중복된 모든 행을 잡아내므로 원인 추적에 좋습니다.
복합 키 중복 진단
실무에서는 user_id 하나가 아니라 user_id + date 같은 복합키가 조인 키인 경우가 많습니다.
key = ["user_id", "date"]
left_counts = left.groupby(key).size().rename("left_n")
right_counts = right.groupby(key).size().rename("right_n")
problem = (
pd.concat([left_counts, right_counts], axis=1)
.fillna(0)
.astype(int)
.query("left_n > 1 or right_n > 1")
.sort_values(["left_n", "right_n"], ascending=False)
)
print(problem.head(20))
여기서 left_n과 right_n이 모두 2 이상인 키는, 조인 시 최소 4배 이상 늘어날 후보입니다.
폭증 기여도가 큰 키 찾기(곱셈 기반)
“중복이 있다”에서 끝내지 말고, 어떤 키가 행 폭증의 대부분을 만드는지까지 보면 해결이 빨라집니다.
impact = (
pd.concat([left_counts, right_counts], axis=1)
.fillna(0)
.astype(int)
)
impact["expected_rows"] = impact["left_n"] * impact["right_n"]
top = (
impact.query("left_n > 0 and right_n > 0")
.sort_values("expected_rows", ascending=False)
.head(20)
)
print(top)
expected_rows가 큰 키부터 보면, 실제로 조인 결과의 대부분을 차지하는 “폭증 핫스팟”을 찾을 수 있습니다.
3단계: validate로 조인 형태를 강제 검증하기
pandas에는 조인 형태를 검증하는 강력한 옵션이 있습니다. 이걸 켜두면, 예상치 못한 다대다 조인을 즉시 예외로 중단시킬 수 있습니다.
merged = left.merge(
right,
on="user_id",
how="left",
validate="many_to_one", # 왼쪽은 여러 개 가능, 오른쪽은 키 유일해야 함
)
validate에 들어갈 수 있는 값은 다음과 같습니다.
"one_to_one""one_to_many""many_to_one""many_to_many"
팁:
- 차원 테이블(예: 사용자 속성, 상품 마스터)을 붙이는 조인은 보통
many_to_one이 안전합니다. - 팩트 테이블끼리 붙이는 조인은
many_to_many가 될 가능성이 높으므로, 조인 전에 집계나 키 재정의가 필요합니다.
4단계: 폭증을 막는 해결 전략 5가지
1) 오른쪽(또는 왼쪽)을 유일 키로 만들기: drop_duplicates
차원 테이블인데 중복이 섞여 들어온 경우, “어떤 행을 남길지” 규칙을 정한 뒤 유일키로 정리해야 합니다.
# 최신 업데이트만 남긴다는 규칙 예시
right2 = (right
.sort_values(["user_id", "updated_at"])
.drop_duplicates(["user_id"], keep="last"))
merged = left.merge(right2, on="user_id", how="left", validate="many_to_one")
주의: 무작정 중복 제거는 데이터 손실을 유발할 수 있으니, 반드시 도메인 규칙(최신, 우선순위, 상태값 등)을 명시하세요.
2) 조인 전에 집계해서 키를 유일화하기
이벤트/로그처럼 한 키에 여러 행이 있는 테이블을 그대로 붙이면 폭증이 자연스러운 결과입니다. 대부분은 “필요한 건 요약치”입니다.
# user_id별 마지막 이벤트 시각만 필요하다고 가정
last_event = (left
.groupby("user_id", as_index=False)["event_time"]
.max())
merged = last_event.merge(right, on="user_id", how="left", validate="one_to_many")
집계 후 조인하면, 폭증이 아니라 의도한 형태로 데이터가 정리됩니다.
3) 조인 키를 재정의하기(복합키로 정확도 올리기)
user_id만으로 붙이면 다대다인데, 사실은 user_id + session_id로 붙여야 one-to-one이 되는 경우가 많습니다.
key = ["user_id", "session_id"]
merged = left.merge(right, on=key, how="inner", validate="one_to_one")
키 설계가 잘못되면, 어떤 테크닉으로도 폭증을 “완전히 올바르게” 막을 수 없습니다.
4) 조인 결과에서 의도치 않은 중복을 탐지하는 안전장치
조인 후에도 특정 컬럼 조합이 유일해야 하는데 중복이 생겼다면, 조인 조건이 틀렸거나 데이터 정합성이 깨진 겁니다.
unique_key = ["order_id", "line_id"]
if merged.duplicated(unique_key).any():
bad = merged[merged.duplicated(unique_key, keep=False)].sort_values(unique_key)
raise ValueError(f"Merged has duplicated rows on {unique_key}. Sample:\n{bad.head(20)}")
5) indicator=True로 매칭 상태 확인하기
폭증과 별개로, “어떤 행이 매칭되지 않았는지”를 같이 보면 문제를 더 빨리 좁힐 수 있습니다.
merged = left.merge(right, on="user_id", how="left", indicator=True)
print(merged["_merge"].value_counts())
# 매칭 실패 샘플 확인
print(merged.loc[merged["_merge"] == "left_only", ["user_id"]].drop_duplicates().head(20))
실전 체크리스트: merge 전에 이것만은 확인
- 조인 키 후보에 대해
groupby(key).size()로 분포를 본다. - 차원 테이블을 붙이는 조인이라면
validate="many_to_one"을 기본값처럼 사용한다. - 폭증이 예상되는 팩트-팩트 조인은 조인 전에 집계하거나, 키를 더 세분화한다.
- 조인 후 유일해야 하는 키 조합에 대해
duplicated로 검증한다. - 데이터 전처리 중 경고가 많이 뜬다면(특히 슬라이싱 후 대입), 코드가 의도대로 동작하는지 점검한다. 필요 시 pandas SettingWithCopyWarning 완전 해결 가이드를 참고한다.
자주 하는 실수와 반례
“왼쪽 조인이니까 행 수는 안 늘겠지”
how="left"라도 오른쪽 키가 중복이면 늘어납니다. 왼쪽의 각 행이 오른쪽의 여러 행과 매칭되기 때문입니다.
“중복이 있어도 결과에서 drop_duplicates 하면 되지 않나”
조인 결과에서의 중복 제거는 대개 정보를 임의로 버리는 것입니다. 중복이 왜 생겼는지(키 설계, 집계 필요, 차원 테이블 정합성)를 먼저 해결해야 합니다.
“merge가 느려졌는데도 원인을 모르겠다”
행 폭증은 성능 문제로도 직결됩니다. 조인 결과가 수십 배로 커지면 메모리와 시간이 급증합니다. 조인 전 expected_rows를 추정하는 습관이 도움이 됩니다.
결론
pandas.merge 후 행이 폭증하는 현상은 대부분 버그가 아니라 다대다 조인의 자연스러운 결과입니다. 해결의 핵심은 다음 3가지입니다.
- 키 중복을 수치로 진단하고(중복키, 곱셈 기여도)
validate로 조인 형태를 강제하며- 조인 전에 유일화(집계/정제)하거나 키를 재정의한다
이 과정을 체크리스트처럼 자동화해두면, 데이터 파이프라인에서 “조용히 틀어지는” 종류의 사고를 크게 줄일 수 있습니다.