한 행이 무엇을 뜻하는지 정하는 데이터 모델
주문 3건이 상품 행 5개로 펼쳐질 때 집계 단위가 어떻게 바뀌는지 확인합니다. 중복 없는 키만으로 매출 합계를 보장할 수 없는 이유와 SUM DISTINCT의 함정을 합성 데이터·SQLite 예제로 재현합니다.
합성 주문의 매출은 250,000원입니다. 상품 정보를 붙이려고 테이블을 조인한 뒤
주문 금액을 더했더니 400,000원이 됐습니다. SUM(DISTINCT 금액)으로 바꾸자
이번에는 150,000원이 나옵니다. 키 중복과 합계만 번갈아 확인해서는 원인을
설명하기 어렵습니다. 결과의 한 행이 무엇을 나타내는지부터 봐야 합니다.
아래 예제는 한 상점의 주문과 상품 행을 단순화한 합성 데이터입니다. 금액은 원화의 상품 금액이고, 할인은 상품 행 단가에 이미 반영됐다고 가정합니다. 세금·배송비·환불은 별도로 더하지 않으므로 주문 금액과 상품 행 금액의 합이 같아야 합니다.
한 행의 의미를 먼저 정해야 하는 이유
분석용 데이터를 모아 두는 데이터 웨어하우스에서 grain이라고 부르는 것은 한 행이 나타내는 업무상 측정 단위입니다. ‘주문 한 건’, ‘주문 안의 상품 행 하나’, ‘고객의 하루 활동 집계’처럼 그 행을 읽는 사람이 무엇을 세고 있는지 설명할 수 있어야 합니다.
Ralph Kimball의 2008년 글도 grain을 먼저 선언하고, 그 단위에 맞는 키와 측정값을 정하도록 설명합니다. 키 목록을 나열하는 것만으로 한 행의 의미가 정의되지는 않습니다. Kimball Group의 Fact Tables
| 테이블 또는 결과 | 한 행의 의미 | 식별 기준 예시 | 더할 수 있는 값의 예시 |
|---|---|---|---|
| orders | 주문 한 건 | 주문 ID | 같은 기준의 주문 금액 |
| order_items | 주문 안의 상품 행 하나 | 주문 ID + 행 번호 | 수량 × 해당 행의 단가 |
| order_item_totals | 상품 행을 주문별로 집계한 한 건 | 주문 ID | 주문별 상품 금액 합계 |
‘고객 ID가 있다’는 설명만으로 고객당 한 행인 테이블이라고 판단할 수는 없습니다. 주문 테이블에도 고객 ID가 반복될 수 있기 때문입니다. 행의 의미, 키가 유일한 범위, 지표를 합산할 수 있는 범위를 함께 적는 것이 좋습니다.
주문 수·상품 행 수·상품 수량은 어떻게 다른가
주문은 세 건이고 고객은 두 명입니다. u1이 두 번 주문했습니다.
| 주문 ID | 고객 ID | 주문 금액 |
|---|---|---|
| o601 | u1 | 100,000원 |
| o602 | u2 | 100,000원 |
| o603 | u1 | 50,000원 |
상품 행은 다음과 같습니다. SKU는 상품을 구분하는 코드이고, 행 번호는 주문 안의 각 행을 구분합니다.
| 주문 ID | 행 번호 | SKU | 수량 | 행 단가 | 수량 × 단가 |
|---|---|---|---|---|---|
| o601 | 1 | A | 1 | 40,000원 | 40,000원 |
| o601 | 2 | B | 2 | 30,000원 | 60,000원 |
| o602 | 1 | A | 2 | 50,000원 | 100,000원 |
| o603 | 1 | C | 1 | 30,000원 | 30,000원 |
| o603 | 2 | C | 1 | 20,000원 | 20,000원 |
o603에는 같은 SKU C가 다른 단가로 두 행에 있습니다. 이 예제에서는 조건이 다른
별도 상품 행이라고 정했습니다. (주문 ID, SKU)만으로 한 행을 남기면 정상적인
상품 행이 사라집니다. 상품 행의 키는 (주문 ID, 행 번호)입니다.
상품 수량은 1 + 2 + 2 + 1 + 1 = 7개입니다. 상품 행은 5개이고, SKU 종류는
A·B·C 세 가지입니다. 하나의 숫자가 맞는지를 확인하려면 먼저 어떤 단위를
세기로 했는지 정해야 합니다.
상품 행과 조인한 뒤 주문 금액을 더하면 어떻게 되나
주문 o601을 상품 테이블에 연결하면 두 행이 나옵니다. 두 행 모두 주문 o601에 속하므로 주문 총액 100,000원이 각각 표시됩니다. 원천에 주문이 중복 저장된 상황과는 구분해야 합니다. 조인 결과가 상품 행 단위로 펼쳐진 것입니다.
전체 주문을 같은 방식으로 연결하고 주문 금액을 더하는 쿼리입니다.
SELECT
COUNT(*) AS joined_rows,
COUNT(DISTINCT o.order_id) AS distinct_orders,
SUM(o.order_amount_krw) AS revenue
FROM orders AS o
JOIN order_items AS i ON o.order_id = i.order_id;
결과는 5행, 고유 주문 3건, 합계 400,000원입니다. 주문 건수에는 DISTINCT를
사용했지만 주문 금액의 반복까지 해결되지는 않았습니다. o601과 o603은 각각
상품 행이 두 개라 주문 금액도 두 번씩 더해졌습니다.
반복 합계 = 100,000 × 2 + 100,000 × 1 + 50,000 × 2 = 400,000원
SUM DISTINCT가 주문 중복을 해결하지 못하는 이유
SUM(DISTINCT order_amount_krw)는 금액 값의 중복을 제거합니다. 주문의 중복을
제거하는 연산과는 다릅니다. 예제에는 서로 다른 100,000원 주문 o601과 o602가
있는데, 금액만 고유화하면 100,000원을 한 번만 더합니다.
금액 값만 고유화한 합계 = 100,000 + 50,000 = 150,000원
SQLite의 집계 함수 문서도 DISTINCT가 집계 함수에 전달되는 중복 값을 제거하는
방식이라고 설명합니다. 어느 값에 DISTINCT를 적용했는지가 중요합니다.
SQLite 집계 함수의 DISTINCT
상품 행으로 펼친 결과에서 (주문 ID, 행 번호)는 여전히 유일합니다. 중복 없는
행 식별자가 있어도 주문 총액을 상품 행 단위로 합산하면 잘못된 값이 나옵니다.
키의 유일성과 측정값의 집계 단위는 별도로 확인해야 합니다.
주문 단위로 먼저 집계한 뒤 연결하기
주문 전체의 매출만 필요하다면 상품 테이블을 붙이지 않고 주문 테이블에서 바로 합산하는 방법이 가장 명확합니다.
SELECT SUM(order_amount_krw) AS revenue
FROM orders;
주문별 상품 수량이나 행 수도 함께 필요하다면 상품을 주문당 한 행으로 먼저
집계한 결과를 연결할 수 있습니다. 예제에서는 조회할 때 다음 집계를 계산하도록
order_item_totals라는 뷰를 정의했습니다.
SELECT
order_id,
COUNT(*) AS item_lines,
SUM(quantity) AS item_units,
SUM(quantity * unit_price_krw) AS item_revenue_krw
FROM order_items
GROUP BY order_id;
이 결과에는 주문마다 한 행이 있습니다. 주문 테이블과 연결해도 주문 금액이 상품 행 수만큼 반복되지 않습니다. 다음 쿼리의 결과는 250,000원입니다.
SELECT SUM(o.order_amount_krw) AS revenue
FROM orders AS o
JOIN order_item_totals AS t ON o.order_id = t.order_id;
주문별로 집계하면 SKU별 상세 정보는 그 결과에 남지 않습니다. 상품별 매출이
필요하다면 원래 상품 테이블에서 수량 × 단가를 SKU별로 더합니다. 예제의
SKU별 금액은 A 140,000원, B 60,000원, C 50,000원이고 합계는 250,000원입니다.
분석 질문에 필요한 단위가 무엇인지에 따라 사용할 테이블이 달라집니다. 모든 테이블을 미리 한 장으로 합쳐 놓는 것만으로 여러 질문에 정확하게 답할 수 있는 데이터 모델이 만들어지지는 않습니다.
키와 제약 조건으로 어디까지 검사할 수 있나
예제의 주문 테이블은 주문 ID를 기본키로, 상품 테이블은 주문 ID와 행 번호를 복합 기본키로 사용합니다. 상품 행은 원래 주문을 참조하도록 외래키를 둡니다. 핵심 구조는 다음과 같습니다.
CREATE TABLE order_items (
order_id TEXT NOT NULL,
line_no INTEGER NOT NULL,
sku TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price_krw INTEGER NOT NULL,
PRIMARY KEY (order_id, line_no),
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
다운로드 예제의 전체 schema.sql에는 양의 정수 수량과 비음수 정수 금액을
확인하는 조건도 있습니다. SQLite의 외래키는 연결마다 명시적으로 켜고 실제
활성화됐는지 확인합니다. 문서도 런타임 설정을 확인하도록 안내합니다.
SQLite 외래키 지원
제약 조건만으로 충분하지 않은 검증도 있습니다. 상품 행의 외래키는 존재하지 않는 주문을 가리키는 행을 막아 주지만, 모든 주문에 상품 행이 적어도 하나 있는지까지 보장하지는 않습니다. 원천에서 상품 행이 누락됐는지와 주문·상품 금액이 맞는지는 별도로 대사합니다.
예제는 주문을 왼쪽에 두고 상품 집계 결과를 연결해 누락을 확인합니다. 상품 행이 없는 주문을 0원 상품으로 간주하지 않습니다. 실제로 단가가 0원인 상품 행이 있는 경우와 정보가 빠진 경우는 다른 상태입니다.
측정값이 어느 키에 의존하는지 확인하기
이 예제에서는 주문 ID가 정해지면 해당 주문의 총액도 하나로 정해집니다.
주문 ID → 주문 금액이라는 관계로 표현할 수 있습니다. 상품 행으로 펼친 결과에
주문 금액을 붙여도 그 금액이 상품 행의 금액으로 바뀌지는 않습니다.
주문 o의 금액을 v(o), 연결된 상품 행 수를 m(o)라고 하면 잘못 합산한 금액은 다음처럼 표현됩니다.
조인 후 주문 금액 합계 = Σ m(o) × v(o)
원래 주문 금액 합계 = Σ v(o)
상품 행 수가 주문마다 다르므로 전체 행 수의 비율로 한 번 나눠서 보정하는 방법도 일반적인 해결책이 되지 않습니다. 어느 주문이 몇 번 반복됐는지와 각 주문의 금액이 함께 영향을 줍니다. 원래 집계 단위에서 다시 계산해야 합니다.
GA4의 이벤트 단위와 상품 단위도 구분하기
GA4 전자상거래 문서는 이벤트 전체의 정보를 담는 event scope와 상품 정보를 담는
item scope를 구분합니다. purchase 이벤트의 전체 거래 정보와 items 배열의
각 상품 정보를 같은 단위로 취급하지 않는 이유입니다.
GA4 전자상거래 범위
Google은 전자상거래 이벤트 횟수와 상품 수량을 다루는 지표도 구분해 설명합니다. 분석 화면에서는 지표의 포함 이벤트와 지원되는 차원 조합을 함께 확인해야 합니다. 이벤트 단위 지표와 상품 단위 지표가 이름이 비슷하다고 서로 대신할 수는 없습니다. GA4 전자상거래 지표
이 글의 주문·상품 테이블은 자체 원장을 단순화한 합성 모델입니다. 실제 GA4 내보내기 스키마나 플랫폼의 자동 집계 결과를 그대로 구현한 것은 아닙니다.
SQLite에서 직접 재현하는 방법
주문·상품 데이터 모델 예제 ZIP을 풀고 폴더 안에서 실행합니다. Python 3.9 이상과 표준 라이브러리를 사용합니다. 검증 환경의 SQLite 버전은 3.43.2입니다.
python3 analysis.py
python3 -m unittest
schema.sql로 메모리 내 데이터베이스를 만들고 합성 JSON을 넣은 뒤 실제 SQL을
실행합니다. 파일로 데이터베이스를 저장하거나 외부 데이터베이스에 연결하지 않습니다.
출력에는 집계 값뿐 아니라 상품별 결과, 주문·상품 대사표, 실제로 펼쳐진 다섯 행이
함께 포함됩니다.
검증은 중복 주문 키, 중복 상품 행 키, 연결되지 않은 상품 행, 키의 누락, 잘못된 수량·금액을 확인합니다. 주문에 상품이 없는 경우와 상품 금액 합계가 맞지 않는 경우도 검사합니다. 입력 순서를 바꾸어도 같은 결과가 나오는지 확인했습니다.
데이터 모델의 첫 설명에 ‘이 테이블의 한 행은 무엇인가’를 적어 보세요. 그 문장이 정해져야 키, 금액, 수량과 조인 결과를 같은 기준으로 검토할 수 있습니다.
공식 문서는 2026년 9월 6일 확인했습니다. Kimball 자료는 집계 단위의 개념을 설명하는 2008년 원문을 참고했습니다.
마케팅 데이터 모델·품질·지표 정의 연재 6편입니다. 이전 글: 광고비·부가세·수수료·환율을 같은 기준으로 맞추기
다음 글: 사용자·기기·세션·주문 식별자를 구분하는 방법
🐹
자주 묻는 질문
데이터의 grain은 기본키와 같은 뜻인가요?
grain은 한 행이 어떤 업무상 사실이나 측정을 나타내는지 설명하는 정의이고, 기본키는 그 행을 식별하는 구현 수단입니다. 키가 유일해도 다른 단위의 금액을 반복해서 합산하면 지표는 잘못될 수 있습니다.
주문과 상품을 조인하면 조인 자체가 잘못된 건가요?
그렇지는 않습니다. 주문의 각 상품을 보고 싶다면 주문과 상품 행을 연결할 수 있습니다. 다만 조인 결과의 한 행은 상품 행에 대응하므로 반복된 주문 총액을 그대로 더하지 않도록 집계 단위를 확인해야 합니다.
SUM DISTINCT로 중복 매출을 제거할 수 있나요?
금액만 DISTINCT 처리하면 같은 금액의 서로 다른 정상 주문도 하나로 줄어듭니다. 예제에서는 100,000원 주문 두 건과 50,000원 주문 한 건이 150,000원으로 잘못 합산됩니다. 거래 식별자와 금액의 단위를 함께 봐야 합니다.
같은 주문에 같은 상품 코드가 두 번 있으면 중복인가요?
원천의 상품 행 정의에 따라 다릅니다. 같은 상품도 단가·조건이 다른 별도 행일 수 있습니다. 예제는 주문 ID와 행 번호를 상품 행의 키로 사용하고, SKU만으로 행을 삭제하지 않습니다.