Spark 작업 중 겪는 성능 저하와 OOM 오류, 그 원인이 데이터 스큐 때문일 수 있습니다. 테크리드를 위한 스큐 진단 및 효과적인 해결 전략을 친근하게 알려드립니다.
Spark 작업이 느려지는 진짜 이유: 데이터 스큐, 이대로 두면 안 됩니다
안녕하세요! 팀원들이 Spark 작업이 자꾸 느려진다거나, 원인 모를 OOM (Out Of Memory) 에러가 난다고 푸념하진 않나요? 분명 리소스는 충분히 할당했는데, 왜 특정 작업만 유독 발목을 잡는지 답답할 때가 많으실 텐데요. 그 주범 중 하나가 바로 데이터 스큐(Data Skew)일 가능성이 매우 높습니다.
데이터 스큐는 분산 처리 환경에서 마치 '병목 현상'처럼 작용해서 전체 작업의 성능을 크게 저하시키고, 심지어는 실패로 이끌기도 하거든요. 특히 팀의 기술 스택과 운영 전략을 고민하는 테크리드나 엔지니어링 매니저님들이라면, 이 문제를 정확히 이해하고 해결하는 것이 팀의 생산성과 서비스 안정성에 직결된다는 것을 잘 아실 겁니다. 오늘은 Spark 작업에서 흔히 마주치는 데이터 스큐 문제를 어떻게 진단하고, 어떤 전략으로 해결해야 하는지 쉽고 친근하게 이야기해볼게요.
📑 목차
Image by Hans on Pixabay
Spark 작업, 왜 자꾸 느려지고 OOM이 발생할까요? (데이터 스큐의 그림자)
Spark는 데이터를 여러 Worker 노드(Executor)에 분산해서 처리하는 강력한 도구죠. 그런데 이 분산 처리가 항상 균등하게 이루어지는 건 아니랍니다. 특정 키 값에 해당하는 데이터가 다른 키 값보다 훨씬 많을 때, 즉 데이터가 한쪽으로 쏠리는 현상을 데이터 스큐라고 부릅니다.
예를 들어, 쇼핑몰 주문 데이터를 사용자 ID로 그룹핑하거나 조인하는 상황을 생각해볼까요? 대부분의 사용자 ID는 비슷한 수의 주문을 가지고 있겠지만, '탈퇴 회원'이나 '테스트 계정' 같은 특정 ID는 수백만 건의 주문을 가질 수도 있겠죠. 이때 Spark는 이 많은 데이터를 단 하나의 Executor에 몰아주려고 합니다. 마치 운동장에서 모든 공을 한 명의 선수에게만 던져주는 것과 비슷한 상황인 거죠.
이렇게 되면 무슨 일이 벌어질까요? 다른 Executor들은 일찍이 자기 할 일을 끝내고 놀고 있는데, 이 '몰빵 받은' Executor만 혼자서 엄청난 양의 데이터를 처리하느라 허덕입니다. 결국 이 Executor는 메모리 부족으로 OOM 에러를 내며 주저앉거나, 아니면 엄청난 시간을 소모하며 전체 작업의 완료를 지연시키게 됩니다. 다른 Executor들이 아무리 빨라도, 가장 느린 Executor가 전체 작업의 속도를 결정하게 되는 셈이죠. 이게 바로 Spark의 분산 처리 병목 현상의 핵심입니다.
우리 팀 Spark 작업에 데이터 스큐가 있는지 진단하는 비법
데이터 스큐는 눈에 보이지 않는 문제라서, 어디서부터 손을 대야 할지 막막할 수 있습니다. 하지만 Spark는 문제를 진단할 수 있는 아주 강력한 도구들을 제공하고 있어요. 바로 Spark UI와 로그, 그리고 모니터링 시스템을 활용하는 것이죠.
Spark UI, 최고의 탐정 도구
Spark UI는 Spark 애플리케이션의 내부 동작을 시각적으로 보여주는 대시보드입니다. 여기서 데이터 스큐의 흔적을 찾을 수 있어요.
- Stages 탭 → 특정 Stage 클릭 → Tasks 탭 확인:
- 각 Task의 'Input Size'나 'Duration'을 잘 살펴보세요. 만약 특정 Task 몇 개만 다른 Task보다 현저히 크거나, 완료 시간이 길다면 데이터 스큐를 의심해봐야 합니다. 일반적으로 Spark는 Task들을 가능한 한 균등하게 분배하려고 노력하거든요.
- 특히 Shuffle Read Size / Records나 Shuffle Write Size / Records 컬럼에서 특정 Task의 값이 압도적으로 높다면, 해당 Task가 처리하는 파티션에 데이터가 몰려있다는 강력한 증거가 됩니다.
- Executors 탭 확인:
- 전체 Executor들의 'Active Tasks', 'Total Tasks', 'Memory Usage', 'GC Time' 등을 비교해보세요. 특정 Executor만 유독 활성 태스크 수가 많거나, 메모리 사용량이 치솟고, GC(Garbage Collection) 시간이 길다면 그 Executor가 스큐 된 데이터를 처리하고 있을 가능성이 높습니다.
예시로, Spark UI의 Task 탭에서 다음과 같은 상황을 발견했다면 스큐가 거의 확실합니다.
Task ID | Executor ID | Host | Status | Locality | Duration | GC Time | Input Size | Shuffle Read Size
--------|-------------|------|--------|----------|----------|---------|------------|-------------------
... | ... | ... | ... | ... | ... | ... | ... | ...
100 | 5 | host5| SUCCESS| PROCESS_LOCAL | 5s | 200ms | 100MB | 50MB
101 | 6 | host6| SUCCESS| PROCESS_LOCAL | 6s | 250ms | 110MB | 55MB
... | ... | ... | ... | ... | ... | ... | ... | ...
150 | 3 | host3| SUCCESS| PROCESS_LOCAL | 1500s | 800s | 50GB | 25GB
... | ... | ... | ... | ... | ... | ... | ... | ...
위 예시에서 Task 150은 다른 Task들에 비해 Duration, GC Time, Input Size, Shuffle Read Size가 압도적으로 높죠? 이런 Task가 발견된다면 즉시 스큐 문제를 의심해야 합니다.
로그와 모니터링 시스템을 활용한 증거 수집
Spark UI 외에도 로그 파일이나 Grafana 같은 모니터링 시스템에서 단서를 찾을 수 있습니다.
- Executor 로그: 특정 Executor의 로그에서 'java.lang.OutOfMemoryError' 메시지가 반복적으로 나타나거나, 'GC overhead limit exceeded' 같은 경고가 보인다면 해당 Executor가 과도한 데이터를 처리하고 있다는 증거입니다.
- 모니터링 시스템: Grafana, Prometheus 등으로 Spark 클러스터의 리소스 사용률을 모니터링하고 있다면, 특정 Executor의 CPU 사용률, 메모리 사용률, 네트워크 I/O가 다른 Executor들에 비해 비정상적으로 높게 치솟는 패턴을 발견할 수 있습니다. 이는 실시간으로 스큐 문제를 감지하는 데 큰 도움이 됩니다.
Image by IamNotPerfect on Pixabay
데이터 스큐, 이렇게 해결하면 됩니다! 실전 전략
데이터 스큐 문제를 진단했다면, 이제 해결책을 찾아야겠죠? 몇 가지 효과적인 전략들을 소개해 드릴게요. 팀의 상황과 데이터 특성에 맞춰 적절한 방법을 선택하는 것이 중요합니다.
스큐 해소의 기본: 데이터 재분배와 최적화
데이터 스큐를 해결하는 핵심은 결국 '데이터를 균등하게 재분배'하는 것입니다.
- Salting (솔팅):가장 고전적이면서도 강력한 방법 중 하나입니다. 스큐가 발생하는 키 값에 임의의 접두사나 접미사를 추가하여, 논리적으로는 같은 키지만 물리적으로는 다른 파티션으로 분산되도록 하는 기법이죠. 예를 들어, 'test_user'라는 스큐된 키가 있다면, 여기에 'test_user_1', 'test_user_2', ..., 'test_user_N'처럼 숫자를 붙여 여러 파티션으로 나눌 수 있습니다.이렇게 하면 스큐된 키가 여러 파티션으로 분산되어 동시에 처리될 수 있으므로, 병목 현상이 크게 줄어듭니다. 다만, 조인 대상 테이블도 같은 방식으로 솔팅해야 하고, 조인 후에는 다시 원래 키로 복구하는 작업이 필요할 수 있어 코드가 조금 복잡해질 수 있습니다.
// 스큐된 키에 0부터 (num_salt_buckets - 1)까지의 랜덤 숫자를 붙여 새로운 키 생성 val numSaltBuckets = 100 val saltedDf = originalDf.withColumn( "salted_key", concat(col("skewed_key"), lit("_"), (rand() * numSaltBuckets).cast("int")) ) // 조인 후 다시 원래 키로 정리 (필요하다면)- Spark AQE (Adaptive Query Execution) 활용:최신 Spark 버전(Spark 3.x 이상)에서는 Adaptive Query Execution (AQE) 기능이 skewed join optimization을 포함하고 있습니다. AQE는 런타임에 쿼리 실행 계획을 동적으로 조정하여 성능을 최적화하는데, 이 과정에서 데이터 스큐를 자동으로 감지하고 처리해줍니다. 스큐된 파티션을 여러 서브 파티션으로 나누거나, 작은 서브 파티션을 브로드캐스트하여 조인하는 방식 등으로 스큐를 완화하죠.AQE를 사용하면 개발자가 직접 솔팅 코드를 작성할 필요 없이, Spark가 알아서 스큐를 처리해주니 굉장히 편리합니다. 하지만 아주 극심한 스큐나 특정 상황에서는 수동 솔팅이 더 효과적일 수도 있으니, 항상 Spark UI를 통해 AQE가 제대로 작동하는지 확인하는 습관을 들이는 것이 좋습니다.
// AQE 활성화 (대부분 기본으로 활성화되어 있습니다.) spark.conf.set("spark.sql.adaptive.enabled", "true") spark.conf.set("spark.sql.adaptive.skewedJoin.enabled", "true")
세심한 데이터 전처리 및 아키텍처 개선
데이터 자체를 다루는 방식이나 아키텍처를 개선하는 방법도 있습니다.
- 스큐 유발 키 사전 필터링 또는 분리 처리:만약 특정 스큐 유발 키(예: '탈퇴 회원' ID)의 데이터가 분석적으로 중요하지 않거나, 전체 데이터의 극히 일부를 차지하면서도 성능에 치명적이라면, 아예 해당 키를 미리 필터링하여 제거하거나, 별도의 Spark 작업으로 분리하여 처리하는 것을 고려해볼 수 있습니다. 예를 들어, 스큐된 키만 따로 모아서 처리한 후, 그 결과를 다시 메인 데이터셋과 Union하는 방식이죠.
- 작은 테이블 브로드캐스트 조인 (Broadcast Join):두 테이블을 조인할 때, 한쪽 테이블이 매우 작다면 (일반적으로 수백 MB 이하) 해당 테이블을 모든 Executor의 메모리에 복사(브로드캐스트)하여 조인을 수행할 수 있습니다. 이렇게 하면 셔플 없이 로컬에서 조인이 이루어지므로 스큐 문제가 발생하지 않고, 훨씬 빠르게 조인을 완료할 수 있습니다.
// 자동 브로드캐스트 임계값 설정 (기본값 10MB) // 이보다 작은 테이블은 자동으로 브로드캐스트 조인됩니다. spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50MB") // 50MB로 상향 조정 예시 // 특정 DataFrame을 명시적으로 브로드캐스트 import org.apache.spark.sql.functions.broadcast val resultDf = df1.join(broadcast(df2), "key")- 데이터 파티셔닝 전략 재검토:Spark 작업의 파티셔닝 전략이 스큐를 악화시키는 경우도 있습니다. 조인이나 그룹핑에 사용되는 키를 기반으로 데이터를 다시 해시 파티셔닝(Hash Partitioning)하거나, 레인지 파티셔닝(Range Partitioning)을 사용하여 데이터가 최대한 균등하게 분산되도록 조정할 수 있습니다. `repartition()` 함수를 사용해 파티션 수를 조절하거나, 특정 키로 재분배할 수 있습니다.
// 'key' 컬럼을 기준으로 200개의 파티션으로 재분배 val repartitionedDf = originalDf.repartition(200, col("key"))
위에서 언급된 주요 스큐 해결 전략들을 간략히 비교해볼까요?
| 전략 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| Salting | 매우 극심한 스큐에 효과적, 수동 제어 가능 | 코드 복잡도 증가, 조인 대상도 솔팅 필요 | AQE로 해결되지 않는 고질적인 스큐, 특정 키에 대한 심층 분석 |
| Spark AQE (Skewed Join) | 자동 감지 및 최적화, 개발 부담 감소 | Spark 3.x 이상, 모든 스큐를 해결하진 못함 | 대부분의 스큐 상황, 개발 효율성 중시 |
| Broadcast Join | 셔플 없이 초고속 조인 가능, 스큐 원천 차단 | 작은 테이블에만 적용 가능 (메모리 제한) | 조인 대상 중 하나의 테이블이 매우 작을 때 |
| 파티셔닝 재조정 | 데이터 분산의 근본적인 개선 | 추가 셔플 발생 가능, 키 선택 중요 | 데이터 저장 방식/처리 흐름 개선 필요 시 |
Image by Counselling on Pixabay
팀 운영 관점에서 데이터 스큐를 바라보는 시선
데이터 스큐는 단순히 하나의 기술적인 문제로 끝나지 않습니다. 팀의 생산성, 서비스의 안정성, 그리고 궁극적으로는 데이터 기반 의사결정의 속도에까지 영향을 미치는 중요한 이슈입니다. 테크리드나 엔지니어링 매니저님들은 이러한 관점에서 스큐 문제를 관리해야 합니다.
- 사전 예방 및 모니터링 시스템 구축:스큐는 언제든 발생할 수 있으므로, Spark UI나 모니터링 시스템을 통해 스큐 발생 징후를 조기에 감지할 수 있는 알림 체계를 구축하는 것이 중요합니다. 특정 Executor의 메모리 사용량, GC 시간, Task Duration 등이 임계치를 넘어서면 알림을 받도록 설정해두면 좋겠죠.
- 지식 공유 및 코드 리뷰 문화 정착:팀원들이 Spark 작업을 설계하고 개발할 때부터 데이터 스큐를 인지하고 예방할 수 있도록 교육하고, 코드 리뷰 시 조인이나 그룹핑 로직의 스큐 가능성을 함께 검토하는 문화를 만드는 것이 중요합니다. 특정 서비스의 데이터 특성상 스큐가 자주 발생하는 키 값이나 패턴이 있다면, 이를 문서화하여 공유하는 것도 큰 도움이 됩니다.
- 지속적인 최적화와 개선:데이터 스큐는 데이터의 양과 특성이 변함에 따라 계속해서 모습을 바꿀 수 있습니다. 따라서 한 번 해결했다고 끝이 아니라, 주기적으로 Spark 작업의 성능을 모니터링하고 최적화하는 과정을 팀의 업무 프로세스에 포함시키는 것이 좋습니다. 새로운 Spark 버전의 기능(AQE 등)을 적극적으로 활용하는 것도 좋은 방법이죠.
마무리하며: 스큐, 더 이상 팀의 발목을 잡지 않도록!
지금까지 Spark 작업에서 데이터 스큐가 왜 발생하고, 어떻게 진단하며, 어떤 전략으로 해결할 수 있는지 이야기해봤습니다. 데이터 스큐는 복잡한 분산 처리 환경에서 필연적으로 마주칠 수 있는 문제이지만, 정확한 이해와 적절한 해결 전략만 있다면 충분히 극복할 수 있습니다.
테크리드/엔지니어링 매니저님들께서 이 글을 통해 팀의 Spark 작업 성능을 한 단계 더 끌어올리고, 데이터 엔지니어링 파이프라인의 안정성을 확보하는 데 도움이 되셨기를 바랍니다. 혹시 여러분 팀은 어떤 기발한 방법으로 데이터 스큐를 해결하고 계신가요? 댓글로 경험을 공유해주시면 다른 분들께도 큰 도움이 될 거예요!
📌 함께 읽으면 좋은 글
- [데이터 엔지니어링] Airflow DAG 실행 시간, 이렇게 줄였습니다: 실전 최적화 경험기
- [생산성 자동화] 수동 모니터링 vs. Telegram 봇 자동화: 클라우드 인스턴스 관리, 무엇이 더 효율적일까요?
- [AI 머신러닝] LLM 개발 팀, 우리 프롬프트는 어디로 사라졌을까요? 혼돈 속 생산성 비결
이 글이 도움이 되셨다면 공감(♥)과 댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.
'데이터 엔지니어링' 카테고리의 다른 글
| 데이터 분석 보고서가 지연될 때, Spark Catalyst Optimizer는 어떤 마법을 부릴까? (0) | 2026.07.17 |
|---|---|
| SQL 재귀 CTE, 무작정 쓰면 망합니다: 계층형 데이터 처리의 숨겨진 함정과 최적화 전략 (0) | 2026.07.13 |
| 데이터 레이크하우스 전환: 성공을 위한 5가지 핵심 체크리스트 (1) | 2026.07.11 |
| dbt 증분 모델 구현, 이 실수 때문에 데이터 유실과 비효율에 시달렸습니다 (0) | 2026.07.08 |
| Airflow DAG 실행 시간, 이렇게 줄였습니다: 실전 최적화 경험기 (0) | 2026.07.07 |