PySpark 조인 완전 마스터 — semi, anti, null-safe, 그리고 함정들
inner/outer 를 넘어 semi·anti join 으로 필터링을 효율화하고, null-safe 조인으로 NULL 키 버그를 잡고, 다중 키·비등가 조인·중복 폭발·컬럼 모호성 같은 실전 함정을 피하는 법을 PySpark 코드로 정리합니다.
조인은 데이터 처리의 절반입니다. 그런데 많은 사람이 inner/left 만 쓰고, semi/anti 의 효율도, null-safe 조인의 필요성도 모른 채 미묘한 버그와 성능 문제를 만듭니다. "존재 여부만 확인하려고 조인했더니 행이 두 배가 됐다"거나 "NULL 키 때문에 결과가 틀어졌다" 같은 일이 흔합니다.
이 글은 PySpark 의 모든 조인 타입을 용도별로 정리하고, 실전에서 자주 부딪히는 함정 — 중복 폭발, 컬럼 모호성, NULL 키 — 을 피하는 법을 다룹니다.
1. 조인 타입 전체 지도
a.join(b, "key", how="inner") # 기본how | 반환 | 용도 |
|---|---|---|
inner | 양쪽 매칭 | 일반 조인 |
left(left_outer) | 왼쪽 전부 + 매칭 | 보강(enrichment) |
right | 오른쪽 전부 + 매칭 | (드묾) |
full(full_outer) | 양쪽 전부 | 양쪽 비교 |
left_semi | 왼쪽 중 매칭되는 행만 | 존재 필터(EXISTS) |
left_anti | 왼쪽 중 매칭 안 되는 행만 | 부재 필터(NOT EXISTS) |
cross | 카테시안 곱 | (위험, 명시 필요) |
2. Semi Join — 존재 확인을 효율적으로
"주문이 있는 고객만"처럼 존재 여부로 필터할 때, inner join 을 쓰면 오른쪽 컬럼이 붙고 중복이 생깁니다(한 고객이 주문 5건이면 5행). left_semi 는 왼쪽 행만, 중복 없이, 매칭되는 것만 반환합니다.
# ❌ inner join: 주문 수만큼 고객이 중복됨 + 불필요한 컬럼
customers.join(orders, "cust_id") # 고객이 주문 건수만큼 늘어남
# ✅ left_semi: 주문 있는 고객만, 중복 없이, 고객 컬럼만
customers.join(orders, "cust_id", "left_semi")left_semi = "오른쪽에 매칭이 존재하는 왼쪽 행" (오른쪽 컬럼 안 붙음, 중복 없음)SQL 의 WHERE EXISTS (...) 와 같고, IN 서브쿼리보다 명확하고 효율적입니다.
3. Anti Join — 부재 확인 (차집합)
"주문이 없는 고객", "마스터에 없는 신규 키" — 부재 필터는 left_anti 가 정답입니다.
# 주문 이력이 없는 고객
inactive = customers.join(orders, "cust_id", "left_anti")
# 신규 키 찾기 (타깃에 아직 없는 소스 행) — 백필·증분에 유용
new_records = source.join(target, "id", "left_anti")left_anti = "오른쪽에 매칭이 없는 왼쪽 행" = 차집합(왼쪽 - 오른쪽)anti join 은 백필·증분 적재에서 "아직 없는 것만 추리기"에 자주 쓰입니다(별도 글 "PySpark 대규모 중복 제거와 SCD Type 2"의 신규 키 판별).
| 목적 | 안티패턴 | 정답 |
|---|---|---|
| 존재하는 것만 | inner(중복) | left_semi |
| 없는 것만 | left join + null 필터 | left_anti |
4. 함정 ① NULL 키 — null-safe 조인
일반 조인에서 NULL = NULL 은 매칭되지 않습니다(SQL 표준). NULL 키끼리 붙어야 하는 경우(또는 NULL 을 키로 다룰 때) 버그가 생깁니다.
# 일반 조인: NULL 키는 매칭 안 됨 (의도와 다를 수 있음)
a.join(b, a.k == b.k)
# null-safe 조인: NULL = NULL 을 매칭으로 취급 (<=> 연산자)
a.join(b, a["k"].eqNullSafe(b["k"]))eqNullSafe(<=>)는 NULL 끼리도 매칭합니다. 단, NULL 키가 많으면 그 자체가 스큐를 유발하므로(별도 글 "PySpark 데이터 스큐 완전 정복"), 보통은 조인 전에 NULL 키를 분리·처리하는 편이 안전합니다.
5. 함정 ② 중복 폭발 (Fan-out)
조인 키가 한쪽에서 유일하지 않으면, 행이 곱으로 늘어납니다. 양쪽 다 키 중복이 있으면 폭발합니다.
a: key=1 이 3행, b: key=1 이 4행 → 조인 결과 key=1 이 12행 💥# 조인 전에 한쪽을 키당 1행으로 보장 (의도한 게 아니라면)
b_unique = b.dropDuplicates(["key"]) # 또는 window 로 대표행 선택
a.join(b_unique, "key")흔한 사고: 차원 테이블에 중복이 있는 줄 모르고 조인 → 팩트가 부풀어 집계가 틀림. 조인 전 양쪽의 키 유일성을 확인하는 습관이 중요합니다.
6. 함정 ③ 컬럼 모호성 (Ambiguous Column)
같은 이름의 컬럼이 양쪽에 있으면 조인 후 모호성 에러가 납니다.
# ❌ 모호: 양쪽에 amount 가 있으면 어느 쪽인지 모호
a.join(b, "key").select("amount") # AnalysisException 가능
# ✅ 방법 1: 동등 키는 문자열/리스트로 조인하면 키 컬럼이 하나로 합쳐짐
a.join(b, ["key"])
# ✅ 방법 2: alias 로 명확히
a.alias("a").join(b.alias("b"), "key").select("a.amount", "b.amount")
# ✅ 방법 3: 조인 전에 미리 rename
b2 = b.withColumnRenamed("amount", "b_amount")join(b, "key")(문자열/리스트 키)는 키 컬럼을 하나로 합쳐 모호성을 줄입니다. a.k == b.k(표현식)는 양쪽 키 컬럼이 모두 남습니다.
7. 다중 키·비등가(non-equi) 조인
# 다중 키
a.join(b, ["key1", "key2"])
# 비등가 조인 (범위) — 셔플·폭발 주의
a.join(b, (a.ts >= b.valid_from) & (a.ts < b.valid_to))비등가(범위) 조인은 등가 조인보다 훨씬 비쌉니다(broadcast 가능하지 않으면 거의 cross 에 가까움). 유효기간 조인 같은 범위 조인은 as-of 패턴으로 전환을 고려하세요(별도 글 "PySpark As-of Join").
8. Cross Join — 명시적으로만
카테시안 곱은 실수로 만들면 재앙입니다. Spark 는 기본적으로 의도치 않은 cross join 을 막습니다.
# 의도적 cross join 은 명시
a.crossJoin(b)
# 실수 방지 설정 (기본 false 권장 — 실수성 cross 차단)
spark.conf.set("spark.sql.crossJoin.enabled", "false")9. 조인 성능 — 분배 방식
조인 타입과 별개로, 어떻게 분배되는가(broadcast vs sort-merge)가 성능을 좌우합니다.
| 상황 | 전략 | 참고 |
|---|---|---|
| 한쪽 작음 | broadcast | "Broadcast 변수와 대형 Lookup" |
| 양쪽 큼·반복 | 버킷팅 | "PySpark 버킷팅" |
| 키 스큐 | salt/AQE | "PySpark 데이터 스큐" |
| 큰 테이블 조인 | dynamic filtering | (CBO) |
semi/anti join 도 내부적으로 broadcast/sort-merge 로 실행되므로 같은 최적화가 적용됩니다.
10. 정리
| 목적 | 조인 |
|---|---|
| 일반 매칭 | inner |
| 보강 | left |
| 존재 필터(중복 없이) | left_semi |
| 부재 필터(차집합) | left_anti |
| NULL 키 매칭 | eqNullSafe |
| 양쪽 비교 | full_outer |
조인 마스터의 핵심은 "의도에 맞는 조인 타입을 고르는 것"입니다. 존재 확인엔 semi, 부재 확인엔 anti 를 쓰면 중복 폭발과 불필요한 컬럼을 피하고, null-safe 로 NULL 키 버그를 막으며, 조인 전 키 유일성 확인으로 fan-out 을 예방합니다. inner/left 만 쓰던 습관에서 벗어나 semi/anti 를 손에 익히면, 조인 코드가 더 정확하고 효율적이 됩니다.
이 글은 Spark 3.5 기준으로 작성되었습니다. 복잡한 조인 로직·성능 최적화 설계가 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀