- Published on
pandas merge 후 행 수 폭증 원인 7가지 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 두 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 이전 단계에서 이미 데이터가 중복되어 있으면 조인 시 그 중복이 그대로 증폭됩니다.
대표 패턴:
- 배치가 같은 데이터를 재적재해서 동일 키가 중복
concat후ignore_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'])]
실전 체크리스트: 폭증을 “원인별로” 바로 잡는 순서
len(left), len(right), len(out)를 기록하고 증가 배율을 계산validate=로 조인 관계를 선언해 즉시 실패시키기(one-to-one 등)- 키 중복 수:
duplicated(key).sum() - 키별 매칭 곱(product) 상위 10개를 뽑아 “폭증 유발 키”를 특정
- 결측 키 비율 확인 +
fillna로 더미 매칭을 만들었는지 점검 indicator=True로 outer/left/right 유입 비율 확인- 이력/구간 데이터면
merge_asof또는 스냅샷 설계로 전환
대용량에서 폭증이 발생하면 메모리 사용량이 급격히 증가해 커널이 프로세스를 종료할 수 있습니다. 실제로 OOM이 의심되면 리눅스 OOM Killer로 프로세스 죽음 진단·방지에서 dmesg/cgroup 기준으로 원인을 먼저 확정하는 것이 좋습니다.
결론: drop_duplicates()로 덮지 말고 “카디널리티”를 고정하라
merge 후 행 수 폭증의 본질은 대부분 조인 카디널리티(1:1, 1:N, M:N) 제어 실패입니다. 해결의 핵심은 아래 두 가지입니다.
- 조인 전: 키 유일성, 결측, 전처리(정규화)로 인해 키가 붕괴하지 않았는지 검증
- 조인 시:
validate로 관계를 선언하고, 이력/구간 문제는 동등 조인이 아닌 적절한 조인 전략(merge_asof, 스냅샷)으로 설계
이 원칙만 지켜도 “왜 이렇게 늘었지?”라는 디버깅 시간을 크게 줄이고, 데이터 품질까지 함께 지킬 수 있습니다.