- Published on
판다스 merge 중복·유실 디버깅 완전 정복
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 데이터프레임을 merge 했는데 결과 행이 갑자기 폭증하거나, 반대로 기대한 행이 사라지는 경험은 판다스에서 매우 흔합니다. 문제는 대개 merge 자체가 “이상하게 동작”하는 게 아니라, 조인 키의 품질(유일성·정규화·dtype) 과 조인 방식(카디널리티·how 선택) 이 우리가 생각한 전제와 달라서 발생합니다.
이 글은 merge 결과가 중복·유실될 때, 감으로 찍지 않고 재현 가능한 진단 루틴으로 원인을 좁혀가는 방법을 정리합니다. 서버 장애를 체크리스트로 추적하듯(예: systemd 서비스가 계속 재시작될 때 진단 체크리스트), 데이터 조인도 “가정 확인”이 핵심입니다.
1) 먼저 확인할 3가지: 기대 행 수, 키, 조인 타입
merge 디버깅은 아래 3줄에서 시작합니다.
- 기대 행 수: 결과가
left기준으로 유지돼야 하는가, 아니면 교집합만 남아야 하는가 - 조인 키: 어떤 컬럼(들)로 붙이는가, 그 키가 정말 유일한가
- 조인 타입:
how가 의도와 일치하는가 (left,inner,right,outer)
최소 재현 예시
import pandas as pd
left = pd.DataFrame({
"user_id": [1, 2, 3],
"name": ["a", "b", "c"],
})
right = pd.DataFrame({
"user_id": [1, 1, 2],
"grade": ["gold", "vip", "silver"],
})
out = left.merge(right, on="user_id", how="left")
print(out)
print("left rows:", len(left), "out rows:", len(out))
위 예시는 right 에서 user_id=1 이 중복이라 left 의 user_id=1 행이 2개로 늘어납니다. 이게 바로 가장 흔한 “중복 폭증” 패턴입니다.
2) 중복 폭증의 1순위 원인: 키가 유일하지 않다 (many-to-many)
merge 결과 행 수가 늘어나는 가장 큰 이유는 조인 키가 양쪽 중 하나(혹은 양쪽 모두)에서 유일하지 않아서 입니다.
left키 유일,right키 중복: one-to-many →left행이 복제됨left키 중복,right키 유일: many-to-one →left자체가 중복이므로 결과도 중복- 양쪽 모두 중복: many-to-many → 카티션 곱처럼 폭증 가능
키 유일성 즉시 점검 코드
key = ["user_id"]
print("left duplicated keys:", left.duplicated(subset=key).sum())
print("right duplicated keys:", right.duplicated(subset=key).sum())
# 어떤 키가 중복인지 상위 몇 개 확인
print(right[right.duplicated(subset=key, keep=False)].sort_values(key).head(10))
validate 로 조인 카디널리티를 강제하라
판다스 merge 에는 “내 가정이 맞는지”를 런타임에 검증하는 validate 옵션이 있습니다. 디버깅뿐 아니라 운영 코드에서도 강력 추천합니다.
out = left.merge(
right,
on="user_id",
how="left",
validate="one_to_one", # 또는 "one_to_many", "many_to_one", "many_to_many"
)
가정이 틀리면 예외가 나므로, 중복 폭증을 조기에 차단할 수 있습니다.
해결 전략
- “원래 한 행이어야 하는데 중복이다”라면,
right를 먼저 집계/정규화해서 키를 유일하게 만든 뒤 조인합니다.
right_agg = (right
.sort_values(["user_id"]) # 필요 시 최신순 정렬 등
.groupby("user_id", as_index=False)
.agg({"grade": "last"}))
out = left.merge(right_agg, on="user_id", how="left", validate="one_to_one")
집계 기준(최신, 최대, 우선순위)은 도메인 규칙이므로 명시적으로 정해야 합니다.
3) 유실의 1순위 원인: how="inner" 또는 키 불일치
행 유실은 보통 두 가지입니다.
inner조인이라 교집합만 남는다left조인인데도 키가 매칭되지 않아 오른쪽 컬럼이NaN으로 비고, 이후 필터링에서 떨어져 나간다
how 점검
out_inner = left.merge(right, on="user_id", how="inner")
out_left = left.merge(right, on="user_id", how="left")
print(len(left), len(out_inner), len(out_left))
매칭 실패 키를 바로 뽑아내기: indicator=True
indicator 는 조인 디버깅에서 거의 필수입니다.
out = left.merge(right, on="user_id", how="left", indicator=True)
print(out["_merge"].value_counts())
# 오른쪽 매칭이 없던 left 키
missing = out.loc[out["_merge"] == "left_only", "user_id"].unique()
print("missing keys:", missing)
left_only: 왼쪽에만 존재(매칭 실패)right_only: 오른쪽에만 존재both: 정상 매칭
4) dtype 불일치: 숫자 1 과 문자열 "1" 은 절대 붙지 않는다
키 컬럼 dtype이 다르면 값이 같아 보여도 매칭이 안 됩니다.
dtype 확인 및 통일
print(left.dtypes)
print(right.dtypes)
left["user_id"] = left["user_id"].astype("string")
right["user_id"] = right["user_id"].astype("string")
out = left.merge(right, on="user_id", how="left")
실무에서는 다음이 특히 흔합니다.
- CSV 로딩: 숫자처럼 보이지만 문자열로 들어옴
- DB 로딩: 한쪽은
int64, 한쪽은object - 코드에서
astype(int)하다가 결측치 때문에float로 변형
5) 공백·대소문자·제로패딩: “같은 키처럼 보이는데” 안 붙는 이유
문자열 키는 정규화가 안 되어 있으면 매칭 실패가 급증합니다.
대표 정규화 루틴
def norm_key(s: pd.Series) -> pd.Series:
return (s.astype("string")
.str.strip() # 앞뒤 공백 제거
.str.replace(r"\s+", " ", regex=True) # 연속 공백 축약
.str.lower()) # 대소문자 통일
left["email"] = norm_key(left["email"])
right["email"] = norm_key(right["email"])
out = left.merge(right, on="email", how="left", indicator=True)
print(out["_merge"].value_counts())
제로패딩도 흔한 함정입니다. 예를 들어 코드가 00123 형태로 관리되는데 한쪽은 정수로 읽어 123 이 되어버리면 매칭이 깨집니다. 이런 경우는 “문자열로 유지 + 길이 맞추기”가 안전합니다.
left["code"] = left["code"].astype("string").str.zfill(5)
right["code"] = right["code"].astype("string").str.zfill(5)
6) 결측치(NaN) 키: 붙지 않는 게 정상인데, 때로는 붙은 것처럼 보인다
판다스 조인에서 결측 키는 일반적으로 매칭되지 않습니다. 그런데 디버깅이 어려운 이유는 다음과 같습니다.
- 키가 결측인 행이 많으면
left조인 결과에서 오른쪽 컬럼이 대거NaN이 되고 - 이후
dropna나 필터가 들어가면 “유실”처럼 보입니다.
결측 키를 먼저 격리
key = "user_id"
left_null = left[left[key].isna()]
right_null = right[right[key].isna()]
print("left null keys:", len(left_null))
print("right null keys:", len(right_null))
결측이 의미 있는 값(예: 미지정 고객)을 뜻한다면, 조인 전에 센티넬 값을 부여할지(예: "UNKNOWN")를 정책적으로 결정해야 합니다.
7) 키가 여러 컬럼일 때: 부분 키 중복이 폭증을 만든다
복합키 조인은 한 컬럼만 보고 “유일하다”고 착각하기 쉽습니다. 반드시 조인에 쓰는 전체 키 조합으로 중복을 봐야 합니다.
keys = ["date", "store_id", "sku"]
print(left.duplicated(subset=keys).sum())
print(right.duplicated(subset=keys).sum())
out = left.merge(right, on=keys, how="left", validate="one_to_one")
또한 날짜는 문자열 포맷 차이로 자주 어긋납니다.
left["date"] = pd.to_datetime(left["date"]).dt.date
right["date"] = pd.to_datetime(right["date"]).dt.date
8) 조인 후 중복 제거(drop_duplicates)는 최후의 수단
merge 결과가 중복이라며 무조건 drop_duplicates 를 걸면, 겉보기 행 수는 맞아도 데이터가 임의로 유실될 수 있습니다. 특히 many-to-many에서 어떤 행이 남는지는 정렬/입력 순서에 좌우됩니다.
정말로 중복 제거가 필요하다면, “어떤 기준으로 한 행을 대표로 선택할지”를 먼저 정의하고 그 기준을 코드로 표현해야 합니다.
# 예: user_id별로 updated_at이 가장 최신인 grade만 남긴다
right_sorted = right.sort_values(["user_id", "updated_at"])
right_latest = right_sorted.groupby("user_id", as_index=False).tail(1)
out = left.merge(right_latest[["user_id", "grade"]], on="user_id", how="left", validate="one_to_one")
9) 디버깅을 자동화하는 체크리스트 함수
반복되는 점검은 함수로 만들어두면 팀 단위로 품질이 올라갑니다.
import pandas as pd
def debug_merge(left: pd.DataFrame, right: pd.DataFrame, on, how="left"):
if isinstance(on, str):
on = [on]
print("--- basic ---")
print("left rows:", len(left), "right rows:", len(right))
print("keys:", on, "how:", how)
print("--- dtypes ---")
for k in on:
print(k, "left:", left[k].dtype, "right:", right[k].dtype)
print("--- null keys ---")
for k in on:
print(k, "left null:", int(left[k].isna().sum()), "right null:", int(right[k].isna().sum()))
print("--- duplicated keys ---")
print("left dup:", int(left.duplicated(subset=on).sum()))
print("right dup:", int(right.duplicated(subset=on).sum()))
out = left.merge(right, on=on, how=how, indicator=True)
print("--- merge indicator ---")
print(out["_merge"].value_counts(dropna=False))
return out
# 사용 예
# out = debug_merge(left, right, on="user_id", how="left")
이 방식은 운영 장애 대응에서 “관측 지표를 먼저 표준화”하는 것과 같습니다. 캐시/동기화 문제를 단계적으로 좁혀가듯(예: GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅), 조인도 관측 포인트를 고정하면 원인 규명이 빨라집니다.
10) 실전 케이스별 빠른 처방전
케이스 A: 결과 행 수가 left 보다 크다
right키 중복 여부 확인validate="one_to_one"또는validate="many_to_one"로 가정 강제- 필요 시
right를 집계해서 키 유일화
케이스 B: 결과에서 특정 ID가 사라졌다
how가inner인지 확인indicator=True로left_only키 추출- dtype/공백/제로패딩/날짜 포맷 정규화
케이스 C: 조인 후 필터에서 대량 유실
- 조인 직후 오른쪽 컬럼
NaN비율 확인
out = left.merge(right, on="user_id", how="left")
print(out["grade"].isna().mean())
- 조인 실패가 원인인지, 원래 값이 없는 게 정상인지 정책 확인
11) 마무리: merge 는 “데이터 계약”을 검증하는 과정이다
merge 문제의 본질은 코드가 아니라 데이터 계약(contract) 입니다.
- 키는 유일해야 하는가
- 결측 키를 허용하는가
- 문자열 키 정규화 규칙은 무엇인가
- many-to-many가 허용되는가, 허용된다면 폭증이 정상인가
이 계약을 코드로 강제하는 가장 실용적인 도구가 validate 와 indicator 입니다. 여기에 dtype/정규화/결측 격리까지 루틴화하면, “이상한 중복·유실”은 대부분 재현 가능하게 설명되고 해결됩니다.
추가로 데이터가 DB에서 온다면, 조인 전에 원천 테이블에서 유일성(유니크 인덱스)과 카디널리티를 확인하는 습관이 큰 도움이 됩니다. 쿼리 성능과 데이터 품질을 함께 보는 관점은 PostgreSQL 느린 쿼리, EXPLAIN ANALYZE로 인덱스 튜닝 같은 접근과도 맞닿아 있습니다.