Snowflake MigrationClickHouse Workshops

Snowflake vs ClickHouse

두 엔진이 스토리지, 컴퓨트, SQL 방언에서 어디가 다른지 — 그리고 어떤 Snowflake 관용구에 직접적인 ClickHouse 대응물이 없는지.

이 문서는 Snowflake에서 ClickHouse로 마이그레이션하는 파트너를 위한 레퍼런스다. 설계 결정을 좌우하는 아키텍처 차이와, NYC Taxi 워크로드에서 마주칠 여섯 가지 SQL 방언 차이를 다룬다.


1. 아키텍처 비교

스토리지

Snowflake는 모든 물리적 스토리지 결정을 대신 내려 준다. 데이터는 클라우드 오브젝트 스토리지에 압축된 컬럼형 마이크로 파티션으로 저장된다. 당신은 웨어하우스 크기와 테이블 구조만 고르고, 나머지는 Snowflake가 처리한다 — 클러스터링, 컴팩션, 파일 관리가 모두 자동이다.

ClickHouse는 물리적 스토리지 결정을 명시적으로 내리도록 요구한다. 테이블을 만들 때 다음을 지정한다.

  • 엔진 (데이터가 어떻게 저장되고, 머지되고, 중복 제거되는지를 결정한다)
  • ORDER BY (물리적 정렬 순서이자 primary index가 된다)
  • 선택적으로, PARTITION BY, TTL, SETTINGS (압축 코덱, 머지 동작)

이것들은 성능 튜닝 노브가 아니라 정확성에 관한 결정이다. 잘못된 엔진은 조용히 틀린 쿼리 결과를 만들어 낼 수 있다. 잘못된 ORDER BY는 빨라야 할 쿼리가 테이블 전체를 스캔하게 만들 수 있다.

쿼리 실행

Snowflake는 가상 웨어하우스를 사용하는 shared-nothing MPP다. 웨어하우스는 쿼리를 처리하는 컴퓨트 노드 클러스터다. 웨어하우스가 실행 중인 동안 비용을 지불한다 — 유휴 시간에도 크레딧이 소모된다. 자동 일시 중단이 도움이 되지만, 콜드 스타트가 지연을 더한다.

ClickHouse는 벡터화 실행을 사용한다. ClickHouse Cloud는 각 컴퓨트 서비스를 독립적으로 오토스케일하고, 유휴 상태에서는 0까지 축소한다. 여러 컴퓨트 서비스가 동일한 스토리지를 공유할 수 있다(SharedMergeTree를 통해) — 이것이 ClickHouse Cloud의 컴퓨트-컴퓨트 분리 모델이며, 각 서비스는 공통 데이터 레이어 위의 독립적인 컴퓨트 계층이다.

동시성 모델

Snowflake는 별도의 웨어하우스를 만들어 워크로드를 격리한다. ETL은 TRANSFORM_WH를, 분석은 ANALYTICS_WH를 쓴다. 웨어하우스마다 전용 컴퓨트가 있으므로, 느린 ETL 잡이 분석 쿼리를 굶길 수 없다.

ClickHouse Cloud는 컴퓨트-컴퓨트 분리로 같은 패턴을 지원한다. 동일한 스토리지를 공유하는 여러 컴퓨트 서비스를 프로비저닝할 수 있다. 각 서비스는 독립적으로 오토스케일하는 컴퓨트 계층이다 — ETL은 한 서비스에서, 인터랙티브 분석은 다른 서비스에서 실행되며 둘 사이에 리소스 경쟁이 없다. 단일 서비스 내부에서는 소프트 쿼터(사용자별 또는 쿼리별 max_threads, priority, max_memory_usage)와 리소스 제한이 설정된 사용자 프로파일로 워크로드 격리를 달성한다. 쿼리가 밀리초 단위로 끝나는 대부분의 분석 워크로드에서는 단일 서비스로 충분하고, 쿼리별 쿼터가 더 가벼운 선택이다.

비용 모델

SnowflakeClickHouse Cloud
컴퓨트크레딧 (웨어하우스-초)컴퓨트 유닛 (스토리지와 별도)
스토리지$23/TB/월약 $0.023/GB/월 (더 저렴)
스케일 투 제로자동 일시 중단만완전한 스케일 투 제로 지원
데이터 전송인그레스 무료, 이그레스 과금표준 클라우드 이그레스 요율

가장 큰 차이는 이것이다. Snowflake에서는 쿼리가 실행되는지 여부와 무관하게 웨어하우스 시간에 대해 지불한다. ClickHouse Cloud에서는 쿼리 사이에 컴퓨트가 0까지 축소된다. 버스티한 분석 워크로드에서 ClickHouse Cloud는 일반적으로 동등한 Snowflake 구성보다 3-8배 저렴하다.


2. SQL 방언 차이

NYC Taxi 워크로드에는 변환이 필요한 여섯 개의 구문이 있다. 전부 01-setup-snowflake/queries/의 Q1–Q7에 등장한다.

차이 1: QUALIFY

QUALIFY는 윈도 함수 결과로 행을 필터링하는 Snowflake 확장으로, HAVING이 집계 결과로 필터링하는 것과 비슷하다. 이 마이그레이션에서는 QUALIFY를 방언 차이로 취급하고 서브쿼리로 재작성한다 — 모든 SQL 엔진에서 동작하는 보편적으로 이식 가능한 패턴이다.

-- Snowflake
SELECT
    trip_id,
    pickup_at,
    fare_amount,
    ROW_NUMBER() OVER (PARTITION BY pickup_location_id ORDER BY fare_amount DESC) AS fare_rank
FROM fact_trips
WHERE pickup_at >= CURRENT_DATE - 7
QUALIFY fare_rank <= 10;

-- ClickHouse: wrap in a subquery
SELECT trip_id, pickup_at, fare_amount, fare_rank
FROM (
    SELECT
        trip_id,
        pickup_at,
        fare_amount,
        ROW_NUMBER() OVER (PARTITION BY pickup_location_id ORDER BY fare_amount DESC) AS fare_rank
    FROM analytics.fact_trips
    WHERE pickup_at >= today() - 7
)
WHERE fare_rank <= 10;

이것이 중요한 이유: QUALIFY는 Q3에 등장한다. 서브쿼리 재작성은 안전하고 이식 가능한 패턴이다 — 타깃 SQL 엔진과 무관하게 동작하고, 윈도 함수 결과를 명시적으로 드러낸다. Snowflake 고유 문법의 위험은 그것이 조용히 그대로 옮겨진다고 가정하는 것이다. 마이그레이션이 끝났다고 말하기 전에 항상 모든 쿼리를 테스트하라.

차이 2: VARIANT 콜론 경로 문법

Snowflake의 VARIANT 타입은 중첩 필드 접근에 콜론 경로 표기법을 쓴다: column:field.subfield::TYPE. ClickHouse는 준정형 데이터를 String으로 저장하고 쿼리 시점에 JSONExtract* 함수로 추출한다.

-- Snowflake
SELECT
    trip_metadata:driver.rating::FLOAT  AS driver_rating,
    trip_metadata:app.version::STRING   AS app_version,
    trip_metadata:surge_multiplier::FLOAT AS surge
FROM trips_raw;

-- ClickHouse
SELECT
    JSONExtractFloat(trip_metadata, 'driver', 'rating')   AS driver_rating,
    JSONExtractString(trip_metadata, 'app', 'version')    AS app_version,
    JSONExtractFloat(trip_metadata, 'surge_multiplier')   AS surge
FROM default.trips_raw;

JSONExtract* 계열 전체는 다음과 같다: JSONExtractFloat, JSONExtractInt, JSONExtractString, JSONExtractBool, JSONExtractKeys, JSONExtractArrayRaw, JSONExtractRaw. 중첩 객체나 배열을 문자열로 받아 추가 처리해야 할 때는 JSONExtractRaw를 쓴다.

왜 ClickHouse JSON 타입은 쓰지 않는가? JSON 타입(이전에는 실험적 기능)은 최근 ClickHouse 버전에서 사용할 수 있지만 의미론이 다르고, 아직 모든 사용 사례에 대해 프로덕션 수준으로 검증되지는 않았다. 마이그레이션 랩에서는 String + JSONExtract*가 안전하고 잘 이해된 선택이다.

차이 3: LATERAL FLATTEN

Snowflake의 LATERAL FLATTEN은 VARIANT 컬럼 안의 배열을 행으로 펼친다. ClickHouse에는 직접적인 대응물이 없다.

-- Snowflake: explode a VARIANT array into rows
SELECT t.trip_id, f.value:stop_name::STRING AS stop_name
FROM trips_raw t,
LATERAL FLATTEN(input => t.trip_metadata:route_stops) f;

-- ClickHouse Option 1: JSONExtract into Array, then arrayJoin
SELECT
    trip_id,
    arrayJoin(JSONExtract(trip_metadata, 'route_stops', 'Array(String)')) AS stop_name
FROM default.trips_raw;

-- ClickHouse Option 2: Pre-flatten the column during dbt staging
-- In stg_trips.sql, extract all array elements to separate columns
-- or use the dbt model to reshape the data at load time

배열의 스키마가 알려져 있고 개수가 제한적일 때는 미리 펼치는 방식(옵션 2)이 낫다. 애드혹 쿼리이거나 배열 길이가 가변적일 때는 arrayJoin(옵션 1)이 낫다.

차이 4: MERGE INTO

Snowflake의 MERGE INTO는 기본 업서트 메커니즘이다. ClickHouse에는 MERGE 구문이 없다. 올바른 ClickHouse 대응물은 테이블 엔진에 따라 달라진다.

-- Snowflake
MERGE INTO fact_trips t
USING staging_trips s ON t.trip_id = s.trip_id
WHEN MATCHED THEN UPDATE SET t.fare_amount = s.fare_amount, t.updated_at = s.updated_at
WHEN NOT MATCHED THEN INSERT VALUES (s.trip_id, s.pickup_at, ...);

-- ClickHouse with ReplacingMergeTree: just INSERT
-- RMT deduplicates by the ORDER BY key during background merges.
-- Use FINAL at query time to get the latest version:
INSERT INTO analytics.fact_trips SELECT * FROM staging_trips;

SELECT * FROM analytics.fact_trips FINAL WHERE trip_id = '...';

-- ClickHouse with dbt delete_insert incremental:
-- dbt handles the upsert by: DELETE WHERE key IN (new batch), then INSERT
-- This is the recommended approach for the analytics layer

dbt-clickhouse의 delete_insert 증분 전략은 분석 모델에서 MERGE INTO에 의미상 가장 가까운 대응물이다. 들어오는 배치의 키와 일치하는 기존 행을 삭제한 다음, 들어오는 모든 행을 삽입한다 — 파티션 단위로 원자적이다.

ReplacingMergeTree의 핵심 함정: 백그라운드 중복 제거는 비동기다. 머지 사이에는 한 행의 이전 버전과 새 버전이 테이블에 함께 존재한다. 키당 정확히 한 행을 반환해야 하는 쿼리에는 항상 FINAL을 쓰라. 전체 중복 제거 의미론은 MergeTree 엔진을 참고하라.

차이 5: Snowflake Streams (CDC)

Snowflake Streams는 테이블의 행 수준 변경(INSERT, UPDATE, DELETE)을 추적한다. METADATA$ACTION, METADATA$ISUPDATE, METADATA$ROW_ID 시스템 컬럼을 노출한다. ClickHouse에는 이에 상응하는 내부 메커니즘이 없다.

ClickHouse 대응물: 프로듀서 직접 컷오버

ClickHouse에는 Snowflake Streams에 상응하는 내부 CDC 메커니즘이 없다. 이 마이그레이션에서 쓰는 패턴은 CDC 커넥터보다 단순하다.

  • 벌크 로드를 먼저 한다 — scripts/02_migrate_trips.py가 Snowflake의 모든 과거 행을 배치로 읽어 ClickHouse에 삽입한다
  • 그다음 프로듀서를 컷오버한다 — scripts/03_cutover.sh가 Snowflake 프로듀서를 멈추고, ClickHouse Cloud에 직접 쓰는 ClickHouse 프로듀서를 시작한다
  • CDC 윈도가 필요 없다 — 마이그레이션 스크립트가 과거 데이터 로드를 처리하고, 라이브 쓰기는 프로듀서가 이어받는다. trips_raw의 ReplacingMergeTree(_synced_at)가 마이그레이션 재시도나 프로듀서 재시도를 멱등하게 만든다

컷오버 이후에는 dbt delete_insert 전략이 분석 레이어의 업서트를 처리한다. Snowflake Streams와 Tasks는 완전히 폐기된다.

차이 6: 날짜/시간 함수

Snowflake와 ClickHouse는 날짜 함수 이름이 다르다. 대부분은 기계적인 치환이다.

SnowflakeClickHouse비고
DATE_TRUNC('hour', ts)toStartOfHour(ts)그 외: toStartOfDay, toStartOfMonth, toStartOfWeek
DATE_TRUNC('day', ts)toDate(ts)
DATEADD('day', n, ts)ts + INTERVAL n DAY또는 addDays(ts, n)
DATEDIFF('minute', t1, t2)dateDiff('minute', t1, t2)함수 이름이 소문자
CURRENT_DATEtoday()
CURRENT_TIMESTAMP()now()
TO_TIMESTAMP(epoch, 9)fromUnixTimestamp64Nano(epoch)CH에서는 단위가 명시적
YEAR(ts)toYear(ts)
MONTH(ts)toMonth(ts)
EXTRACT(epoch FROM ts)toUnixTimestamp(ts)

DateTime vs DateTime64: ClickHouse의 DateTime은 초 단위 정밀도를 갖는다. 밀리초 정밀도(Snowflake의 TIMESTAMP_NTZ에 대응)가 필요하면 DateTime64(3, 'UTC')를 쓴다. 3은 소수점 이하 자릿수 스케일이고, 'UTC'는 타임존이다.


3. 데이터 이동 옵션

방법사용 시점비고
Python 마이그레이션 스크립트 (scripts/02_migrate_trips.py)Snowflake → ClickHouse 벌크 로드snowflake-connector-python + clickhouse-connect로 직접 연결, 재개 가능, 추가 서비스 불필요 — 이 랩에서 사용
ClickPipesKafka, S3, Kinesis, PostgreSQL CDC, MySQL CDC관리형 커넥터, Snowflake를 소스로 지원하지 않음
remoteSecure()다른 ClickHouse 서비스에서 애드혹으로 가져오기Snowflake 소스에는 적용 불가
오브젝트 스토리지 경유대규모 일회성 로드Snowflake → S3 내보내기 → ClickHouse S3 테이블 함수, AWS 계정과 IAM 설정 필요
JDBC/ODBC커스텀 ETL 파이프라인유연하지만 커스텀 오케스트레이션 필요

이 랩에서는 Python 마이그레이션 스크립트가 올바른 선택이다. 추가 클라우드 서비스가 필요 없고(S3도, Kafka도), 완전히 디버깅 가능하며, 파트너가 다른 랩 단계를 위해 이미 설치해 둔 패키지(snowflake-connector-python, clickhouse-connect)를 사용한다.


4. CDC 아키텍처 비교

Snowflake Streams + TasksClickHouse (이 랩)
변경 추적테이블의 내부 스트림 객체 (TRIPS_CDC_STREAM)대응물 없음 — 컷오버 이후 프로듀서가 ClickHouse에 직접 쓴다
변경 이벤트METADATA$ACTION: INSERT/UPDATE/DELETEClickHouse 프로듀서의 직접 INSERT
지연설정 가능한 태스크 스케줄 (최소 1분)설정 가능한 배치 간격 (기본 10초)
소비SQL 태스크가 스트림을 읽어 타깃으로 내보낸다Python 프로듀서 (producer/producer.py)
스키마 변경수동 조율프로듀서 코드가 스키마를 제어

마이그레이션 이후에는 프로듀서가 ClickHouse에 직접 쓰므로 Streams나 Tasks가 필요 없다. dbt delete_insert 전략이 분석 레이어의 업서트를 처리한다. 주기적 집계(Snowflake Tasks)에는 Refreshable Materialized View라는 ClickHouse 네이티브 대체물이 있다 — 이 랩의 dbt 프로젝트에 analytics.mv_live_trip_feed가 하나 들어 있지만, 랩에서 그 갱신 간격을 켜지는 않는다(모듈 05 참고).


5. 비용 모델 심층 분석

Snowflake: 크레딧 기반

Snowflake 크레딧 하나는 약 $3(Enterprise)이다. 비용 = 웨어하우스 크기 × 실행 시간. SMALL 웨어하우스는 시간당 1 크레딧을 소모한다. MEDIUM은 2다. 자동 일시 중단이 최소 60초라는 것은, 쿼리 하나만 실행해도 최소한 1시간의 1/60에 해당하는 비용이 든다는 뜻이다.

NYC Taxi 랩 기준(X-Small 웨어하우스, 시간당 1 크레딧):

  • 파트 1 설정: 약 2–4 크레딧 (약 $6–12)
  • 8시간 세션당 지속 비용: 하루 약 4–8 크레딧 (약 $12–24)
  • ANALYTICS_WH 리소스 모니터가 월 50 크레딧(약 $150)으로 상한을 둔다

ClickHouse Cloud: 컴퓨트 + 스토리지 분리

ClickHouse Cloud는 컴퓨트와 스토리지에 대해 별도로 과금한다.

  • 컴퓨트: Development 티어는 활성 시 시간당 약 $0.10이고, 유휴 시 0까지 축소된다
  • 스토리지: 약 $0.023/GB/월 (Snowflake의 $23/TB보다 훨씬 저렴)
  • ClickPipes: 지원되는 소스(Kafka, S3, Kinesis, PostgreSQL CDC, MySQL CDC — Snowflake는 아님)에 대해 Cloud 구독에 포함

NYC Taxi 랩 기준:

  • 5천만 행 × 행당 약 300바이트 비압축 = 약 15GB → ClickHouse에서 압축 후 약 8GB
  • 스토리지 비용: 약 $0.18/월
  • 파트 3 랩 활성 구간(약 2시간)의 컴퓨트: 약 $0.20–0.40

파트 3 총비용: 약 $2–4 대 같은 세션에 대한 Snowflake의 약 $6–12.

이 비용 차이는 왜 많은 조직이 Snowflake로 시작했다가(운영이 더 단순하다) 분석 워크로드가 커지면서 ClickHouse로(비용은 낮고 성능은 높다) 마이그레이션하는지를 설명한다.

이 페이지의 내용

KO