Published on

pandas merge 후 행 폭증·NaN - 키 중복 5분 진단

Authors

서론

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)이라 매칭이 전부 실패 → segmentNaN.

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분 안에 확인해야 합니다.

핵심은 세 가지입니다.

  1. 중복 검사 + validate로 관계를 코드로 고정
  2. dtype/문자열 정규화로 매칭 실패를 제거
  3. indicator와 결측률/폭증률 지표로 품질 게이트를 자동화

이 3가지만 습관화해도, merge가 “조용히” 데이터를 망가뜨리는 사고를 크게 줄일 수 있습니다.