LabHub

블로그

Apache Spark 실전 운영 가이드: 성능 튜닝, 셔플, 스큐, AQE, 스트리밍 운영

한국어English日本語

Apache Spark 실전 운영 가이드

들어가며

Apache Spark는 빠른 엔진이지만, 프로덕션에서 느려지는 이유도 꽤 예측 가능합니다. 많은 팀이 처음에는 "executor memory를 더 키우면 해결되겠지"라고 생각하지만, 실제 병목은 메모리 크기보다 셔플 패턴, 파티션 설계, 조인 전략, 파일 레이아웃, 스트리밍 상태 관리에서 더 자주 터집니다.

특히 Databricks, EMR, self-managed YARN, Kubernetes 위에서 Spark를 운영할 때 공통으로 보이는 패턴이 있습니다.

이 글은 Spark 입문서가 아니라, 운영 환경에서 Spark 잡을 빠르고 안정적으로 돌리기 위한 실전 기준을 정리한 글입니다.

Spark에서 실제로 중요한 실행 모델

Spark 성능을 이해하려면 API보다 먼저 실행 모델을 봐야 합니다.

  1. Driver는 DAG를 만들고 스케줄링을 담당합니다.
  2. Executor는 실제 task를 실행합니다.
  3. Stage 경계는 보통 shuffle이 생기는 지점에서 나뉩니다.
  4. Task 수와 partition 수는 거의 직접적으로 연결됩니다.

즉, Spark 튜닝은 "노드를 몇 대 더 붙일까"보다 어디서 shuffle이 생기고 partition이 어떻게 만들어지는가를 먼저 봐야 합니다.

실무에서 가장 먼저 확인할 질문은 단순합니다.

Spark UI에서 SQL 탭, stage 탭, task distribution을 보는 습관이 중요한 이유가 여기에 있습니다.

셔플, 파티셔닝, 데이터 스큐가 대부분을 결정한다

셔플은 언제 비싸지는가

셔플은 네트워크, 디스크 I/O, 정렬 비용이 한 번에 겹칩니다. 아래 상황에서 비용이 특히 커집니다.

파티션 수를 볼 때 흔히 하는 실수

파티션이 너무 적으면 병렬성이 부족하고, 너무 많으면 task scheduling과 작은 파일 오버헤드가 폭증합니다. 정답은 고정값이 아니라 데이터 크기와 stage 특성에 따라 달라집니다.

운영에서는 다음 원칙이 더 실용적입니다.

spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.shuffle.partitions", "400")

스큐는 평균값으로 보이지 않는다

데이터 스큐는 평균 execution time만 보면 놓치기 쉽습니다. stage 평균은 괜찮아 보여도, 일부 task가 몇 배 오래 걸리면서 전체 job wall-clock time을 끌어내립니다.

대표적인 대응은 이렇습니다.

스큐는 Spark 옵션만으로 해결되는 문제가 아니라, 업스트림 데이터 모델과 키 분포 문제인 경우가 많습니다.

AQE, 조인 전략, 캐시는 어떻게 써야 하는가

AQE는 기본값이 아니라 운영 가드레일이다

Adaptive Query Execution은 Spark 3 이후 실전 운영에서 사실상 기본입니다. 실행 중 통계를 바탕으로 partition coalescing, skew handling, join strategy 변경을 수행하므로, 특히 입력 분포가 매일 바뀌는 데이터 파이프라인에 유리합니다.

다만 AQE를 켰다고 모든 것이 해결되지는 않습니다.

조인 전략은 명시적으로 점검해야 한다

실무에서는 다음 순서로 확인하는 편이 안전합니다.

  1. 작은 테이블을 broadcast 가능한가
  2. join key 분포가 균등한가
  3. sort-merge join이 정말 필요한가
  4. partition pruning이 먹는가
  5. 실행 계획에서 의도한 조인 전략이 실제로 잡혔는가
import org.apache.spark.sql.functions.broadcast

val result = factDf.join(broadcast(dimDf), Seq("customer_id"), "left")

캐시는 "많이"가 아니라 "다시 읽을 가치가 있을 때"만

캐시는 자주 재사용되는 expensive intermediate에만 쓰는 것이 맞습니다. 반대로 한 번만 읽는 데이터, 이미 columnar format으로 잘 저장된 데이터, 메모리 압박이 큰 cluster에서는 오히려 손해가 납니다.

캐시를 검토할 때는 아래를 같이 봐야 합니다.

cache() 호출보다 중요한 것은 언제 unpersist 할 것인가입니다.

Structured Streaming 운영에서 자주 망가지는 지점

Structured Streaming은 batch API와 유사해서 접근은 쉬우나, 운영은 오히려 더 엄격해야 합니다.

watermark 없이 stateful 연산을 키우지 말 것

stateful aggregation이나 stream-stream join에서 watermark가 없으면 state store가 끝없이 커질 수 있습니다.

val aggregated =
  events
    .withWatermark("event_time", "10 minutes")
    .groupBy(window($"event_time", "5 minutes"), $"user_id")
    .count()

운영 체크리스트는 다음과 같습니다.

스트리밍은 코드보다 운영 계약이 중요하다

실패 복구, 재처리, checkpoint 보존 기간, schema evolution, sink 중복 허용 여부가 정리되지 않으면 스트리밍 파이프라인은 장애 때마다 의미가 바뀝니다.

Spark Structured Streaming 운영 문서는 항상 다음 질문을 포함해야 합니다.

프로덕션 튜닝을 위한 실전 워크플로우

제가 가장 실용적이라고 보는 순서는 아래와 같습니다.

  1. Spark UI에서 가장 느린 stage 하나만 고른다
  2. shuffle read/write, spill, skewed task를 먼저 본다
  3. 실행 계획에서 join strategy와 partition 수를 확인한다
  4. source file layout와 predicate pushdown을 본다
  5. AQE, broadcast, repartition, pre-aggregation 중 하나만 바꿔서 다시 측정한다
  6. throughput과 cost를 함께 본다

핵심은 한 번에 모든 파라미터를 만지지 않는 것입니다. Spark 튜닝이 어려운 이유는 옵션이 많아서가 아니라, 실험 설계를 하지 않으면 무엇이 효과가 있었는지 알 수 없기 때문입니다.

마무리

Spark 운영의 핵심은 "메모리를 더 준다"가 아니라 데이터 이동을 줄이고, skew를 제어하고, 실행 계획을 의도대로 만들고, 스트리밍 상태를 제한하는 것입니다.

특히 아래 네 가지를 팀의 기본 운영 규칙으로 두면 효과가 큽니다.

Spark는 강력한 엔진이지만, 성능은 옵션이 아니라 운영 습관에서 나옵니다.

References

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다