Published on

Pandas merge 후 NaN 폭증·행수 증가 원인 7가지

Authors

서로 다른 테이블을 pandas.merge()로 붙였는데 결과가 이상하게 커지거나(rows 폭증), 값이 대거 NaN으로 바뀌는 경험은 데이터 분석/ETL에서 매우 흔합니다. 이 현상은 “Pandas가 이상하다”기보다 조인의 수학(카디널리티)키 품질(정규화/타입/중복/결측) 문제로 설명되는 경우가 대부분입니다.

이 글에서는 실무에서 가장 자주 발생하는 NaN 폭증·행수 증가 원인 7가지를 재현 코드와 함께 정리하고, merge 전후에 반드시 넣어야 할 진단 루틴까지 다룹니다.

> 참고로 이런 류의 문제는 CI/배포 파이프라인에서도 비슷하게 나타납니다. 캐시나 상태가 꼬여 “갑자기 결과가 늘거나 사라지는” 현상을 다룬 글로는 GitLab CI 캐시 꼬임 - 빌드 완전 초기화 가이드도 함께 보면 사고방식에 도움이 됩니다.

merge 전 필수 진단 체크리스트

원인 7가지를 보기 전에, merge 직전/직후에 아래를 습관처럼 찍어두면 디버깅 속도가 크게 올라갑니다.

import pandas as pd

def profile_key(df, key_cols, name="df"):
    if isinstance(key_cols, str):
        key_cols = [key_cols]

    print(f"\n[{name}] rows={len(df):,} cols={len(df.columns)}")
    print("dtypes:")
    print(df[key_cols].dtypes)

    # 결측
    na = df[key_cols].isna().sum()
    print("na count:")
    print(na)

    # 중복(키 기준)
    dup = df.duplicated(key_cols).sum()
    print(f"duplicate keys: {dup:,}")

    # 유니크
    nunique = df[key_cols].nunique(dropna=False)
    print("nunique:")
    print(nunique)


def merge_with_audit(left, right, on, how="left"):
    # indicator로 매칭 상태 확인
    out = left.merge(right, on=on, how=how, indicator=True)
    print(out["_merge"].value_counts(dropna=False))
    return out
  • duplicate keys가 0이 아니면 행수 폭증 가능성(특히 many-to-many)
  • na count가 있으면 NaN 폭증 가능성(특히 outer/left 조인에서)
  • dtypes가 다르면 “매칭이 안 되는” NaN이 늘어나는 전형적인 패턴

또한 Pandas 1.2+에서는 validate= 옵션으로 조인 카디널리티를 강제할 수 있습니다.

out = left.merge(right, on="id", how="left", validate="one_to_one")
# one_to_one / one_to_many / many_to_one / many_to_many

이 한 줄이 실무에서 merge 사고를 가장 많이 막아줍니다.

원인 1) 조인 키 중복으로 many-to-many가 발생

가장 흔한 행수 폭증 원인입니다. 왼쪽/오른쪽 모두에 같은 키가 여러 번 나오면, 조인 결과는 곱집합처럼 늘어납니다.

재현

import pandas as pd

left = pd.DataFrame({
    "user_id": [1, 1, 2],
    "event": ["view", "click", "view"]
})

right = pd.DataFrame({
    "user_id": [1, 1, 2],
    "tier": ["A", "B", "A"]
})

m = left.merge(right, on="user_id", how="left")
print(m)
print("rows:", len(m))

user_id=1이 양쪽에 2개씩 있으니 결과는 2×2=4행이 되어 전체 행수가 늘어납니다.

해결

  • 조인 전에 “오른쪽 테이블은 user_id당 1행이어야 한다” 같은 규칙을 명확히 하고 drop_duplicates/집계로 정규화
  • validate="many_to_one" 등으로 조인 계약을 강제
right_dedup = right.sort_values("tier").drop_duplicates("user_id", keep="last")
out = left.merge(right_dedup, on="user_id", how="left", validate="many_to_one")

원인 2) 조인 키 dtype 불일치 (int vs str, timezone 포함)

1(int)과 '1'(str)은 절대 매칭되지 않습니다. 이 경우 행수는 그대로인데, 오른쪽 컬럼이 대량 NaN으로 채워집니다.

재현

left = pd.DataFrame({"id": [1, 2, 3]})
right = pd.DataFrame({"id": ["1", "2"], "score": [10, 20]})

out = left.merge(right, on="id", how="left")
print(out)

해결

  • merge 전에 dtype을 강제 통일
  • 날짜는 timezone까지 포함해 일관성 유지
left["id"] = left["id"].astype("string")
right["id"] = right["id"].astype("string")

out = left.merge(right, on="id", how="left")

시간 컬럼은 특히 위험합니다.

left["ts"] = pd.to_datetime(left["ts"], utc=True)
right["ts"] = pd.to_datetime(right["ts"], utc=True)

원인 3) 문자열 키 정규화 실패 (공백/대소문자/제로폭 문자)

겉보기엔 같은데 실제로는 다른 문자열 때문에 매칭이 안 되어 NaN이 폭증합니다.

  • 앞뒤 공백: 'abc' vs 'abc '
  • 대소문자: 'Seoul' vs 'SEOUL'
  • 보이지 않는 문자: \u200b(zero-width space)

재현

left = pd.DataFrame({"code": ["A01", "B02", "C03"]})
right = pd.DataFrame({"code": ["A01 ", "B02", "C03"], "v": [1, 2, 3]})

print(left.merge(right, on="code", how="left"))

해결

import re

def normalize_code(s: pd.Series) -> pd.Series:
    return (s.astype("string")
              .str.replace("\u200b", "", regex=False)
              .str.strip()
              .str.upper())

left["code"] = normalize_code(left["code"])
right["code"] = normalize_code(right["code"])

out = left.merge(right, on="code", how="left")

원인 4) NULL/NaN 키가 조인에 섞임 (특히 outer/left)

키 컬럼에 결측이 있으면 당연히 매칭이 어렵고, how="left"/how="outer"에서 오른쪽 값이 NaN으로 늘어납니다. 게다가 키가 여러 컬럼(composite key)일 때 한 컬럼이라도 NaN이면 매칭이 깨집니다.

진단

key = ["user_id", "date"]
print(left[key].isna().sum())
print(right[key].isna().sum())

해결

  • 조인 키 결측 행을 사전에 제거하거나
  • 비즈니스적으로 허용되는 대체 키/기본값을 채우거나
  • 결측을 별도 버킷으로 분리해 후처리
left_clean = left.dropna(subset=["user_id", "date"])
right_clean = right.dropna(subset=["user_id", "date"])

out = left_clean.merge(right_clean, on=["user_id", "date"], how="left")

> “결측이 있는 키를 그대로 두고 outer로 다 붙인 다음 NaN을 채우겠다”는 접근은 결과 해석이 매우 어려워집니다. 운영 데이터 파이프라인에서는 이런 누수가 누적되어 장애로 이어지곤 하는데, 데이터 품질 관점은 이벤트 정합성 문제를 다룬 DDD 이벤트 중복·순서꼬임? Outbox+Debezium 해법에서의 사고방식과도 통합니다.

원인 5) 조인 키 선택 실수 (부분 키/잘못된 granularity)

user_id만으로 조인해야 하는데 사실은 user_id + date가 키였거나, 반대로 너무 많은 컬럼을 키로 잡아 매칭이 안 되는 경우입니다.

  • 키를 너무 적게 잡으면: 중복 매칭 → 행수 폭증
  • 키를 너무 많이 잡으면: 매칭 실패 → NaN 폭증

해결 패턴

  1. “이 테이블의 자연키가 무엇인지”를 먼저 정의
  2. 조인 후 indicator=True로 미매칭 비율 확인
out = left.merge(right, on=["user_id"], how="left", indicator=True)
print(out["_merge"].value_counts(normalize=True))

# 미매칭이 크면 키가 부족/과잉일 가능성

그리고 가능하면 validate로 계약을 고정합니다.

out = left.merge(right, on=["user_id", "date"], how="left", validate="one_to_one")

원인 6) outer 조인/잘못된 how 선택으로 NaN이 ‘정상적으로’ 증가

how="outer"는 양쪽의 모든 키를 보존합니다. 따라서 한쪽에만 존재하는 키는 반대편 컬럼이 NaN이 되는 것이 정상 동작입니다. 문제는 이것을 “데이터가 깨졌다”고 오해하는 경우입니다.

재현

left = pd.DataFrame({"id": [1, 2], "a": [10, 20]})
right = pd.DataFrame({"id": [2, 3], "b": [200, 300]})

print(left.merge(right, on="id", how="outer"))

해결

  • 목적이 “left 기준으로 보강(enrich)”이면 how="left"
  • 목적이 “교집합만”이면 how="inner"
  • outer가 필요하다면 _merge로 출처를 명확히 남기고 후속 처리
out = left.merge(right, on="id", how="outer", indicator=True)
# left_only / right_only 행을 별도 테이블로 빼서 품질 리포트 생성
left_only = out[out["_merge"] == "left_only"]
right_only = out[out["_merge"] == "right_only"]

원인 7) 조인 전 필터/집계 타이밍 오류 (중복을 만든 뒤 merge)

“merge 전에 집계했어야 하는데 merge 후에 집계”하거나, 반대로 “집계를 했는데 그룹 기준이 틀려 중복이 남아 있는” 경우가 많습니다. 특히 이벤트 로그(다대일)를 사용자 마스터(일대일)에 붙일 때 흔합니다.

잘못된 예: 이벤트를 그대로 붙임 → 행수 폭증

users = pd.DataFrame({"user_id": [1, 2]})
events = pd.DataFrame({
    "user_id": [1, 1, 1, 2],
    "event": ["view", "click", "view", "view"],
    "ts": pd.to_datetime(["2026-01-01", "2026-01-01", "2026-01-02", "2026-01-01"])
})

out = users.merge(events, on="user_id", how="left")
print(len(out))

올바른 예: 먼저 목적에 맞게 집계 후 merge

# user_id 단위로 이벤트 수를 붙이고 싶다
agg = events.groupby("user_id").size().reset_index(name="event_cnt")

out = users.merge(agg, on="user_id", how="left", validate="one_to_one")
out["event_cnt"] = out["event_cnt"].fillna(0).astype(int)
print(out)

집계 타이밍을 잘못 잡으면 “merge가 문제”처럼 보이지만, 실제로는 데이터 모델링(팩트/디멘전션)과 granularity 문제입니다.

실무용 처방전: NaN 폭증·행수 증가를 빠르게 잡는 순서

  1. profile_key(left/right)키 결측/중복/dtype 확인
  2. merge(..., indicator=True)미매칭 비율 확인
  3. validate=카디널리티 계약을 강제해서 조기 실패시키기
  4. 문자열 키는 strip/upper/제로폭 제거정규화 함수를 공통 적용
  5. 이벤트 로그는 merge 전에 목적 단위로 집계/중복 제거

아래는 위 과정을 한 번에 적용하는 최소 템플릿입니다.

def safe_left_merge(left, right, on, *, validate="many_to_one"):
    # 1) dtype 통일(예: string)
    for c in (on if isinstance(on, list) else [on]):
        left[c] = left[c].astype("string")
        right[c] = right[c].astype("string")

    # 2) 키 결측 제거(정책에 맞게 조정)
    left2 = left.dropna(subset=(on if isinstance(on, list) else [on]))
    right2 = right.dropna(subset=(on if isinstance(on, list) else [on]))

    # 3) 조인 + 감사
    out = left2.merge(right2, on=on, how="left", validate=validate, indicator=True)
    audit = out["_merge"].value_counts(normalize=True)
    return out.drop(columns=["_merge"]), audit

마무리

merge 이후 NaN이 늘거나 행수가 폭증하는 현상은 대부분 (1) 중복 키로 인한 many-to-many, (2) dtype/정규화 불일치로 인한 매칭 실패, (3) how 선택과 집계 타이밍 오류로 설명됩니다.

핵심은 merge를 “그냥 붙이는 함수”로 보지 말고, 키의 계약(유일성/결측/타입)과 카디널리티를 검증하는 단계로 만들자는 것입니다. validateindicator를 기본값처럼 사용하면, 원인을 ‘나중에’ 찾는 대신 ‘즉시’ 잡을 수 있습니다.