Published on

pandas merge 후 행 수 폭증? 중복키 진단·해결

Authors

서로 다른 데이터프레임을 merge로 붙였는데 결과 행 수가 갑자기 수배~수십 배로 늘어나는 경험은 흔합니다. 겉으로는 “조인했을 뿐”인데, 내부적으로는 카티션 곱(중복 키의 곱셈) 이 발생해 데이터가 폭증합니다. 이 글에서는 왜 이런 일이 생기는지, 어디서 중복이 생겼는지, 그리고 의도한 1:1 / 1:N / N:1 조인으로 강제하는 방법을 단계별로 다룹니다.

특히 분석 파이프라인에서 merge 폭증은 단순 성능 문제를 넘어, 지표 왜곡(매출 중복 합산 등)과 다운스트림 오류를 만들기 때문에 조기에 잡아야 합니다. (pandas에서 경고가 조용히 지나가는 경우도 많습니다.)

관련해서 데이터프레임 조작 중 흔히 함께 터지는 문제인 SettingWithCopyWarning은 별도 글인 pandas SettingWithCopyWarning 완전 정복도 참고하면 좋습니다.

1) merge 후 행 수가 늘어나는 근본 원리

merge는 SQL의 JOIN과 동일합니다. 키가 유일(Unique)하지 않으면 조인 결과는 다음처럼 늘어납니다.

  • 왼쪽에 키 A가 3행
  • 오른쪽에 키 A가 4행
  • A로 조인하면 결과는 3×4 = 12행

즉, many-to-many 조인이 되면 행 수는 “덧셈”이 아니라 “곱셈”으로 증가합니다.

재현 예시: 의도치 않은 many-to-many

import pandas as pd

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

right = pd.DataFrame({
    "user_id": [1, 1, 1, 3],
    "tier": ["gold", "gold", "silver", "bronze"],
})

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

user_id=1이 양쪽 모두 중복이므로 1에 대한 결과가 폭증합니다.

2) 가장 먼저 해야 할 진단: merge 검증 옵션 사용

pandas는 조인 관계를 명시적으로 검증할 수 있습니다. 폭증 버그를 예방하는 가장 강력한 장치입니다.

  • validate="one_to_one" (1:1)
  • validate="one_to_many" (1:N)
  • validate="many_to_one" (N:1)
  • validate="many_to_many" (N:N)

예: “오른쪽은 유니크해야 한다”를 강제

merged = left.merge(
    right,
    on="user_id",
    how="left",
    validate="many_to_one"  # left는 중복 가능, right는 유니크여야 함
)

만약 right.user_id가 유니크가 아니면 즉시 MergeError가 발생합니다. 운영 파이프라인에서는 이 옵션을 “기본값처럼” 쓰는 습관이 매우 중요합니다.

3) 중복키 위치를 정확히 찾는 체크리스트

행 수가 폭증했다면 “어느 쪽 키가 중복인지”부터 확인해야 합니다.

3.1 키 유니크 여부 확인

key = ["user_id"]

left_dup = left.duplicated(subset=key, keep=False).sum()
right_dup = right.duplicated(subset=key, keep=False).sum()

print("left duplicated rows:", left_dup)
print("right duplicated rows:", right_dup)

3.2 어떤 키가 중복인지 Top-N으로 보기

(
    right.groupby("user_id")
         .size()
         .sort_values(ascending=False)
         .head(10)
)

3.3 조인 전후 행 수 비교 + 폭증 계수

before = len(left)
after = len(merged)
print("multiplier:", after / before)

multiplier가 1보다 크면(특히 정수가 아닌 애매한 값이면) many-to-many 가능성이 큽니다.

4) 해결 전략 1: 조인 키를 ‘정규화’해서 가짜 중복 제거

중복처럼 보이지만 사실은 데이터 품질 문제인 경우가 많습니다.

  • 공백/대소문자 차이: "abc" vs "ABC" vs "abc "
  • 타입 차이: 문자열 "001" vs 정수 1
  • 날짜/시간 단위 차이: date vs datetime

예: 문자열 키 정규화

for df in (left, right):
    df["user_id"] = df["user_id"].astype(str).str.strip()

키 정규화는 조인 성공률을 높이지만, 반대로 “원래 다른 값인데 같아져서” 중복을 만들 수도 있습니다. 따라서 정규화 후에는 반드시 validate로 관계를 확인하세요.

5) 해결 전략 2: 조인 전에 오른쪽(또는 왼쪽)을 1행/키로 축약

조인 대상 중 한쪽이 “사실상 차원 테이블(dim)”이어야 한다면, 조인 전에 키당 1행으로 만들어야 합니다.

5.1 가장 최신 레코드만 남기기(예: 프로필 스냅샷)

right_latest = (
    right.sort_values(["user_id", "updated_at"])
         .drop_duplicates(subset=["user_id"], keep="last")
)

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

5.2 집계로 축약하기(예: 주문 테이블을 사용자별 합계로)

orders = pd.DataFrame({
    "user_id": [1, 1, 2],
    "amount": [100, 150, 80]
})

orders_agg = orders.groupby("user_id", as_index=False).agg(
    total_amount=("amount", "sum"),
    order_cnt=("amount", "size")
)

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

핵심은 “조인 후에 집계”가 아니라, 의도된 그레인(grain)을 먼저 만들고 조인하는 것입니다.

6) 해결 전략 3: many-to-many가 의도라면 ‘폭증’을 통제하라

가끔은 N:N 조인이 비즈니스적으로 맞습니다. 예를 들어:

  • 사용자-태그(다대다)
  • 상품-카테고리(다대다)

이때는 폭증 자체가 정상이며, 문제는 다음입니다.

  1. 폭증이 어느 정도인지 예측/상한을 둔다
  2. 조인 후 지표 계산에서 중복 합산을 방지한다

6.1 조인 폭증 상한 체크(가드레일)

max_multiplier = 5.0
multiplier = len(merged) / len(left)

if multiplier > max_multiplier:
    raise ValueError(f"merge exploded: x{multiplier:.2f}")

6.2 중복 합산 방지: 조인 후 distinct 키로 재집계

예를 들어 사용자 이벤트에 태그를 붙인 뒤 매출을 합산하면, 태그 수만큼 매출이 중복될 수 있습니다. 이때는 원자 단위 키(예: order_id) 로 중복을 제거하거나, 조인 전에 적절히 축약해야 합니다.

# 예: order_id 기준으로 중복 제거 후 합산
safe = merged.drop_duplicates(subset=["order_id"])
revenue = safe["amount"].sum()

7) merge 결과가 이상할 때 자주 하는 실수들

7.1 조인 키를 잘못 잡은 경우(부분키 누락)

예: 사실은 (user_id, date)로 조인해야 하는데 user_id만 사용하면 날짜별 레코드가 서로 곱해집니다.

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

7.2 컬럼명이 같아서 의도치 않은 키가 사용되는 경우

on을 명시하지 않으면, 공통 컬럼 전체가 키로 사용됩니다(또는 left_on/right_on 혼동). 항상 명시적으로 쓰는 편이 안전합니다.

7.3 결측 키(NaN) 처리

pandas에서 NaN 조인 처리는 버전/옵션에 따라 기대와 다르게 느껴질 수 있습니다. 결측이 키에 존재한다면 조인 전에 제거하거나, 별도 값으로 채우는 정책을 정하세요.

left_keyed = left.dropna(subset=["user_id"])
right_keyed = right.dropna(subset=["user_id"])

8) 실전 템플릿: 안전한 merge 함수 만들기

분석/ETL 코드에서 merge가 반복된다면 “검증+로깅”을 템플릿화하는 것이 좋습니다.

import pandas as pd

def safe_merge(left: pd.DataFrame,
               right: pd.DataFrame,
               *,
               on,
               how="left",
               validate=None,
               max_multiplier=None,
               suffixes=("_x", "_y")):
    before = len(left)

    out = left.merge(
        right,
        on=on,
        how=how,
        validate=validate,
        suffixes=suffixes,
    )

    after = len(out)
    multiplier = after / before if before else float("inf")

    if max_multiplier is not None and multiplier > max_multiplier:
        # 디버깅을 위해 중복키를 같이 출력해도 좋습니다.
        raise ValueError(f"merge exploded: before={before}, after={after}, x{multiplier:.2f}")

    return out

# 사용 예
merged = safe_merge(
    left,
    right_latest,
    on=["user_id"],
    validate="many_to_one",
    max_multiplier=1.2
)

이렇게 해두면, 조인 폭증이 ‘조용한 데이터 오류’로 숨어들지 못합니다.

9) 마무리: 폭증은 버그가 아니라 ‘관계 명세’의 부재

merge 후 행 수 폭증의 90%는 “pandas가 이상하다”가 아니라, 조인 관계(1:1, 1:N, N:1, N:N)를 코드로 명시하지 않았기 때문입니다. 다음 3가지만 습관화해도 대부분의 사고를 막을 수 있습니다.

  1. validate=로 조인 관계 강제하기
  2. 조인 전 키 중복 여부를 duplicated/groupby.size()로 확인하기
  3. 필요하면 조인 전 축약(최신 1건/집계)으로 그레인 맞추기

데이터프레임 조작 과정에서 경고/부작용까지 함께 관리하려면 pandas SettingWithCopyWarning 완전 정복도 함께 읽어두면, 조인 전후 전처리 코드의 안정성이 크게 올라갑니다.