[Databricks] 멀티 리전 Delta Table 최적화: OPTIMIZE & VACUUM 자동화 파이프라인 구축기

반응형

📌 이 글의 핵심 요약 (Key Takeaways)

멀티 리전 Databricks 환경에서 발생하는 고질적인 'Small File 문제'와 스토리지 낭비를 해결하기 위해, 메타데이터 기반으로 OPTIMIZE & VACUUM 작업을 완전히 자동화한 데이터 파이프라인 구축기입니다. 본 글에서는 수백 개의 분산된 델타 테이블 중 정리가 필요한 대상 테이블을 자동으로 판별하는 동적 메타데이터 관리 기법, 데이터 유실 없이 디스크 용량을 확보하는 안전한 VACUUM 자동화 아키텍처, 그리고 멀티 리전 간의 효율적인 배치 최적화 노하우를 실무 코드와 함께 다룹니다.

 


안녕하세요. 주형권입니다.
최근 Databricks 환경을 운영하면서 피할 수 없는 숙명과도 같은 문제를 마주했습니다. 바로 데이터 엔지니어들의 영원한 골칫거리인 Small File 문제입니다. 현재 팀에서는 여러 글로벌 워크스페이스(멀티 리전)를 운영 중인데, 데이터가 쌓이고 MERGE가 빈번해지다 보니 파일들이 끝없이 잘게 쪼개지기 시작했습니다.

수동으로 치우는 것도 한계가 있다. 시스템이 알아서 찾고, 알아서 청소하게 만들자.

 
평균 파일 사이즈가 5MB에서 130MB 수준으로 파편화되니 성능이 떨어지는 건 당연한 수순이었습니다.(심지어 저희는 삭제도 합니다.) 매번 수동으로 OPTIMIZE와 VACUUM을 돌리는 건 비효율의 극치이며, 수백 개의 테이블 중 당장 어떤 테이블이 문제인지 파악하기도 어렵습니다. 그래서 매주 주 주말, 스스로 대상을 찾아내서 최적화를 수행하는 자동화 파이프라인을 만들기로 결심했습니다.
 

⚠️ 잠깐, Small File이 대체 왜 문제일까?
본격적인 파이프라인 구축기에 앞서, 파일 크기가 작은 것이 왜 문제가 되는지, 그리고 왜 일회성 정리가 아닌 꾸준한 관리가 필요한지 짧게 짚고 넘어가겠습니다.

1. 숨 막히는 Metadata 오버헤드와 I/O 병목 Spark 클러스터가 데이터를 읽을 때, 실제 데이터를 처리하는 시간보다 수만 개의 자잘한 파일 목록(List)을 읽어오고 각 파일에 대한 네트워크 커넥션을 맺고 끊는 데(Open/Close) 더 많은 시간을 허비하게 됩니다. 극단적으로 말해 1GB짜리 파일 1개를 읽는 것보다, 1MB짜리 파일 1,000개를 읽는 것이 훨씬 느리고 클러스터의 Driver 노드에 엄청난 메모리 부하(OOM)를 유발합니다. 또한 클라우드 스토리지(S3 등)의 API 호출 횟수도 급증하여 불필요한 비용이 발생합니다.

2. Delta Lake의 숙명: "변경할 때마다 파일은 늘어난다"Databricks의 근간인 Delta Lake는 데이터의 일관성을 유지하기 위해 MVCC(Multi-Version Concurrency Control) 방식을 사용합니다. 즉, 기존 데이터를 UPDATE 하거나 DELETE 할 때 원본 파일을 직접 수정하지 않고, 새로운 데이터 파일을 만들고 기존 파일은 논리적으로 무효화(Tombstone) 시킵니다.또한, 실시간이나 마이크로 배치로 데이터가 계속(Streaming) 들어오게 되면 필연적으로 작은 파일들이 우후죽순 생성될 수밖에 없는 구조입니다.

결국, 데이터를 활발하게 변경하고 적재하는 살아있는 플랫폼일수록 Small File은 끊임없이 증식합니다. 이것이 바로 우리가 일회성 수동 정리가 아닌, "지속적이고 자동화된 관리 파이프라인"을 구축해야만 했던 이유입니다.

 

1. 비용과 안정성을 고려한 아키텍처 설계 🧠

처음 설계를 시작할 때 가장 중요하게 생각한 건 "안전장치""비용 효율성"이었습니다. Databricks의 컴퓨팅 자원도 결국 비용입니다. 무작정 전체 테이블을 대상으로 OPTIMIZE를 수행하면 그 비용의 압박은 고스란히 엔지니어에게 돌아옵니다. (이걸 하는 이유도 비용인데...)
 
따라서 다음과 같은 4가지 핵심 설계 원칙을 세웠습니다.

  1. 각 워크스페이스 독립 실행
  2. 멀티 리전 간의 불필요한 의존성을 없애고, 크로스 워크스페이스 API 호출 없이 각 환경에서 로컬로 완결되도록 구성했습니다.
  3. 자동 후보 선정
  4. 무분별한 실행이 아닌, information_schema와 DESCRIBE DETAIL을 활용해 실시간으로 테이블 상태를 진단하여 꼭 필요한 대상만 추려냅니다.
  5. 우선순위 기반 처리
    CRITICAL ➔ WARNING ➔ VACUUM_ONLY 순으로 상태별 심각도를 나누어 시급한 대상부터 우선 처리합니다.
  6. 실행 로그 중앙화
    결과를 테이블에 저장해서 언제든 실행 이력을 추적할 수 있도록 구성했습니다.

이를 바탕으로 설계한 전체 아키텍처는 다음과 같습니다. 토요일 오전 9시가 되면 각 워크스페이스의 스케줄러가 알아서 진단, 최적화, 로깅까지 한 사이클을 돌고 종료되는 구조입니다. 사실 아래의 전체적인 아키텍처를 보면 구조가 굉장히 단순합니다. 각각 Workspace에서 독립적으로 수행하고 이를 각각의 로그 테이블에 저장하고 주기적으로 중앙에 로그를 쏘는 구조입니다.

자동 Optimize / VACCUM 아키텍처

 
중앙 Workspace에 하나로 로그를 모두 모은 이유는 추후에 Report 시스템이나 대시보드를 만들거나 알림을 주기 위해서 한번에 모았습니다. 중앙 Workspace에서 데이터를 모아서 한 번에 조작을 해야지 추후에 뭘 하든 편하기 때문입니다.


2. Optimize / VACCUM 대상 테이블 선정 🎯

어느 워크스페이스에 배포해도 코드 수정 없이 동작하도록 단일 노트북(5개 Cell)으로 파이프라인을 구성했습니다. 그중에서 가장 공들인 부분은 단연 후보 진단 로직입니다. 해당 로직은 노트북 상단 셀에 아래와 같이 구성했습니다. (128 ~ 512MB로 로직을 만든 이유는 위의 Small파일 부분에 설명)
 
먼저, 클러스터 환경에 상관없이 파라미터를 받고 리전을 판별하는 초기 설정 코드입니다.

import time
from datetime import datetime

# 1. 위젯 파라미터 설정
dbutils.widgets.text("dry_run", "true", "Dry Run Mode")
dbutils.widgets.text("max_table_size_gb", "500", "Max Table Size (GB)")
dbutils.widgets.text("min_table_size_gb", "10", "Min Table Size (GB)")
dbutils.widgets.text("optimize_timeout_min", "180", "Optimize Timeout (min)")

dry_run = dbutils.widgets.get("dry_run").strip().lower() == "true"
max_table_size_gb = int(dbutils.widgets.get("max_table_size_gb"))
min_table_size_gb = int(dbutils.widgets.get("min_table_size_gb"))
timeout_seconds = int(dbutils.widgets.get("optimize_timeout_min")) * 60

# 2. 워크스페이스 URL 기반 리전 자동 판별
workspace_url = spark.conf.get("spark.databricks.workspaceUrl", "")
if "a" in workspace_url:
    region_prefix = "a_"
elif "b" in workspace_url:
    region_prefix = "b_"
else:
    region_prefix = "c_"

start_time = time.time()

 
가장 공들인 부분은 단연 후보 진단 로직입니다. information_schema를 통해 대상을 수집하고 DESCRIBE DETAIL로 실제 사이즈를 진단합니다.

from pyspark.sql import Row

# Delta 테이블 목록 조회 및 Exclude 테이블 필터링 과정 생략...

candidates = []

# 3. 테이블별 상태 진단 및 후보 판별
for tbl in tables_df.collect():
    full_name = f"`{tbl.table_catalog}`.`{tbl.table_schema}`.`{tbl.table_name}`"
    detail = spark.sql(f"DESCRIBE DETAIL {full_name}").collect()[0]
    
    num_files = int(detail["numFiles"])
    size_bytes = int(detail["sizeInBytes"])
    size_gb = size_bytes / (1024**3)
    
    # 설정한 파라미터 사이즈 필터링
    if size_gb <= min_table_size_gb or size_gb >= max_table_size_gb or num_files <= 50:
        continue
        
    avg_file_mb = (size_bytes / num_files / (1024**2)) if num_files > 0 else 0
    is_candidate = False

    # 핵심 판별 로직: 사이즈 및 DML 이력 확인
    if avg_file_mb < 128:
        is_candidate = True
    elif avg_file_mb < 512:
        history = spark.sql(f"DESCRIBE HISTORY {full_name} LIMIT 50").collect()
        has_dml = any(h["operation"] in ("DELETE","UPDATE","MERGE") for h in history)
        if has_dml:
            is_candidate = True

    if is_candidate:
        candidates.append(Row(
            table_catalog=tbl.table_catalog, table_schema=tbl.table_schema, table_name=tbl.table_name, 
            avg_file_size_mb=avg_file_mb, size_gb=size_gb, num_files=num_files
        ))

# 결과를 Temp View로 저장하여 다음 작업으로 전달
spark.createDataFrame(candidates).createOrReplaceTempView("optimize_candidates")

 
이 로직을 보면 임계값이 1GB가 아닌 512MB로 설정되어 있습니다. 여기에는 비용과 효율을 고려한 설계 의도가 담겨 있습니다.

  • 128MB 미만 (무조건 실행): 이미 Small File 문제가 심각한 상태이므로, 히스토리를 묻고 따질 것 없이 무조건 OPTIMIZE 대상으로 편입시킵니다.
  • 128MB ~ 512MB 구간 (DML 이력 확인): 사실 512MB 수준만 되어도 읽기(I/O) 성능상 큰 무리가 없습니다. 단순 쿼리 성능 향상만을 위해 이 큰 파일들을 1GB로 합치는 작업은 컴퓨팅 비용 낭비입니다. 하지만 최근 트랜잭션에 DML(DELETE, UPDATE, MERGE) 이력이 있다면, 논리적으로 삭제된 가비지 파일들이 S3에 대량으로 쌓여 스토리지 비용을 갉아먹고 있을 확률이 높습니다. 따라서 이 구간은 가비지 파일을 지우는 VACUUM 효율 극대화에 초점을 맞추어 대상을 선정합니다.
  • 512MB 이상 (대상 제외): 이상적인 파일 포맷을 유지하고 있는 상태이므로, 굳이 컴퓨팅 비용을 들여 재배치하지 않도록 원천 제외했습니다.

3. 설계 과정에서 겪은 이슈들 🤦🏻‍♂️

머릿속의 설계가 한 번에 매끄럽게 돌아가는 법은 없습니다. 이번에도 예상치 못한 변수들을 마주했습니다.

이슈 원인 및 해결 과정
네트워크 차단 (VPC 방화벽)  초기에 중앙 Workspace에서 통합 제어 하려 하였으나 API 호출이 방화벽에 의해 차단된 것을 발견 ➔ 하지만 각각의 Workspace에서 중앙 Workspace로 보내는 Outbound는 열려 있음을 확인 ➔ 이를 기반으로 각자 독립 실행 후 로그를 저장하고 중앙 Workspace로 보내서 관리 하도록 설정 

🚩 물론 앞서 말한것과 같이 중앙에서 관리 해야지 알림/대시보드등 만들기 편함도 있었습니다. 결국 내가 Romote 실행이냐 수행하고 보내주느냐의 차이였던거 같습니다. 나중에 각 Workspace에서 로그를 남기면 여러 Workspace에서 Job을 관리 해야하므로 귀찮긴 했지만 이것을 위해서 네트워크를 허용하는 것은 너무 리스크가 크다고 판단 하였습니다.
너무 큰 테이블  너무 큰 테이블의 경우 (1TB 이상) 각각 따로 Optimize와 VACCUM을 수행 하도록 하였습니다. 이 테이블들의 경우 모두 크기가 크고 주요 테이블인 경우가 많아서 따로 처리 하였으며, 자동화에서 제외 하도록 예외처리 테이블을 만들었습니다. (해당 테이블은 자동 Optimize / VACCUM 안함)
적재/삭제/변경 동시성 이슈 이게 제일 고민이였는데, Optimize / VACCUM을 할때 테이블에 작업을 하면 동시성 이슈로 인해서 실패 합니다. Databricks의 경우 ACID를 보장 하기 때문에 이 작업을 할때 SELECT 이외에 작업을 하면 실패하 하는 경우가 여럿 있었습니다.

그래서 최대한 주말에 적재 없는 시간을 확인하여 오전 시간에 하도록 하였습니다. 또한 주 1회만 하여 최대한 비용과 스케줄 겹침(?) 현상을 최소화 하였습니다. 추가적으로 Timeout 시간을 변수로 받아서 최대 시간을 조정 하였습니다. 
비용 이슈 역시 문제는 돈 입니다. 뭘 하든 돈이 필요하고, 이 작업도 큰 테이블인 경우 또는 Small 파일이 많으면 큰 컴퓨팅이 필요하며, 오래 걸립니다. 실제로 수십 TB 테이블의 경우 1시간 이상 소요됩니다. (매주 1시간 이상) 그래서 이것도 배보다 배꼽이 큰 경우가 발생 할 수 있습니다.

그래서 아래에서 다시 언급 하겠지만 dry_run을 통해서 최대한 비용과 시간을 예측해서 작업을 진행 하였습니다.

 
사실 별로 신경을 안 쓰면 이슈가 아닐 수도 있는데, 이 작업은 운영 작업이다 보니 적재나 삭제등과 같이 필수 작업에 영향을 최소로 해야 했습니다. Optimize와 VACCUM 자체가 엄청나게 어려운 작업이 아니지만 (아닐 수도 있지만) 이것으로 인해서 중요한 작업이 안되면 큰 이슈가 있기에 신중을 기해서 만들었습니다. 
 


4. 실제 수행해 보기 : Job 설정 및 dry_run 결과 📊

모든 구성을 마치고, 파이프라인을 Databricks Job으로 등록했습니다. Job의 안정성과 유연성을 확보하기 위해, 노트북의 핵심 설정값들을 아래와 같이 위젯 파라미터로 추출하여 관리했습니다.
 

파라미터 (Parameter) 설정값 설계 의도
dry_run false 안전장치. true로 설정하면 실제 OPTIMIZE/VACUUM 없이 시뮬레이션만 수행하여, 운영 환경에 적용하기 전 안전하게 결과를 예측할 수 있습니다.
max_table_size_gb 500 비용 통제. 실수로 수십 TB가 넘는 초대형 테이블에 OPTIMIZE가 실행되어 과도한 비용이 발생하는 것을 막기 위한 상한선입니다.

🚩 위에서 1TB라고 하였는데, 조금 걱정되서 우선 500GB로 설정 했습니다.
min_table_size_gb 10 효율성. 10GB 미만의 아주 작은 테이블은 최적화 대상에서 제외하여, Job이 더 중요한 테이블에 집중하도록 합니다.
optimize_timeout_min 180 예산 및 시간 관리. Job의 최대 실행 시간을 3시간으로 제한하여, 예기치 못한 이슈로 Job이 무한정 실행되는 것을 방지합니다.

 
이를 바탕으로 실제 최적화와 로깅을 수행하는 실행 루프 코드는 다음과 같이 구성했습니다. 타임아웃 조건을 체크하며 우선순위에 따라 직렬로 실행합니다.

def execute_optimize_vacuum(row, dry_run):
    full_table_name = f"`{row['table_catalog']}`.`{row['table_schema']}`.`{row['table_name']}`"
    
    if dry_run:
        return "SKIPPED (dry_run)"
        
    try:
        # OPTIMIZE 실행
        spark.sql(f"OPTIMIZE {full_table_name}")
        # 과거 Tombstone 파일 삭제 (7일 보존)
        spark.sql(f"VACUUM {full_table_name} RETAIN 168 HOURS")
        return "SUCCESS"
    except Exception as e:
        return f"FAILED: {str(e)[:100]}"

candidates = spark.table("optimize_candidates").collect()

# 우선순위 정렬 (CRITICAL -> WARNING -> VACUUM_ONLY)
critical = [r for r in candidates if r["avg_file_size_mb"] < 32]
warning = [r for r in candidates if 32 <= r["avg_file_size_mb"] < 128]
vacuum_only = [r for r in candidates if r["avg_file_size_mb"] >= 128]
sorted_candidates = critical + warning + vacuum_only

results = []
for idx, row in enumerate(sorted_candidates):
    if time.time() - start_time > timeout_seconds:
        print(f"Timeout reached. Stopping at {idx}/{len(sorted_candidates)}")
        break
        
    result_status = execute_optimize_vacuum(row, dry_run)
    results.append({"table_name": row["table_name"], "status": result_status})

# 결과를 로컬 모니터링 테이블에 Append
if results:
    from pyspark.sql.types import StructType, StructField, StringType
    schema = StructType([
        StructField("table_name", StringType(), True),
        StructField("status", StringType(), True)
    ])
    
    results_df = spark.createDataFrame(results, schema=schema)
    results_df.write.mode("append").saveAsTable("monitoring.governance.optimize_execution_log")

 
Job 운영 설정

  • 스케줄: 매주 토요일 09:00 KST
  • 클러스터: r5d.xlarge, Autoscale (2~10 workers) / 추후 소요시간에 따라 조정
  • 타임아웃: 180분 (위 파라미터와 연동)

실제 적용 전, dry_run 파라미터를 true로 변경하여 AIC 환경을 먼저 스캔해 보았습니다.
 
[A 워크스페이스 dry_run 실행 결과]

  • 스캔 대상 테이블: 160개 (Exclude 12개 제외)
  • 최종 실행 후보: 19개
    • CRITICAL (< 32MB): 5개 🚨
    • WARNING (32~128MB): 12개 ⚠️
    • VACUUM_ONLY (DML 이력 존재): 2개 🧹

결과는 예상보다 심각했습니다. 가장 상태가 좋지 않았던 테이블은 24,001개의 파일이 평균 4.89MB 단위로 파편화되어 있었습니다. 데이터 스캔 속도가 느려질 수밖에 없는 명확한 원인이었습니다. 

 
확실히 테이블을 만드는 사람이 대다수고 이것을 관리하거나 하는 사람을 따로 두지 않으면 위와 같이 하나의 Workspace에서도 안 좋은 결과가 있을 수 있습니다. Optimize / VACCUM 작업이 어느 정도 자동으로 Databricks에서 해준다고 알고 있지만 어디까지나 한계는 존재하며 사람이 매번 할 수도 없습니다. 제가 생각했던 것보다 진짜 많은 테이블이 관리가 안 되는 것을 확인하고 조금 놀랐습니다.


5. 자동 리포팅 및 장애 알림 체계 구축

아무리 훌륭한 최적화 파이프라인을 구축했더라도, "매주 작업이 잘 돌았는지, 실패한 테이블은 없는지" 일일이 Databricks에 접속해 로그를 뒤져보는 것은 데이터 엔지니어에게 또 다른 운영 리소스 낭비입니다. 그래서 파이프라인 구축의 마지막 퍼즐로 Airflow를 활용한 이메일 자동 리포팅 체계를 얹었습니다. 매주 월요일 아침, 주말간 수행된 최적화 결과를 데이터플랫폼팀 전체가 한눈에 파악할 수 있도록 구성했습니다.


마무리 : 최적화 자동화가 가져다준 효과 ✨

파이프라인을 운영 환경에 배포하고 몇 주간 모니터링 로그를 지켜보며, 우리 팀은 기대 이상의 성과를 거둘 수 있었습니다. 단순히 눈에 보이는 수치 개선을 넘어, 데이터 엔지니어링의 본질적인 목적에 한 걸음 더 다가선 느낌입니다. 이번 최적화 자동화가 가져다준 효과는 크게 네 가지로 요약할 수 있습니다.
 
1. 쿼리 성능(Performance)의 극적인 향상
수만 개로 쪼개져 클러스터를 괴롭히던 Small File들이 128MB 이상의 건강한 크기로 압축(Compaction)되었습니다. 파일 스캔 시 발생하던 메타데이터 읽기 오버헤드와 I/O 병목이 해소되면서, 다운스트림 배치 Job과 분석가들의 대시보드 쿼리 속도가 체감할 수 있을 정도로 눈에 띄게 빨라졌습니다.
 
2. 낭비되는 스토리지 비용(Cost) 절감
Databricks에서 MERGE나 UPDATE가 발생할 때마다 보이지 않는 곳에 쌓여가던 과거의 데이터(Tombstone)들을 VACUUM을 통해 물리적으로 완벽히 걷어냈습니다. 이는 단순히 클라우드 요금 청구서를 줄이는 것을 넘어, 불필요한 데이터를 보관하지 않음으로써 시스템 전반의 효율을 높이는 건강한 체질 개선이었습니다.
 
3. 데이터 엔지니어의 운영 부담(Toil) 제로화
사실 가장 뿌듯한 부분입니다. 예전에는 주말마다 엔지니어가 직접 스파크 UI를 들여다보며 "어떤 테이블이 느려졌나?" 고민하고 수동으로 최적화 명령을 날려야 했습니다. 이제는 매주 토요일 아침, 스케줄러가 스스로 상태를 진단하고 가장 아픈 테이블부터 치료한 뒤 리포트(Log)만 남깁니다. 우리는 그저 월요일 출근 후 로그 테이블을 쓱 훑어보기만 하면 됩니다.
 
4. 멀티 리전 관리의 일관성 확보
AIC, EIC, KIC로 나뉜 3개의 거대한 워크스페이스를 하드코딩 하나 없이 단일 코드베이스로 제어할 수 있게 되었습니다. 향후 새로운 리전이나 워크스페이스가 추가되더라도 동일한 파이프라인을 그대로 배포하기만 하면 알아서 동작할 것입니다.
 
마치며...
이번 작업을 통해 다시 한번 느낀 것은, "데이터 플랫폼의 규모가 커질수록 백그라운드 관리 작업의 자동화는 선택이 아닌 생존의 문제"라는 점입니다. 데이터가 쌓이는 속도를 사람의 손으로 따라잡을 수는 없으니까요. 물론 이것으로 최적화의 모든 과제가 끝난 것은 아닙니다. 다음 스텝으로는 Z-Ordering을 적용해 쿼리의 Data Skipping 효율을 극대화하는 방안이나, 테이블의 특성(예: 시계열 데이터 vs 디멘전 데이터)에 따라 최적화 주기를 동적으로 조절하는 고도화 작업을 고민하고 있습니다.
 
당분간 Databricks의 Small File이라는 오랜 숙제에 대해서는 한시름 놓아도 될 것 같습니다. 비슷한 성능 이슈나 스토리지 비용 문제로 밤잠을 설치고 계신 데이터 엔지니어 동료분들께, 이 자동화 파이프라인 구축기가 작은 영감과 돌파구가 되기를 진심으로 바랍니다.
 
긴 글 읽어주셔서 감사합니다.


함께 보면 좋은 글

[Databricks] Databricks 비용 대시보드 만들기
[Databricks] Optimize / VACUUM

반응형