Published on

판다스 merge 중복·유실 디버깅 완전 정복

Authors

서로 다른 데이터프레임을 merge 했는데 결과 행이 갑자기 폭증하거나, 반대로 기대한 행이 사라지는 경험은 판다스에서 매우 흔합니다. 문제는 대개 merge 자체가 “이상하게 동작”하는 게 아니라, 조인 키의 품질(유일성·정규화·dtype)조인 방식(카디널리티·how 선택) 이 우리가 생각한 전제와 달라서 발생합니다.

이 글은 merge 결과가 중복·유실될 때, 감으로 찍지 않고 재현 가능한 진단 루틴으로 원인을 좁혀가는 방법을 정리합니다. 서버 장애를 체크리스트로 추적하듯(예: systemd 서비스가 계속 재시작될 때 진단 체크리스트), 데이터 조인도 “가정 확인”이 핵심입니다.

1) 먼저 확인할 3가지: 기대 행 수, 키, 조인 타입

merge 디버깅은 아래 3줄에서 시작합니다.

  1. 기대 행 수: 결과가 left 기준으로 유지돼야 하는가, 아니면 교집합만 남아야 하는가
  2. 조인 키: 어떤 컬럼(들)로 붙이는가, 그 키가 정말 유일한가
  3. 조인 타입: 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 이 중복이라 leftuser_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" 또는 키 불일치

행 유실은 보통 두 가지입니다.

  1. inner 조인이라 교집합만 남는다
  2. 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가 사라졌다

  • howinner 인지 확인
  • indicator=Trueleft_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가 허용되는가, 허용된다면 폭증이 정상인가

이 계약을 코드로 강제하는 가장 실용적인 도구가 validateindicator 입니다. 여기에 dtype/정규화/결측 격리까지 루틴화하면, “이상한 중복·유실”은 대부분 재현 가능하게 설명되고 해결됩니다.

추가로 데이터가 DB에서 온다면, 조인 전에 원천 테이블에서 유일성(유니크 인덱스)과 카디널리티를 확인하는 습관이 큰 도움이 됩니다. 쿼리 성능과 데이터 품질을 함께 보는 관점은 PostgreSQL 느린 쿼리, EXPLAIN ANALYZE로 인덱스 튜닝 같은 접근과도 맞닿아 있습니다.