Blog
pysparksparkjoinsemi-joinanti-joindata-engineering

PySpark 조인 완전 마스터 — semi, anti, null-safe, 그리고 함정들

inner/outer 를 넘어 semi·anti join 으로 필터링을 효율화하고, null-safe 조인으로 NULL 키 버그를 잡고, 다중 키·비등가 조인·중복 폭발·컬럼 모호성 같은 실전 함정을 피하는 법을 PySpark 코드로 정리합니다.

Data Dynamics2026年6月5日9 min read
This post is not yet translated. The original Korean version is shown below.

조인은 데이터 처리의 절반입니다. 그런데 많은 사람이 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 엔지니어링 팀