dbt 세만틱 레이어 · 메트릭: MetricFlow로 지표 표준화
들어가며
지금까지 다섯 단계에 걸쳐 우리는 dbt로 신뢰할 수 있는 변환 그래프를 세우는 법을 익혔습니다. 모델·ref·소스로 DAG를 그리고, 테스트·문서화로 신뢰를 얹고, 매크로·Jinja로 반복을 없애고, incremental·snapshot으로 규모와 이력을 감당했으며, 5단계 dbt 패키지 · CI에서는 패키지와 Slim CI로 팀 규모의 안전한 배포까지 세웠습니다. 이제 웨어하우스에는 테스트를 통과하고 리뷰를 거친 mart 테이블들이 놓여 있습니다.
그런데 여기서 끝이 아닙니다. mart가 아무리 깨끗해도, 그 위에서 “매출”을 계산하는 SQL은 여전히 대시보드마다 제각각일 수 있습니다. 경영 회의에서 두 팀이 서로 다른 “이번 달 매출”을 들고 오는 사고는 mart의 품질 문제가 아니라 지표 정의가 소비자마다 흩어져 있다는 구조의 문제입니다. 이 글은 dbt-Essential 시리즈의 마지막 6단계(심화)로(dbt Essential Curriculum), dbt 세만틱 레이어(Semantic Layer)와 MetricFlow로 지표 정의를 코드로 한 번만 내리고 모든 소비자가 그 정의를 공유하게 만드는 — dbt를 변환 도구에서 조직의 지표 단일 진실 공급원으로 끌어올리는 — 여정으로 시리즈를 완주합니다.
📌 이 글에서 다루는 내용
- 세만틱 레이어의 문제의식: 같은 이름·다른 SQL의 지표 파편화, 차원 조합마다 늘어나는 mart 테이블 폭발, “단일 정의 → 다중 소비”의 가치와 조회 시점 SQL 생성이라는 해법
- MetricFlow: semantic model 위에 선언하는 entity·dimension·measure, metric 4유형(simple·ratio·derived·cumulative), 지표+차원 질의에서 조인·집계 SQL을 생성하는 원리,
mf query로 검증하는 흐름 - 지표 표준화: JDBC·GraphQL API로 BI·노트북이 같은 정의를 소비, saved query와 export, 지표 변경도 PR·리뷰·테스트를 거치는 거버넌스, “어디까지 mart로 굽고 어디부터 세만틱 레이어에 맡길까”의 실무 판단 기준
한눈에 보기 — 하나의 정의, 여러 소비자
세만틱 레이어의 핵심 아이디어는 한 문장입니다 — 지표 정의는 코드로 한 번만, SQL은 조회 시점에 생성한다. 지표(무엇을 어떻게 집계하나)와 차원(무엇으로 쪼개나)의 정의를 dbt 프로젝트의 YAML로 내려 두면, MetricFlow가 “이 지표를 이 차원으로”라는 질의를 받을 때마다 조인과 집계가 올바른 SQL을 만들어 웨어하우스에 실행합니다. BI 대시보드도, 노트북도, 사내 앱도 각자 SQL을 다시 쓰는 대신 같은 정의에 질의합니다.
flowchart TD
DEF["metric 정의 (YAML, dbt 프로젝트)<br/>semantic model + metrics<br/>버전 관리 · PR 리뷰 · CI 테스트"]
subgraph SL["dbt 세만틱 레이어"]
MF["MetricFlow<br/>entity 그래프에서 조인 경로 해석"]
GEN["조회 시점 SQL 생성<br/>조인 · 집계 · 시간 그레인"]
end
WH[("데이터 웨어하우스<br/>mart 테이블 위에서 실행")]
BI["BI 대시보드<br/>(JDBC)"]
NB["노트북 · Python<br/>(GraphQL / SDK)"]
APP["사내 앱 · API<br/>(GraphQL)"]
BI -->|"지표 + 차원 질의"| MF
NB -->|"지표 + 차원 질의"| MF
APP -->|"지표 + 차원 질의"| MF
DEF --> MF
MF --> GEN
GEN --> WH
WH -->|"일관된 결과"| BI
WH -->|"일관된 결과"| NB
WH -->|"일관된 결과"| APP
이 그림이 이 글의 뼈대입니다. 먼저 왜 이 구조가 필요한지(지표 파편화), 다음으로 어떻게 정의하고 SQL이 생성되는지(MetricFlow), 마지막으로 이 정의를 누가 어떻게 소비하고 지키는지(지표 표준화·거버넌스)를 다룹니다.
세만틱 레이어의 문제의식 — 같은 이름, 다른 SQL
“매출”이 세 개인 회사
지표 파편화는 추상적인 위험이 아니라 어느 조직에나 있는 현실입니다. 같은 fct_orders mart를 보고도, 소비자마다 “매출”을 이렇게 씁니다.
-- 대시보드 A (마케팅): 환불을 빼지 않은 총주문액
SELECT date_trunc('month', ordered_at) AS month,
SUM(order_total) AS revenue
FROM analytics.fct_orders
GROUP BY 1;
-- 대시보드 B (재무): 환불 제외, 부가세 제외
SELECT date_trunc('month', ordered_at) AS month,
SUM(order_total - refund_amount - tax_amount) AS revenue
FROM analytics.fct_orders
WHERE status != 'cancelled'
GROUP BY 1;
-- 노트북 C (데이터 분석가): 테스트 주문까지 제외
SELECT date_trunc('month', ordered_at) AS month,
SUM(order_total - refund_amount) AS revenue
FROM analytics.fct_orders
WHERE status != 'cancelled' AND is_test_order = false
GROUP BY 1;
세 쿼리는 모두 “매출(revenue)”이라는 이름을 달고 있지만 서로 다른 숫자를 냅니다. 어느 것도 틀렸다고 말하기 어렵고 — 각자의 맥락에서는 합리적인 선택입니다 — 바로 그래서 위험합니다. 회의실에서 숫자가 어긋난 뒤에야 발견되고, “어느 쿼리가 진짜인가”를 사람이 고고학처럼 파헤쳐야 합니다. 1~2단계에서 세운 테스트·문서화는 테이블의 품질을 보증하지만, 테이블 위에서 벌어지는 집계의 일관성까지는 보증하지 못합니다.
mart 테이블 폭발 — 조합마다 테이블을 굽는 방식의 한계
파편화를 막는 고전적 처방은 “집계까지 mart로 구워서 내려 주자”였습니다. 그런데 이 방식은 지표 × 차원의 조합 수만큼 테이블을 요구합니다.
revenue_by_day
revenue_by_region
revenue_by_region_month
revenue_by_region_channel_month
active_users_by_day
active_users_by_region_week
...
지표가 M개, 차원이 D개면 잠재 조합은 M × 2^D — 요청이 올 때마다 _by_ 테이블이 하나씩 늘고, 각 테이블은 저마다의 WHERE 절과 미묘하게 다른 집계 로직을 품은 채 관리 밖으로 밀려납니다. 결국 “집계 로직의 중복”이라는 원래 문제가 테이블의 형태로 물질화되었을 뿐입니다. 3단계에서 매크로가 SQL 소스 코드의 중복을 걷어냈다면, 이번에 걷어내야 할 것은 집계 정의의 중복입니다.
해법 — 정의는 한 번, SQL은 조회 시점에
세만틱 레이어는 발상을 뒤집습니다. 집계 결과를 미리 구워 두는 대신, “무엇을 어떻게 집계하는가”라는 정의만 코드로 내려 두고, 실제 SQL은 질의가 도착한 시점에 생성합니다.
- 정의는 dbt 프로젝트 안의 YAML입니다 — 모델·테스트와 같은 저장소에서 버전 관리되고, PR 리뷰와 CI를 거칩니다.
- 소비자는 SQL이 아니라 의도를 보냅니다 — “revenue를 region과 month로”. 조인·집계·필터가 올바른 SQL을 쓰는 일은 MetricFlow의 몫입니다.
- 단일 정의 → 다중 소비 — BI, 노트북, 사내 앱이 같은 정의에 질의하므로, 어디서 보든 “매출”은 하나의 숫자입니다. 정의가 바뀌면 모든 소비자가 동시에 새 정의를 따릅니다.
중요한 것은 세만틱 레이어가 미리 계산된 숫자의 저장소가 아니라 SQL 컴파일러라는 감각입니다. 3단계에서 Jinja가 “SQL을 생성하는 SQL”이었다면, MetricFlow는 한 층 위에서 “지표 정의로부터 SQL을 생성하는 엔진”입니다.
MetricFlow — 정의에서 SQL이 나오는 원리
semantic model — 모델 위에 선언하는 의미 계층
MetricFlow는 dbt 세만틱 레이어의 쿼리 생성 엔진입니다. 그 출발점은 semantic model — 기존 dbt 모델(주로 mart) 위에 “이 테이블이 의미상 무엇인가”를 선언하는 계층입니다. semantic model은 세 가지 요소로 구성됩니다.
- entity: 이 테이블의 식별자이자 조인 키.
primary(이 테이블의 그레인을 정의) /foreign(다른 semantic model로 건너가는 다리) - dimension: 지표를 쪼개는 축.
time(시간 — 그레인 지원) /categorical(범주) - measure: 집계의 원료. 어떤 컬럼을 어떤 함수(
sum·count_distinct·avg…)로 집계하는가
# models/marts/orders.yml
semantic_models:
- name: orders
description: "주문 1건 = 1행. 매출 계열 지표의 원천."
model: ref('fct_orders')
defaults:
agg_time_dimension: ordered_at
entities:
- name: order # primary: 이 테이블의 그레인
type: primary
expr: order_id
- name: customer # foreign: customers로 건너가는 조인 키
type: foreign
expr: customer_id
dimensions:
- name: ordered_at
type: time
type_params:
time_granularity: day
- name: is_food_order
type: categorical
measures:
- name: order_total # 집계의 원료
agg: sum
- name: order_count
agg: count
expr: 1
- name: refund_amount
agg: sum
고객 차원을 다른 semantic model로 분리해 두면, MetricFlow가 필요할 때 알아서 조인합니다.
# models/marts/customers.yml
semantic_models:
- name: customers
model: ref('dim_customers')
entities:
- name: customer # orders의 foreign entity와 이름이 만난다
type: primary
expr: customer_id
dimensions:
- name: region
type: categorical
- name: signup_channel
type: categorical
여기서 눈여겨볼 것은 조인을 어디에도 쓰지 않았다는 점입니다. orders의 foreign entity customer와 customers의 primary entity customer가 이름으로 만나는 순간, MetricFlow는 두 semantic model 사이의 조인 경로를 entity 그래프로 이해합니다. 1단계에서 ref()가 모델 간 의존성 그래프를 세웠듯, 여기서는 entity가 semantic model 간 조인 그래프를 세웁니다.
metric — 4가지 유형
measure가 집계의 원료라면, metric은 소비자에게 노출되는 지표의 공식 정의입니다. 네 유형이 있습니다.
# models/marts/metrics.yml
metrics:
# 1) simple — measure 하나를 그대로 지표로
- name: revenue
label: "매출"
description: "환불 차감 전 총주문액. 재무 매출은 net_revenue를 사용할 것."
type: simple
type_params:
measure: order_total
- name: order_count
label: "주문 수"
type: simple
type_params:
measure: order_count
# 2) ratio — 분자/분모 지표의 비율 (조회 차원이 분자·분모에 일관 적용)
- name: average_order_value
label: "평균 주문 금액 (AOV)"
type: ratio
type_params:
numerator: revenue
denominator: order_count
# 3) derived — 다른 지표들의 수식 조합
- name: net_revenue
label: "순매출"
type: derived
type_params:
expr: revenue - refunds
metrics:
- name: revenue
- name: refunds
# 4) cumulative — 시간 창을 누적하는 지표
- name: revenue_7d
label: "최근 7일 매출"
type: cumulative
type_params:
measure: order_total
window: 7 days
지표에 필터를 조건으로 못 박을 수도 있습니다. “음식 주문 매출”처럼 정의 자체에 조건이 들어가는 지표는, 소비자가 WHERE 절을 기억하는 대신 정의가 조건을 품게 만듭니다.
metrics:
- name: food_revenue
label: "음식 주문 매출"
type: simple
type_params:
measure: order_total
filter: |
{{ Dimension('order__is_food_order') }} = true
ratio와 derived의 진가는 차원이 자동으로 일관 적용된다는 데 있습니다. AOV를 region별로 조회하면 분자(revenue)와 분모(order_count)가 같은 region 분해로 계산됩니다 — 분자와 분모를 서로 다른 쿼리에서 따로 구해 붙이다 생기는 고전적 실수가 구조적으로 사라집니다.
SQL 생성의 원리 — 질의에서 조인·집계가 나오기까지
이제 소비자가 이렇게 질의한다고 합시다 — “revenue를 월별 × 고객 region별로”. MetricFlow가 SQL을 만드는 과정은 대략 네 단계입니다.
- 지표 해석:
revenue→ simple metric → measureorder_total(SUM) → measure가 사는 semantic model은orders - 조인 경로 해석:
customer__region차원은customers에 산다 → entity 그래프에서orders.customer(foreign)↔customers.customer(primary)경로 발견 → LEFT JOIN 결정 - 시간 그레인 처리:
metric_time__month→orders의 기본 시간 차원ordered_at을 month로 truncate - 집계 배치: 그룹 기준(month, region)으로 SUM을 배치해 최종 SQL 생성
mf query CLI로 이 흐름 전체를 로컬에서 검증할 수 있습니다.
# 정의 정합성 검사 (CI에도 건다)
mf validate-configs
# 정의된 지표 목록 확인
mf list metrics
# 지표 + 차원 질의 — 조인·집계는 MetricFlow의 몫
mf query \
--metrics revenue \
--group-by metric_time__month,customer__region \
--order metric_time__month
# 생성될 SQL을 눈으로 확인
mf query --metrics revenue --group-by metric_time__month,customer__region --explain
--explain이 보여 주는 생성 SQL은 대략 이런 모양입니다(웨어하우스별로 문법은 다릅니다).
-- MetricFlow가 생성한 SQL (개념적 형태)
SELECT
date_trunc('month', o.ordered_at) AS metric_time__month,
c.region AS customer__region,
SUM(o.order_total) AS revenue
FROM analytics.fct_orders o
LEFT JOIN analytics.dim_customers c
ON o.customer_id = c.customer_id -- entity 그래프가 해석한 조인
GROUP BY 1, 2;
소비자는 이 SQL을 한 줄도 쓰지 않았습니다. 조인 키를 잘못 짚을 일도, 환불 차감을 빼먹을 일도 없습니다 — 정의가 옳으면 모든 질의가 옳습니다. 그리고 정의가 틀렸다면, 고칠 곳도 한 곳뿐입니다.
지표 표준화 — 정의를 조직의 자산으로
소비 — BI·노트북·앱이 같은 정의를 쓴다
정의를 세웠으니 이제 소비자를 연결할 차례입니다. dbt 세만틱 레이어는 두 가지 API를 노출합니다.
- JDBC API (Arrow Flight SQL 기반): BI 도구가 표준 JDBC 드라이버로 접속해, 테이블 대신 지표에 질의합니다. Tableau·Hex·Mode 등이 이 경로로 통합됩니다.
- GraphQL API: 노트북·사내 앱·커스텀 서비스가 HTTP로 질의합니다. Python SDK도 이 위에 얹혀 있습니다.
JDBC로 들어온 질의는 SQL처럼 생겼지만, FROM 절이 테이블이 아니라 세만틱 레이어 질의 함수라는 점이 다릅니다.
-- BI 도구가 JDBC로 보내는 질의 — 테이블이 아니라 '정의'에 묻는다
select * from {{
semantic_layer.query(
metrics=['revenue', 'average_order_value'],
group_by=[Dimension('metric_time').grain('month'),
Dimension('customer__region')]
)
}}
자주 쓰는 질의 조합은 saved query로 이름 붙여 저장하고, 필요하면 export로 웨어하우스에 테이블·뷰로 구체화할 수도 있습니다 — “정의는 세만틱 레이어에, 자주 쓰는 결과는 테이블로”를 한 선언에서 관리하는 장치입니다.
saved_queries:
- name: weekly_kpi
description: "주간 경영 리뷰용 표준 KPI 묶음"
query_params:
metrics:
- revenue
- average_order_value
group_by:
- TimeDimension('metric_time', 'week')
- Dimension('customer__region')
exports:
- name: weekly_kpi_table # dbt build 시 테이블로 구체화
config:
export_as: table
거버넌스 — 지표도 PR·리뷰·테스트를 거치는 소프트웨어 자산
세만틱 레이어의 조용한 승리는 기술이 아니라 프로세스에 있습니다. 지표 정의가 dbt 프로젝트의 YAML이 된 순간, 지표는 5단계에서 세운 소프트웨어 규율의 궤도에 그대로 올라탑니다.
- 변경은 PR로: “매출에서 부가세를 빼자”는 결정이 슬랙 스레드가 아니라 diff로 남습니다. 리뷰어가 승인하고, Git 이력이 “언제, 누가, 왜 바꿨는가”에 답합니다.
- CI가 정합성을 지킵니다:
mf validate-configs를 CI에 걸어 깨진 참조·조인 불능 차원을 병합 전에 잡고, Slim CI의 state 비교는 지표가 딛고 선 모델의 변경까지 함께 검증합니다. - 정의가 곧 문서입니다: metric의
label·description이 데이터 카탈로그에 노출되어, “이 지표가 정확히 무엇인가”를 묻는 채널 질문이 정의 문서로 대체됩니다.
2단계에서 테스트·문서가 테이블을 신뢰할 수 있는 자산으로 만들었다면, 세만틱 레이어는 같은 규율로 지표를 신뢰할 수 있는 자산으로 만듭니다.
어디까지 mart로 굽고, 어디부터 세만틱 레이어에 맡길까
세만틱 레이어가 생겼다고 mart가 필요 없어지는 것은 아닙니다. 실무의 판단 기준은 “행을 만드는 일은 mart, 행을 접는(집계하는) 일은 세만틱 레이어”입니다.
| 판단 기준 | mart로 굽는다 | 세만틱 레이어에 맡긴다 |
|---|---|---|
| 로직의 성격 | 행 수준 비즈니스 로직 — 환불 차감, 상태 정제, 테스트 주문 제거 | 집계와 차원 조합 — SUM/COUNT, group by의 축 |
| 변환 비용 | 무겁고 반복되는 변환 (incremental로 증분 처리) | 조회 시점 계산이 감당되는 가벼운 집계 |
| 소비 패턴 | 조회 빈도가 매우 높고 지연에 민감한 고정 화면 (또는 export로 구체화) | 차원 조합이 자주 바뀌는 탐색적 분석·셀프서비스 |
| 정의의 성격 | 한 테이블 안에서 완결되는 파생 컬럼 | 여러 지표를 엮는 공식 (ratio·derived), 시간 창(cumulative) |
바꿔 말해 4단계까지 배운 것들(incremental·snapshot·매크로)은 좋은 원료 테이블을 만드는 기술이고, 세만틱 레이어는 그 원료 위에서 집계의 일관성을 보증하는 계층입니다. fct_orders에 net_order_total 같은 행 수준 파생 컬럼을 굽는 것은 여전히 mart의 일이지만, 그것을 월별·지역별로 어떻게 접을지는 이제 _by_ 테이블이 아니라 정의가 담당합니다. 조회 성능이 발목을 잡는 조합만 export(또는 소비층 캐시)로 구체화하면, “유연성은 정의로, 성능은 선택적 구체화로”라는 균형이 잡힙니다.
정리
- 파편화는 구조의 문제다: “매출”이 대시보드마다 다른 것은 사람의 실수가 아니라, 지표 정의가 소비자마다 흩어져 있는 구조의 필연입니다. 조합마다 mart를 굽는 처방은 중복을 테이블의 형태로 물질화할 뿐입니다.
- 정의는 한 번, SQL은 조회 시점에: 세만틱 레이어는 미리 계산된 숫자의 저장소가 아니라 SQL 컴파일러입니다. semantic model(entity·dimension·measure)과 metric(simple·ratio·derived·cumulative)을 YAML로 선언하면, MetricFlow가 entity 그래프에서 조인 경로를 해석해 질의마다 올바른 SQL을 생성합니다.
- 정의가 옳으면 모든 질의가 옳다: ratio·derived 지표는 차원이 분자·분모에 일관 적용되고, 필터는 정의가 품습니다.
mf validate-configs와mf query --explain으로 정의와 생성 SQL을 검증합니다. - 지표도 소프트웨어 자산이다: JDBC·GraphQL API로 BI·노트북·앱이 같은 정의를 소비하고, 지표 변경은 PR·리뷰·CI를 거쳐 diff와 이력으로 남습니다. 경계 원칙은 하나 — 행을 만드는 일은 mart, 행을 접는 일은 세만틱 레이어.
그리고 이것으로 dbt-Essential 시리즈 6단계 완주입니다. 우리는 모델링 기초에서 출발했습니다 — SELECT 하나가 모델이 되고 ref()가 의존성 그래프를 세우는 원리를 그리고(1단계), 테스트·문서화·lineage로 그 그래프에 “이 숫자를 믿어도 되는가”라고 물을 수 있는 신뢰를 얹었습니다(2단계). 그 위에 재사용과 규모를 쌓았습니다 — 매크로·Jinja로 수십 모델에 흩어진 반복을 한 곳으로 모으고(3단계), incremental·snapshot으로 대용량의 비용과 변화의 이력을 감당했습니다(4단계). 마지막으로 협업과 표준화로 넓혔습니다 — 패키지·Slim CI로 팀 규모의 안전한 배포를 세우고(5단계), 오늘 세만틱 레이어·MetricFlow로 조직의 지표까지 코드의 규율 아래 두었습니다(6단계). “웨어하우스 안의 SQL을 소프트웨어처럼 다룬다”는 dbt의 단순한 발상이, 모델에서 테스트로, 배포에서 지표로 확장되는 전 과정을 완주한 것입니다. 이제 여러분은 dbt 프로젝트의 그래프를 읽고, 신뢰할 수 있는 변환 파이프라인을 설계·운영하며, 팀의 배포와 조직의 지표 표준까지 책임질 수 있는 애널리틱스 엔지니어링의 손을 갖췄습니다. 완주를 축하합니다 — 커리큘럼의 체크박스를 모두 채우러 갑시다.
다음 학습 (Next Learning)
- dbt Essential Curriculum — 시리즈 로드맵으로 돌아가 6단계 도장을 모두 채우기
- 데이터 변환·처리(Processing): 배치·스트림 엔진과 SQL 변환 — 이 시리즈가 갈라져 나온 오버뷰 단계, 처리 지도에서 dbt의 위치 복습
- Airflow Essential Curriculum — dbt 실행을 지휘하는 오케스트레이션 자매 시리즈
- Spark Essential Curriculum — 처리 스케일을 담당하는 자매 시리즈, “나눠서 처리”와 “웨어하우스 안에서 변환”의 짝