- Published on
pandas merge 후 행 폭증·NaN - 키 중복 5분 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
pandas.merge()를 했는데 결과 행(row)이 원본보다 폭증하거나, 기대하던 컬럼이 전부 NaN으로 채워지는 경우가 있습니다. 이 현상은 대개 조인 키의 중복(1:N, N:M), 키 타입/정규화 불일치, 조인 방식(how)과 키 선택 오류에서 발생합니다. 문제는 “코드는 정상 실행”되기 때문에 데이터 품질 이슈로 늦게 발견된다는 점입니다.
이 글은 디버깅 시간을 줄이기 위해, merge 직후 이상 징후를 5분 내로 진단하는 순서(체크리스트)와, 실무에서 자주 쓰는 방지 패턴(검증/중복 제거/정규화)을 코드로 제공합니다.
> 장애 진단을 빠르게 좁히는 방식은 인프라든 데이터든 비슷합니다. 원인 후보를 좁히는 접근은 EKS ALB Ingress 502/504 - TLS 핸드셰이크 실패 진단 같은 글에서 다루는 “증상→가설→검증” 루프와 동일합니다.
0분: 문제를 재현 가능한 최소 예제로 축소
먼저 “행 폭증”과 “NaN” 두 증상을 각각 재현해보면 원인 유형이 바로 보입니다.
행 폭증(N:M 조인) 재현
import pandas as pd
left = pd.DataFrame({
"user_id": [1, 1, 2],
"event": ["click", "view", "click"],
})
right = pd.DataFrame({
"user_id": [1, 1, 3],
"segment": ["A", "B", "C"],
})
out = left.merge(right, on="user_id", how="left")
print(out)
print("left rows:", len(left), "right rows:", len(right), "out rows:", len(out))
user_id=1이 양쪽에 각각 2개씩 있으므로 결과는 2×2=4개로 곱셈 폭증합니다.- 이건 버그가 아니라 관계형 조인의 정상 동작입니다.
NaN 폭발(키 불일치) 재현
left = pd.DataFrame({
"user_id": ["001", "002", "003"],
"value": [10, 20, 30],
})
right = pd.DataFrame({
"user_id": [1, 2, 3],
"segment": ["A", "B", "C"],
})
out = left.merge(right, on="user_id", how="left")
print(out)
- 겉보기엔 같은 키지만 실제로는 문자열("001") vs 정수(1)이라 매칭이 전부 실패 →
segment가NaN.
1분: merge 결과에서 ‘이상 징후’ 먼저 수치로 확인
1) 행 수가 왜 늘었는지: 증가율부터 계산
expected_max = len(left) # left join이면 보통 left 행 수를 기대
print("row ratio:", len(out) / len(left))
left join인데len(out) > len(left)면 거의 항상 left 키 중복 또는 right 키 중복이 있고, 그 조합이 1:N 또는 N:M입니다.
2) NaN이 왜 늘었는지: 신규 컬럼 결측률 확인
new_cols = [c for c in out.columns if c not in left.columns]
na_rate = out[new_cols].isna().mean().sort_values(ascending=False)
print(na_rate)
- 결측률이 갑자기 1.0(100%)에 가깝다면 키 매칭 자체가 실패했을 확률이 큽니다(타입/공백/대소문자/패딩 문제).
2분: 키 중복 여부를 즉시 검사 (폭증의 90%)
1) 각 테이블에서 키 유니크 여부 확인
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)
- 둘 중 하나라도 0이 아니면, 조인 결과가 늘어날 수 있습니다.
- 특히 양쪽 모두 중복이면 N:M 가능성이 큽니다.
2) 어떤 키가 문제인지 Top N으로 보기
print(left[key].value_counts().head(10))
print(right[key].value_counts().head(10))
여기서 상위에 뜨는 키가 “폭증의 중심”입니다.
3) merge의 관계를 강제 검증: validate 옵션
실무에서 가장 강력한 안전장치입니다.
# 기대 관계에 맞게 선택
# "one_to_one", "one_to_many", "many_to_one", "many_to_many"
out = left.merge(right, on="user_id", how="left", validate="many_to_one")
- 예: left는 이벤트 로그로 중복 가능(many), right는 사용자 마스터로 유니크(one)라면
many_to_one이 맞습니다. - 관계가 깨지면
MergeError로 즉시 실패시켜 데이터 사고를 예방합니다.
3분: 키 불일치(타입/정규화/결측)로 인한 NaN 진단
NaN이 늘어나는 경우는 “조인 키가 사실상 다른 값”인 경우가 많습니다.
1) dtype부터 맞추기
print(left["user_id"].dtype, right["user_id"].dtype)
# 예: 둘 다 문자열로 통일
left["user_id"] = left["user_id"].astype("string")
right["user_id"] = right["user_id"].astype("string")
정수/문자열뿐 아니라 category, object, string[python] 등도 혼재하면 예기치 않은 매칭 실패가 납니다.
2) 공백/대소문자/패딩 정규화
left["user_id"] = left["user_id"].str.strip().str.lower()
right["user_id"] = right["user_id"].str.strip().str.lower()
ID가 " abc" vs "abc"처럼 보이지 않는 공백 때문에 매칭이 깨지는 경우가 흔합니다.
3) 매칭 실패한 키를 직접 추출
indicator=True로 “어디서 왔는지”를 표시하면 진단이 빨라집니다.
out = left.merge(
right,
on="user_id",
how="left",
indicator=True
)
unmatched = out.loc[out["_merge"] == "left_only", "user_id"].drop_duplicates()
print("unmatched keys sample:", unmatched.head(20).tolist())
left_only가 많으면 right에 없는 키를 left가 들고 있거나, 정규화/타입 문제입니다.
4분: 조인 키를 잘못 잡았는지 확인 (복합키 누락이 흔함)
행 폭증은 “사실은 복합키로 조인해야 하는데 단일 키로 조인”했을 때도 자주 발생합니다.
예를 들어 user_id만으로는 유니크하지 않고 user_id + date가 유니크인 경우:
# 잘못된 조인(폭증 가능)
wrong = left.merge(right, on="user_id", how="left")
# 올바른 조인(복합키)
correct = left.merge(right, on=["user_id", "date"], how="left", validate="many_to_one")
체크 방법:
candidate = ["user_id"]
print("right unique by user_id?", right.duplicated(candidate).any())
candidate2 = ["user_id", "date"]
print("right unique by user_id+date?", right.duplicated(candidate2).any())
5분: 해결 패턴 4가지 (실무에서 바로 쓰는 처방)
패턴 A) 마스터(차원) 테이블을 유니크로 강제
right가 원래 유니크여야 하는데 중복이 섞였다면, 병합 전에 정리합니다.
# 가장 최근 레코드만 남기는 예시
right = right.sort_values("updated_at").drop_duplicates("user_id", keep="last")
out = left.merge(right, on="user_id", how="left", validate="many_to_one")
중복 제거 기준(최신/최대/우선순위)은 도메인 규칙이므로 명시적으로 선택해야 합니다.
패턴 B) 조인 전에 집계해서 1행으로 축약
right가 사용자별 여러 행을 갖는 것이 정상이라면, 조인 전에 목적에 맞게 집계합니다.
right_agg = (right
.groupby("user_id", as_index=False)
.agg(segment_cnt=("segment", "nunique"))
)
out = left.merge(right_agg, on="user_id", how="left", validate="many_to_one")
패턴 C) 폭증을 의도했다면 ‘폭증량’을 모니터링 지표로 고정
N:M 조인이 비즈니스적으로 맞는 경우도 있습니다(예: 태그-문서 관계 확장). 그 경우엔 폭증이 “정상”이므로, 폭증률을 지표로 고정해 이상을 감지합니다.
row_ratio = len(out) / len(left)
if row_ratio > 5:
raise ValueError(f"merge row explosion suspicious: ratio={row_ratio:.2f}")
패턴 D) merge 직후 품질 게이트(결측/중복/관계)를 자동화
def merge_with_guards(left, right, on, how, validate, new_cols_expected=None):
out = left.merge(right, on=on, how=how, validate=validate, indicator=True)
# 1) 매칭 실패율
left_only_rate = (out["_merge"] == "left_only").mean()
# 2) 신규 컬럼 결측률
if new_cols_expected:
na_rate = out[new_cols_expected].isna().mean().max()
else:
na_rate = 0.0
# 임계값은 데이터 특성에 맞게 조정
if left_only_rate > 0.01:
raise ValueError(f"too many unmatched keys: {left_only_rate:.3%}")
if na_rate > 0.01:
raise ValueError(f"too many NaNs in merged cols: {na_rate:.3%}")
return out.drop(columns=["_merge"])
이런 “가드레일”은 타입 시스템이 없는 데이터 파이프라인에서 특히 중요합니다. (성격은 다르지만, 설정 하나로 빌드가 터지는 문제를 추적하는 과정은 TypeScript 5.5 noImplicitAny 폭탄 - inferred type 디버깅 같은 글의 디버깅 흐름과 유사합니다.)
자주 하는 실수 체크리스트 (요약)
행 폭증
-
left.duplicated(key)또는right.duplicated(key)가 0이 아닌데도 방치 - 사실은 복합키인데 단일 키로 merge
-
validate없이 merge를 수행해 관계 붕괴를 조기에 못 잡음
NaN 폭발
- 키 dtype 불일치(문자열 vs 정수, category 혼재)
- 공백/대소문자/패딩("001" vs "1") 정규화 누락
-
indicator=True로 매칭 실패 키를 샘플링하지 않음
결론
pandas merge 이후 행이 폭증하거나 NaN이 쏟아지면, 먼저 “조인 키가 유니크한가/정규화되어 있는가/복합키가 필요한가”를 5분 안에 확인해야 합니다.
핵심은 세 가지입니다.
- 중복 검사 +
validate로 관계를 코드로 고정 - dtype/문자열 정규화로 매칭 실패를 제거
indicator와 결측률/폭증률 지표로 품질 게이트를 자동화
이 3가지만 습관화해도, merge가 “조용히” 데이터를 망가뜨리는 사고를 크게 줄일 수 있습니다.