Published on

pandas merge 후 행이 폭증할 때 - 중복키 진단법

Authors

서로 다른 DataFrame을 merge했을 뿐인데 행이 갑자기 수십 배로 늘어나는 경험은 데이터 파이프라인에서 꽤 흔합니다. 특히 로그/이벤트 테이블과 마스터 테이블을 붙이거나, 집계 전후의 데이터를 합칠 때 자주 발생합니다. 이 현상은 pandas의 버그가 아니라 **조인 키가 유일하지 않아 발생한 조합 폭발(cartesian-like expansion)**인 경우가 대부분입니다.

이 글에서는 pandas.merge가 행을 늘리는 정확한 메커니즘을 이해하고, 어떤 키가 중복인지, 어느 쪽에서 중복이 발생했는지, 중복이 의도된 1:N인지 실수로 생긴 것인지를 진단하는 방법을 단계별로 정리합니다. 이벤트 데이터에서 중복/유실을 다루는 사고방식은 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략에서 다루는 “정합성 제약을 명시적으로 검증한다”는 원칙과도 닮아 있습니다.

1) 왜 merge 후 행이 폭증하나: N×M 매칭의 원리

merge는 SQL JOIN과 동일하게 동작합니다. 키가 유일하면(예: left의 키가 1개, right도 1개) 결과는 1:1로 깔끔합니다. 하지만 키가 중복되면 다음과 같은 일이 벌어집니다.

  • left에 키 KN행 존재
  • right에 키 KM행 존재
  • 결과에는 키 K에 대해 N×M행이 생성

즉, 조인 자체가 “곱셈”을 수행합니다. 실무에서 자주 생기는 패턴은 아래와 같습니다.

  • right가 마스터라고 믿었는데 사실은 동일 키가 여러 버전(이력)으로 존재
  • left가 이벤트라고 생각했는데 중복 이벤트가 섞여 있음(재처리/중복 적재)
  • 키 컬럼의 공백/대소문자/타입 불일치로 인해 의도치 않은 키 분화 또는 합쳐짐

2) 가장 빠른 1차 점검: merge 전후 행 수와 키 유일성

먼저 “폭증”이 어느 키에서 생겼는지 보기 전에, 조인 제약을 기계적으로 확인하는 습관이 중요합니다.

import pandas as pd

# 예시 데이터
left = pd.DataFrame({
    "user_id": [1, 1, 2, 3],
    "event": ["click", "view", "click", "buy"],
})
right = pd.DataFrame({
    "user_id": [1, 1, 2],
    "segment": ["A", "B", "A"],
})

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

위 예시에서 user_id=1은 left에 2행, right에 2행이므로 결과에서 4행이 됩니다.

다음으로 각 테이블에서 조인 키가 유일한지 즉시 확인합니다.

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)
  • right_dups > 0인데 right를 “차원/마스터”로 가정했다면 거의 확정적으로 설계/데이터 품질 이슈입니다.

3) validate=로 조인 제약을 강제하라 (가장 강력한 예방책)

pandas는 merge(..., validate=...)로 조인 관계를 검증할 수 있습니다. 이 옵션을 켜면, 중복으로 인해 1:1이 깨지는 순간 즉시 예외를 던져 “조용한 폭증”을 막습니다.

# left: many, right: one 이라고 기대한다면
# (예: 이벤트 left + 유일한 유저 마스터 right)
merged = left.merge(
    right,
    on="user_id",
    how="left",
    validate="many_to_one"  # 또는 'm:1'
)

가능한 값은 다음과 같습니다.

  • "one_to_one" ("1:1")
  • "one_to_many" ("1:m")
  • "many_to_one" ("m:1")
  • "many_to_many" ("m:m")

실무 팁:

  • 대부분의 “행 폭증” 사고는 사실상 m:m 조인이 발생했는데도 눈치채지 못한 케이스입니다.
  • 파이프라인/노트북에서라도 validate를 기본값처럼 습관화하면, 문제를 가장 이른 지점에서 잡을 수 있습니다.

4) 어떤 키가 폭증을 만들었는지: 중복 키의 분포를 수치로 보기

키가 하나가 아니라 복합키인 경우(예: user_id, date)가 많습니다. 복합키의 중복 분포를 보면 “어느 값에서 N×M이 큰지”가 드러납니다.

keys = ["user_id"]

left_counts = left.groupby(keys).size().rename("left_n")
right_counts = right.groupby(keys).size().rename("right_m")

stats = pd.concat([left_counts, right_counts], axis=1).fillna(0).astype(int)
stats["expected_join_rows"] = stats["left_n"] * stats["right_m"]

# 폭증 기여도가 큰 키부터 보기
print(stats.sort_values("expected_join_rows", ascending=False).head(10))
  • expected_join_rows가 큰 키가 곧 폭증의 주범입니다.
  • left 또는 right 중 한쪽이 0이면 조인 결과에 기여하지 않습니다.

이 방식은 SQL로도 흔히 하는 “조인 전 cardinality 점검”을 pandas에서 재현한 것입니다.

5) 의도된 중복 vs 의도치 않은 중복을 구분하는 체크리스트

중복이 항상 나쁜 것은 아닙니다. 예를 들어 “주문(1) : 주문상품(N)” 같은 관계는 1:N이 정상입니다. 문제는 내가 기대한 제약과 실제 데이터가 다를 때입니다.

5.1 right가 마스터인데 중복이라면

원인 후보:

  • SCD(변경이력) 테이블인데 최신 1건만 필터링하지 않음
  • 유효기간 컬럼(valid_from, valid_to)을 조인 조건에 포함하지 않음
  • 소스가 중복 적재됨(재처리, 배치 재실행)

해결 패턴(최신 1건만 남기기):

# 예: updated_at 기준 최신 레코드만 남겨 마스터화
right_latest = (right
    .sort_values(["user_id", "updated_at"])
    .drop_duplicates(["user_id"], keep="last")
)

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

5.2 left가 이벤트인데 중복이라면

원인 후보:

  • 동일 이벤트가 여러 번 적재(Exactly-once 보장 실패)
  • 이벤트 키(event_id)가 없거나 신뢰할 수 없음

해결 패턴(이벤트 고유키로 dedup):

left_dedup = left.drop_duplicates(["event_id"], keep="first")
merged = left_dedup.merge(right, on="user_id", how="left", validate="many_to_one")

이때 “중복 제거가 맞는가?”는 도메인 의사결정입니다. 이벤트 소싱/로그 기반 시스템에서 중복과 유실을 어떻게 복구하는지의 관점은 위에서 언급한 글(Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략)의 접근이 참고가 됩니다.

6) indicator=True로 매칭 상태를 시각화하라

행 폭증과 별개로, 조인 결과가 “어느 쪽에만 존재하는지”를 확인하면 키 품질 문제(누락, 타입 불일치)가 빨리 드러납니다.

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

print(merged["_merge"].value_counts())
  • left_only가 많다면 right에 없는 키가 많음
  • right_only가 많다면 left에 없는 키가 많음
  • 기대와 다르면 조인 키 정규화(공백 제거, 타입 통일)를 의심해야 합니다.

7) 타입/정규화 문제로 인한 “가짜 중복”도 흔하다

키가 문자열/정수로 섞이거나, 공백/대소문자 차이로 인해 같은 엔티티가 서로 다른 키로 취급되면 조인 결과가 비정상적으로 커지거나(혹은 매칭이 안 되거나) 합니다.

# 예: 문자열 키 정규화
for df in (left, right):
    df["user_id"] = (df["user_id"]
        .astype(str)
        .str.strip()
        .str.lower()
    )

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

정규화는 조인 직전에 “양쪽 모두”에 적용해야 합니다. 한쪽만 바꾸면 매칭이 더 깨집니다.

8) 그래도 m:m이 필요하다면: 폭증을 통제하는 설계로 바꿔라

업무적으로 m:m 조인이 필요한 경우도 있습니다(예: 사용자-태그, 상품-카테고리). 이때는 폭증이 “정상”이므로, 다음을 고려해야 합니다.

  • 결과 행 수가 커지는 것이 비용/메모리 측면에서 감당 가능한가?
  • 조인 전에 필터링/집계로 cardinality를 줄일 수 있는가?
  • 조인 결과를 바로 집계할 거라면, 조인 대신 키별 집계 후 결합이 가능한가?

예: right에서 키별로 먼저 집계해 1:1로 만든 뒤 조인

right_agg = (right
    .groupby("user_id")
    .agg(segment_cnt=("segment", "nunique"))
    .reset_index()
)

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

이 패턴은 “조인으로 행을 늘린 뒤 다시 줄이는” 비효율을 피합니다.

9) 실전 디버깅 루틴(요약)

다음 루틴을 그대로 체크리스트로 사용하면 재현/진단 속도가 크게 올라갑니다.

  1. merge 전후 행 수 출력: len(left), len(right), len(merged)
  2. validate로 기대 제약 강제: many_to_one / one_to_one
  3. 중복 키 카운트: duplicated(keys).sum()
  4. 키별 N×M 기여도 계산: left_n * right_m 상위 키 확인
  5. indicator=True로 매칭 상태 확인
  6. 키 정규화/타입 통일
  7. 필요 시 최신 1건 필터링(SCD) 또는 사전 집계로 1:1화

데이터 조인의 폭증 문제는 본질적으로 “키의 정합성” 문제입니다. 인프라에서 인증/권한이 어긋나면 요청이 실패하듯, 데이터에서도 키 제약이 어긋나면 조인이 폭발합니다. 운영 환경에서 원인별로 체계적으로 점검하는 접근은 Kubernetes 401 Unauthorized 원인별 해결 가이드처럼 “증상→가능 원인→검증 방법”으로 분해하는 습관과도 연결됩니다.

10) 결론: merge는 결합이 아니라 ‘제약을 드러내는 테스트’다

pandas.merge 후 행이 폭증했다면, 그 자체가 중요한 신호입니다.

  • 내가 기대한 관계가 1:1 또는 1:N인데 실제 데이터는 m:m이다
  • 키가 정규화/타입 통일이 안 되어 있다
  • 마스터라고 믿은 테이블이 사실은 이력/중복을 포함한다

가장 좋은 해결책은 “사후 dedup”이 아니라, validate로 제약을 강제하고, 중복 키의 분포를 수치로 진단한 뒤, **도메인 규칙(최신 1건, 유효기간, 고유 이벤트 키 등)**을 명시적으로 코드에 반영하는 것입니다. 이렇게 하면 조인이 폭증하는 순간을 ‘디버깅 지옥’이 아니라 ‘빠른 실패(fail fast)’로 바꿀 수 있습니다.