Blog
databrickssnowflakemigrationlakehouseunity-catalog

Snowflake에서 Databricks로의 마이그레이션 — 2개월간의 실전 기록

Airflow + Snowflake 기반 데이터 플랫폼을 Databricks Lakehouse 로 이관하면서 마주친 문제들과 해결 과정, 그리고 거버넌스·비용·운영 관점에서 얻은 교훈을 정리합니다.

Data Dynamics2026년 5월 8일25 min read

데이터 플랫폼 이전(마이그레이션)은 이삿짐 옮기는 일처럼 단순하지 않습니다. 단순히 박스를 들어 새 집에 내려놓는 것이 아니라, 새 공간에 맞게 가구 배치를 다시 설계하고 생활 방식도 바꿔야 하는 작업이거든요.

이 글은 Airflow + Snowflake 환경에서 운영되던 클라이언트의 데이터 플랫폼을 약 2개월에 걸쳐 Databricks Lakehouse로 이관한 프로젝트의 실전 기록입니다. 단순히 쿼리를 옮기는 작업이 아니라, 거버넌스·아키텍처·비용 모델 자체를 새로 설계해야 했던 과정을 단계별로 정리합니다.

이 글에서 배우는 것

  • Snowflake → Databricks 마이그레이션의 실제 동기와 목표 설정 방법
  • Unity Catalog와 Medallion Architecture로 거버넌스를 재설계하는 방법
  • Bronze 계층에서 COPY INTO와 Auto Loader를 어떻게 나눠 쓰는지
  • Silver/Gold SQL 변환 시 자주 마주치는 차이점과 해결 패턴
  • 비용 최적화를 위한 클러스터 선택 전략과 실제 절감 사례

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)을 제련해서 은(Silver)으로 만들고, 최종 제품(Gold)으로 완성하는 흐름을 생각하면 이해하기 쉽습니다.

계층역할데이터 형태
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)**에는 그럭저럭 작동했습니다. 하지만 현실 운영에 들어서자 두 가지 큰 문제가 수면 위로 떠올랐습니다.

  1. 타입 미스매치: Snowflake 의 NUMBER(38,0), VARIANT, TIMESTAMP_TZ 등이 Parquet 으로 unload 되는 과정에서 의도치 않은 타입으로 떨어졌습니다. Databricks 쪽에서 다시 캐스팅하는 코드가 테이블마다 누적되었습니다.
  2. 증분 처리에 부적합: 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 매핑에서 자주 부딪힌 차이

"SQL은 표준이니까 그냥 복사하면 되겠지?"라는 생각은 현실에서 무너집니다. 아래 표는 실제로 가장 많이 막혔던 차이점들입니다. 미리 파악해두면 시행착오가 크게 줄어듭니다.

영역SnowflakeDatabricks (Spark SQL)
Semi-structuredVARIANT, OBJECT, ARRAYSTRUCT, MAP, ARRAY + from_json
타임존TIMESTAMP_TZTIMESTAMP (UTC 권장)
Merge 동작MERGE (row-level)MERGE INTO (Delta, 동일 의미)
시점 조회AT (TIMESTAMP => ...)TIMESTAMP AS OF / VERSION AS OF
Generated columnDEFAULT ... AS ...GENERATED ALWAYS AS (...)
Stored procedureJavaScript / SQL ScriptingPython notebook + Workflows

특히 VARIANT 가 광범위하게 사용된 테이블은, Databricks 로 옮기면서 JSON 의 어떤 키를 실제로 쓰는가 를 분석해서 일부는 컬럼으로 평탄화하고, 나머지는 STRING (또는 VARIANT 타입) 으로 보관하는 식으로 정리했습니다. 이 작업만으로도 다운스트림 쿼리 비용이 눈에 띄게 줄었습니다.

Stored procedure 는 SQL 로 강제로 변환하지 않음

가장 많이 고민한 부분이 JavaScript 기반 stored procedure였습니다. "SQL로 만들어놓은 걸 SQL로 옮기는 게 당연하지 않나?"라는 생각을 버리고, 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 비교

"그냥 Serverless 쓰면 편하지 않나요?" 라고 생각하기 쉽지만, 실제로 매일 도는 ETL 잡에 대해 둘 다 측정해봤더니 상황에 따라 결과가 달랐습니다.

기준Job ClusterServerless
시작 시간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/프로시저를 다시 작성한 것

이 세 가지가 단순 플랫폼 전환으로 절감된 비용보다 더 큰 가치를 만들었습니다.

마치며 — 핵심 요약

  • 마이그레이션의 출발점은 "왜 이전하는가"를 명확히 하는 것입니다. 비용만이 이유라면 중간에 흔들리기 쉽습니다.
  • Unity Catalog 구조(카탈로그·스키마·그룹)를 코드 한 줄 옮기기 전에 설계해두세요. 나중에 바꾸면 비용이 두 배입니다.
  • 풀로드는 COPY INTO, 증분 적재는 Auto Loader — 하나의 도구로 둘 다 해결하려 하면 어느 쪽도 깔끔하지 않습니다.
  • JavaScript stored procedure는 SQL로 옮기지 말고 PySpark + Workflow로 재구현하세요. 테스트 가능성과 운영 명확성이 훨씬 좋아집니다.
  • Databricks 비용은 잡의 길이와 빈도에 따라 Job Cluster와 Serverless를 섞어 써야 최적화됩니다.
  • Databricks 경험자가 팀 안에 없다면 초기 아키텍처 결정을 외부 전문가와 함께 검토하는 것을 강력히 권합니다 — 초기 실수를 되돌리는 비용이 큽니다.

참고

— Data Dynamics 엔지니어링 팀