- Published on
Pandas merge 후 NaN 폭증·행수 증가 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 테이블을 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 폭증
해결 패턴
- “이 테이블의 자연키가 무엇인지”를 먼저 정의
- 조인 후
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 폭증·행수 증가를 빠르게 잡는 순서
profile_key(left/right)로 키 결측/중복/dtype 확인merge(..., indicator=True)로 미매칭 비율 확인validate=로 카디널리티 계약을 강제해서 조기 실패시키기- 문자열 키는
strip/upper/제로폭 제거등 정규화 함수를 공통 적용 - 이벤트 로그는 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를 “그냥 붙이는 함수”로 보지 말고, 키의 계약(유일성/결측/타입)과 카디널리티를 검증하는 단계로 만들자는 것입니다. validate와 indicator를 기본값처럼 사용하면, 원인을 ‘나중에’ 찾는 대신 ‘즉시’ 잡을 수 있습니다.