Published on

pandas merge 후 행 수 폭증 원인 7가지 진단법

Authors

서로 다른 두 DataFrame을 merge했는데 결과 행 수가 갑자기 수배~수십 배로 늘어나면, 대부분은 조인 키의 유일성(uniqueness) 붕괴 또는 조인 조건의 비의도적 확장에서 시작합니다. 문제는 “왜 늘었는지”를 모르면 임시로 drop_duplicates() 같은 처방을 하게 되고, 이는 데이터 품질을 더 악화시킬 수 있다는 점입니다.

이 글에서는 pandas.merge()에서 행 수가 폭증하는 대표 원인 7가지를 재현 코드 + 진단 쿼리 + 해결 전략으로 정리합니다. (행 폭증은 메모리 사용량도 급격히 키우므로, 대용량에서 프로세스가 죽는다면 리눅스 OOM Killer로 프로세스 죽을 때 진단법도 함께 참고하세요.)


사전 점검: “정상” 행 수 기대치부터 계산하기

조인 결과 행 수는 대략 다음과 같이 결정됩니다.

  • 1:1 조인: 결과 행 수 ≈ 기준(left) 행 수 (inner 기준은 매칭되는 것만)
  • 1:N 조인: 결과 행 수 ≈ 기준(left) 행 수 × 평균 매칭 수
  • M:N 조인: 결과 행 수가 곱셈 수준으로 폭증 가능

따라서 먼저 “키가 유일한가”를 확인해야 합니다.

import pandas as pd

# merge 전 기본 정보
print(len(left), len(right))

# 키 유일성 체크
key = ['user_id']
print('left dup keys:', left.duplicated(key).sum())
print('right dup keys:', right.duplicated(key).sum())

# 키별 카디널리티(매칭 수 분포) 확인
print(left.groupby(key).size().describe())
print(right.groupby(key).size().describe())

또한 pandas에는 조인 관계를 강제/검증하는 validate 옵션이 있습니다.

out = left.merge(right, on='user_id', how='inner', validate='one_to_one')
# one_to_many, many_to_one, many_to_many 도 가능

validate가 실패하면 “왜 폭증하는지”를 찾을 단서가 바로 생깁니다.


1) 양쪽 키가 중복되어 M:N 조인이 발생

가장 흔한 원인입니다. 예를 들어 주문 테이블과 이벤트 테이블을 user_id로만 조인하면, 사용자별로 주문도 여러 개, 이벤트도 여러 개인 경우 M×N으로 늘어납니다.

재현

left = pd.DataFrame({
    'user_id': [1, 1, 2],
    'order_id': ['o1', 'o2', 'o3']
})
right = pd.DataFrame({
    'user_id': [1, 1, 1, 2],
    'event': ['a', 'b', 'c', 'd']
})

out = left.merge(right, on='user_id', how='inner')
print(out)
print('rows:', len(out))

진단

k = ['user_id']
left_cnt = left.groupby(k).size().rename('left_n')
right_cnt = right.groupby(k).size().rename('right_n')
card = left_cnt.to_frame().join(right_cnt, how='inner')
card['product'] = card['left_n'] * card['right_n']
print(card.sort_values('product', ascending=False).head(10))

해결

  • 조인 키를 더 구체화(예: user_id + date, user_id + session_id)
  • 한쪽을 집계(예: 이벤트는 사용자별 최신 1건만)
  • 비즈니스 규칙상 1건이어야 한다면, 조인 전 유일성 강제
right_latest = (right.sort_values('event_time')
                   .drop_duplicates(['user_id'], keep='last'))
out = left.merge(right_latest, on='user_id', how='left', validate='many_to_one')

2) 조인 키 선택이 잘못됨(부분 키, 누락된 키)

원래는 ['user_id', 'order_date']로 조인해야 하는데 user_id만 넣으면, 서로 다른 날짜의 레코드가 모두 섞이며 폭증합니다.

진단 포인트

  • “조인 후 중복이 늘어난 컬럼”을 찾으면 누락된 키가 드러납니다.
# 조인 후 order_id가 여러 번 반복되는지 확인
out = left.merge(right, on='user_id', how='inner')
print(out['order_id'].value_counts().head())

해결

  • 실제 엔티티를 유일하게 만드는 키를 조인 조건에 포함
out = left.merge(right, on=['user_id', 'order_date'], how='inner', validate='one_to_one')

3) 키 컬럼의 타입/표현 불일치로 “의도치 않은 매칭”이 발생

보통 타입 불일치는 매칭이 줄어드는 방향(누락)으로 나타나지만, 다음 케이스에서는 폭증이 가능합니다.

  • 문자열 전처리 과정에서 키가 과도하게 정규화됨 (예: 앞자리만 남김)
  • 숫자형을 문자열로 바꾸며 자리수/포맷이 붕괴
  • 카테고리/코드 값에서 공백 제거가 잘못되어 서로 다른 값이 동일해짐

진단

print(left['key'].map(type).value_counts())
print(right['key'].map(type).value_counts())

# 정규화 전/후 유니크 수 비교
print(left['key'].nunique(), left['key_norm'].nunique())

해결

  • 정규화는 “동치여야 하는 값만” 동치가 되도록 최소화
  • 조인 전에 키의 분포/유니크 수를 비교
left['key'] = left['key'].astype('string').str.strip()
right['key'] = right['key'].astype('string').str.strip()

# 정규화 후에도 유니크가 과도하게 줄지 확인
assert left['key'].nunique() > 0.8 * len(left['key'].unique())

4) 결측치(NaN/None) 키가 대량으로 존재하고, 이를 특정 값으로 채워 조인

pandas의 merge는 기본적으로 NaN끼리도 매칭되는 동작이 관찰될 수 있고(버전/상황에 따라 차이), 많은 팀이 이를 피하려고 fillna('UNKNOWN') 같은 처리를 합니다. 문제는 결측이 많은 두 테이블에서 동일한 더미 값으로 채우면, 해당 더미 값이 초대형 M:N 조인을 만들어 폭증합니다.

재현

left = pd.DataFrame({'k': [None, None, 'A'], 'v1': [1, 2, 3]})
right = pd.DataFrame({'k': [None, None, 'A'], 'v2': [10, 20, 30]})

out = (left.fillna('UNKNOWN')
          .merge(right.fillna('UNKNOWN'), on='k'))
print(out)

진단

print(left['k'].isna().mean(), right['k'].isna().mean())

해결

  • 결측 키는 조인 대상에서 제외하거나(필터링)
  • 결측을 채우더라도 좌/우에 서로 다른 더미 값을 넣어 매칭을 막거나
  • 결측을 키가 아닌 별도 처리 로직으로 분리
left_nonnull = left[left['k'].notna()]
right_nonnull = right[right['k'].notna()]

out = left_nonnull.merge(right_nonnull, on='k', how='inner', validate='many_to_one')

5) how='outer' 또는 예상치 못한 조인 타입으로 “행이 늘어나는 것이 정상”인 경우

outer는 양쪽의 모든 키를 포함하므로, 매칭이 적으면 left/right에만 존재하는 행이 그대로 추가됩니다. 폭증처럼 보이지만 사실은 설계대로일 수 있습니다.

진단

out = left.merge(right, on='k', how='outer', indicator=True)
print(out['_merge'].value_counts())

해결

  • 목적이 “left를 보강”이면 보통 how='left'
  • 목적이 “교집합”이면 how='inner'
  • indicator=True로 어느 쪽에서 유입됐는지 항상 확인
out = left.merge(right, on='k', how='left', indicator=True)

6) 조인 전 중복 행이 이미 존재(업스트림 중복, append 누적, 잘못된 explode)

merge 자체가 문제가 아니라, merge 이전 단계에서 이미 데이터가 중복되어 있으면 조인 시 그 중복이 그대로 증폭됩니다.

대표 패턴:

  • 배치가 같은 데이터를 재적재해서 동일 키가 중복
  • concatignore_index=True로만 끝내고 중복 제거/검증 누락
  • 리스트 컬럼을 explode한 뒤, 원복 키를 제대로 유지하지 못함

진단

# 완전 중복 행
print('left full dup rows:', left.duplicated().sum())
print('right full dup rows:', right.duplicated().sum())

# 키 기준 중복
print('left key dup:', left.duplicated(['k']).sum())
print('right key dup:', right.duplicated(['k']).sum())

해결

  • 업스트림에서 중복 제거 기준을 명확히(키 + 최신시간 등)
  • 조인 직전 validate로 관계를 고정
left = left.sort_values('updated_at').drop_duplicates(['k'], keep='last')
out = left.merge(right, on='k', how='left', validate='one_to_one')

7) 조인 키에 “시간/구간” 조건이 필요한데 동등(=) 조인만 수행

로그/이벤트/가격/상태 이력처럼 “유효기간”이 있는 테이블은 k만으로는 매칭이 결정되지 않습니다.

예:

  • 사용자 상태 이력(valid_from, valid_to)과 주문 시점(order_time)을 매칭해야 함
  • 환율 테이블을 날짜로만 붙이면, 시간대/장중 구간이 필요한데 하루 전체가 매칭되어 늘어남

진단

  • 조인 후 한 행이 여러 이력 레코드에 매칭되는지 확인
out = orders.merge(status_hist, on='user_id', how='left')
print(out.groupby('order_id').size().sort_values(ascending=False).head(10))

해결

  • pandas의 단순 merge로는 “구간 조인(range join)”이 어렵기 때문에,
    • 사전에 이력을 스냅샷 테이블로 변환하거나
    • merge_asof(정렬 기반 근접 조인) + 추가 필터로 해결
# 상태 이력을 시작 시점 기준으로 정렬하고, 주문 시점 이전의 가장 최근 상태를 붙임
orders2 = orders.sort_values('order_time')
status2 = status_hist.sort_values('valid_from')

out = pd.merge_asof(
    orders2,
    status2,
    left_on='order_time',
    right_on='valid_from',
    by='user_id',
    direction='backward'
)

# valid_to가 있다면 주문 시간이 유효기간 내인지 추가 필터
out = out[(out['valid_to'].isna()) | (out['order_time'] < out['valid_to'])]

실전 체크리스트: 폭증을 “원인별로” 바로 잡는 순서

  1. len(left), len(right), len(out)를 기록하고 증가 배율을 계산
  2. validate=로 조인 관계를 선언해 즉시 실패시키기(one-to-one 등)
  3. 키 중복 수: duplicated(key).sum()
  4. 키별 매칭 곱(product) 상위 10개를 뽑아 “폭증 유발 키”를 특정
  5. 결측 키 비율 확인 + fillna로 더미 매칭을 만들었는지 점검
  6. indicator=True로 outer/left/right 유입 비율 확인
  7. 이력/구간 데이터면 merge_asof 또는 스냅샷 설계로 전환

대용량에서 폭증이 발생하면 메모리 사용량이 급격히 증가해 커널이 프로세스를 종료할 수 있습니다. 실제로 OOM이 의심되면 리눅스 OOM Killer로 프로세스 죽음 진단·방지에서 dmesg/cgroup 기준으로 원인을 먼저 확정하는 것이 좋습니다.


결론: drop_duplicates()로 덮지 말고 “카디널리티”를 고정하라

merge 후 행 수 폭증의 본질은 대부분 조인 카디널리티(1:1, 1:N, M:N) 제어 실패입니다. 해결의 핵심은 아래 두 가지입니다.

  • 조인 전: 키 유일성, 결측, 전처리(정규화)로 인해 키가 붕괴하지 않았는지 검증
  • 조인 시: validate로 관계를 선언하고, 이력/구간 문제는 동등 조인이 아닌 적절한 조인 전략(merge_asof, 스냅샷)으로 설계

이 원칙만 지켜도 “왜 이렇게 늘었지?”라는 디버깅 시간을 크게 줄이고, 데이터 품질까지 함께 지킬 수 있습니다.