이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • fact와 dimension은 각각 어떤 질문에 답하기 위해 나누는가?
  • fact table의 grain을 먼저 정하지 않으면 어떤 집계 오류가 생기는가?
  • 회원 등급, 상품 카테고리처럼 바뀌는 dimension은 과거 리포트에서 어떻게 다루는가?
  • 백엔드 이벤트와 운영 table은 fact/dimension 모델에 어떤 정보를 제공해야 하는가?

개요

fact는 측정 가능한 사건이다.

dimension은 그 사건을 해석하는 기준이다.

주문 금액, 주문 수, 환불 금액은 fact에 가깝다.

회원 등급, 상품 카테고리, 지역, 캠페인은 dimension에 가깝다.

fact와 dimension을 나누는 목적은 분석 저장소를 예쁘게 정규화하는 것이 아니라 같은 지표를 같은 기준으로 반복 계산하기 위해서다.

원리

fact table을 설계할 때 가장 먼저 정할 것은 grain이다.

grain은 fact row 하나가 무엇을 의미하는지다.

예를 들어 주문 1건, 주문 상품 1개, 결제 1건, 일별 상품별 집계는 모두 다른 grain이다.

grain이 불명확하면 주문 수, 상품 수, 매출 합계가 join 과정에서 쉽게 부풀려진다.

dimension은 fact를 설명하는 기준이다.

dimension은 현재 값만 볼 수도 있고, 과거 시점의 값을 보존할 수도 있다.

Fact 예시

CREATE TABLE fact_order_line (
    order_line_id bigint PRIMARY KEY,
    order_id bigint NOT NULL,
    member_id bigint NOT NULL,
    product_id bigint NOT NULL,
    quantity integer NOT NULL,
    paid_amount numeric(14, 2) NOT NULL,
    ordered_at timestamp NOT NULL,
    source_event_id varchar(36) NOT NULL,
    loaded_at timestamp NOT NULL
);

이 fact의 grain은 주문 상품 line 하나다.

주문 한 건에 상품이 세 개 있으면 fact row는 세 개다.

따라서 주문 수를 세려면 COUNT(DISTINCT order_id)가 필요하고, 판매 상품 수를 세려면 SUM(quantity)가 필요하다.

Dimension 예시

CREATE TABLE dim_product (
    product_id bigint NOT NULL,
    product_name varchar(200) NOT NULL,
    category varchar(100) NOT NULL,
    brand varchar(100) NOT NULL,
    valid_from timestamp NOT NULL,
    valid_to timestamp NULL,
    PRIMARY KEY (product_id, valid_from)
);

상품 카테고리는 바뀔 수 있다.

과거 리포트가 주문 당시 카테고리 기준이어야 하는지, 현재 카테고리 기준이어야 하는지 먼저 정해야 한다.

이 판단 없이 dimension을 current snapshot으로만 만들면 과거 매출 분류가 조용히 바뀐다.

Grain을 잘못 잡은 예

SELECT p.category,
       COUNT(o.order_id) AS order_count,
       SUM(o.paid_amount) AS paid_amount
FROM orders o
JOIN order_lines l ON l.order_id = o.order_id
JOIN products p ON p.product_id = l.product_id
GROUP BY p.category;

이 쿼리는 주문 금액이 상품 line 수만큼 중복 집계될 수 있다.

운영 DB join에서는 동작하는 SQL처럼 보여도 분석 grain이 맞지 않으면 지표가 틀린다.

Event와 연결

event는 fact 생성의 좋은 원천이 될 수 있다.

{
  "eventId": "evt-20260630-001",
  "eventType": "OrderLinePaid",
  "orderId": 101,
  "orderLineId": 9001,
  "productId": 77,
  "memberId": 15,
  "quantity": 2,
  "paidAmount": "39000.00",
  "occurredAt": "2026-06-30T09:30:00+09:00"
}

이 event는 fact grain을 orderLineId로 잡는 데 필요한 정보를 제공한다.

반대로 event에 line id가 없으면 나중에 상품별 매출을 정확히 만들기 어렵다.

판단 축

  • fact row 하나가 무엇을 뜻하는지 한 문장으로 말할 수 있어야 한다.
  • 측정값은 fact에 두고, 분류와 설명 기준은 dimension에 둔다.
  • dimension 값이 바뀌면 과거 리포트를 어떤 기준으로 볼지 정한다.
  • fact에는 source event id나 source key를 남겨 추적 가능하게 한다.
  • mart 집계 전에 fact와 dimension의 join cardinality를 확인한다.

Slowly Changing Dimension

dimension 변경 이력을 다루는 대표 방식은 SCD Type 1과 Type 2다.

Type 1은 현재 값으로 덮어쓴다.

Type 2는 유효 기간을 두고 과거 값을 보존한다.

회원 등급이 리포트 시점 기준이면 Type 1도 가능하다.

주문 당시 회원 등급으로 매출을 봐야 한다면 Type 2가 필요하다.

실패 모델

  • fact grain이 주문인지 주문 line인지 불명확하다.
  • dimension current snapshot으로 과거 리포트가 계속 바뀐다.
  • fact와 dimension join이 1:N으로 늘어나 매출이 부풀려진다.
  • event payload에 분석에 필요한 dimension key가 없다.
  • refund와 cancel을 별도 fact로 볼지 negative fact로 볼지 정하지 않는다.

검증 SQL

SELECT order_id, COUNT(*) AS line_count, SUM(paid_amount) AS line_amount
FROM fact_order_line
WHERE ordered_at::date = DATE '2026-06-30'
GROUP BY order_id
HAVING SUM(paid_amount) < 0;
SELECT product_id, COUNT(*) AS active_dimension_count
FROM dim_product
WHERE valid_to IS NULL
GROUP BY product_id
HAVING COUNT(*) > 1;

첫 번째 SQL은 fact 값의 이상을 찾고, 두 번째 SQL은 dimension 이력의 중복 active row를 찾는다.

운영 지표

  • fact_row_count: source event나 source table 대비 fact 생성 건수다.
  • fact_amount_sum: 핵심 금액 합계 검증에 쓴다.
  • dimension_active_duplicate_count: 현재 dimension 중복 여부다.
  • unknown_dimension_key_count: fact가 dimension을 찾지 못한 건수다.
  • late_arriving_fact_count: 늦게 들어온 fact로 mart 재계산이 필요한 수다.

개인 프로젝트 기준

개인 프로젝트에서는 fact와 dimension을 완벽히 분리하지 않아도 된다.

하지만 fact grain은 반드시 적어야 한다.

예를 들어 README에 “fact_order_line은 주문 상품 line 1개가 row 1개다”라고 남기는 것만으로도 많은 집계 오류를 막는다.

기업 운영 기준

기업 운영에서는 fact와 dimension 정의가 지표 계약이 된다.

분석 팀, 데이터 팀, 백엔드 팀이 grain, cancel/refund 처리, dimension 이력 기준을 함께 합의해야 한다.

schema 변경 시 fact 생성 logic과 mart 영향을 함께 검토한다.

실전 팁

  • fact table 이름만 보고 grain을 알 수 없으면 주석이나 문서에 명시한다.
  • 금액과 count가 join 후 늘어나는지 항상 검증한다.
  • dimension key가 없는 event는 나중에 분석 비용을 만든다.
  • 과거 리포트가 바뀌면 먼저 dimension 이력 처리 방식을 확인한다.
  • mart 집계 전 fact-level 검증을 먼저 통과시킨다.

위험 신호!

  • fact_order가 주문, 주문 line, 결제를 모두 섞어 담는다.
  • dimension 변경 이력 없이 현재 상품 카테고리만 저장한다.
  • dashboard SQL마다 COUNT(*), COUNT(DISTINCT order_id)가 섞여 있다.
  • fact에 source event id가 없어 오류 추적이 어렵다.
  • late arriving data가 들어와도 과거 mart를 다시 만들지 않는다.

확인 질문

확인 질문

  • fact grain을 먼저 정하지 않으면 어떤 문제가 생기는가?
    • join과 집계 과정에서 count와 금액이 중복되거나 누락된다.
  • 상품 카테고리가 바뀌었을 때 과거 리포트가 흔들리는 이유는 무엇인가?
    • dimension을 현재 snapshot으로만 보면 과거 fact가 현재 dimension 값으로 다시 해석되기 때문이다.
  • 백엔드 event schema가 fact 설계에 영향을 주는 이유는 무엇인가?
    • event에 fact grain과 dimension key가 없으면 warehouse에서 정확한 fact를 만들기 어렵다.

참고 문서