Apache Spark — 느린 잡의 답은 실행 계획과 이벤트 로그에 있다 · 드라이버와 잡 · 이론
변환은 계획만 쌓고, 행동이 잡을 띄워 스테이지와 태스크로 나눈다
한 줄 요약
Spark 코드는 위에서 아래로 실행되는 것처럼 보이지만, 실제로는 드라이버가 계획을 쌓다가 행동(action)을 만나는 순간 그 계획 전체를 잡(job)으로 바꿔 스테이지와 태스크로 쪼개 실행기에 나눠 준다. 무슨 일이 언제 일어났는지는 코드가 아니라 이벤트 로그가 말해 준다.
왜 이렇게 나뉘어 있나
처음 Spark 코드를 읽으면 spark.read.csv(...) 줄에서 파일을 읽고, filter 줄에서 거르고, count 줄에서 센다고 생각하기 쉽다. 그렇게 읽으면 성능 문제를 엉뚱한 곳에서 찾는다. "읽기가 40초 걸린다" 고 보이는 자리가 사실은 앞에서 쌓아 둔 변환 스무 개가 한꺼번에 도는 자리다.
Spark 가 일을 이렇게 나눈 이유는 자료가 한 대의 메모리에 들어가지 않는다는 전제에서 출발했기 때문이다. 계산을 여러 프로세스에 나눠 주려면, 누군가는 전체 그림을 알고 일을 조각내 나눠 줘야 하고, 조각을 받은 쪽은 자기 몫만 계산하면 된다. [클러스터 모드 개요의 용어표](https://spark.apache.org/docs/4.2.0/cluster-overview.html#glossary)가 이 역할을 이렇게 정의한다.
- 드라이버(driver): 앱의 main() 을 돌리고 SparkContext 를 만드는 프로세스. 계획을 세우고 태스크를 나눠 준다.
- 실행기(executor): 워커 노드에 앱마다 띄우는 프로세스로, 태스크를 돌리고 자료를 메모리나 디스크에 들고 있는다.
- 태스크(task): 실행기 하나에 보내는 일의 단위.
- 잡(job): 행동 하나에 응답해 생기는, 여러 태스크로 이뤄진 병렬 계산.
- 스테이지(stage): 잡을 서로 의존하는 더 작은 태스크 묶음으로 나눈 것. 문서는 MapReduce 의 맵·리듀스 단계와 비슷하다고 적는다.
잡의 정의에 "행동에 응답해" 라는 말이 들어 있는 것이 핵심이다. 행동이 없으면 잡도 없다.
어떻게 동작하나
[RDD 프로그래밍 가이드](https://spark.apache.org/docs/4.2.0/rdd-programming-guide.html#rdd-operations)는 "Spark 의 모든 변환은 게으르다" 고 못 박는다. 변환은 결과를 바로 계산하지 않고 어떤 변환을 걸었는지만 기억하며, 행동이 드라이버에 결과를 돌려 달라고 할 때 비로소 계산된다. 문서는 이 설계의 이득도 함께 적는다. map 다음에 reduce 가 올 것을 미리 알면 큰 중간 결과 대신 줄인 결과만 드라이버로 돌려보낼 수 있다. 끝을 보고 나서 길을 정할 수 있다는 것이 게으름의 값이다.
df = spark.read.csv("/data/shop/orders.csv", header=True, schema=ddl) # 계획 1줄paid = df.filter(F.col("status") == "paid") # 계획 +1big = paid.withColumn("bulk", F.col("qty") >= 5) # 계획 +1big.count() # 행동 — 여기서 잡이 뜨고, 위 세 줄이 한 번에 실행된다행동을 만나면 드라이버는 계획을 물리 계획으로 바꾸고, 그것을 셔플 경계에서 자른다. 셔플이 없는 구간(읽기·거르기·칼럼 추가 같은 좁은 변환)은 한 태스크 안에서 줄줄이 이어 달릴 수 있으므로 한 스테이지가 된다. groupBy 처럼 같은 키를 한곳에 모아야 하는 넓은 변환이 나오면, 앞 스테이지의 모든 태스크가 결과를 셔플 파일로 쓴 뒤에야 뒤 스테이지가 그것을 읽을 수 있으므로 거기서 스테이지가 갈린다.
스테이지 안에서는 파티션 하나가 태스크 하나다. 그래서 파티션 수가 곧 동시에 일할 수 있는 조각 수다. 파일을 읽을 때 파티션 수는 파일 크기만으로 정해지지 않는다. [성능 조정 문서](https://spark.apache.org/docs/4.2.0/sql-performance-tuning.html#tuning-partitions)를 보면 파일을 쪼갤 최소 파티션 수(spark.sql.files.minPartitionNum)의 기본값이 spark.sql.leafNodeDefaultParallelism 이고, 그 값의 기본은 SparkContext 의 기본 병렬도다. 작은 파일도 코어 수만큼은 나눠 읽으려 한다는 뜻이다.
local 모드에서는 무엇이 드라이버이고 무엇이 실행기인가
이 실습 파드에는 클러스터가 없다. [마스터 URL 표](https://spark.apache.org/docs/4.2.0/submitting-applications.html#master-urls)에서 local 은 워커 스레드 하나(병렬 없음), local[K] 는 워커 스레드 K 개다. local 모드에서는 드라이버 JVM 하나가 실행기 역할까지 한다. 태스크는 그 JVM 안의 스레드로 돈다.
그래도 드라이버·실행기·잡·스테이지·태스크의 구분은 그대로 남는다. 계획을 세우는 코드와 태스크를 돌리는 코드가 같은 프로세스에 있을 뿐이다. 이것이 local 모드로 배우는 것이 헛되지 않은 이유다. 이벤트 로그에 남는 잡·스테이지·태스크 이벤트의 모양은 클러스터에서와 같다. 이 파드는 local[2] 에 드라이버 메모리 1g 로 맞춰 두었다.
이벤트 로그 — 끝난 앱을 되짚는 증거
드라이버의 웹 UI(4040)는 앱이 살아 있는 동안만 보인다. 끝난 앱을 보려면 [모니터링 문서](https://spark.apache.org/docs/4.2.0/monitoring.html#viewing-after-the-fact)대로 히스토리 서버를 쓰는데, 히스토리 서버는 앱이 남긴 이벤트 로그로 UI 를 다시 짓는다. 이벤트 로그가 없으면 끝난 앱에 대해 알 수 있는 것이 거의 없다.
이벤트 로그는 JSON 한 줄이 이벤트 하나다. 잡이 시작하면 SparkListenerJobStart, 스테이지가 끝나면 SparkListenerStageCompleted 가 찍힌다. 그래서 "행동 세 번이면 잡도 세 개" 같은 추측을 줄 수를 세는 것으로 검증할 수 있다.
주의할 것이 하나 있다. [설정 문서](https://spark.apache.org/docs/4.2.0/configuration.html#spark-ui)를 보면 Spark 4.2 의 기본값은 spark.eventLog.compress 가 true, 압축 코덱이 zstd, spark.eventLog.rolling.enabled 가 true 다. 기본 그대로 두면 로그는 여러 파일로 굴려지고 압축되어 grep 으로 바로 읽히지 않는다. 이 실습은 평문 한 파일로 바꿔 두었다. 운영에서 같은 방법을 쓰려면 이 두 설정을 먼저 확인해야 한다.
현장에서 만나는 모습
행동 하나가 잡 하나가 아니다. 스키마를 주지 않고 머리줄 있는 CSV 를 읽으면 Spark 는 머리줄을 확인하려고 작은 잡을 먼저 띄운다. 파일을 쓰는 행동도 잡을 하나 넘게 만들 수 있다. 그래서 코드를 세지 말고 로그를 센다. 이 실습에서도 행동 셋을 부른 앱의 잡 수를 짐작하지 않고 로그에서 직접 센다.
오류가 나는 자리가 두 가지다. 경로가 틀리면 행동을 기다리지 않고 read 하는 순간 분석 단계에서 실패한다. 이때의 오류 조건 이름이 [PATH_NOT_FOUND](https://spark.apache.org/docs/4.2.0/sql-error-conditions.html#path-not-found) 다. 반면 자료 안의 값이 문제라면 계획을 세울 때는 모르고, 행동이 실제로 자료를 읽을 때에야 터진다. 앞의 것은 코드 줄과 오류가 맞아떨어지고, 뒤의 것은 엉뚱한 줄(행동)에서 터진다.
드라이버가 먼저 죽는다. 결과를 눈으로 보려고 collect() 를 부르면 전체 결과가 드라이버 한 곳으로 모인다. [RDD 가이드](https://spark.apache.org/docs/4.2.0/rdd-programming-guide.html#printing-elements-of-an-rdd)는 이것이 드라이버의 메모리를 넘치게 할 수 있으니 몇 개만 볼 때는 take 를 쓰라고 권한다. 실행기가 아무리 많아도 드라이버는 하나다.
변환 안의 print 는 드라이버 화면에 안 나온다. 변환 안의 코드는 실행기에서 돌기 때문이다. local 모드에서는 우연히 보이지만 클러스터로 옮기면 사라진다.
실무에서 진짜 중요한 것
- 변환은 계획이고, 행동이 일을 시킨다. 느린 자리는 대개 행동이 있는 줄이지만 원인은 그 앞의 변환 전체다.
- 스테이지 경계는 셔플이다. 스테이지 수를 줄이는 일은 곧 셔플을 줄이는 일이다.
- 파티션 하나가 태스크 하나다. 파티션 수가 동시에 일할 수 있는 한도를 정한다.
- 잡 수는 추측하지 말고 이벤트 로그에서 센다. 스키마 추론·머리줄 확인·쓰기가 잡을 더 만든다.
- 이벤트 로그의 기본은 굴리기와 zstd 압축이다. 읽는 도구를 고르기 전에 설정부터 본다.
- 드라이버는 하나다. collect 로 결과를 드라이버에 모으는 것은 가장 흔한 드라이버 메모리 사고다.
다음 실습에서 할 것
spark-submit 으로 판을 확인한 뒤 주문 30만 건을 세는 첫 잡을 띄운다. 변환만 쌓은 앱을 돌려 이벤트 로그에 잡이 하나도 없음을 확인하고, 행동을 여러 번 부른 앱에서 잡이 실제로 몇 개 떴는지 로그로 센다. groupBy 로 셔플을 일으켜 스테이지가 갈리는 것을 보고, local[1] 과 local[2] 에서 같은 파일의 파티션 수가 어떻게 달라지는지 견준다. 마지막으로 없는 경로를 읽어 오류 조건 이름을 받아 보고, 숫자를 보고서로 정리한다.