Published on

pandas merge에서 NaN 폭증? 키 dtype 불일치 해결

Authors

pandas에서 merge를 했는데 결과 컬럼이 NaN으로 가득 차는 경우가 있습니다. 보통은 조인 자체가 실패했다기보다, 키가 “같아 보이는데 실제로는 다른 값” 으로 취급되어 매칭이 거의 일어나지 않은 상황입니다. 이때 가장 흔한 원인이 조인 키의 dtype 불일치입니다.

예를 들어 왼쪽은 숫자 123이고 오른쪽은 문자열 "123"이면, 눈으로는 동일해 보여도 pandas는 서로 다른 값으로 보고 매칭하지 않습니다. 그 결과 left 조인에서는 오른쪽 컬럼들이 대량으로 NaN이 되고, inner 조인에서는 행이 급감합니다.

이 글에서는 mergeNaN이 폭증할 때 가장 먼저 의심해야 할 dtype 문제를 빠르게 진단하고, 안전하게 해결하는 정규화 패턴을 코드로 정리합니다. (추가로 조인 자체가 잘못되어 행이 늘어나는 문제는 별도 글인 Pandas merge 후 row가 늘어나는 이유와 join 디버깅도 함께 참고하면 좋습니다.)

증상: merge는 됐는데 NaN이 폭증한다

대표적인 증상은 다음과 같습니다.

  • how="left"로 조인했는데, 오른쪽에서 가져온 컬럼이 대부분 NaN
  • how="inner"로 조인했더니 결과 행 수가 예상보다 크게 줄어듦
  • 키 값이 겉보기에는 동일한데 매칭률이 낮음

아래 예시는 흔한 실수(숫자 키 vs 문자열 키)로 인해 매칭이 거의 0이 되는 상황입니다.

import pandas as pd

left = pd.DataFrame({
    "user_id": [101, 102, 103],
    "event": ["view", "click", "purchase"],
})

right = pd.DataFrame({
    "user_id": ["101", "102"],
    "country": ["KR", "JP"],
})

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

이 경우 country는 대부분 NaN이 됩니다. 이유는 left.user_idint64, right.user_idobject(문자열)이라 키가 매칭되지 않기 때문입니다.

원인 1순위: 조인 키 dtype 불일치

merge는 기본적으로 키 컬럼의 dtype과 값이 정확히 일치해야 매칭됩니다. 다음 케이스가 특히 자주 터집니다.

  • int64 vs object(문자열)
  • float64(예: 101.0) vs int64(예: 101)
  • datetime64[ns] vs 문자열 날짜
  • pandas의 nullable 정수 Int64 vs 일반 int64 자체는 보통 문제를 덜 일으키지만, 결측 처리 과정에서 문자열로 섞이면 망가짐
  • 앞뒤 공백, 제로 패딩(예: "00123" vs "123") 등 “값 정규화” 문제

10초 진단: dtype과 샘플 값을 동시에 확인

key = "user_id"

print("left dtype:", left[key].dtype)
print("right dtype:", right[key].dtype)

print("left sample:", left[key].head().tolist())
print("right sample:", right[key].head().tolist())

dtype만 보지 말고 실제 값 형태도 같이 봐야 합니다. 예를 들어 오른쪽이 object인데 내부 값은 숫자 문자열인지, 공백이 섞였는지, None이 있는지에 따라 처방이 달라집니다.

진짜로 얼마나 매칭되는지 측정하기

NaN 폭증을 “감”으로 보지 말고, 매칭률을 수치로 확인하면 원인 파악이 빨라집니다.

left join에서 매칭률 계산

out = left.merge(right, on="user_id", how="left", indicator=True)

match_rate = (out["_merge"] == "both").mean()
print("match_rate:", match_rate)

print(out["_merge"].value_counts(dropna=False))
  • both 비율이 낮으면 매칭 실패가 많다는 뜻입니다.
  • dtype 불일치면 보통 both가 0에 가깝게 나옵니다.

매칭 실패한 키를 바로 뽑기

missing_keys = out.loc[out["_merge"] != "both", "user_id"].drop_duplicates()
print(missing_keys.head(20))

이 리스트를 보면 공백, 제로 패딩, 소수점 .0 같은 패턴이 보이는 경우가 많습니다.

해결 전략: 키를 “의미 기준”으로 정규화하라

중요한 원칙은 하나입니다.

  • 키가 식별자(ID) 라면 문자열로 맞추는 편이 안전한 경우가 많습니다.
  • 키가 수치 연산 대상이라면 숫자로 맞추되, 결측과 변환 실패를 엄격히 처리해야 합니다.

아래는 실무에서 재사용하기 좋은 정규화 패턴들입니다.

패턴 1: ID는 문자열로 통일 (공백 제거 포함)

가장 흔한 처방입니다. 특히 사용자 ID, 주문 ID, SKU 같은 값은 숫자처럼 보여도 문자열로 관리하는 게 안전합니다(제로 패딩, 매우 큰 정수, 외부 시스템 포맷 등).

key = "user_id"

left2 = left.copy()
right2 = right.copy()

left2[key] = left2[key].astype("string").str.strip()
right2[key] = right2[key].astype("string").str.strip()

out2 = left2.merge(right2, on=key, how="left")
print(out2)
  • astype("string")은 pandas의 문자열 dtype으로, None도 비교적 일관되게 다루기 좋습니다.
  • str.strip()은 보이지 않는 공백 문제를 제거합니다.

제로 패딩이 필요한 경우

예: 오른쪽은 "00123"인데 왼쪽은 123이라면, 길이를 맞춰야 합니다.

key = "product_code"
width = 5

left2[key] = left2[key].astype("string").str.strip().str.zfill(width)
right2[key] = right2[key].astype("string").str.strip().str.zfill(width)

패턴 2: 숫자 키로 통일 (실패는 NaN으로 보내고 검증)

정말 숫자 의미가 있는 키(예: 연도, 회차, 정수 코드)라면 숫자로 통일할 수 있습니다. 다만 문자열이 섞여 있거나 공백이 있으면 변환 실패가 생기므로, to_numeric으로 안전 변환 후 검증하는 게 좋습니다.

key = "user_id"

left2 = left.copy()
right2 = right.copy()

left2[key] = pd.to_numeric(left2[key], errors="coerce")
right2[key] = pd.to_numeric(right2[key], errors="coerce")

# 변환 실패(결측) 확인
print("left invalid:", left2[key].isna().sum())
print("right invalid:", right2[key].isna().sum())

out2 = left2.merge(right2, on=key, how="left")

errors="coerce"는 변환 불가 값을 NaN으로 바꿉니다. 이때 변환 실패가 많다면, 단순 dtype 문제가 아니라 원천 데이터 품질 문제일 수 있습니다.

패턴 3: datetime 키 정규화 (문자열 날짜 vs datetime)

날짜 조인에서 NaN 폭증은 흔합니다. 한쪽은 datetime64[ns]인데 다른 쪽은 문자열이면 거의 매칭이 안 됩니다.

key = "dt"

left2 = left.copy()
right2 = right.copy()

left2[key] = pd.to_datetime(left2[key], errors="coerce", utc=True)
right2[key] = pd.to_datetime(right2[key], errors="coerce", utc=True)

# timezone이 섞이면 비교가 깨질 수 있으니 utc로 통일하거나 tz 제거
left2[key] = left2[key].dt.tz_convert("UTC").dt.tz_localize(None)
right2[key] = right2[key].dt.tz_convert("UTC").dt.tz_localize(None)

out2 = left2.merge(right2, on=key, how="left")
  • utc=True로 파싱 기준을 통일하면 “같은 시각인데 타임존 표현이 달라서 불일치”하는 문제를 줄일 수 있습니다.
  • 최종적으로 tz-aware와 tz-naive가 섞이지 않게 정리해야 합니다.

패턴 4: 조인 전에 키 유니크/중복을 점검해 NaN 원인을 분리

NaN 폭증의 원인이 dtype인지, 아니면 단순히 오른쪽에 키가 없어서인지 구분하려면, 조인 전에 키 집합을 비교해보는 게 빠릅니다.

key = "user_id"

# dtype 정규화는 먼저 해두는 게 좋음
l = left[key].astype("string").str.strip()
r = right[key].astype("string").str.strip()

l_set = set(l.dropna().unique())
r_set = set(r.dropna().unique())

only_left = list(l_set - r_set)
only_right = list(r_set - l_set)

print("only_left count:", len(only_left))
print("only_right count:", len(only_right))
print("only_left sample:", only_left[:10])
  • only_left가 매우 많다면, 오른쪽 테이블에 없는 키가 많거나 정규화가 아직 부족한 것입니다.
  • dtype을 맞춘 뒤에도 only_left가 많다면, 데이터 소스 자체의 커버리지 문제일 수 있습니다.

실전 체크리스트: NaN 폭증 시 바로 보는 7가지

  1. 키 dtype 확인: df[key].dtype
  2. 키 샘플 확인: head()로 공백, .0, 제로 패딩 패턴 확인
  3. indicator=True로 매칭률 수치화
  4. astype("string").str.strip()로 1차 정규화 후 재시도
  5. 숫자 키라면 to_numeric(errors="coerce") 후 변환 실패 건수 확인
  6. 날짜 키라면 to_datetime 및 tz 통일
  7. 조인 실패 키 목록을 뽑아 원천 데이터 품질 문제인지 확인

재사용 가능한 유틸: 키 정규화 함수 예시

프로젝트에서 반복된다면 정규화 로직을 함수로 고정해두는 편이 안전합니다.

import pandas as pd

def normalize_join_key(s: pd.Series) -> pd.Series:
    # ID 성격의 키를 문자열로 통일하는 보편적 버전
    # 1) pandas StringDtype으로
    # 2) 앞뒤 공백 제거
    # 3) 빈 문자열은 결측으로
    s = s.astype("string").str.strip()
    s = s.replace({"": pd.NA})
    return s

key = "user_id"
left2 = left.copy()
right2 = right.copy()

left2[key] = normalize_join_key(left2[key])
right2[key] = normalize_join_key(right2[key])

out = left2.merge(right2, on=key, how="left", indicator=True)
print(out["_merge"].value_counts())

이렇게 해두면, “어떤 테이블은 int, 어떤 테이블은 string” 같은 흔한 혼종을 조인 단계에서 빠르게 정리할 수 있습니다.

마무리: NaN 폭증은 조인 실패의 신호, dtype부터 의심

merge 결과에 NaN이 갑자기 늘었다면, 우선 조인 조건이 틀렸다고 단정하기보다 키 dtype과 값 정규화 문제를 먼저 확인하는 게 가장 빠른 해결책입니다. 특히 ID 컬럼은 문자열로 통일하고 공백 제거만 해도 매칭률이 극적으로 회복되는 경우가 많습니다.

추가로, NaN 폭증과는 반대로 조인 후 행 수가 예상보다 늘어나는 문제(키 중복으로 인한 row explosion)까지 함께 점검하려면 Pandas merge 후 row가 늘어나는 이유와 join 디버깅도 같이 읽어보면 전체 조인 디버깅 관점이 정리됩니다.