05 벤치마크와 컷오버
ClickHouse에서 대시보드를 재구축하고, 7개 쿼리 전부를 양쪽 엔진에서 벤치마크하고, 프로듀서를 컷오버하고, 패리티를 검증하고, 정리한다.
시작 지점
모듈 04 완료 상태: analytics 레이어가 채워지고 테스트를 통과했다. analytics.fact_trips가
약 5천만 행을 담고 있고, analytics.dim_taxi_zones, analytics.dim_payment_type,
analytics.dim_vendor, analytics.dim_date가 완전히 적재되었고, dbt test가 처음부터
끝까지 통과하고, analytics.taxi_zones_dict가 살아 있으며 dictGet()으로 자치구를
반환한다. analytics.agg_hourly_zone_trips는 여전히 비어 있다 — 결함이 아니라 설계상
그렇고 — 이 모듈의 컷오버까지 그 상태로 남는다. Snowflake 프로듀서는 여전히 실행 중이고,
Snowflake와 ClickHouse 사이의 공백도 여전히 열려 있다. 약 45분을 예상하라.
이유
여기까지의 모든 모듈은 준비 작업이었다. 모듈 03은 ClickHouse가 5천만 행을 담을 수 있음을 증명했고, 모듈 04는 dbt 파이프라인이 그 위에서 돌아감을 증명했다. 그 어느 것도 단독으로는 파트너가 "마이그레이션 완료"라고 승인할 만한 것이 아니다 — 그러려면 두 가지가 더 필요하다. 숫자, 그리고 컷오버다.
숫자는 2단계의 벤치마크다. 마이그레이션 계획에 있는 그 일곱 개 쿼리를 Snowflake와 ClickHouse에 연달아 실행하고, 각각 세 번 실행한 값의 중앙값을 취한다. 그것이 "ClickHouse가 더 빠를 것이다"를, 파트너가 자기 이해관계자 앞에 내놓을 수 있는 구체적이고 방어 가능한 속도 향상 수치로 바꿔 준다.
3단계의 컷오버가 나머지 절반이다. 지금까지의 모든 모듈은 두 시스템을 나란히 돌려 왔다. Snowflake가 기록 시스템이고 ClickHouse가 그 뒤를 따라잡는 구조였다. 쓰기 경로를 실제로 옮기지 않는 마이그레이션은 마이그레이션이 아니라 복사다. 3단계는 Snowflake 프로듀서를 멈추고, 모듈 01부터 지속적인 쓰기가 열어 두었던 공백을 닫고, 새 트립을 대신 ClickHouse에 쓰기 시작한다 — ClickHouse가 기록 시스템이 되는 순간이다.
이 모듈은 또한 모듈 06의 서면 평가 전 마지막 학습 모듈이며, 그 평가는 여기서 만든 결과물에 대한 오픈북이다. 대시보드, 벤치마크 CSV, 패리티 확인은 5단계에서 무엇이든 정리하기 전에 모두 실물로 존재해야 한다.
개념 — 내부 동작
BI 레이어, 기계적으로. 1단계에서 bash superset/add_clickhouse_connection.sh는 Superset
REST API를 직접 호출한다 — Superset UI에서 수동으로 클릭할 것이 없다. 이 스크립트는
ClickHouse 연결을 등록하고, 커밋된 대시보드 익스포트를 임포트해, 모듈 01이 만든 세 개의
Snowflake 대시보드 옆에 네 개의 ClickHouse 대시보드를 추가한다(총 일곱 개).
| 대시보드 | 대응 대상 | 무엇을 보여 주는가 |
|---|---|---|
| CH — Operations Command Center | Snowflake Dashboard 1 | 라이브 fact_trips 데이터(컷오버 이후). 동일한 KPI, 더 빠른 쿼리 |
| CH — Executive Weekly Report | Snowflake Dashboard 2 | ROW_NUMBER() 서브쿼리로 다시 쓴 QUALIFY |
| CH — Driver & Quality Analytics | Snowflake Dashboard 3 | Snowflake의 LATERAL FLATTEN 대신 JSONExtractString |
| CH — Capabilities Showcase | (신규 — Snowflake 대응물 없음) | 근사 함수, 딕셔너리 조인, SAMPLE 절 |
일곱 개 벤치마크 쿼리가 다루는 것. 2단계는 마이그레이션 계획에 있는 그 일곱 개 쿼리를 양쪽 엔진에 실행하고 실제 소요 시간을 비교한다. 각 쿼리는 모듈 02 계획에 나온 특정 방언 격차나 엔진 기능을 겨냥한다.
| 쿼리 | 다루는 것 |
|---|---|
| Q1 | 자치구별 시간당 매출 |
| Q2 | 롤링 7일 평균 거리 |
| Q3 | 상위 10개 트립 — Snowflake QUALIFY 대 ClickHouse ROW_NUMBER() 서브쿼리 |
| Q4 | 드라이버 평점 — Snowflake LATERAL FLATTEN 대 ClickHouse JSONExtractString |
| Q5 | 서지 요금 — Snowflake VARIANT 대 ClickHouse String + JSONExtract* |
| Q6 | 시간별 집계 — Snowflake MERGE 대 ClickHouse ReplacingMergeTree |
| Q7 | CDC / 라이브 데이터 신선도 |
컷오버 공백. Snowflake 프로듀서는 모듈 01부터 분당 약 60건의 트립을 써 왔고, 한 번도
멈추지 않았다. 모듈 03의 마이그레이션 스크립트는 그 스크립트가 실행된 시점의 TRIPS_RAW를
포착했고, 모듈 04는 그 스냅샷 위에 dbt 파이프라인을 구축했다. 마이그레이션 스크립트의
마지막 배치 이후 Snowflake에 쓰인 모든 트립은 Snowflake에만 존재한다 — ClickHouse의 꼬리가
빠져 있다. 3단계의 --resume 따라잡기 패스가 정확히 그 공백을 닫는다. 이미 ClickHouse에
있는 max(pickup_at)을 읽어 그 이후에 쓰인 행만 가져오므로, 모듈 01부터 열려 있던 공백을
닫는 패스가 원래 대량 마이그레이션에 걸린 40-50분이 아니라 수 초에서 수 분 걸린다. 이를
건너뛰고 그냥 컷오버하면 ClickHouse는 그 공백에 떨어진 트립을 영구히 잃는다 — 4단계가
잡아내도록 만들어진 조용한 패리티 실패인데, 3단계를 순서대로 실행했을 때에만 잡힌다.
agg_hourly_zone_trips는 여기서, 오직 여기서 채워진다. 이 테이블은 설계상 모듈 03부터
비어 있었다. 증분 필터가 WHERE pickup_at >= now() - INTERVAL 2 HOUR이라서 라이브 프로듀서가
쓴 행만 매칭하는데, 이 모듈까지는 쓰고 있던 프로듀서가 Snowflake 쪽뿐이었다. 3단계가
ClickHouse 프로듀서를 시작하면 새 행이 드디어 그 2시간 윈도우 안에 떨어지고, 이 테이블과
이를 사용하는 모든 대시보드 차트가 이 랩에서 처음으로 빈 상태를 벗어난다.
1단계 — ClickHouse 대시보드 추가
7개의 데이터셋, 18개의 차트, 4개의 대시보드를 Superset UI에서 하나씩 만드는 전체 수동 구축 과정은 Superset on ClickHouse를 따르라. 수동 단계를 건너뛰고 한 번에 전부 임포트하려면:
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && source .clickhouse_state
bash superset/add_clickhouse_connection.sh커밋된 superset/dashboards/dashboard_export_*.zip은 ClickHouse 호스트가
your-instance.clickhouse.cloud로 가려져 있다. 위의 단축 경로는 임포트 전에 .env의
값으로 URI를 패치하므로 이 과정은 투명하다 — 가려진 것을 알아채지도 못한다. 하지만 대신
Superset UI에서 ZIP을 수동으로 임포트하면 생성되는 데이터베이스 연결은 연결되지 않는다 —
이후 그 연결을 편집해 실제 CLICKHOUSE_HOST와 자격 증명을 가리키게 해야 한다. 정확한
방법은
Superset on ClickHouse를 참고하라.
검증:
http://localhost:8088 (admin / admin)을 열어라. Dashboards 아래에 총 7개가 보여야
한다 — Snowflake 대시보드 3개와 CH — 접두어가 붙은 4개다.
2단계 — 벤치마크 실행
일곱 개 쿼리 전부를 Snowflake와 ClickHouse에 연달아 실행하고 실제 소요 시간을 비교한다.
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && source .clickhouse_state
./scripts/run_benchmark.sh예상 출력:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NYC Taxi Lab — Query Benchmark: Snowflake vs ClickHouse
(median of 3 runs each)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Query Snowflake ClickHouse Speedup
────────────────────────────────────────────────────────────────────────
Q1 Hourly revenue by borough 5.0s 0.7s 6x
Q2 Rolling 7-day avg distance 5.5s 0.8s 6x
Q3 Top 10 trips (QUALIFY→subquery) 5.0s 0.7s 6x
Q4 Driver ratings (JSON flatten) 5.4s 0.8s 6x
Q5 Surge pricing (VARIANT) 5.1s 0.7s 6x
Q6 Hourly aggregation (MERGE→RMT) 5.9s 0.8s 7x
Q7 CDC/live data freshness 7.9s 0.8s 9x
────────────────────────────────────────────────────────────────────────
Total 40.1s 5.6s 7x avg
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━이 수치는 대표적인 실행 한 번의 결과이며 보장값이 아니다 — 웨어하우스 크기, ClickHouse Cloud 티어, 그리고 그 시점에 어느 서비스에서 무엇이 함께 돌고 있는지에 따라 본인의 숫자는 달라진다.
스크립트는 모든 실행 결과를
workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse/scripts/benchmark_results_<timestamp>.csv에
쓴다. 디스크에 보관하라 — 모듈 06의 평가에 필요한 두 파일 중 하나이므로, 5단계의 정리
과정에서 삭제하지 마라.
3단계 — ClickHouse로 컷오버
마이그레이션 공백. Snowflake 프로듀서는 이 랩 내내 실행되며 분당 약 60건의 트립을 써
왔다. 모듈 03의 마이그레이션 스크립트는 그 스크립트가 실행된 시점의 TRIPS_RAW를 포착했다 —
그 이후에 쓰인 행은 Snowflake에만 존재한다. 쓰기 경로를 컷오버하기 전에 그 공백을 닫아라.
아래 네 단계를 순서대로 모두 실행하라 — 두 시스템을 일관되게 유지하는 것이 바로 그 순서다.
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && source .clickhouse_state
source .venv/bin/activate
# Step 1: Stop the Snowflake producer (freeze the dataset)
docker stop nyc_taxi_producer
# Step 2: Catch up the delta — only migrates rows with pickup_at newer than
# what's already in ClickHouse. Runs in seconds to minutes, not the original
# 40-50 minutes, because only the gap rows move.
python scripts/02_migrate_trips.py --resume
# Step 3: Refresh the analytics tables with the newly migrated rows
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse/dbt/nyc_taxi_dbt_ch"
dbt run
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
# Step 4: Start the ClickHouse producer
source .env && source .clickhouse_state
./scripts/03_cutover.sh2단계를 건너뛰지 마라. --resume은 이미 ClickHouse에 있는 max(pickup_at)을 읽어
Snowflake 쿼리에 WHERE PICKUP_DATETIME > <watermark> 필터를 붙이므로, 모듈 03의 원래
마이그레이션 실행 도중과 그 이후에 쓰인 행 — ClickHouse가 한 번도 본 적 없는 행 — 만
전송한다. 이것 없이 컷오버하면 ClickHouse는 그 구간에 떨어진 트립을 영구히 잃는다. 4단계의
패리티 확인은 정확히 그것을 잡아내도록 만들어졌지만, 이 단계를 먼저 실행했을 때에만
그렇다.
./scripts/03_cutover.sh는 Type "cutover" to confirm을 묻고, 자체 안전망으로 1-3단계를
반복한다. Snowflake 프로듀서를 다시 중지하고(이미 했다면 아무 일도 하지 않는다), dbt run을
한 번 더 실행하고, ClickHouse 프로듀서(nyc_taxi_ch_producer)를 빌드해 시작한다. 프로듀서가
시작된 뒤 30초 후에 새 행이 default.trips_raw에 떨어지고 있는지 확인하고 dbt를 한 번 더
실행한다 — 그 실행이 드디어 agg_hourly_zone_trips에 첫 행을 채우며, 모듈 04가 설계상
남겨 둔 공백을 닫는다.
검증:
-- Most recent trip should be within the last 60 seconds
SELECT max(pickup_at) AS most_recent_trip FROM default.trips_raw;
-- Row count should be increasing — wait 60 seconds and run again
SELECT count() FROM default.trips_raw;
-- agg_hourly_zone_trips should now have rows for the first time in the lab
SELECT count() FROM analytics.agg_hourly_zone_trips;docker ps | grep nyc_taxi_ch_producer # should show runninganalytics 레이어를 신선하게 유지하기. fact_trips와 agg_hourly_zone_trips는 dbt 증분
모델이며 자동으로 갱신되지 않는다. 03_cutover.sh는 프로듀서가 살아 있음을 확인한 뒤 dbt run을 한 번 실행하지만, 새 트립이 쌓이면서 대시보드는 낡아 간다. 최신 숫자가 필요할 때마다
dbt/nyc_taxi_dbt_ch에서 dbt run을 다시 실행하라(프로덕션이라면 cron, Airflow, dbt Cloud로
스케줄링하겠지만 랩에서는 온디맨드로 충분하다). 반면 analytics.mv_live_trip_feed는
갱신 가능한 materialized view다 — 모듈 04의 dbt run이 이미
engine = 'ReplacingMergeTree(refreshed_at)'로 만들었다 — 그러나 이 랩은 그 갱신 주기를 켜지
않는다. 스스로 재실행되게 만들어 주는 MODIFY REFRESH EVERY 30 SECOND 문은 모델 파일 안에
주석으로만 존재한다. 활성화하려면 직접 실행하는 한 줄짜리 ALTER TABLE 문이 필요하다.
그것 없이는 mv_live_trip_feed가 dbt가 만든 그 한 번만 갱신된다.
이 단계를 되돌려 Snowflake 프로듀서로 돌아가야 한다면, 역방향 컷오버:
docker stop nyc_taxi_ch_producer
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/01-setup-snowflake/superset"
docker-compose --env-file ../.env up -d producer4단계 — 패리티 검증
--resume 따라잡기 패스가 실행되고 ClickHouse 프로듀서가 동작 중이므로 두 시스템은 이제
패리티 상태여야 한다. 그것을 확인하라.
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && source .clickhouse_state
bash scripts/01_verify_migration.sh예상 출력:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Migration Parity Check
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ ClickHouse default.trips_raw: 50,008,250 rows
✓ Snowflake NYC_TAXI_DB.RAW.TRIPS_RAW: 50,008,250 rows
✓ Row count parity: PASS (difference: 0 rows = 0.0000%)
✓ trip_metadata populated: 50,008,250 non-empty rows
pickup_at range: 2022-03-30 2026-03-31
✓ ClickHouse has 50,008,250 rows — migration looks complete
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━본인의 행 수는 다를 것이다. 중요한 것은 패리티 줄이다. 이 시점에는 Snowflake 프로듀서가
멈춰 있어 새 행이 그쪽에 떨어지지 않으므로 행 수가 정확히 일치해야 하고, --resume 패스
도중 배치가 아직 전송 중이었다면 몇 행 이내로 차이 날 수 있는데 이는 스크립트가 검사하는
0.01% 임계값 안에 충분히 들어온다.
패리티 확인이 실패하면(차이가 0.01%보다 크면) 공백이 완전히 닫히지 않았다는 뜻이다 — 따라잡기 패스를 다시 실행하고 재확인하라.
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && source .clickhouse_state
python scripts/02_migrate_trips.py --resume
bash scripts/01_verify_migration.sh5단계 — 정리
무엇이든 정리하기 전에 마이그레이션이 올바른 최종 상태인지 확인하라.
| 확인 항목 | 명령 | 예상 |
|---|---|---|
| 행 수 패리티 | bash scripts/01_verify_migration.sh | 행 수 일치율 99.9% 이상 |
| dbt 테스트 | dbt test (dbt/nyc_taxi_dbt_ch에서) | 모든 테스트 통과 |
| Superset 대시보드 | http://localhost:8088 열기 | 대시보드 7개 표시(SF 3개 + CH 4개) |
| 벤치마크 결과 | cat scripts/benchmark_results_<timestamp>.csv | 7개 쿼리 모두에 속도 향상 값이 있다 |
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && source .clickhouse_state
bash scripts/01_verify_migration.sh
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse/dbt/nyc_taxi_dbt_ch"
dbt test네 항목 모두 통과하면, 모듈 06에 필요한 모든 것은 두 파일이며 둘 다 정리 후에도 남는다.
workshop_public/snowflake_migration_lab/02-plan-and-design/migration-plan.md와
workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse/scripts/benchmark_results_<timestamp>.csv다.
모듈 06은 약 60분짜리 오픈북 지필 평가이며 그 외에는 아무것도 필요하지 않다 — 서면 시험을
치는 동안 유료 ClickHouse Cloud 서비스를 계속 켜 둘 이유가 없다. 두 파일의 내용을 접근
가능한 곳에 복사하거나 적어 둔 다음, 전부 정리하라.
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse"
source .env && ./teardown.sh이 명령은 ClickHouse Cloud 서비스(terraform destroy를 통해)와, 컷오버를 수행했다면
ClickHouse 트립 프로듀서 컨테이너를 파괴한다.
Part 1의 Snowflake 리소스는 이 스크립트로 정리되지 않는다. Snowflake 쪽은 별도로 정리하라.
cd "$(git rev-parse --show-toplevel)/workshop_public/snowflake_migration_lab/01-setup-snowflake"
source .env && ./teardown.sh완료 확인 방법
이 모듈의 이 지점까지 오면 다음을 순서대로 확인했어야 한다.
- 패리티 확인 통과 — 4단계의
01_verify_migration.sh가 행 수 차이 0.01% 미만으로 PASS를 보고했다. - 디스크의 벤치마크 CSV — 2단계가 7개 쿼리 모두에 속도 향상 값을 담은
benchmark_results_<timestamp>.csv를 썼고, 5단계 정리 전에 보관해 두었다. - 대시보드 7개 존재 — 1단계의 Superset 확인에서 Snowflake 대시보드 3개와
CH —대시보드 4개가 나란히 보였다. - ClickHouse 프로듀서 쓰기 중 — 3단계의 검증 블록에서 5단계가 정리를 위해 중지하기
전에
default.trips_raw의 행이 늘어나고nyc_taxi_ch_producer가 실행 중임이 보였다.
그중 어느 것이 그 시점에 성립하지 않았다면, 지금 이 확인들을 다시 실행하기보다 해당 단계로 돌아가라 — 5단계가 이미 ClickHouse Cloud 서비스를, 그리고 컷오버가 있었다면 프로듀서 컨테이너까지 파괴했다.
종료 상태
마이그레이션이 완료되고 측정되었다. 5천만 행이 Snowflake에서 ClickHouse로 옮겨져 패리티가 검증되고, 일곱 개 쿼리가 정면으로 벤치마크되어 모두에서 ClickHouse가 더 빠르고, BI 레이어가 원래의 Snowflake 대시보드 3개 옆에 ClickHouse 대시보드 4개로 재구축되고, 쓰기 경로가 Snowflake에서 ClickHouse로 완전히 넘어갔다. 두 클라우드 환경 모두 정리되었다 — ClickHouse Cloud 서비스도, ClickHouse 프로듀서 컨테이너도 없고, Part 1의 정리까지 실행했다면 Snowflake 웨어하우스도 없다.
정리 후에도 남는 두 파일이 모듈 06에 필요한 전부다.
workshop_public/snowflake_migration_lab/02-plan-and-design/migration-plan.md와
workshop_public/snowflake_migration_lab/03-migrate-to-clickhouse/scripts/benchmark_results_<timestamp>.csv다.
모듈 06은 오픈북 서면 평가다 — 그 두 파일만 가져오면 된다.