Snowflake에서 Databricks로의 마이그레이션 — 2개월간의 실전 기록
Airflow + Snowflake 기반 데이터 플랫폼을 Databricks Lakehouse 로 이관하면서 마주친 문제들과 해결 과정, 그리고 거버넌스·비용·운영 관점에서 얻은 교훈을 정리합니다.
이 글은 Airflow + Snowflake 환경에서 운영되던 클라이언트의 데이터 플랫폼을 약 2개월에 걸쳐 Databricks Lakehouse 로 이관한 프로젝트의 실전 기록입니다. 단순히 쿼리를 옮기는 작업이 아니라, 거버넌스·아키텍처·비용 모델 자체를 새로 설계해야 했던 과정을 단계별로 정리합니다.
1. 프로젝트 개요
출발점
- 기존 스택: Airflow (오케스트레이션) + Snowflake (DW) + 다양한 BI/ML 도구
- 대상 스택: Databricks Lakehouse (Unity Catalog + Delta Lake + Workflows)
- 기간: 약 2개월
- 인력: 데이터 엔지니어 2~3명
마이그레이션의 동기
클라이언트가 Snowflake 를 떠나기로 결정한 배경은 단순한 비용 문제가 아니었습니다. 다음의 복합적인 이유가 있었습니다.
- 데이터 엔지니어링 / ML / BI 가 분산되어 있어, 같은 데이터에 대해 도구마다 다른 사본이 존재했습니다.
- 세분화된 권한 관리가 어렵고, 부서·도메인 단위의 데이터 정책 적용이 일관되지 않았습니다.
- 컴퓨팅 비용이 상승 추세였고, ML 워크로드를 위한 별도의 GPU/Spark 인프라까지 필요했습니다.
- 엔지니어링 팀이 Spark 기반 처리에 대한 기술적 통제권을 더 확보하고 싶어했습니다.
따라서 이 마이그레이션의 목표는 "Snowflake 를 Databricks 로 바꾼다" 가 아니라 "분산된 데이터 자산을 단일 Lakehouse 로 통합하면서 거버넌스를 재설계한다" 였습니다.
2. 거버넌스 구조 먼저 — Unity Catalog 와 Medallion
Unity Catalog 부터 시작
가장 먼저 한 일은 코드 한 줄을 옮기기 전에 Unity Catalog 의 카탈로그 / 스키마 / 그룹 구조를 설계하는 것이었습니다.
이전 환경에서는 Snowflake 의 Database/Schema 명명 규칙이 부서마다 다르고, 권한도 개별 유저 단위로 흩어져 있었습니다. 이를 그대로 이전하면 동일한 혼란이 Databricks 에서도 재현될 것이 분명했기 때문에, 먼저 다음 원칙을 세웠습니다.
- 카탈로그는 환경 단위(
prod,stg,dev) - 스키마는 비즈니스 도메인 단위(
sales,marketing,finance등) - 권한은 개인이 아닌 그룹(Account-level group) 에 부여
- 그룹은 IdP (예: Azure AD / Okta) 의 그룹과 SCIM 으로 동기화
-- 카탈로그 / 스키마 생성 예시
CREATE CATALOG IF NOT EXISTS prod;
CREATE SCHEMA IF NOT EXISTS prod.sales;
-- 그룹 단위 권한 부여
GRANT USE CATALOG ON CATALOG prod TO `data-platform-readers`;
GRANT USE SCHEMA, SELECT ON SCHEMA prod.sales TO `sales-analysts`;Medallion Architecture (Bronze / Silver / Gold)
스키마 안의 테이블은 Medallion Architecture 를 따라 계층화했습니다.
| 계층 | 역할 | 데이터 형태 |
|---|---|---|
| Bronze | 원천 시스템에서 들어온 그대로의 raw 데이터 | append-only, 스키마 변동 허용 |
| Silver | 정제·표준화된 비즈니스 엔터티 | 타입 통일, PII 마스킹, 중복 제거 |
| Gold | 분석·BI 용 집계 / 비즈니스 메트릭 | dashboard·ML feature 직접 소비 |
이 계층 분리 덕분에 마이그레이션을 계층별로 끊어서 진행할 수 있었고, "어떤 테이블이 어디까지 옮겨졌는가" 를 추적하기도 쉬웠습니다.
3. Bronze 계층 — 처음엔 COPY INTO, 결국 Auto Loader
처음엔 가장 단순해 보이는 선택부터
수백 개의 Snowflake 원본 테이블을 Databricks 로 가져와야 했습니다. 처음에는 가장 직관적인 방식으로 시작했습니다.
-- Snowflake 에서 외부 스토리지 (S3 / ADLS) 로 unload
COPY INTO @ext_stage/sales/
FROM raw.sales
FILE_FORMAT = (TYPE = PARQUET)
HEADER = TRUE;Snowflake 에서 객체 스토리지로 COPY INTO 로 unload 한 뒤, Databricks 측에서도 동일하게 COPY INTO 로 적재하는 시나리오였습니다.
-- Databricks 측 적재
COPY INTO prod.bronze.sales
FROM 's3://landing/sales/'
FILEFORMAT = PARQUET;부딪힌 문제 — 타입 미스매치와 운영 부담
이 방식은 초기 풀로드(full load) 에는 그럭저럭 작동했지만 두 가지 큰 문제가 있었습니다.
- 타입 미스매치: Snowflake 의
NUMBER(38,0),VARIANT,TIMESTAMP_TZ등이 Parquet 으로 unload 되는 과정에서 의도치 않은 타입으로 떨어졌습니다. Databricks 쪽에서 다시 캐스팅하는 코드가 테이블마다 누적되었습니다. - 증분 처리에 부적합:
COPY INTO는 멱등성을 일부 보장하지만, 새로 도착한 파일만 안전하게 처리하려면 별도의 상태 관리가 필요했습니다. 수백 개 테이블에 대해 이 코드를 직접 작성하는 비용이 너무 컸습니다.
결정 — full load 는 COPY INTO, 증분은 Auto Loader
결국 다음과 같이 역할을 나눴습니다.
- 최초 풀로드 :
COPY INTO(한 번만 실행되고 끝나므로 단순함이 장점) - 증분 적재 : Auto Loader (
cloudFiles)
Auto Loader 를 도입하면서 얻은 가장 큰 이점은 스키마 추론과 진화(schema evolution) 였습니다.
df = (
spark.readStream
.format("cloudFiles")
.option("cloudFiles.format", "parquet")
.option("cloudFiles.schemaLocation", "/Volumes/prod/_schemas/sales")
.option("cloudFiles.schemaEvolutionMode", "addNewColumns")
.load("s3://landing/sales/")
)
(
df.writeStream
.option("checkpointLocation", "/Volumes/prod/_checkpoints/sales")
.trigger(availableNow=True)
.toTable("prod.bronze.sales")
)수백 개 테이블에 동일한 패턴을 적용해야 했으므로, 위 코드를 테이블 메타데이터 기반의 제너레이터로 감싸 한 번에 생성·운영했습니다. 결과적으로 테이블당 작성해야 할 코드가 거의 사라졌고, 신규 컬럼이 원천에 추가되어도 Bronze 가 자동으로 따라가게 되었습니다.
교훈: full load 와 incremental load 는 서로 다른 도구를 쓰는 편이 단순합니다. 하나의 도구로 두 가지를 모두 해결하려고 하면 어느 쪽도 깔끔하지 않게 됩니다.
4. Silver / Gold 계층 — 레거시 SQL 과 프로시저 변환
자동 변환 도구는 결국 포기
Silver / Gold 계층에는 수년간 누적된 Snowflake SQL, 뷰, JavaScript 기반의 stored procedure 가 있었습니다. 처음에는 BladeBridge 같은 자동 변환 솔루션을 도입할 계획이었으나, 다음 이유로 수동 변환 으로 전환했습니다.
- 라이선스 비용이 프로젝트 예산을 크게 초과
- 자동 변환 결과의 검증 비용이 결국 수동 변환과 큰 차이가 없을 정도로 컸음
- 변환 과정 자체가 레거시 로직을 정리할 절호의 기회 였음
Snowflake → Databricks SQL 매핑에서 자주 부딪힌 차이
| 영역 | Snowflake | Databricks (Spark SQL) |
|---|---|---|
| Semi-structured | VARIANT, OBJECT, ARRAY | STRUCT, MAP, ARRAY + from_json |
| 타임존 | TIMESTAMP_TZ | TIMESTAMP (UTC 권장) |
| Merge 동작 | MERGE (row-level) | MERGE INTO (Delta, 동일 의미) |
| 시점 조회 | AT (TIMESTAMP => ...) | TIMESTAMP AS OF / VERSION AS OF |
| Generated column | DEFAULT ... AS ... | GENERATED ALWAYS AS (...) |
| Stored procedure | JavaScript / SQL Scripting | Python notebook + Workflows |
특히 VARIANT 가 광범위하게 사용된 테이블은, Databricks 로 옮기면서 JSON 의 어떤 키를 실제로 쓰는가 를 분석해서 일부는 컬럼으로 평탄화하고, 나머지는 STRING (또는 VARIANT 타입) 으로 보관하는 식으로 정리했습니다. 이 작업만으로도 다운스트림 쿼리 비용이 눈에 띄게 줄었습니다.
Stored procedure 는 SQL 로 강제로 변환하지 않음
가장 많이 고민한 부분이 JavaScript 기반 stored procedure 였습니다. 처음에는 Databricks 의 SQL Scripting 으로 비슷하게 옮기는 것을 시도했지만, 다음 이유로 PySpark + Workflow 조합으로 재구현하는 방향으로 바꿨습니다.
- 분기·반복·예외 처리 로직이 SQL 보다 Python 에서 훨씬 명료
- 단위 테스트가 가능 (pytest 로 검증 가능)
- 실행 단위가 명확해져서 Workflow 의 task 로 그대로 매핑됨
# 기존 Snowflake stored procedure 의 핵심 로직을 PySpark 로 재구현한 예시
from pyspark.sql import functions as F
def build_daily_sales(spark, run_date: str) -> None:
src = (
spark.table("prod.silver.orders")
.where(F.col("order_date") == F.lit(run_date))
)
agg = (
src.groupBy("store_id", "product_id")
.agg(
F.sum("amount").alias("gmv"),
F.countDistinct("order_id").alias("orders"),
)
.withColumn("run_date", F.lit(run_date))
)
(
agg.write
.format("delta")
.mode("overwrite")
.option("replaceWhere", f"run_date = '{run_date}'")
.saveAsTable("prod.gold.daily_sales")
)replaceWhere 를 활용해서 멱등 재실행이 가능하게 만든 것이 운영상 큰 차이를 만들었습니다. 같은 날짜의 잡을 몇 번을 다시 돌려도 결과가 동일합니다.
5. 비용 모델의 재설계
Snowflake 와 Databricks 는 비용 사고방식의 차이
Snowflake 에서는 "warehouse 를 키워두면 알아서 빨라지고, 멈추면 돈이 안 나간다" 는 단순한 멘탈 모델이 가능했습니다. Databricks 는 클러스터 / 잡 / 데이터 / 스토리지 의 차원이 분리되어 있어, 같은 사고방식으로 접근하면 비용이 새기 쉽습니다.
다음 원칙을 세웠습니다.
- interactive (개발/탐색) → All-purpose cluster 또는 SQL Warehouse (Serverless)
- scheduled (배치) → Job cluster (잡 시작 시 생성, 종료 시 자동 해제)
- ad-hoc 분석 → Serverless SQL Warehouse + auto-stop
Job Cluster vs Serverless 비교
매일 도는 ETL 잡에 대해 Job cluster 와 Serverless 를 둘 다 측정해봤습니다.
| 기준 | Job Cluster | Serverless |
|---|---|---|
| 시작 시간 | 1~3분 | 수 초 |
| 단가 | 낮음 | 높음 |
| 짧고 빈번한 잡 | 시작 시간 비중이 큼 | 유리 |
| 길고 무거운 잡 | 유리 | 단가가 누적되어 불리 |
결론적으로 잡의 평균 실행 시간 / 빈도 에 따라 둘을 섞어 썼습니다. 모든 잡을 한쪽으로 몰아넣지 않은 것이 비용 절감의 핵심이었습니다.
클러스터 메트릭으로 인스턴스 타입을 다시 골랐다
초기에는 "기본값으로 일단 시작" 했지만, 이후 Databricks 의 cluster metrics (CPU / Memory / GC / Shuffle) 를 보면서 인스턴스 타입을 재조정했습니다.
- 메모리 압박이 큰 잡 → memory-optimized
- CPU 만 빠듯한 잡 → compute-optimized
- 셔플이 많은 잡 → 디스크 IO 가 좋은 시리즈
이 단계만으로도 일부 잡은 30~50% 비용 절감이 났습니다.
6. Lakehouse Federation 으로 외부 시스템과 연결
모든 데이터를 Databricks 로 옮길 필요는 없었습니다. 일부 OLTP 시스템(예: Oracle, PostgreSQL) 은 운영팀이 계속 소유했고, 분석 측면에서는 읽기만 가능하면 충분 했습니다.
이런 시스템에는 Lakehouse Federation (현 Lakeflow Connect) 을 사용해서 물리적인 데이터 이동 없이 연결했습니다.
-- 외부 Oracle 을 카탈로그로 마운트
CREATE CONNECTION oracle_prod TYPE oracle
OPTIONS (
host '...', port '1521', user '...', password '...'
);
CREATE FOREIGN CATALOG oracle_prod
USING CONNECTION oracle_prod
OPTIONS (database 'ORCL');
-- 그 다음부터는 평범한 카탈로그처럼 SELECT 가능
SELECT *
FROM oracle_prod.app.customer c
JOIN prod.silver.orders o
ON o.customer_id = c.id;원천에 부담을 주지 않을 만한 가벼운 조회·조인에만 한정해 사용한다면, 별도의 ETL 을 만들지 않고도 Lakehouse 안에서 통합 뷰를 제공할 수 있습니다.
7. 자주 겪은 이슈들
마이그레이션 중반 이후 반복적으로 발견된 안티패턴을 정리합니다. 처음 Databricks 에 옮겨오는 팀이라면 거의 동일한 지점에서 막히게 됩니다.
7.1. job 의존성을 너무 잘게 쪼개지 마라
Airflow 시절의 습관 때문에, 모든 단계를 별개의 Databricks job 으로 만든 경우가 많았습니다. job 간 의존성이 늘어날수록 다음 문제가 생깁니다.
- job 마다 클러스터 시작 비용이 누적됨
- 실패 지점이 많아져 운영 모니터링이 복잡해짐
기준: 같은 클러스터에서 순차로 실행되어도 무방한 단계라면, 별도 job 이 아니라 한 Workflow 안의 task 로 묶는 것이 좋습니다.
7.2. display, collect, toPandas 의 함정
개발 노트북에서는 결과를 확인하려고 display(df) 나 df.collect() 를 자주 쓰지만, 이를 그대로 운영 잡에 두고 가는 사례가 많았습니다.
collect/toPandas는 모든 데이터를 드라이버로 모음 → OOM 의 단골 원인display는 인터랙티브 컨텍스트 외부에서는 의미 없는 비용을 발생시킴
운영 코드에서는 모두 제거하거나 LIMIT 을 동반한 show 로 바꿔야 합니다.
7.3. pandas 단일 노드 처리
기존 Python 코드를 그대로 옮겨온 경우, 핵심 변환을 pandas 로 수행하고 있는 부분이 자주 발견됐습니다. 데이터가 작을 때는 문제가 없지만, 그대로 두면 어느 시점에 반드시 터집니다.
- 가능한 한 PySpark DataFrame API 로 재작성
- 그게 어렵다면 pandas API on Spark (
pyspark.pandas) 로 우회
7.4. 보안이 유지되어야할 값을 노트북에 그대로 넣는 일
마이그레이션 초기에 가장 흔한 보안 실수가 노트북 셀에 비밀번호를 그대로 적어두는 것이었습니다. 반드시 dbutils.secrets 와 secret scope 를 거쳐 접근해야 합니다.
jdbc_password = dbutils.secrets.get(scope="prod", key="oracle_password")8. 권장 팀 구성
이 정도 규모(원천 수백 테이블, 2개월 일정) 에서 추천하는 팀 구성은 다음과 같습니다.
| 역할 | 인원 | 책임 |
|---|---|---|
| 클라우드 인프라 엔지니어 | 1명 | 네트워크, IAM, 스토리지, Databricks workspace 설정 |
| PySpark 숙련 데이터 엔지니어 | 1~2명 | Silver/Gold 계층 변환, 잡 작성 |
| Databricks 경험자 | 1명 이상 | Unity Catalog 설계, 클러스터 정책, 비용 가드레일 |
| (선택) 분석/BI 담당 | 1명 | Gold 계층 검증, 대시보드 마이그레이션 |
특히 Databricks 경험자가 한 명도 없는 팀이 단독으로 시작하는 것은 권장하지 않습니다. 초기에 잡혀버린 카탈로그/권한/클러스터 정책의 잘못된 결정은 나중에 되돌리는 비용이 큽니다.
9. 마이그레이션 이후의 변화
2개월 뒤 운영에 들어간 시점에서 측정된 주요 변화입니다.
| 영역 | 변화 |
|---|---|
| 인프라 비용 | 이전 대비 약 25% 수준 |
| 워크플로우 통합 | 데이터 엔지니어링 / ML / BI 가 동일 플랫폼에서 동작 |
| 거버넌스 | Unity Catalog 기반의 일관된 그룹·도메인 정책 |
| 데이터 품질 | 중복 데이터 제거 + 파이프라인 로직 정비로 결과 일관성 향상 |
가장 중요한 교훈
이 프로젝트에서 얻은 가장 큰 교훈은 "마이그레이션은 시스템 이전이 아니라 데이터 자산을 다시 설계할 기회" 라는 점이었습니다. 단순히 Snowflake 의 테이블을 Databricks 의 테이블로 1:1 옮겼다면, 비용은 어느 정도 줄었을지 몰라도 거버넌스·품질 측면의 문제는 그대로 따라왔을 것입니다.
옮기는 김에:
- 누가 봐도 더 이상 사용되지 않는 테이블을 과감히 폐기하고,
- 같은 데이터를 가리키는 여러 사본을 하나의 Silver 테이블로 통합하고,
- 비효율적인 SQL/프로시저를 다시 작성한 것
이 절감된 비용보다 더 큰 가치를 만들었습니다.
참고
— Data Dynamics 엔지니어링 팀