Blog
pysparksparkjoinsemi-joinanti-joindata-engineering

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

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

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

요리 레시피에 비유하자면, inner join 만 쓰는 건 팬 하나로 모든 요리를 하는 셈입니다. 할 수 있지만 비효율적이고, 원하는 결과와 다를 때가 많습니다. "존재 여부만 확인하려고 조인했더니 행이 두 배가 됐다"거나 "NULL 키 때문에 결과가 틀어졌다" 같은 일이 조인에서 자주 일어납니다.

이 글은 PySpark 의 모든 조인 타입을 용도별로 정리하고, 실전에서 자주 부딪히는 함정 — 중복 폭발, 컬럼 모호성, NULL 키 — 을 피하는 법을 다룹니다.

이 글에서 배우는 것

  • inner/left 부터 semi/anti 까지 모든 조인 타입의 용도와 차이
  • left_semi 로 중복 없이 존재 여부를 필터하는 방법
  • left_anti 로 차집합(없는 것만 찾기)을 정확하게 구현하는 방법
  • NULL 키가 조인에서 일으키는 버그와 eqNullSafe 사용법
  • 중복 폭발(fan-out)·컬럼 모호성 함정을 피하는 실전 패턴

1. 조인 타입 전체 지도

먼저 PySpark 에서 쓸 수 있는 조인 타입을 한 눈에 살펴보겠습니다. 각각 언제 쓰는지 용도를 먼저 파악해 두면, 적재적소에 고를 수 있습니다.

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 join 후 null 필터를 쓰는 분이 많습니다. 그런데 left_anti 를 쓰면 한 줄로 더 명확하게 표현됩니다. 부재 필터에는 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 조인

SQL 표준에서 NULL = NULLtrue 가 아닙니다. 그래서 일반 조인에서 NULL 키끼리는 매칭되지 않습니다. 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)

조인 결과가 예상보다 훨씬 많은 행이 나온다면 중복 폭발(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)

조인 후 특정 컬럼을 선택하려는데 AnalysisException 이 나고 막막할 때가 있습니다. 같은 이름의 컬럼이 양쪽에 있어 Spark 가 어느 쪽인지 구분하지 못하는 것이 원인입니다.

# ❌ 모호: 양쪽에 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 를 손에 익히면, 조인 코드가 더 정확하고 효율적이 됩니다.

마치며 — 핵심 요약

  • 존재 여부 필터에는 left_semi 를 쓰세요. inner join 은 중복을 만들고 불필요한 컬럼이 붙습니다.
  • **부재 필터(차집합)**에는 left_anti 를 쓰세요. "left join 후 null 필터"보다 명확하고 효율적입니다.
  • NULL = NULL 은 일반 조인에서 매칭되지 않습니다. NULL 키가 의미 있는 경우 eqNullSafe 를 사용하세요.
  • 조인 전에 양쪽 키의 유일성을 확인하는 습관이 중복 폭발(fan-out) 버그를 예방합니다.
  • 같은 이름 컬럼이 양쪽에 있으면 리스트 형식 join(b, ["key"]) 로 키를 합치거나, alias 로 명확히 구분하세요.
  • 조인 타입을 제대로 고르면 코드가 짧아지고, 성능이 올라가고, 버그가 줄어듭니다. 도구를 알면 코드가 달라집니다.

이 글은 Spark 3.5 기준으로 작성되었습니다. 복잡한 조인 로직·성능 최적화 설계가 필요하시면 언제든 문의해 주세요.

— Data Dynamics 엔지니어링 팀