Published on

pandas merge 후 행 폭증 해결 - 키 중복·카디널리티

Authors

데이터 분석 파이프라인에서 pandas.merge 한 번으로 데이터가 깔끔히 붙을 것이라 기대했는데, 결과 행 수가 원본보다 수십 배로 폭증하는 경우가 있습니다. 이 현상은 버그가 아니라 조인 키의 중복카디널리티(관계 차수) 가 의도와 달라져서 발생하는 전형적인 증상입니다.

특히 로그/이벤트 테이블과 차원 테이블을 붙이거나, 여러 소스에서 온 식별자를 매핑 테이블로 연결할 때 자주 터집니다. 문제를 방치하면 메모리 사용량이 급증하고, 집계 결과가 조용히 왜곡됩니다. 마치 DB에서 조인 조건이 느슨해 filesort 나 임시 테이블이 폭증하는 것과 비슷한 패턴인데, 원인 규명과 제어가 핵심입니다. 관련해서 성능 관점의 사고방식은 MySQL 8 filesort·tmp table 폭증 튜닝 실전 글의 문제 접근법과도 통합니다.

왜 merge 후 행이 폭증하나: 조인의 곱셈 효과

조인은 키가 같은 행들을 서로 매칭합니다. 만약 왼쪽에 키 A 가 3행, 오른쪽에 키 A 가 4행이면 결과는 3 * 4 = 12 행이 됩니다. 즉 다대다(many-to-many) 조인은 결과 행 수가 곱셈으로 커집니다.

  • 의도: 이벤트(다수) left 에 사용자 프로필(단수) right 를 붙이는 다대일(many-to-one)
  • 현실: 프로필 테이블에도 동일 사용자 키가 여러 번 존재해서 다대다(many-to-many)

이때 폭증은 “merge가 이상하다”가 아니라 “키의 유일성이 깨졌다”는 신호입니다.

1단계: merge 전후 행 수와 키 중복을 수치로 확인

가장 먼저 해야 할 일은 “어느 키에서 곱셈이 발생했는지”를 수치로 잡는 것입니다.

import pandas as pd

# 예시 데이터
left = pd.DataFrame({
    "user_id": [1, 1, 1, 2, 3],
    "event": ["a", "b", "c", "d", "e"],
})

right = pd.DataFrame({
    "user_id": [1, 1, 2, 4],
    "country": ["KR", "US", "KR", "JP"],
})

print(len(left), len(right))
merged = left.merge(right, on="user_id", how="left")
print(len(merged))

위 예시는 user_id=1 이 양쪽에 중복되어 결과가 폭증합니다.

다음으로, 조인 키의 중복 분포를 확인합니다.

key = "user_id"

left_dup = left[key].value_counts()
right_dup = right[key].value_counts()

print("left duplicated keys:")
print(left_dup[left_dup > 1])

print("right duplicated keys:")
print(right_dup[right_dup > 1])

여기서 양쪽 모두 1 같은 키가 2 이상이면, 그 키는 결과를 곱셈으로 키웁니다.

폭증 기여도가 큰 키 찾기

어떤 키가 행 폭증을 주도하는지 “기여도”를 계산하면 빠르게 원인을 좁힐 수 있습니다.

# 조인 키별 왼쪽/오른쪽 개수
lcnt = left.groupby(key).size().rename("l")
rcnt = right.groupby(key).size().rename("r")

# inner join 기준으로 실제로 매칭되는 키만
card = pd.concat([lcnt, rcnt], axis=1).dropna()

# 각 키가 만들어내는 결과 행 수는 l*r
card["out"] = card["l"] * card["r"]

# 폭증 상위 키
print(card.sort_values("out", ascending=False).head(10))

이 표에서 out 이 큰 키가 “폭증의 주범”입니다.

2단계: 카디널리티를 명시적으로 검증하기

pandas는 조인 카디널리티를 강제/검증하는 기능을 제공합니다. merge(..., validate=...) 를 쓰면 의도와 다른 관계가 나오면 즉시 예외를 내서, 조용한 데이터 오염을 막을 수 있습니다.

  • "one_to_one": 양쪽 키 유일
  • "one_to_many": 왼쪽 유일, 오른쪽 중복 가능
  • "many_to_one": 왼쪽 중복 가능, 오른쪽 유일
  • "many_to_many": 둘 다 중복 가능(검증 없음)
# 이벤트 테이블(left)은 중복 가능, 프로필(right)은 유일해야 한다는 의도
merged = left.merge(
    right,
    on="user_id",
    how="left",
    validate="many_to_one",
)

이 코드가 실패한다면, 문제는 merge가 아니라 right의 키 유일성입니다.

3단계: 해결 전략 1 — 오른쪽 테이블을 유일 키로 정규화

가장 흔한 해결은 “조인 대상(보통 차원/매핑 테이블)”을 키 기준으로 1행만 남기도록 정규화하는 것입니다. 단, 어떤 행을 남길지 정책이 필요합니다.

(1) 최신 값만 남기기

# 예: updated_at이 가장 최신인 프로필만 유지
right2 = (
    right.sort_values("updated_at")
         .drop_duplicates(subset=["user_id"], keep="last")
)

merged = left.merge(right2, on="user_id", how="left", validate="many_to_one")

(2) 집계로 단일 행 만들기

프로필이 아니라 “사용자별 여러 속성”을 붙이는 경우, 집계로 축약합니다.

# 예: user_id별 country가 여러 개면 대표값/리스트로 축약
right2 = right.groupby("user_id", as_index=False).agg({
    "country": "first",  # 또는 lambda s: ",".join(sorted(set(s)))
})

merged = left.merge(right2, on="user_id", how="left", validate="many_to_one")

이 방식은 “다대다를 억지로 1대다로 바꾸는” 것이므로, 비즈니스적으로 타당한 축약 규칙이 있어야 합니다.

4단계: 해결 전략 2 — 조인 키를 더 구체화해 1대1 또는 1대다로 만들기

키가 너무 넓어서 중복이 생긴 경우, 조인 조건을 강화해야 합니다. 예를 들어 user_id 만으로는 프로필이 여러 버전(기간/상태별)로 존재한다면, 다음과 같이 키를 확장합니다.

  • user_id + effective_date
  • user_id + source
  • user_id + profile_type
# 복합 키 조인
keys = ["user_id", "source"]
merged = left.merge(right, on=keys, how="left", validate="many_to_one")

복합 키를 쓸 때는 결측/공백/타입 불일치가 흔한 함정입니다.

# 타입 통일(문자열/정수 혼재 방지)
left["user_id"] = left["user_id"].astype("int64")
right["user_id"] = right["user_id"].astype("int64")

# 문자열 키는 공백 제거
left["source"] = left["source"].astype("string").str.strip()
right["source"] = right["source"].astype("string").str.strip()

5단계: 해결 전략 3 — 다대다 자체가 의도라면, 결과 폭증을 ‘통제’한다

어떤 도메인에서는 다대다 조인이 정상입니다. 예를 들어 사용자와 태그, 상품과 카테고리 같은 관계는 본질적으로 다대다입니다. 이때는 폭증을 “없애는” 것이 아니라 “필요한 형태로 변환”해야 합니다.

(1) 다대다 조인 후 즉시 집계

m = left.merge(right, on="user_id", how="inner", validate="many_to_many")

# 예: user_id별 태그 개수만 필요
out = m.groupby("user_id", as_index=False).agg(tag_cnt=("country", "nunique"))

(2) 브릿지 테이블을 별도로 유지

분석 모델링 관점에서 다대다를 한 번에 평탄화하면 폭증이 불가피합니다. 브릿지 테이블(관계 테이블)을 분리해 두고, 필요한 시점에만 조인하거나 집계해서 가져오는 편이 안전합니다.

6단계: “겉보기 중복”과 “진짜 중복” 구분하기

키가 중복처럼 보여도 실제로는 다른 행일 수 있습니다. 예를 들어 오른쪽 테이블에 user_id 는 같지만 status 가 다르거나 기간이 겹치지 않는 경우입니다. 이때 단순 drop_duplicates 는 데이터 손실로 이어집니다.

다음 체크리스트로 중복의 성격을 구분하세요.

  • 동일 키에서 다른 컬럼이 실제로 다른가
  • 기간 컬럼이 있고, 기간이 겹치는가
  • 소스 시스템이 다른데 하나로 합쳐 조인하고 있지 않은가
# 동일 user_id에서 어떤 컬럼이 다른지 빠르게 확인
cols = ["user_id", "country"]
conflicts = right.groupby("user_id")["country"].nunique()
print(conflicts[conflicts > 1].head(20))

7단계: 실무에서 자주 터지는 원인 5가지

1) 키 타입 불일치로 인한 예기치 않은 매칭/미매칭

정수 1 과 문자열 "1" 이 섞이면 조인이 비정상적으로 비거나, 반대로 전처리 과정에서 모두 문자열로 바꾸며 앞자리 0 이 사라져 다른 키와 합쳐지는 문제가 납니다.

2) 트레일링 스페이스, 대소문자, 정규화 누락

"abc""abc " 는 다른 키입니다. 전처리로 통일하세요.

3) 매핑 테이블이 사실상 이력 테이블인데 최신만 쓰지 않음

이력 테이블을 그대로 조인하면 다대다가 됩니다. 최신/유효기간 조건을 반영해야 합니다.

4) 조인 키가 너무 추상적

email 같은 키는 중복될 수 있습니다(공용 메일, 변경 이력). 가능하면 불변 식별자 사용.

5) 사전 필터링이 누락됨

오른쪽 테이블에서 특정 상태만 조인해야 하는데 전체를 붙여 폭증.

right_active = right[right["status"] == "ACTIVE"]
merged = left.merge(right_active, on="user_id", how="left", validate="many_to_one")

8단계: 디버깅 루틴(템플릿) — 폭증을 재현 가능하게 만들기

아래 루틴을 습관처럼 적용하면 “원인 추정”이 아니라 “원인 확정”으로 접근할 수 있습니다.

def diagnose_merge(left, right, key, how="left"):
    import pandas as pd

    if isinstance(key, str):
        key = [key]

    print("rows left/right:", len(left), len(right))

    # 중복 키 개수
    ldup = left.duplicated(subset=key).sum()
    rdup = right.duplicated(subset=key).sum()
    print("duplicated left/right:", ldup, rdup)

    # 카디널리티 테이블(키별 곱셈)
    lcnt = left.groupby(key).size().rename("l")
    rcnt = right.groupby(key).size().rename("r")
    card = pd.concat([lcnt, rcnt], axis=1).dropna()
    card["out"] = card["l"] * card["r"]
    print("top exploding keys:")
    print(card.sort_values("out", ascending=False).head(10))

    merged = left.merge(right, on=key, how=how)
    print("merged rows:", len(merged))
    return merged

이 함수로 “폭증 상위 키”를 바로 뽑고, 해당 키만 필터링해서 샘플을 눈으로 확인하면 해결 속도가 급격히 빨라집니다.

9단계: 예방 — validate 와 테스트를 파이프라인 기본값으로

행 폭증은 대부분 데이터 품질 이슈이므로, 한 번 해결해도 다시 재발할 수 있습니다. 예방책은 간단합니다.

  • merge에는 기본적으로 validate 를 붙인다
  • 차원/매핑 테이블 로딩 직후 drop_duplicates 가 아니라 “유일성 검증”을 한다
  • 배치/파이프라인에 행 수 변화율 알람을 둔다

Git에서 rebase 후 커밋이 중복되는 문제도 “중복이 생길 수 있는 구조를 미리 검증하지 않아서” 터지는 경우가 많습니다. 데이터 조인도 마찬가지로, 사후 수습보다 사전 검증이 훨씬 싸게 먹힙니다. 참고로 유사한 원인 규명 방식은 Git rebase 후 PR에 커밋이 중복될 때 원인·복구 글의 접근과도 닮아 있습니다.

마무리

pandas.merge 후 행이 폭증하면, 거의 항상 답은 키 중복으로 인한 다대다 조인입니다. 해결은 크게 세 가지 축으로 정리됩니다.

  1. 카디널리티를 수치로 진단하고 주범 키를 찾기
  2. validate 로 의도한 관계를 강제하기
  3. 유일 키 정규화, 키 구체화, 또는 다대다를 인정하고 즉시 집계/모델링하기

이 과정을 루틴화하면, 행 폭증은 “가끔 터지는 미스터리”가 아니라 “재현 가능하고 통제 가능한 품질 이슈”가 됩니다.