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
This post is not yet translated. The original Korean version is shown below.

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 엔지니어링 팀