LabHub
배우기 러닝패스 코스

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

explain 은 Spark 가 무엇을 덜 읽기로 했는지 보여 주는 영수증이다

LabHub 에서 이어서 보기

한 줄 요약

Spark 는 쓴 코드를 그대로 돌리지 않는다. 최적화기가 조건을 파일 리더 쪽으로 밀어내고, 쓰지 않는 칼럼을 잘라 내고, 필요 없는 파티션 디렉터리를 건너뛰도록 계획을 고친다. 그 결과는 explain 의 물리 계획에 PushedFilters·ReadSchema·PartitionFilters 로 찍히고, 실행 중에 AQE 가 한 번 더 고친 최종 계획은 행동이 끝난 뒤에야 보인다.

개념 지도: 최종 계획 · 얼마나 읽었는가 · 최적화를 믿지 말고 확인한다. · 파싱된 논리 계획

왜 계획을 읽어야 하나

같은 결과를 내는 두 질의가 하나는 3초, 하나는 3분 걸린다. 코드는 거의 같아 보인다. 차이는 대개 얼마나 읽었는가에서 난다. 조건 하나를 함수로 감싸는 바람에 Parquet 파일 전체를 읽었다거나, select("*") 한 줄 때문에 필요 없는 칼럼 스무 개를 디스크에서 올렸다거나 하는 식이다.

이런 차이는 코드를 아무리 읽어도 안 보인다. Spark 가 코드를 어떤 계획으로 바꿨는지를 봐야 한다. 그 영수증이 explain 이다. 여기서 중요한 태도가 하나 있다. 최적화를 믿지 말고 확인한다. 최적화기는 할 수 있을 때만 하고, 못 했을 때 경고를 띄우지 않는다.

어떻게 동작하나

[SQL EXPLAIN 참조](https://spark.apache.org/docs/4.2.0/sql-ref-syntax-qry-explain.html#parameters)에 따르면 EXTENDED 는 네 가지 계획을 보여 준다. 질의에서 뽑아낸 파싱된 논리 계획, 이름과 타입을 확정한 분석된 논리 계획, 최적화 규칙을 거친 최적화된 논리 계획, 그리고 실제로 돌 물리 계획이다. [PySpark 의 explain](https://spark.apache.org/docs/4.2.0/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrame.explain.html)은 이것을 모드로 고른다. 기본(simple)은 물리 계획만, extended 는 논리와 물리 모두, codegen 은 생성된 코드, cost 는 통계가 있을 때 논리 계획과 통계, formatted 는 물리 계획 개요와 노드별 상세를 나눠 보여 준다.

최적화된 논리 계획과 물리 계획 사이를 견주면 최적화기가 한 일이 보인다. 파일을 읽는 노드 한 줄에 대부분이 모여 있다.

+- FileScan parquet [order_id#0,qty#3,day#7]     PartitionFilters: [isnotnull(day#7), (day#7 = 2026-01-03)]     PushedFilters: [IsNotNull(qty), GreaterThanOrEqual(qty,5)]     ReadSchema: struct<order_id:string,qty:int>

조건 밀어내기(PushedFilters). where(qty >= 5) 가 Filter 노드로만 있지 않고 파일 리더에게 넘겨졌다는 뜻이다. [Parquet 설정](https://spark.apache.org/docs/4.2.0/sql-data-sources-parquet.html#configuration)에서 spark.sql.parquet.filterPushdown 의 기본값은 true 다. 넘긴 조건으로 무엇을 건너뛸 수 있는지는 파일이 가진 통계에 달려 있다. [통계 활용 절](https://spark.apache.org/docs/4.2.0/sql-performance-tuning.html#leveraging-statistics)은 Spark 가 자료 소스에서 직접 읽는 통계의 예로 Parquet 메타데이터에 든 개수와 최소·최대값을 든다. 최소·최대값만 봐도 조건을 만족할 수 없는 덩어리는 열지 않아도 된다.

칼럼 가지치기(ReadSchema). 질의가 끝까지 쓰는 칼럼만 읽는다. Parquet 은 칼럼별로 모아 저장하므로 두 칼럼만 읽으면 나머지 칼럼의 바이트는 디스크에서 올라오지 않는다. CSV 는 줄 단위라 이 이득이 훨씬 작다. 파일을 한 번 Parquet 으로 바꿔 두는 이유가 여기에 있다.

파티션 가지치기(PartitionFilters). day=2026-01-03/ 처럼 값이 디렉터리 이름에 들어 있는 칼럼에 걸린 조건이다. [Parquet 의 파티션 발견](https://spark.apache.org/docs/4.2.0/sql-data-sources-parquet.html#partition-discovery)은 이런 경로에서 파티션 칼럼을 자동으로 뽑아 스키마에 넣는다. 그 칼럼에 걸린 조건은 파일을 열기도 전에 디렉터리 목록 단계에서 처리되므로, 해당하지 않는 날짜는 비용이 0 이다.

연속 필터 합치기. where(a).where(b) 처럼 조건을 여러 번 나눠 써도 최적화된 논리 계획에서는 Filter 하나로 합쳐진다. 그래서 가독성을 위해 조건을 나눠 쓰는 것은 성능을 해치지 않는다.

밀어내기가 깨지는 순간

밀어내기는 조건이 칼럼 그대로와 상수의 비교일 때 가장 잘 된다. 리더가 알아듣는 것은 "qty 가 5 이상" 같은 단순한 모양이다. upper(status) = 'PAID' 처럼 칼럼을 함수로 감싸면 리더에게 넘길 모양이 아니게 되어 PushedFilters 에서 그 조건이 빠진다. 결과는 같고, 오류도 경고도 없다. 읽는 양만 늘어난다. 이 실습에서 바로 이 차이를 계획으로 확인한다.

파티션 칼럼도 같다. 날짜 파티션에 day = '2026-01-03' 을 걸면 PartitionFilters 에 들어가지만, 날짜 문자열을 가공해서 거르면 가지치기가 되지 않을 수 있다. 거르는 조건은 저장된 모양 그대로의 칼럼에 건다는 습관이 여기서 나온다.

AQE — 실행 도중 바뀌는 계획

[성능 조정 문서](https://spark.apache.org/docs/4.2.0/sql-performance-tuning.html#adaptive-query-execution)에 따르면 적응형 질의 실행(AQE)은 런타임 통계로 실행 도중 계획을 다시 최적화하는 기법이고, Spark 3.2.0 부터 기본으로 켜져 있다. 이 말은 계획 읽기에 중요한 결과를 낳는다. 행동 전에 찍은 explain 은 최종 계획이 아니다.

행동 전에 찍으면 맨 위에 AdaptiveSparkPlan isFinalPlan=false 가 보인다. 셔플 단계가 실제로 끝나 크기를 알게 된 뒤에야 AQE 가 셔플 파티션을 합치거나 조인 방식을 바꾼다. 행동을 한 번 부른 뒤 같은 DataFrame 의 계획을 다시 보면 isFinalPlan=true 와 함께 AQEShuffleRead 같은 노드가 나타난다. 실행된 계획을 확인하려면 이 최종 계획을 봐야 한다. [웹 UI 문서](https://spark.apache.org/docs/4.2.0/web-ui.html#sql-tab)의 SQL 탭에서도 질의마다 Details 로 네 계획을 펼치고, 연산자마다 몇 행이 지나갔는지 같은 지표를 볼 수 있다.

현장에서 만나는 모습

"필터를 걸었는데 왜 다 읽지?" 가장 흔한 원인은 함수로 감싼 조건, 타입이 다른 비교(문자열 칼럼과 숫자 상수), 그리고 CSV 를 그대로 읽는 것이다. 셋 다 계획의 PushedFilters·ReadSchema 한 줄로 드러난다.

"select 는 나중에 하니까 상관없다" 는 대체로 맞다. 최적화기가 끝까지 쓰는 칼럼만 남기기 때문이다. 다만 중간에 cache() 를 하거나 파이썬 UDF 에 행 전체를 넘기면 그 지점에서 필요한 칼럼이 늘어난다. 늘 ReadSchema 로 확인한다.

통계가 없으면 계획이 흔들린다. 통계 활용 절은 통계가 없거나 부정확하면 Spark 가 좋은 계획을 고르지 못한다고 적고, explain(mode="cost") 로 추정치를, SQL UI 의 isRuntime=true 로 실행 중 통계를 보라고 안내한다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

주문 CSV 를 날짜 칼럼을 더한 Parquet 으로 바꾼 뒤 explain 의 extended 와 formatted 계획을 찍어 본다. 조건과 칼럼 선택이 PushedFilters 와 ReadSchema 에 어떻게 찍히는지 확인하고, 같은 조건을 upper() 로 감쌌을 때 밀어내기가 사라지는 것을 견준다. 날짜로 나눠 쓴 Parquet 에서 하루를 골라 PartitionFilters 에 조건이 찍히는 것을 보고, 고객별 집계를 행동으로 돌린 뒤 AQE 최종 계획과 이벤트 로그를 읽어 셔플 파티션 200개 가운데 실제로 셔플을 읽은 태스크가 몇 개였는지 센다. 마지막으로 연속 필터가 하나로 합쳐지고 1 + 2 같은 상수식이 미리 계산되는 모습을 최적화된 논리 계획에서 확인한다.