- Published on
Pandas merge 후 row가 늘어나는 이유와 join 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 두 데이터프레임을 merge 했는데 결과 row 수가 원본보다 늘어나면, 대부분은 "Pandas가 이상하게 동작한다"가 아니라 조인 키의 카디널리티(cardinality)가 기대와 다르다는 신호입니다. 특히 한쪽은 유니크라고 믿었는데 실제로는 중복이 있거나, 두 개 이상의 컬럼을 키로 쓴다고 생각했는데 일부 컬럼의 결측치나 타입 불일치로 인해 의도치 않은 매칭이 발생하는 경우가 흔합니다.
이 글에서는 merge 후 row가 늘어나는 대표 원인(특히 many-to-many)과, 정확히 어느 키가 폭증을 만들었는지를 찾아내는 디버깅 루틴을 코드로 정리합니다. 데이터가 크고 파이프라인이 복잡할수록 조인 폭증은 비용 문제로 바로 이어집니다. 비슷한 맥락에서 운영 비용을 빠르게 진단하는 글로는 VPC NAT Gateway 비용 폭증 10분 진단·절감도 참고할 만합니다.
merge 후 row 수가 늘어나는 3가지 대표 원인
1) many-to-many 조인(중복 키 x 중복 키)
가장 흔한 원인입니다. 왼쪽과 오른쪽 모두에서 조인 키가 중복이면, 해당 키에 대해 데카르트 곱이 발생합니다.
- 왼쪽에 키
A가 3행 - 오른쪽에 키
A가 4행 - 결과에는 키
A가 12행
즉, 전체 row 수는 "왼쪽 row 수"나 "오른쪽 row 수"와 무관하게 증가할 수 있습니다.
2) one-to-many 또는 many-to-one인데 기대를 잘못함
예를 들어 주문 테이블(orders)에 고객 테이블(customers)을 붙이려면 일반적으로 orders.customer_id 는 중복이고 customers.customer_id 는 유니크여야 합니다.
하지만 고객 테이블이 실제로는 customer_id 가 중복(예: 이력 테이블, 멀티테넌트 키 누락, soft delete 중복)이라면, 조인은 many-to-many로 변질됩니다.
3) 조인 키의 타입/정규화 불일치로 인한 의도치 않은 매칭
- 문자열 공백, 대소문자, 제로패딩
- 숫자형과 문자열형 혼재
- 날짜 타입과 문자열 날짜 혼재
- 결측치 처리 방식 차이
이 경우 "매칭이 너무 많이"라기보다 "매칭이 너무 적게"가 흔하지만, 특정 값이 정규화 과정에서 동일해지면서 중복이 생겨 row 폭증으로 이어지기도 합니다.
최소 재현 예제: many-to-many가 만드는 row 폭증
import pandas as pd
left = pd.DataFrame({
"user_id": [1, 1, 1, 2],
"event": ["view", "click", "purchase", "view"],
})
right = pd.DataFrame({
"user_id": [1, 1, 3],
"segment": ["A", "B", "C"],
})
merged = left.merge(right, on="user_id", how="left")
print(len(left), len(merged))
print(merged)
위 예제에서 user_id=1 은 왼쪽 3행, 오른쪽 2행이므로 결과는 6행이 됩니다. 원본 left 는 4행인데 merged 는 7행이 되어 "갑자기 row가 늘었다"는 현상이 재현됩니다.
디버깅 1단계: merge 직후 indicator 로 매칭 상태 확인
indicator=True 는 조인 결과가 왼쪽만(left_only), 오른쪽만(right_only), 양쪽(both) 중 어디에 속하는지 알려줍니다. row 폭증 그 자체를 막지는 못하지만, 매칭이 어떻게 분포하는지를 빠르게 파악할 수 있습니다.
m = left.merge(right, on="user_id", how="left", indicator=True)
print(m["_merge"].value_counts(dropna=False))
both비율이 과도하게 높고, 특정 키에서 반복이 많다면 중복 키를 의심합니다.left_only가 많다면 키 정규화/타입 불일치도 점검합니다.
디버깅 2단계: validate 로 조인 카디널리티를 강제
Pandas merge 의 강력한 기능 중 하나가 validate 입니다. 기대하는 조인 형태를 명시하면, 깨졌을 때 즉시 예외를 내서 파이프라인 초기에 잡을 수 있습니다.
"one_to_one""one_to_many""many_to_one""many_to_many"
예를 들어 "오른쪽 키는 유니크"라고 기대한다면 many_to_one 이 맞습니다(왼쪽 many, 오른쪽 one).
# orders(왼쪽) many, customers(오른쪽) one 이라고 기대
merged = orders.merge(customers, on="customer_id", how="left", validate="many_to_one")
여기서 예외가 난다면, 원인은 거의 항상 customers 쪽 customer_id 중복입니다. 이런 방어는 운영에서 장애를 줄이는 방식과 유사합니다. 예를 들어 인증 실패를 조기에 감지하고 원인을 좁히는 패턴은 Spring Security JWT 401 - 키 로테이션과 JWKS 캐시 같은 글에서 다루는 접근과도 닮아 있습니다.
디버깅 3단계: 중복 키를 "정확히" 찾아내기
row 폭증을 만드는 건 대개 "특정 소수의 키"입니다. 전체를 보지 말고, 중복 키를 상위부터 뽑아 확인합니다.
왼쪽/오른쪽 각각 중복 키 TOP N
key = ["user_id"]
left_dupes = (left.groupby(key)
.size()
.sort_values(ascending=False)
.head(20))
right_dupes = (right.groupby(key)
.size()
.sort_values(ascending=False)
.head(20))
print("left dupes\n", left_dupes)
print("right dupes\n", right_dupes)
- 왼쪽만 중복이고 오른쪽 유니크면 row는 늘지 않습니다(왼쪽 row 수 유지).
- 오른쪽만 중복이면
left join에서 row가 늘어납니다. - 양쪽 모두 중복이면 폭증이 심해집니다.
조인 폭증 기여도(키별 예상 결과 row 수) 계산
many-to-many에서 결과 row 수는 키별로 left_count * right_count 의 합입니다. 이를 계산하면 "어느 키가 폭증을 만들었는지"가 바로 보입니다.
key = ["user_id"]
lc = left.groupby(key).size().rename("left_count")
rc = right.groupby(key).size().rename("right_count")
impact = (pd.concat([lc, rc], axis=1)
.fillna(0)
.assign(expected_rows=lambda d: d["left_count"] * d["right_count"])
.sort_values("expected_rows", ascending=False))
print(impact.head(20))
expected_rows 가 큰 키가 곧 "row 폭증의 주범"입니다.
디버깅 4단계: 중복의 원인을 데이터 모델 관점에서 분류
중복 키가 발견되면, 단순히 drop_duplicates 로 덮기 전에 "왜 중복이 생겼는지"를 분류해야 합니다.
1) 이력 테이블(유효기간)인데 최신 1건만 필요
예: 고객 세그먼트가 날짜별로 바뀌는 테이블에서 최신 세그먼트만 붙이려는 경우
# right: user_id 별로 updated_at 최신 1건만 사용
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")
2) 사실은 조인 키가 부족함(복합키가 필요)
예: user_id 뿐 아니라 tenant_id 도 필요하거나, country 까지 포함해야 유니크해지는 경우
# 키를 명확히 확장
merged = left.merge(right, on=["tenant_id", "user_id"], how="left", validate="many_to_one")
3) 집계 후 붙여야 하는데 원본 디테일을 그대로 붙임
예: 주문에 결제수단 통계를 붙이려는데 결제 테이블이 주문당 여러 행을 가짐
pay_agg = (payments.groupby(["order_id"])
.agg(total_paid=("amount", "sum"),
pay_cnt=("payment_id", "count"))
.reset_index())
merged = orders.merge(pay_agg, on="order_id", how="left", validate="one_to_one")
"디테일 테이블을 그대로 붙이면 row가 늘어난다"는 건 SQL 조인에서도 동일합니다. 데이터가 DB에서 온다면, 조인 전에 SQL에서 중복을 제거하거나 집계하는 편이 비용과 성능에 유리할 때가 많습니다. DB 성능 이슈를 다루는 글로는 PostgreSQL autovacuum 지연으로 팽창·슬로우쿼리 잡기도 함께 참고할 수 있습니다.
실전 체크리스트: 조인 전후로 반드시 확인할 것
1) 조인 키 결측치와 타입
for df, name in [(left, "left"), (right, "right")]:
print(name, "dtype", df["user_id"].dtype)
print(name, "nulls", df["user_id"].isna().sum())
타입이 다르면 먼저 맞추세요.
left["user_id"] = left["user_id"].astype("Int64")
right["user_id"] = right["user_id"].astype("Int64")
2) 유니크 기대치 검증(merge 전에 assert)
# 오른쪽은 유니크여야 한다는 계약
if right.duplicated(["user_id"]).any():
dup_keys = right.loc[right.duplicated(["user_id"], keep=False), "user_id"].unique()[:10]
raise ValueError(f"right has duplicate keys, sample={dup_keys}")
3) merge 결과 row 수가 늘어나는지 자동 감지
left join에서 "원래 left row 수 유지"가 기대라면, 결과 길이가 증가하는 순간을 바로 잡아야 합니다.
m = left.merge(right, on="user_id", how="left")
if len(m) != len(left):
raise ValueError(f"row exploded: left={len(left)} merged={len(m)}")
단, 업무적으로 many-to-many가 정상인 경우도 있으니(예: 태그 매핑) 이 검증은 파이프라인 목적에 맞게 조건화하세요.
결론: row 폭증은 버그가 아니라 신호다
Pandas merge 후 row 수가 늘어나는 현상의 본질은 대부분 조인 키 중복으로 인한 many-to-many 매칭입니다. 해결은 drop_duplicates 같은 사후처방보다,
validate로 조인 계약을 코드로 고정하고indicator로 매칭 분포를 확인하며- 키별
left_count * right_count로 폭증 기여도를 계산해 - 데이터 모델 관점에서 "왜 중복이 생겼는지"를 정리
하는 순서로 접근하는 것이 안전합니다.
이 루틴을 습관화하면, 작은 데이터에서는 눈치채기 어려운 조인 폭증이 대규모 배치나 분석 파이프라인에서 비용과 성능 문제로 커지기 전에 빠르게 차단할 수 있습니다.