PySpark Small Files Problem — 수만 개 작은 파일 잡기
Spark 출력이 수만 개의 작은 파일로 쪼개져 다음 잡과 쿼리를 느리게 만드는 Small Files Problem 을 다룹니다. 원인, coalesce vs repartition, AQE, 파티션 쓰기 전략, 그리고 Iceberg/Delta 컴팩션까지 실전 해법을 정리합니다.
Spark 잡은 성공했는데, 출력 디렉터리를 열어보니 2KB 짜리 파일이 4만 개 쌓여 있습니다. 데이터는 100MB 인데 파일은 4만 개. 이걸 다음 잡이 읽으면 태스크 4만 개가 뜨고, 메타스토어·NameNode·오브젝트 스토리지가 비명을 지릅니다. 이것이 데이터 엔지니어링의 고질병 Small Files Problem 입니다.
이 글은 작은 파일이 왜 생기는지, 어떤 피해를 주는지, 그리고 coalesce/repartition 부터 Iceberg/Delta 컴팩션까지 실전 해법을 정리합니다.
1. 왜 작은 파일이 문제인가
작은 파일이 많으면 데이터 양과 무관하게 오버헤드가 폭증합니다.
| 피해 | 이유 |
|---|---|
| 다음 잡이 느림 | 파일 1개당 최소 1 태스크 → 4만 파일 = 4만 태스크 스케줄링 |
| 메타데이터 폭증 | 파일마다 메타 엔트리, NameNode 메모리·HMS 부하 |
| 오브젝트 스토리지 비용 | S3 등은 요청(GET/LIST) 단위 과금 + 지연 |
| 쿼리 플래닝 지연 | 엔진이 모든 파일 footer 를 열어 통계 확인 |
| 압축률 저하 | 파일이 작으면 컬럼 압축·인코딩 효율↓ |
적정 파일 크기 목표는 보통 128MB ~ 1GB 입니다. 수 KB
수 MB 파일이 수천수만 개라면 정리 대상입니다.
2. 작은 파일은 어디서 생기나
원인 ① 셔플 파티션 수가 데이터에 비해 큼
Spark 의 기본 spark.sql.shuffle.partitions 는 200 입니다. 셔플(groupBy/join) 후 쓰면 데이터가 작아도 최대 200개 파일이 나옵니다. 데이터가 더 작으면 파일마다 더 작아집니다.
100MB 데이터 + shuffle.partitions=200 → 파일 200개 × 평균 0.5MB원인 ② 파티션 컬럼 + 많은 파티션 쓰기
partitionBy 로 쓸 때, 각 (출력 파티션 × Spark 파티션) 조합마다 파일이 생깁니다.
파티션 컬럼 dt=365일 × Spark 파티션 200 → 최대 73,000 파일원인 ③ 스트리밍 마이크로배치
Structured Streaming 은 트리거마다 파일을 씁니다. 5초 트리거면 하루 17,000+ 배치 = 막대한 파일.
3. 해법 ① coalesce vs repartition
쓰기 직전 파티션 수를 줄여 출력 파일 수를 제어합니다. 둘의 차이를 정확히 알아야 합니다.
# repartition: 풀 셔플로 균등 재분배 (파티션 수 늘리거나 줄이기 모두)
df.repartition(10).write.parquet("out") # 정확히 10개, 균등
# coalesce: 셔플 없이 파티션 병합 (줄이기 전용)
df.coalesce(10).write.parquet("out") # 10개, 셔플 없음(빠름) 하지만 불균등 가능coalesce(n) | repartition(n) | |
|---|---|---|
| 셔플 | 없음(기존 파티션 병합) | 풀 셔플 |
| 속도 | 빠름 | 느림 |
| 균등성 | 불균등할 수 있음 | 균등 |
| 용도 | 단순 파일 수 축소 | 균등 분배·키 기준 재분배 |
함정:
coalesce(1)로 파일 1개를 만들면, 마지막 단계가 단일 태스크가 되어 그 한 태스크가 전체 데이터를 처리합니다 → 느려지거나 OOM. 적정 개수로 줄이고, 정말 1개가 필요하면 데이터가 작을 때만 하세요.
적정 파티션 수 계산
target_size = 256 * 1024 * 1024 # 256MB
# 대략적인 추정: 총 바이트 / 목표 크기
num_partitions = max(1, int(total_bytes / target_size))
df.repartition(num_partitions).write.parquet("out")4. 해법 ② AQE 자동 병합 (권장)
수동 계산 대신, AQE 가 셔플 후 작은 파티션들을 런타임에 자동 병합하게 둡니다.
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
# 병합 후 목표 파티션 크기
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128MB")AQE 는 데이터 크기를 보고 파티션 수를 동적으로 줄여, 일정 크기의 파일을 만들어 줍니다. 대부분의 셔플 후 쓰기에서 이것만으로 작은 파일이 크게 줄어듭니다.
5. 해법 ③ 파티션 쓰기 전략
partitionBy 로 쓸 때 파일 폭증을 막으려면, 출력 파티션 컬럼 기준으로 재분배한 뒤 씁니다.
# BAD: 각 Spark 파티션이 모든 dt 에 대해 파일 생성 → 폭증
df.write.partitionBy("dt").parquet("out")
# GOOD: dt 로 재분배 → dt 당 파일 수 제어
(df.repartition("dt") # 같은 dt 를 한 파티션에 모음
.write.partitionBy("dt").parquet("out")) # dt 당 파일 1~소수
# dt 당 여러 파일이 필요하면 (큰 파티션)
df.repartition("dt", "bucket_col").write.partitionBy("dt").parquet("out")repartition("dt") 는 같은 dt 행을 한 Spark 파티션으로 모아, dt 디렉터리당 파일 수를 최소화합니다.
6. 해법 ④ Iceberg / Delta — 테이블 포맷의 컴팩션
이 블로그 독자에게 가장 실용적인 해법입니다. Lakehouse 테이블 포맷은 작은 파일을 사후에 병합하는 컴팩션 기능을 내장합니다. 쓰기는 자유롭게 하고, 정기적으로 컴팩션합니다.
Iceberg
# Spark SQL 로 Iceberg 컴팩션 (작은 파일 → 큰 파일 재작성)
spark.sql("""
CALL catalog.system.rewrite_data_files(
table => 'analytics.events',
options => map('target-file-size-bytes', '536870912') -- 512MB
)
""")
# 오래된 스냅샷·고아 파일 정리
spark.sql("CALL catalog.system.expire_snapshots('analytics.events', TIMESTAMP '2026-06-01 00:00:00')")
spark.sql("CALL catalog.system.remove_orphan_files(table => 'analytics.events')")Iceberg 는 쓰기 시 자동 정렬·팬아웃을 제어하는 속성도 있어, 쓰기 단계에서부터 작은 파일을 줄일 수 있습니다. (Trino 쪽 동일 작업은 별도 글 "Trino 로 Iceberg 테이블 유지보수"에서 다뤘습니다.)
Delta Lake
# OPTIMIZE 로 컴팩션 (+ Z-Order 선택)
spark.sql("OPTIMIZE analytics.events WHERE dt >= '2026-06-01'")
spark.sql("OPTIMIZE analytics.events ZORDER BY (user_id)")
# 오래된 파일 정리
spark.sql("VACUUM analytics.events RETAIN 168 HOURS") # 7일
# 쓰기 시 자동 컴팩션 / 최적화 쓰기
spark.conf.set("spark.databricks.delta.optimizeWrite.enabled", "true")
spark.conf.set("spark.databricks.delta.autoCompact.enabled", "true")| 포맷 | 컴팩션 | 정리 |
|---|---|---|
| Iceberg | rewrite_data_files | expire_snapshots, remove_orphan_files |
| Delta | OPTIMIZE (+ZORDER) | VACUUM |
Lakehouse 를 쓴다면 이 패턴이 정석입니다: 스트리밍/잦은 쓰기는 작은 파일을 만들도록 두고, 스케줄러로 정기 컴팩션. 쓰기 지연과 파일 크기를 분리해서 관리할 수 있습니다.
7. 스트리밍의 작은 파일
Structured Streaming 은 본질적으로 작은 파일을 만듭니다. 두 갈래로 대응합니다.
# 1) 트리거 간격을 늘려 배치당 데이터를 키움
query = (df.writeStream
.trigger(processingTime="5 minutes") # 5초가 아니라 5분
.format("iceberg").outputMode("append")
.toTable("analytics.events"))
# 2) 그래도 쌓이는 작은 파일은 별도 컴팩션 잡으로 주기적 정리스트리밍은 "쓰기는 작게, 컴팩션은 따로"가 현실적인 정답입니다.
8. 진단 — 작은 파일 현황 확인
# 디렉터리 파일 수·크기 분포 확인 (Iceberg 메타데이터 테이블)
spark.sql("""
SELECT count(*) AS files,
avg(file_size_in_bytes)/1024/1024 AS avg_mb,
min(file_size_in_bytes)/1024 AS min_kb
FROM catalog.analytics.events.files
""").show()avg_mb 가 한 자릿수이고 files 가 수천 이상이면 컴팩션 대상입니다.
9. 정리
| 해법 | 언제 |
|---|---|
| AQE coalescePartitions | 셔플 후 쓰기 — 먼저 켜기 |
repartition(n) / coalesce(n) | 출력 파일 수 직접 제어 |
repartition(파티션컬럼) + partitionBy | 파티션 쓰기 폭증 방지 |
Iceberg rewrite_data_files / Delta OPTIMIZE | 사후 컴팩션 (정기) |
| 트리거 간격↑ | 스트리밍 |
Small Files Problem 의 핵심은 "쓰기 시점에 파일 수를 제어"하고 "그래도 쌓이는 건 컴팩션으로 정리"하는 두 축입니다. 배치 잡은 AQE + 적정 repartition 으로 쓰기 시점에 잡고, 스트리밍·잦은 적재는 Iceberg/Delta 의 컴팩션을 스케줄링하세요. coalesce(1) 의 유혹만 피하면, 작은 파일은 충분히 길들일 수 있는 문제입니다.
이 글은 Spark 3.5 + Iceberg/Delta 기준으로 작성되었습니다. Lakehouse 적재 파이프라인의 파일 최적화·컴팩션 자동화가 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀