Delta Lake 도입 6개월 회고
파일이 덮어써졌다, 그리고 우리는 아무것도 몰랐다
이 글을 읽고 나면 Delta Lake 도입을 고민할 때 실제로 어떤 문제를 마주치는지, 그리고 우리가 6개월간 겪은 시행착오를 통해 무엇을 바꿀 수 있는지 구체적으로 파악할 수 있다. 벤더 문서에는 나오지 않는 이야기를 쓰려 한다.
도입: 데이터가 사라진 월요일 아침
작년 3월, 새벽 2시에 Slack 알림이 울렸다. 배치 파이프라인이 실행 중인 동안 다른 팀의 Ad-hoc 쿼리가 동일한 Parquet 파일을 읽다가 절반만 쓰인 파일을 잡아버렸다. 집계 숫자가 전날 대비 40%가량 빠진 상태로 대시보드에 올라왔고, 데이터팀은 오전 내내 원인을 추적했다. 결국 문제는 간단했다. 파일이 쓰이는 도중에 다른 프로세스가 읽었던 것이다.
Parquet + S3를 쓰는 팀이라면 한 번쯤 겪었을 상황이다. 쓰기 도중 읽기, 덮어쓰기 실패 후 남은 쓰레기 파일, 스키마가 슬그머니 바뀌어서 파이프라인이 조용히 깨지는 일. 우리 팀은 이 문제들을 각각 별도 스크립트와 암묵적 규칙으로 막아왔는데, 그 방어막이 결국 뚫렸다.
배경: 왜 Parquet + S3만으로는 버텼을까, 그리고 왜 한계에 왔나
기존 구조는 이랬다. Airflow로 Spark 배치를 돌리고, 결과를 S3의 Parquet 파일로 저장한 뒤 Athena나 Redshift Spectrum으로 읽는 전형적인 레이크하우스 흉내였다. 동시성 문제를 막기 위해 파일 경로에 타임스탬프를 붙이고, 쿼리 시점에 최신 파티션만 읽는 규칙을 문서에 써두었다. 팀이 3명일 때는 통했다. 7명이 되고 파이프라인이 30개를 넘어서자 그 문서는 아무도 읽지 않는 Notion 페이지가 되었다.
대안을 세 가지 검토했다.
| 옵션 | 장점 | 단점 |
|---|---|---|
| Apache Hudi | Upsert 성능 우수, CDC 지원 성숙 | 운영 복잡도 높음, 팀 내 경험 없음 |
| Apache Iceberg | 표준화 추세, 멀티 엔진 지원 | 당시 Spark 3.3 환경에서 DML 지원 미흡 |
| Delta Lake | Databricks 생태계 연동, ACID 보장, Python API 성숙 | Databricks 종속 우려 (OSS로 해소 가능) |
우리 환경이 이미 EMR + Spark였고, delta-spark 오픈소스 커넥터가 Databricks 없이도 동작하는 것을 확인한 뒤 Delta Lake를 선택했다.
실행: 3단계로 나눠서 넘어갔다
1단계: 새 파이프라인에만 적용 (1~2개월)
기존 테이블을 건드리지 않고 신규 파이프라인 두 개를 Delta 포맷으로만 만들었다. 목표는 "일단 써본다"였다.
# delta-spark 3.x, PySpark 3.3 기준
from delta import configure_spark_with_delta_pip, DeltaTable
from pyspark.sql import SparkSession
builder = (
SparkSession.builder
.appName("delta-poc")
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
.config(
"spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog",
)
)
spark = configure_spark_with_delta_pip(builder).getOrCreate()
# 첫 적재: S3에 Delta 테이블 생성
df.write.format("delta").mode("overwrite").save("s3://our-lake/events/delta/")
이 단계에서 첫 번째 문제를 만났다. EMR 6.9 환경에서 delta-core JAR 버전과 Spark 버전이 맞지 않으면 ClassNotFoundException이 조용히 터진다. 공식 호환성 매트릭스를 반드시 확인해야 한다. 우리는 이틀을 날렸다.
2단계: UPSERT 패턴 도입 (3~4개월)
이벤트 테이블에서 중복 이벤트를 처리하는 로직을 MERGE INTO로 교체했다.
delta_table = DeltaTable.forPath(spark, "s3://our-lake/events/delta/")
# 신규 데이터와 기존 테이블을 event_id 기준으로 병합
(
delta_table.alias("target")
.merge(
source=new_df.alias("source"),
condition="target.event_id = source.event_id",
)
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute()
)
이전에는 중복 제거를 위해 INSERT OVERWRITE + ROW_NUMBER() 조합을 썼는데, 파티션 단위로 전체를 다시 쓰는 방식이라 파티션이 커질수록 느려졌다. MERGE로 바꾸자 해당 파이프라인의 실행 시간이 23분에서 8분으로 줄었다.
3단계: 기존 테이블 마이그레이션 (5~6개월)
가장 조심스러운 단계였다. CONVERT TO DELTA를 쓰면 기존 Parquet 파일을 Delta 포맷으로 전환할 수 있다.
-- Spark SQL
CONVERT TO DELTA parquet.`s3://our-lake/legacy_table/`
PARTITIONED BY (dt STRING)
이 명령은 기존 파일을 복사하지 않는다. _delta_log/ 디렉터리만 추가한다. 즉 변환은 빠르지만, 변환 전 Parquet 리더가 _delta_log/를 무시하는지 반드시 확인해야 한다. Athena의 경우 Delta Lake 리더를 명시적으로 설정하지 않으면 _delta_log/ 안의 JSON 파일을 데이터로 오인해서 스캔 오류를 낸다.
결과: 숫자와 예상 밖의 것들
정량적 변화
- 파이프라인 오류 중 "파일 충돌" 관련 장애: 월평균 4건 → 0건 (마이그레이션 완료 후 2개월 기준)
- 대표 UPSERT 파이프라인 실행 시간: 23분 → 8분 (약 65% 단축)
VACUUM실행 전 S3 스토리지 증가량: 예상보다 30% 높았음 (아래 회고 참고)
예상과 달랐던 것들
Time Travel(과거 버전 데이터를 쿼리하는 기능)은 솔직히 처음엔 "있으면 좋은 기능" 정도로 생각했다. 그런데 실제로 팀이 가장 많이 쓴 기능이 됐다. 배치 결과가 이상할 때 전날 버전과 비교하는 용도였다.
# 어제 버전 데이터와 오늘 버전 비교
df_yesterday = spark.read.format("delta").option("timestampAsOf", "2024-11-01").load(path)
df_today = spark.read.format("delta").load(path)
반면 Schema Evolution(컬럼 추가·변경을 자동 감지하는 기능)은 기대보다 덜 썼다. 실제 환경에서는 스키마 변경이 의도치 않은 경우가 더 많아서, 자동 진화보다 명시적 검증을 선호하게 됐다.
회고: 다시 한다면 두 가지를 바꾼다
첫째, VACUUM 정책을 처음부터 설계했을 것이다.
Delta Lake는 모든 변경 이력을 _delta_log/와 이전 버전 파일로 보관한다. VACUUM을 주기적으로 실행하지 않으면 S3 비용이 빠르게 불어난다. 우리는 3개월 후에야 이 문제를 인식했고, 그때는 이미 레거시 파일이 꽤 쌓여 있었다.
# 기본 보존 기간(7일)보다 짧게 설정할 경우 안전 옵션 해제 필요
spark.conf.set("spark.databricks.delta.retentionDurationCheck.enabled", "false")
delta_table.vacuum(retentionHours=168) # 7일
둘째, Athena 연동 검증을 마이그레이션 전에 끝냈을 것이다.
분석팀이 Athena를 주력으로 쓰고 있었는데, Delta Lake 테이블을 Athena에서 읽으려면 AWS Glue Data Catalog에 Delta Lake 전용 테이블 타입을 별도로 설정해야 한다. 이 작업을 마이그레이션 막바지에 발견해서 일정이 2주 늘어났다.
일반화할 수 있는 교훈은 하나다. 포맷을 바꾸는 것보다 다운스트림 소비 시스템을 파악하는 데 더 많은 시간을 써야 한다. Delta Lake 자체는 예상대로 동작했다. 문제는 항상 그 옆에 붙어있는 Athena, Redshift Spectrum, 레거시 리더들이었다.
6개월이 지난 지금, 팀은 새 파이프라인을 만들 때 Parquet를 고려하지 않는다. 그게 가장 조용한 성공 지표라고 생각한다.
편집자 주 (needs_review): "UPSERT 파이프라인 실행 시간 23분 → 8분" 수치는 특정 파티션 크기·클러스터 사양 기준이며, 환경에 따라 편차가 크다. 일반적 성능 기준으로 인용 시 주의 필요. Athena Delta Lake 연동 요구 사항은 AWS 릴리스 주기에 따라 변경될 수 있으므로 최신 공식 문서 확인 권장.