Blog
pysparksparksmall-filesicebergdeltadata-engineering

PySpark Small Files Problem — 수만 개 작은 파일 잡기

Spark 출력이 수만 개의 작은 파일로 쪼개져 다음 잡과 쿼리를 느리게 만드는 Small Files Problem 을 다룹니다. 원인, coalesce vs repartition, AQE, 파티션 쓰기 전략, 그리고 Iceberg/Delta 컴팩션까지 실전 해법을 정리합니다.

Data Dynamics2026년 6월 5일10 min read

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.partitions200 입니다. 셔플(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")
포맷컴팩션정리
Icebergrewrite_data_filesexpire_snapshots, remove_orphan_files
DeltaOPTIMIZE (+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 엔지니어링 팀