LabHub
배우기 러닝패스 코스

Apache Spark — 느린 잡의 답은 실행 계획과 이벤트 로그에 있다 · 조인 전략 · 이론

조인은 어떻게 짝을 맞추느냐에 따라 값이 열 배 달라진다

LabHub 에서 이어서 보기

한 줄 요약

Spark 는 같은 조인을 세 가지 방식(브로드캐스트 해시·정렬 병합·셔플 해시)으로 할 수 있고, 어느 것을 고르느냐는 한쪽이 얼마나 작은가에 달려 있으며, 그 판단은 계획 때의 추정과 실행 중의 실측(AQE) 두 번 일어난다.

개념 지도: 한쪽이 얼마나 작은가 · 무엇을 얼마나 옮기느냐 · 브로드캐스트 해시 조인(BroadcastHashJoin). · 정렬 병합 조인(SortMergeJoin).

왜 조인 전략을 알아야 하나

조인은 두 표에서 키가 같은 행을 한자리에 모으는 일이다. 문제는 그 행들이 처음에는 서로 다른 파티션, 다른 실행기에 흩어져 있다는 것이다. 짝을 맞추려면 누군가는 움직여야 한다. 무엇을 얼마나 옮기느냐가 조인 전략의 전부다.

주문 1,000만 건과 상품 300개를 조인한다고 하자. 주문을 키로 다시 나눠 옮기면 1,000만 건이 네트워크와 디스크를 지난다. 상품 300개를 모든 태스크에 한 벌씩 나눠 주면 주문은 제자리에 있고 300개만 복사된다. 결과는 똑같은데 옮기는 양은 수만 배 차이다. 실무에서 "조인이 느리다" 는 말의 절반은 이 선택이 잘못된 경우다.

어떻게 동작하나

세 가지 조인 전략을 나란히 놓은 그림. 브로드캐스트 해시 조인은 작은 표 한 벌을 모든 태스크에 복사하고 큰 표는 움직이지 않는다. 정렬 병합 조인은 양쪽을 키의 해시로 셔플한 뒤 파티션마다 정렬해 나란히 훑는다. 셔플 해시 조인은 양쪽을 셔플한 뒤 파티션마다 작은 쪽으로 해시 표를 만든다

브로드캐스트 해시 조인(BroadcastHashJoin). 작은 쪽 표를 드라이버가 모아 모든 실행기에 한 벌씩 보낸다. 실행기는 그것으로 해시 표를 만들고, 큰 쪽의 각 파티션은 제자리에서 해시 표를 찾아본다. 큰 쪽에 셔플이 없다. 그래서 가장 빠르지만, 작은 쪽이 정말 작아야 한다 — 드라이버와 모든 실행기의 메모리에 통째로 올라가기 때문이다.

Spark 가 이것을 자동으로 고르는 기준이 [성능 조정 문서](https://spark.apache.org/docs/4.2.0/sql-performance-tuning.html)의 spark.sql.autoBroadcastJoinThreshold 다. 기본값은 10MB 이고, 통계로 본 표 크기가 이보다 작으면 브로드캐스트한다. -1 로 두면 자동 브로드캐스트가 꺼진다. 같은 문서의 spark.sql.broadcastTimeout 은 브로드캐스트를 기다리는 시간으로 기본 300초다.

정렬 병합 조인(SortMergeJoin). 양쪽을 조인 키의 해시로 셔플해 같은 키가 같은 번호의 파티션에 모이게 한다. 그다음 파티션마다 양쪽을 키로 정렬하고 두 줄을 나란히 훑으며 짝을 맞춘다. 양쪽 다 셔플과 정렬 비용을 치르지만, 정렬은 메모리가 모자라면 디스크로 흘려 보낼 수 있어 어떤 크기에서도 끝까지 간다. 두 표가 다 클 때의 기본 선택이 이것인 이유다.

셔플 해시 조인(ShuffledHashJoin). 셔플은 정렬 병합과 같다. 다른 점은 파티션마다 정렬 대신 작은 쪽으로 해시 표를 만든다는 것이다. 정렬을 건너뛰니 빠를 수 있지만, 파티션 하나의 작은 쪽이 메모리에 들어가야 한다. 그래서 Spark 는 조건이 맞을 때만 이것을 고른다.

from pyspark.sql import functions as Forders.join(products, "product_id").explain()            # 작으면 BroadcastHashJoinorders.join(F.broadcast(products), "product_id")         # 크기와 상관없이 브로드캐스트orders.join(products.hint("shuffle_hash"), "product_id") # ShuffledHashJoin 요청orders.join(products.hint("merge"), "product_id")        # SortMergeJoin 요청

계획을 읽을 때는 조인 이름보다 그 아래에 무엇이 달려 있는가를 본다. 정렬 병합이면 양쪽 가지에 Exchange hashpartitioning(product_id, …)Sort 가 하나씩 붙는다 — 셔플 두 번과 정렬 두 번이다. 브로드캐스트면 작은 쪽에만 BroadcastExchange 가 붙고 큰 쪽 가지에는 Exchange 가 없다. 셔플 해시면 Exchange 는 양쪽에 있는데 Sort 가 없다. 이 세 모양만 눈에 익히면 조인 이름을 찾지 않아도 무엇을 옮겼는지 보인다.

힌트와 그 한계

추정이 틀릴 때가 있다. CSV 처럼 통계가 없는 원본이나 필터를 여러 번 거친 결과는 크기를 짐작하기 어렵다. 그럴 때 사람이 전략을 알려 주는 것이 힌트다. [힌트 문서](https://spark.apache.org/docs/4.2.0/sql-ref-syntax-qry-select-hints.html)가 드는 조인 힌트는 BROADCAST(별칭 BROADCASTJOIN·MAPJOIN), MERGE(별칭 SHUFFLE_MERGE·MERGEJOIN), SHUFFLE_HASH, SHUFFLE_REPLICATE_NL 넷이다. BROADCAST 힌트가 붙은 쪽은 임계값과 상관없이 브로드캐스트된다. DataFrame API 에서는 [broadcast() 함수](https://spark.apache.org/docs/4.2.0/api/python/reference/pyspark.sql/api/pyspark.sql.functions.broadcast.html)가 같은 일을 한다.

양쪽에 서로 다른 힌트를 붙이면 BROADCAST, MERGE, SHUFFLE_HASH, SHUFFLE_REPLICATE_NL 순서로 앞의 것이 이긴다. 그리고 성능 조정 문서는 분명히 적는다. 힌트는 보장이 아니다. 어떤 전략은 특정 조인 종류를 지원하지 않기 때문이다. 예를 들어 왼쪽 외부 조인에서는 왼쪽의 모든 행을 남겨야 하므로 왼쪽을 브로드캐스트해 해시 표로 쓸 수 없다. 그래서 힌트를 넣은 뒤에는 반드시 계획에서 실제로 무엇이 골라졌는지 확인한다.

실행 중에 바뀌는 계획 — AQE

적응형 쿼리 실행(AQE)은 3.2.0 부터 기본으로 켜져 있다. AQE 는 셔플이 끝난 뒤 실제로 몇 바이트가 나왔는지 보고 남은 계획을 다시 짠다. 조인에서 중요한 것은 정렬 병합을 브로드캐스트 해시로 바꾸는 규칙이다. 성능 조정 문서에 따르면 실행 중 통계로 본 한쪽이 적응형 임계값(spark.sql.adaptive.autoBroadcastJoinThreshold, 기본은 자동 임계값과 같다)보다 작으면 바꾼다.

문서는 여기에 정직한 단서를 단다. 처음부터 브로드캐스트로 계획한 것만큼 효율적이지는 않다. 이미 셔플은 일어났기 때문이다. 대신 양쪽 정렬을 피하고, 로컬 셔플 읽기가 켜져 있으면 셔플 파일을 네트워크 없이 제자리에서 읽는다. 계획에서는 처음 AdaptiveSparkPlan isFinalPlan=false 이던 것이 실행 뒤 isFinalPlan=true 가 되고 그 안에서 조인 이름이 바뀐 것을 볼 수 있다.

정렬 병합을 셔플 해시로 바꾸는 규칙도 있다. 셔플 뒤 모든 파티션이 spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold 보다 작으면 바꾸는데, 이 값의 기본이 0 이라 따로 켜지 않으면 일어나지 않는다.

현장에서 만나는 모습

첫째, 조인 뒤 행 수가 늘어난다. 조인 키가 한쪽에서 유일하지 않으면 짝이 곱해진다. 주문 한 건에 판촉 표의 같은 상품 행이 셋이면 그 주문은 세 줄이 된다. 매출 합계가 부풀어 보고서가 틀리는데 오류는 하나도 나지 않는다. 조인 전에 작은 쪽 키의 유일성을 세어 보는 습관이 이 사고를 막는다.

둘째, 브로드캐스트가 드라이버를 죽인다. 임계값을 크게 올리거나 힌트를 남발하면 수백 MB 짜리 표가 드라이버에 모였다가 모든 실행기로 복사된다. 드라이버 메모리 부족이나 브로드캐스트 시간 초과가 이때 난다.

셋째, "없는 것" 을 찾을 때 외부 조인 뒤 null 을 거르는 대신 안티 조인을 쓴다. [조인 문서](https://spark.apache.org/docs/4.2.0/sql-ref-syntax-qry-select-join.html)는 안티 조인을 오른쪽에 짝이 없는 왼쪽 행을 돌려주는 조인으로 정의한다. 주문이 한 번도 없는 고객을 찾는 질문이 딱 이 모양이고, 결과에는 왼쪽 칼럼만 남는다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

주문과 작은 상품 표를 조인해 Spark 가 스스로 브로드캐스트를 고르는 것을 계획으로 확인한다. 임계값을 꺼서 정렬 병합으로 바뀌는 것을 보고, broadcast 와 shuffle_hash 힌트로 전략을 직접 바꿔 본다. 임계값을 낮춰 둔 채 AQE 가 실행 중에 정렬 병합을 브로드캐스트로 바꾸는 순간을 최종 계획에서 잡고, 판촉 표의 중복 키가 행을 불리는 것을 left_semi 조인의 행 수와 견주어 세어 본 뒤 안티 조인으로 주문 없는 고객을 찾는다.