RDB 설계 시리즈 5편: 주문/결제 도메인 스키마 설계 — 실무 수준까지

RDB 설계 시리즈 5편: 주문/결제 도메인 스키마 설계 — 실무 수준까지


서론

이전 글에서 1:1, 1:N, N:M, 자기참조, 다형성 관계까지 — 관계 설계의 모든 패턴을 다뤘다. 이제 이론은 충분하다.

문제는 “알겠는데 실무에서 어떻게 적용하지?”다.

이 글은 이커머스의 주문/결제 도메인 하나에만 집중한다. 여러 도메인을 얕게 훑는 대신, 한 도메인을 실무에서 통하는 수준까지 파고든다.

교과서에서 보는 주문 스키마는 보통 이렇다:

orders ──1:N──→ order_items

이 구조로도 단순한 쇼핑몰은 돌아가지만, 한 주문 안에 배송이 여러 건으로 나뉘거나, 일부만 취소/환불되거나, 결제가 카드 + 포인트 + 쿠폰으로 쪼개지는 현실을 담기엔 답답해진다. 그래서 회사 규모와 풀필먼트 복잡도에 따라 구조를 확장한다. 이 글은 그 선택지들을 정리하고, 그중 한 가지(배송 단위 중간 레이어를 둔 3단 구조)를 기준으로 주문 도메인을 깊게 설계한다.

다루는 내용:

  1. 주문 구조 선택지 — 2단, 3단, 형제 중 언제 무엇을 쓰는가, “주문상세”라는 용어의 혼란
  2. 상태 머신 — 주문/배송/아이템 레벨 상태를 어떻게 분리할 것인가
  3. 결제 멱등성과 복합 결제 — 카드 + 포인트 + 쿠폰을 하나의 주문에
  4. 취소와 환불 — 취소 주체와 이력, 아이템 단위 환불
  5. 스냅샷 패턴의 확장 — 상품뿐 아니라 배송지까지

권한 시스템(RBAC)과 선착순 재고 스키마 회고는 다음 편에서 별도로 다룬다. 한 번에 여러 도메인을 보여주는 것보다, 하나를 끝까지 파는 편이 실무 설계에 더 도움 된다고 판단했다.


1. 주문 구조를 어떻게 잡을 것인가

1.1 2단 구조로 안 되는 것들

가장 흔한 주문 스키마는 2단 구조다:

orders (주문 전체)
  └── order_items (주문 항목)

이 구조로도 소규모 쇼핑몰은 충분히 돌아간다. 단일 셀러, 배송이 복잡하지 않은 자사몰이라면 여기서 더 쪼갤 이유가 없다. 하지만 다음 상황이 생기면 2단만으로는 담기 어려워진다:

  • 부분 배송: 한 주문에 상품 A는 오늘 도착, 상품 B는 내일 도착
  • 같은 셀러 안의 배송 분리: 냉장/상온 분리, 해외/국내 분리 등
  • 부분 취소/환불: 10개 중 3개만 반품
  • 배송 상태와 주문 상태의 차이: “주문은 결제 완료, 배송 1은 배송 중, 배송 2는 준비 중”

이 모든 걸 orders 테이블의 단일 status 컬럼으로는 담을 수 없다. 억지로 담으려고 하면 상태 값이 폭발하거나(PARTIALLY_SHIPPED_1_OF_3), 상태가 실제 데이터와 어긋나기 시작한다.

1.2 현실의 선택지 — 세 가지 구조

그럼 대안이 뭐냐 — 하나의 정답은 없다. 실무에서는 세 가지 접근이 있다.

A. 2단 + 아이템별 외부 식별자

orders ──1:N──→ order_items
                (각 item이 주문번호와 별개인 "상품주문번호"를 가짐)

테이블 구조는 2단이지만, CS/반품/정산이 item 단위로 돌아가도록 각 item에 독립된 외부 식별자를 부여한다. 구조는 단순하지만 운영은 아이템 단위로 세분화할 수 있다. 한계: 송장번호/배송 상태 같은 “배송 단위” 정보를 둘 곳이 애매해진다 — 아이템마다 복사하거나 별도 shipments 테이블을 옆에 두는 방식으로 해결하게 된다.

B. 3단 — 중간 그룹핑 레이어 (이 글에서 다룸)

orders ──1:N──→ order_groups ──1:N──→ order_items
                (셀러별 or 배송별 묶음)

셀러 묶음이나 배송 묶음이 필요한 마켓플레이스/자사몰에서 흔하다. 중간 레이어의 의미가 “셀러 묶음”(마켓플레이스)이냐 “배송 묶음”(단일 셀러에서 냉장/상온 분리)이냐에 따라 해석이 달라진다. 이 글은 단일 셀러 자사몰을 가정해서 중간 레이어를 “배송 묶음”으로 해석하고 order_deliveries라고 부른다.

C. 형제 구조 + Junction (대형 풀필먼트 스타일)

orders ──1:N──→ order_items              ← 구매 계약 (결제 시점 확정)
       └─1:N──→ shipments ──1:N──→ shipment_items ──N:1─→ order_items
                                          ← 풀필먼트 (창고가 나중에 결정)

주문(구매 계약)과 배송(풀필먼트)을 별개 aggregate로 분리한다. Split shipment(한 라인을 여러 배송으로 나눠 보냄), 결제 시점엔 배송 계획이 없고 창고에서 나중에 생성, 창고 이전에 따른 배송 재배치 같은 복잡한 시나리오를 자연스럽게 지원한다. 한계: 테이블 수, 쿼리/제약 복잡도가 올라간다. DDD aggregate 경계 논의가 필수.

1.3 어떤 구조를 언제 쓰는가

회사 유형권장 구조
소규모 쇼핑몰 (단일 셀러, 단순 배송)A — 2단
중규모 자사몰 (배송 분리, 부분 취소/환불)B — 3단
마켓플레이스 (셀러별 그룹핑 필수)B — 3단 (중간 레이어 = 셀러 묶음)
대형 풀필먼트 (창고 다수, split shipment)C — 형제 + junction
전통 SI / ERP (금융, B2B 백오피스)A 또는 C 변형

“대부분의 회사가 X 구조를 쓴다”고 단정할 수 없다. 규모와 풀필먼트 복잡도에 따라 다르다. 작은 자사몰에서 C 구조를 쓰는 건 과잉 설계고, 대형 마켓플레이스에서 A 구조를 쓰는 건 부족하다.

1.4 “주문상세”라는 용어 주의

한국 실무에서 “주문상세”는 회사마다 다르게 쓰여서, 레퍼런스를 읽을 땐 문맥 확인이 필요하다:

맥락”주문상세”의 의미실제 구조
전통 SI / ERP 시스템ORDER_MST + ORDER_DTL의 DTL — 그냥 order_items2단 (A)
아이템별 외부 식별자를 쓰는 자사몰각 item의 외부 식별자 (상품주문번호 등)2단 + 외부 ID (A)
마켓플레이스 / 오픈마켓배송/상품/셀러 그룹 단위실질 3단 (B)
B2B 엔터프라이즈청구/계약 정보 헤더형제 구조 (C 변형)

“주문 - 주문상세 - 주문항목” 같은 표현은 B의 3단을 가리키는 경우가 많고, “주문 - 주문상세”만 언급하는 경우는 A의 MST-DTL 명명인 경우가 많다.

1.5 이 글이 선택한 구조

이 글은 B (3단)을 기준으로 설계한다. 이유:

  1. 단일 셀러 자사몰 + 배송 단위 분리는 한국 중규모 이커머스에서 가장 흔하다
  2. 2단보다 부분 배송/부분 취소/부분 환불을 자연스럽게 표현할 수 있다
  3. C(형제 + junction)까지 가면 aggregate 경계 논의가 필요해 RDB 시리즈 범위를 벗어난다

다른 패턴이 더 맞는 상황이라면 아래 설계를 적응시키면 된다. 중간 레이어의 이름도 문맥에 따라 order_groups, order_shipments, order_details 등으로 부를 수 있는데, 이 글은 배송 단위 분리가 핵심이라 order_deliveries로 부른다.

역할 분담:

레벨책임
orders결제, 전체 금액, 주문자, 배송지 스냅샷, 전체 상태
order_deliveries배송 묶음, 송장번호, 배송 상태, 출고/배송 완료 시각
order_items상품 라인, 상품/가격/옵션 스냅샷, 수량, 아이템 상태

“결제는 한 번, 배송은 여러 번, 아이템은 배송마다 여러 개”라는 전제다.

1.6 ERD 전체 구조

users
  └─1:N─→ orders
            ├─1:N─→ order_deliveries
            │          └─1:N─→ order_items ──N:1─→ products

            ├─1:N─→ payments
            ├─1:N─→ order_status_histories
            ├─1:N─→ order_cancellations
            └─1:N─→ refunds
                     ├─1:N─→ refund_items ──N:1─→ order_items
                     └─1:N─→ refund_payments ──N:1─→ payments

테이블 하나씩 설계해보자.


2. orders — 주문 총괄과 배송지 스냅샷

CREATE TABLE orders (
    id                      BIGINT AUTO_INCREMENT PRIMARY KEY,            -- 내부 PK (외부 노출 금지)
    user_id                 BIGINT NOT NULL,                              -- 주문자 (users FK)
    order_number            VARCHAR(30) NOT NULL,                         -- 외부 노출용 주문번호 (예: ORD-20260408-00001)
    status                  VARCHAR(20) NOT NULL DEFAULT 'PENDING',       -- 주문 전체 상태 (배송 상태의 집계)

    -- 금액 (모두 원화 기준, 부가세 포함)
    items_amount            DECIMAL(12, 2) NOT NULL,                      -- 상품 금액 합계 (할인 전)
    shipping_fee            DECIMAL(8, 2)  NOT NULL DEFAULT 0,            -- 배송비
    discount_amount         DECIMAL(10, 2) NOT NULL DEFAULT 0,            -- 쿠폰/할인 합계
    total_amount            DECIMAL(12, 2) NOT NULL,                      -- 최종 결제 금액 (items - discount + shipping)

    -- 배송지 스냅샷 (주문 시점의 값으로 고정)
    recipient_name          VARCHAR(100) NOT NULL,                        -- 수령인 이름
    recipient_phone         VARCHAR(20)  NOT NULL,                        -- 수령인 연락처
    shipping_zipcode        VARCHAR(10)  NOT NULL,                        -- 우편번호
    shipping_address        VARCHAR(200) NOT NULL,                        -- 기본 주소 (도로명/지번)
    shipping_address_detail VARCHAR(200),                                 -- 상세 주소 (동/호수, 층 등)
    shipping_memo           VARCHAR(500),                                 -- 배송 요청사항

    -- 상태별 타임스탬프 (반정규화: 자주 조회되는 값)
    ordered_at              TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 주문 생성 시각
    paid_at                 TIMESTAMP,                                    -- 결제 완료 시각
    completed_at            TIMESTAMP,                                    -- 구매 확정 시각
    cancelled_at            TIMESTAMP,                                    -- 취소 시각 (전체 취소 시)

    CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
    CONSTRAINT uq_orders_order_number UNIQUE (order_number),
    CONSTRAINT chk_orders_status CHECK (
        status IN ('PENDING', 'PAID',
                   'PARTIALLY_SHIPPED', 'SHIPPED',
                   'PARTIALLY_DELIVERED', 'DELIVERED', 'COMPLETED',
                   'PARTIALLY_CANCELLED', 'CANCELLED',
                   'PARTIALLY_REFUNDED', 'REFUNDED')
    ),
    CONSTRAINT chk_orders_total_amount CHECK (total_amount >= 0)
);

CREATE INDEX idx_orders_user_id    ON orders(user_id);
CREATE INDEX idx_orders_status     ON orders(status);
CREATE INDEX idx_orders_ordered_at ON orders(ordered_at);

2.1 order_number vs id

API와 UI에서는 id(auto increment)를 노출하지 않는다. 대신 order_number(예: ORD-20260408-00001)를 사용한다.

  • ID를 노출하면 전체 주문 수를 추측할 수 있다 (경쟁사 분석에 악용)
  • 연속된 숫자는 다른 사용자의 주문을 탐색하기 쉽다 (IDOR 취약점)
  • 외부 노출용 번호는 비즈니스 규칙(날짜, 채널 구분 등)을 담을 수 있다
-- ❌ API 응답에 id 직접 노출
GET /orders/42

-- ✅ order_number로 식별
GET /orders/ORD-20260408-00001

2.2 order_number 생성 방식 참고

order_number는 앱 레벨에서 생성하며, 대표적인 방식은 다음과 같다:

방식예시특징
날짜 + 시퀀스ORD-20260408-00001가독성 좋고, 날짜별 리셋 가능. 동시성 제어 필요
UUID v4550e8400-e29b-41d4-a716-446655440000충돌 없음, 분산 환경에 유리. 가독성 낮고 인덱스 성능 불리
UUID v7019654ab-3c4d-7def-8000-abcdef123456시간순 정렬 가능, UUID v4의 인덱스 단점 보완
Snowflake ID176432987654321시간 + 머신 + 시퀀스 조합, 분산 환경 + 정렬 가능. 직접 구현 또는 라이브러리 필요
접두어 + nanoidORD_V1rRnXbaFN짧고 URL-safe, 접두어로 도메인 구분. 충돌 확률 관리 필요

실무 권장: 중소규모 자사몰이라면 날짜 + 시퀀스 방식이 CS 대응과 로그 추적에 가장 편하다. 분산 환경이나 마이크로서비스라면 UUID v7 또는 Snowflake가 적합하다. 어떤 방식이든 UNIQUE 제약은 반드시 걸어야 한다.

2.3 배송지 스냅샷 — 왜 user_addresses를 참조하지 않는가

사용자의 주소록(user_addresses 테이블)을 FK로 참조하면 이런 버그가 생긴다:

1. 고객이 "서울시 강남구" 주소로 주문
2. 고객이 주소록에서 그 주소를 "부산시 해운대구"로 수정
3. 판매자가 주문을 보니 "부산시 해운대구"로 배송해야 한다고 나온다
4. 하지만 실제로는 강남구로 출고됨 → 분쟁

주문 시점의 배송지는 그 시점의 값으로 고정되어야 한다. 2편스냅샷 패턴이 상품 가격뿐 아니라 배송지에도 적용된다.

정규화 관점에서는 중복이지만, 비즈니스 관점에서는 필수다. 실무에서 이걸 놓치고 FK로 연결했다가 크게 당한다.

2.4 상태 머신 설계

2단 구조의 단순 상태:

PENDING → PAID → SHIPPED → DELIVERED → COMPLETED

3단 계층의 상태는 더 풍부하다:

PENDING ──→ PAID ──→ (PARTIALLY_)SHIPPED ──→ (PARTIALLY_)DELIVERED ──→ COMPLETED

(어느 단계에서든)
  ──→ PARTIALLY_CANCELLED / CANCELLED
  ──→ PARTIALLY_REFUNDED / REFUNDED

주문 상태는 근본적으로 “배송 상태의 집계”다:

배송 레벨 상태 조합주문 레벨 상태
모든 배송 READYPAID
일부 SHIPPED 나머지 READYPARTIALLY_SHIPPED
모든 배송 SHIPPED 이상 (아직 DELIVERED 없음)SHIPPED
일부 DELIVERED, 나머지는 배송 중PARTIALLY_DELIVERED
모든 배송 DELIVEREDDELIVERED
고객이 구매 확정COMPLETED

PARTIALLY_DELIVERED가 필요한 이유: “배송 1은 어제 받았고, 배송 2는 내일 온다”는 상황은 실무에서 매우 흔하다. 이걸 SHIPPED로 묶어버리면 고객 UI에서 “일부 도착”을 표시할 수 없고, 이미 받은 상품을 반품하려는데 아직 배송 중인 상품과 상태가 구분되지 않는다.

이 집계는 앱 레벨에서 계산하거나, 배송 상태가 바뀔 때마다 주문 상태를 동기화한다. CHECK 제약은 허용되는 값의 집합만 보장할 뿐, 집계의 일관성까지 책임지지는 않는다. 일관성은 앱 로직(또는 트리거)의 책임이다.

2.5 상태별 타임스탬프 — 왜 orders에 직접?

paid_at, cancelled_at을 주문 테이블에 직접 넣은 이유:

  • “이 주문은 언제 결제됐는가?”는 가장 빈번한 조회 → JOIN 없이 읽을 수 있어야 한다
  • 상태별 타임스탬프는 현재 상태의 스냅샷이다
  • 상세한 이력(누가, 언제, 왜 바꿨는지)은 order_status_histories에 저장한다

이것은 2편의도적 반정규화다.

참고로 shipped_at, delivered_at은 주문 레벨에 두지 않았다. 배송이 여러 건이면 주문 레벨의 “배송 시각”이 애매해진다(첫 배송? 마지막 배송?). 그래서 order_deliveries에 둔다.


3. order_deliveries — 배송 묶음 (핵심 추가)

이 글에서 가장 중요한 추가 개념이다.

CREATE TABLE order_deliveries (
    id                  BIGINT AUTO_INCREMENT PRIMARY KEY,              -- 내부 PK
    order_id            BIGINT      NOT NULL,                           -- 부모 주문 (orders FK)
    delivery_number     VARCHAR(30) NOT NULL,                           -- 외부 식별자 (예: ORD-20260408-00001-D1)
    sequence            SMALLINT    NOT NULL,                           -- 주문 내 배송 순번 (1, 2, 3...)
    status              VARCHAR(20) NOT NULL DEFAULT 'READY',           -- 배송 상태 (READY → SHIPPED → DELIVERED)

    -- 배송 정보 (출고 전에는 NULL)
    carrier             VARCHAR(50),                                    -- 택배사명 (자사 배송/PUDO 포함 가능)
    tracking_number     VARCHAR(50),                                    -- 송장번호

    -- 배송 단위 타임스탬프
    ready_at            TIMESTAMP,                                      -- 배송 준비 완료 시각
    shipped_at          TIMESTAMP,                                      -- 출고 시각
    delivered_at        TIMESTAMP,                                      -- 수령 완료 시각

    CONSTRAINT fk_deliveries_order
        FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
    CONSTRAINT uq_deliveries_delivery_number UNIQUE (delivery_number),
    CONSTRAINT uq_deliveries_order_sequence  UNIQUE (order_id, sequence),
    CONSTRAINT chk_deliveries_status CHECK (
        status IN ('READY', 'SHIPPED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED')
    )
);

CREATE INDEX idx_deliveries_order_id ON order_deliveries(order_id);
CREATE INDEX idx_deliveries_status   ON order_deliveries(status);
CREATE INDEX idx_deliveries_tracking ON order_deliveries(tracking_number);

3.1 왜 배송을 분리하는가

송장번호는 배송 단위다. 한 주문에 여러 송장이 붙을 수 있다:

주문 ORD-20260408-00001 (냉장 + 상온)
  ├─ 배송 1 (D1): 냉장 상품 A, B    → 송장 CJ-1234
  └─ 배송 2 (D2): 상온 상품 C, D    → 송장 CJ-5678
  • 송장번호를 orders에 두면 → “여러 송장”을 표현할 수 없다
  • 송장번호를 order_items에 두면 → “A와 B가 같은 박스”라는 사실을 표현할 수 없다 (같은 송장번호가 두 행에 중복 저장)

배송 단위가 곧 물리적 박스 단위이기 때문에 별도 엔티티로 분리하는 게 자연스럽다.

3.2 배송 상태 머신

READY ──→ SHIPPED ──→ IN_TRANSIT ──→ DELIVERED

  └──→ CANCELLED
  • READY: 결제 완료, 배송 준비 중 (아직 미출고)
  • SHIPPED: 출고 완료, 송장 등록됨
  • IN_TRANSIT: 배송 중 (택배사 시스템 연동 시)
  • DELIVERED: 수령 완료
  • CANCELLED: 출고 전 배송 단위 취소

IN_TRANSIT은 택배사 Webhook을 받을 수 있을 때만 의미가 있다. 연동이 없으면 SHIPPED에서 바로 DELIVERED로 넘어간다.

3.3 delivery_number — 또 하나의 외부 ID?

배송 단위에도 외부 노출용 식별자를 만든다. CS에서 “주문번호 ORD-20260408-00001의 두 번째 배송에 대해 문의드립니다”보다 “배송번호 ORD-20260408-00001-D2”가 훨씬 명확하다.

(order_number, sequence) 조합만으로 UNIQUE가 되긴 하지만, 단일 컬럼 식별자가 조회/로그/URL 설계에서 훨씬 쉽다. 저장 공간 몇 바이트 아끼려다 조회 복잡도가 올라가는 건 손해다.

3.4 delivery_number 생성 방식 참고

delivery_number는 보통 order_number에 배송 순번을 붙여서 만든다. 주문과의 연관성이 즉시 보여야 CS 대응이 빠르기 때문이다.

방식예시특징
주문번호 + 접미사ORD-20260408-00001-D1주문번호만 보면 배송건 추적 가능. 가장 실무적
독립 시퀀스DLV-20260408-00001주문과 무관하게 채번. 물류 시스템이 별도일 때 유리
주문번호 + sequence 컬럼 조합order_number + sequence = 1별도 컬럼 없이 조합으로 식별. 단일 식별자가 없어 조회/로그 불편

실무 권장: 대부분의 자사몰에서는 주문번호 + 접미사가 가장 편하다. CS 상담 시 주문번호 하나로 배송건까지 바로 특정할 수 있고, 고객에게도 직관적이다. 물류가 별도 시스템이라면 독립 시퀀스를 쓰되, 주문번호를 참조 컬럼으로 함께 저장하는 게 좋다.


4. order_items — 스냅샷과 아이템 상태

CREATE TABLE order_items (
    id                  BIGINT AUTO_INCREMENT PRIMARY KEY,              -- 내부 PK
    order_id            BIGINT NOT NULL,                                -- 부모 주문 (조회 편의상 중복 저장)
    order_delivery_id   BIGINT NOT NULL,                                -- 소속 배송 묶음
    product_id          BIGINT NOT NULL,                                -- 상품 참조 (products FK)

    -- 주문 시점 스냅샷 (이후 products 변경과 무관하게 고정)
    product_name        VARCHAR(200)  NOT NULL,                         -- 상품명 스냅샷
    product_option      VARCHAR(200),                                   -- 선택 옵션 (예: "색상: 블랙, 사이즈: M")
    unit_price          DECIMAL(10, 2) NOT NULL,                        -- 주문 시점 단가
    quantity            INT            NOT NULL,                        -- 주문 수량
    subtotal            DECIMAL(12, 2) NOT NULL,                        -- 소계 (unit_price * quantity)

    -- 아이템 단위 상태와 취소/환불 집계 (반정규화)
    status              VARCHAR(20) NOT NULL DEFAULT 'ORDERED',         -- 아이템 상태 (취소/환불 반영)
    cancelled_quantity  INT NOT NULL DEFAULT 0,                         -- 누적 취소 수량 (배송 전 취소)
    refunded_quantity   INT NOT NULL DEFAULT 0,                         -- 누적 환불 수량 (배송 후 반품)

    CONSTRAINT fk_items_order
        FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
    CONSTRAINT fk_items_delivery
        FOREIGN KEY (order_delivery_id) REFERENCES order_deliveries(id) ON DELETE CASCADE,
    CONSTRAINT fk_items_product
        FOREIGN KEY (product_id) REFERENCES products(id),
    CONSTRAINT chk_items_quantity CHECK (quantity > 0),
    CONSTRAINT chk_items_subtotal CHECK (subtotal > 0),
    CONSTRAINT chk_items_status CHECK (
        status IN ('ORDERED', 'PARTIALLY_CANCELLED', 'CANCELLED',
                   'PARTIALLY_REFUNDED', 'REFUNDED')
    ),
    CONSTRAINT chk_items_cancelled_quantity CHECK (
        cancelled_quantity >= 0 AND cancelled_quantity <= quantity
    ),
    CONSTRAINT chk_items_refunded_quantity CHECK (
        refunded_quantity >= 0 AND refunded_quantity <= quantity
    ),
    CONSTRAINT chk_items_total_removed CHECK (
        cancelled_quantity + refunded_quantity <= quantity
    )
);

CREATE INDEX idx_items_order_id    ON order_items(order_id);
CREATE INDEX idx_items_delivery_id ON order_items(order_delivery_id);
CREATE INDEX idx_items_product_id  ON order_items(product_id);

4.1 order_idorder_delivery_id를 둘 다?

엄밀히 말하면 order_delivery_id만 있어도 order_deliveries.order_id로 역추적할 수 있다. 그럼에도 order_id를 중복 저장한 이유:

-- order_id 없이 delivery 경유
SELECT oi.*
FROM order_items oi
JOIN order_deliveries od ON oi.order_delivery_id = od.id
WHERE od.order_id = 42;

-- order_id가 있으면
SELECT * FROM order_items WHERE order_id = 42;

JOIN 한 번이 줄고 인덱스가 직접 먹는다. “주문 전체 상품 조회”가 가장 빈번한 쿼리이므로 이 최적화는 값어치가 있다.

대가: 반정규화가 깨질 위험이다. order_items.order_idorder_deliveries.order_id가 달라지는 경우. 가장 단순한 방어는 INSERT 시 앱 레벨에서 함께 세팅하고 이후 변경을 금지하는 것이다. 더 강하게 가려면 트리거로 동기화를 강제할 수도 있다.

이 트레이드오프는 실무에서 자주 나온다 — “역추적 가능한 컬럼을 중복 저장해서 조회를 단순화할 것인가”. 정답은 없고, 조회 빈도와 쓰기 복잡도를 저울질해서 결정한다.

4.2 상품/옵션 스냅샷

상품 테이블의 가격은 바뀐다. 상품명도 바뀐다. 옵션 조합(S/M/L, 색상)도 사라지거나 추가된다.

주문 시점의 가격/상품명/옵션은 그 시점의 값으로 고정되어야 한다. 2편에서 다룬 스냅샷 패턴이다.

-- ❌ 현재 가격으로 계산 (과거 주문 금액이 바뀜)
SELECT oi.quantity * p.price
FROM order_items oi JOIN products p ON oi.product_id = p.id;

-- ✅ 주문 시점 가격으로 계산
SELECT subtotal FROM order_items WHERE order_id = 42;

product_option은 한 컬럼으로 납작하게 저장하는 편이 보통은 낫다. 주문 이력은 “사람이 읽는 기록”의 성격이 강하기 때문이다. JSON이 유연하지만, “색상: 블랙, 사이즈: M”처럼 사람이 바로 읽을 수 있는 문자열이 CS/정산/엑셀 추출에 편하다.

4.3 아이템 단위 취소/환불 수량

cancelled_quantityrefunded_quantity를 아이템에 직접 둔 이유:

상품 A 10개 구매
  ├─ 3개 취소 (배송 전)   → cancelled_quantity = 3
  ├─ 2개 환불 (반품)     → refunded_quantity  = 2
  └─ 5개는 유효 (10 - 3 - 2)

“이 아이템에서 유효한 수량”을 JOIN 없이 즉시 계산할 수 있다. 상세 이력(어느 환불 건으로 몇 개가 빠졌는지)은 refund_items에 있다. 여기서는 집계 값만 반정규화한다.

CHECK 제약으로 cancelled_quantity + refunded_quantity <= quantity를 강제하는 걸 권장한다. 앱 버그가 아이템 상태를 이상하게 만드는 걸 마지막 선에서 막는다. 같은 행의 여러 컬럼을 비교하는 CHECK는 MySQL 8.0과 PostgreSQL 모두 지원한다.


5. payments — 멱등성과 복합 결제

결제는 “딱 한 번만” 처리되어야 한다. 네트워크 타임아웃으로 재시도가 오거나, 사용자가 결제 버튼을 두 번 누르거나 — 이중 결제가 발생하면 큰 사고다.

CREATE TABLE payments (
    id                  BIGINT AUTO_INCREMENT PRIMARY KEY,              -- 내부 PK
    order_id            BIGINT      NOT NULL,                           -- 부모 주문
    idempotency_key     VARCHAR(64) NOT NULL,                           -- 멱등성 키 (이중 결제 원천 차단)

    payment_method      VARCHAR(20)    NOT NULL,                        -- 결제 수단 (CARD, POINT, COUPON, 간편결제 등)
    amount              DECIMAL(12, 2) NOT NULL,                        -- 이 결제 건의 금액
    status              VARCHAR(20)    NOT NULL DEFAULT 'PENDING',      -- 결제 상태 (PENDING → CONFIRMED / FAILED)

    -- PG사 연동 정보 (POINT/COUPON 같은 자사 자산은 NULL)
    pg_provider         VARCHAR(30),                                    -- PG사 식별자
    pg_transaction_id   VARCHAR(100),                                   -- PG사 거래 ID
    pg_response         JSON,                                           -- PG사 원본 응답 (디버깅/감사용)

    attempted_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,   -- 결제 시도 시각
    confirmed_at        TIMESTAMP,                                      -- 승인 완료 시각
    failed_at           TIMESTAMP,                                      -- 실패 시각
    failure_reason      VARCHAR(500),                                   -- 실패 사유 (CS 대응용, 삭제 금지)

    CONSTRAINT fk_payments_order FOREIGN KEY (order_id) REFERENCES orders(id),
    CONSTRAINT uq_payments_idempotency UNIQUE (idempotency_key),
    CONSTRAINT chk_payments_status CHECK (
        status IN ('PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED')
    ),
    CONSTRAINT chk_payments_method CHECK (
        payment_method IN ('CARD', 'POINT', 'COUPON', 'BANK_TRANSFER',
                           'KAKAO_PAY', 'NAVER_PAY', 'TOSS')
    ),
    CONSTRAINT chk_payments_amount CHECK (amount > 0)
);

CREATE INDEX idx_payments_order_id       ON payments(order_id);
CREATE INDEX idx_payments_status         ON payments(status);
CREATE INDEX idx_payments_pg_transaction ON payments(pg_transaction_id);

5.1 멱등성 키의 역할

1차 시도: idempotency_key = 'pay_abc123' → INSERT 성공 → 결제 진행
2차 시도: idempotency_key = 'pay_abc123' → UNIQUE 위반 → 기존 결과 반환

클라이언트가 같은 키로 재요청하면 UNIQUE 제약이 중복 INSERT를 막고, 앱에서는 기존 결제 결과를 반환한다. 스키마 레벨에서 이중 결제를 원천 차단하는 것이다.

멱등성 키 생성 전략:

전략예시장점단점
클라이언트 UUIDpay_550e8400-e29b-...클라이언트 독립적매번 새 키 생성하면 의미 없음
주문번호 + 수단 + 시도ORD-...-CARD-1디버깅 쉬움시도번호 관리 필요
주문ID + 해시order_123_sha256(...)서버에서 결정적해시 충돌 가능성 (극히 낮음)

실무에서는 주문번호 기반이 가장 많이 쓰인다. “이 결제가 어떤 주문의 어떤 수단, 몇 번째 시도인지”를 바로 읽을 수 있기 때문이다.

5.2 복합 결제 — 왜 1:N인가

한 주문에 여러 결제 수단이 섞이는 경우가 실무에서 매우 흔하다:

주문 총액: 100,000원
  ├─ payments[1]: POINT     10,000원
  ├─ payments[2]: COUPON     5,000원
  └─ payments[3]: CARD      85,000원
                  ─────────
                  합계 100,000원 = orders.total_amount

포인트와 쿠폰도 “결제 수단”으로 모델링한다. 이렇게 하면:

  • 환불 시 “포인트로 낸 건 포인트로 돌려준다”가 자연스럽게 추적된다
  • 회계 기준으로 “현금 결제 vs 포인트 결제” 구분이 가능하다
  • PG사 결제와 자사 자산 차감을 같은 테이블로 일관 관리한다

제약: SUM(payments.amount WHERE order_id = X AND status = 'CONFIRMED') == orders.total_amount. 이건 여러 행 합계라 CHECK로 표현 못 한다. 앱 레벨 또는 트리거에서 검증한다.

5.3 재시도 기록도 1:N

복합 결제가 아니어도 payments는 1:N이다:

1차 시도: 카드 한도 초과 → FAILED
2차 시도: 다른 카드로 → CONFIRMED

실패한 시도를 지우지 않고 남긴다. 이유는 CS 대응이다. “왜 결제가 한 번 실패했다가 됐느냐”는 문의가 오면 실패 사유까지 보여줘야 한다. 실패 이력을 삭제하면 이 대응이 불가능하다.


6. order_status_histories — 어느 레벨에서 추적하나

주문 레벨 상태, 배송 레벨 상태, 아이템 레벨 상태 — 어디까지 이력을 남길까?

CREATE TABLE order_status_histories (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,                  -- 내부 PK
    entity_type     VARCHAR(20) NOT NULL,                               -- 대상 타입 ('ORDER' 또는 'DELIVERY')
    entity_id       BIGINT      NOT NULL,                               -- 대상 id (entity_type 기준 orders.id 또는 order_deliveries.id)
    from_status     VARCHAR(20),                                        -- 이전 상태 (최초 생성이면 NULL)
    to_status       VARCHAR(20) NOT NULL,                               -- 변경 후 상태
    changed_by      VARCHAR(100) NOT NULL,                              -- 변경 주체 (예: 'SYSTEM', 'ADMIN:kim', 'USER:123', 'WEBHOOK:...')
    reason          VARCHAR(500),                                       -- 변경 사유 메모 (선택)
    changed_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,       -- 변경 시각

    CONSTRAINT chk_order_history_entity_type CHECK (
        entity_type IN ('ORDER', 'DELIVERY')
    )
);

CREATE INDEX idx_order_history_entity     ON order_status_histories(entity_type, entity_id);
CREATE INDEX idx_order_history_changed_at ON order_status_histories(changed_at);

6.1 권장: 주문 + 배송 레벨만

  • 주문 레벨 (PENDING → PAID → …): 고객 여정의 큰 단계
  • 배송 레벨 (READY → SHIPPED → DELIVERED): 물류 추적
  • 아이템 레벨: 권장하지 않음. 취소/환불 이력은 이미 order_cancellationsrefund_items가 들고 있으므로 중복이다

너무 많은 레벨에 이력을 남기면 이력 테이블이 빠르게 비대해지고, 디버깅할 때 “어느 테이블을 봐야 하는지” 헷갈린다. 의미 있는 단위에서만 이력을 남기는 게 핵심이다.

6.2 entity_type + entity_id는 다형성이다

4편에서 다룬 다형성 관계의 예다. FK로 강제할 수 없는 게 단점이지만, 이력처럼 “여러 타입을 같은 구조로 저장”할 때는 허용할 만하다.

싫다면 order_status_histories + delivery_status_histories로 테이블을 분리하는 방법도 있다. 판단 기준:

  • “특정 주문의 전체 이력을 한 방에 보고 싶다” → 한 테이블이 편하다
  • “배송 이력만 따로 집계/조회가 많다” → 분리가 낫다

6.3 changed_by의 유형

  • SYSTEM: 자동 처리 (결제 완료 webhook, 타임아웃 취소 등)
  • ADMIN:kim: 관리자 수동 개입
  • USER:123: 고객이 직접 (취소 요청 등)
  • WEBHOOK:CJ: 외부 시스템 (택배사 배송 완료 콜백)

changed_by를 FK로 묶지 않는 이유: 주체가 users뿐만이 아니라 시스템/외부/관리자 등 여러 종류기 때문이다. 문자열로 두는 게 실용적이다.


7. order_cancellations — 취소 이력

취소를 order_status_histories에 녹일 수도 있지만, 별도 테이블로 뺀다. 이유: 취소는 사유주체가 핵심이고, 부분 취소면 대상 배송/아이템까지 기록해야 하기 때문이다.

CREATE TABLE order_cancellations (
    id                  BIGINT AUTO_INCREMENT PRIMARY KEY,              -- 내부 PK
    order_id            BIGINT      NOT NULL,                           -- 부모 주문
    order_delivery_id   BIGINT,                                         -- 배송 단위 취소면 참조, 주문 전체 취소면 NULL

    -- 누가, 왜
    cancelled_by_type   VARCHAR(20) NOT NULL,                           -- 취소 주체 타입 (CUSTOMER, SELLER, ADMIN, SYSTEM)
    cancelled_by_id     VARCHAR(100),                                   -- 주체 식별자 (user_id/admin_id; SYSTEM이면 NULL)
    reason_code         VARCHAR(50) NOT NULL,                           -- 취소 사유 코드 (enum, 집계/통계용)
    reason_detail       VARCHAR(1000),                                  -- 취소 사유 상세 (자유 텍스트, CS용)

    -- 환불과의 연결 (취소 시 즉시 환불이 생성되는 경우)
    refund_id           BIGINT,                                         -- 연결된 환불 건 (없으면 NULL)

    cancelled_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,   -- 취소 시각

    CONSTRAINT fk_cancellations_order
        FOREIGN KEY (order_id) REFERENCES orders(id),
    CONSTRAINT fk_cancellations_delivery
        FOREIGN KEY (order_delivery_id) REFERENCES order_deliveries(id),
    CONSTRAINT chk_cancellations_by_type CHECK (
        cancelled_by_type IN ('CUSTOMER', 'SELLER', 'ADMIN', 'SYSTEM')
    )
);

CREATE INDEX idx_cancellations_order_id    ON order_cancellations(order_id);
CREATE INDEX idx_cancellations_reason_code ON order_cancellations(reason_code);

7.1 취소 주체별 분석 필요성

CS, 정산, 매출 분석에서 취소 주체는 결정적으로 중요하다:

주체분석 관점
CUSTOMER반품률, 이탈 패턴
SELLER품절률, 셀러 신뢰도
ADMIN운영 개입, 이상 거래 차단
SYSTEM결제 타임아웃, 재고 부족 자동 취소

이 구분 없이 “취소됨”으로만 기록하면 “셀러 품절로 인한 취소”가 “고객 변심”과 섞여서 셀러 품질 평가가 불가능해진다. 나중에 스키마를 바꾸는 건 쉽지 않으니 처음부터 넣는 게 낫다.

7.2 reason_code vs reason_detail

reason_code고정된 enum, reason_detail자유 텍스트다.

reason_code   = 'OUT_OF_STOCK'
reason_detail = '공급사 재고 소진, 4월 15일 이후 입고 예정'

코드는 집계/통계에, 텍스트는 CS 대응에 쓴다. 둘 다 필요하다. 둘 중 하나만 남기면 “코드만 있으면 상세 맥락이 없고, 텍스트만 있으면 집계가 안 된다”.


8. refunds + refund_items — 아이템 단위 환불

환불이 왜 복잡한가:

  • 부분 환불: 10만원 중 3만원만 환불
  • 아이템 단위 환불: 어느 상품이 환불됐는지 추적
  • 복합 결제 환불: 카드로 결제한 건 카드로, 포인트는 포인트로 돌려줘야 함
  • 다중 환불: 1차 부분 환불 후 2차 추가 환불

이 네 가지를 전부 표현하려면 환불도 3개 테이블로 분해해야 한다.

-- 환불 한 건 (환불 요청 단위)
CREATE TABLE refunds (
    id                  BIGINT AUTO_INCREMENT PRIMARY KEY,              -- 내부 PK
    order_id            BIGINT      NOT NULL,                           -- 부모 주문
    refund_number       VARCHAR(30) NOT NULL,                           -- 외부 식별자 (예: REF-20260408-00001)
    amount              DECIMAL(12, 2) NOT NULL,                        -- 이 환불 건 총액 (refund_items 합과 일치)
    status              VARCHAR(20)    NOT NULL DEFAULT 'REQUESTED',    -- 환불 상태 (REQUESTED → APPROVED → COMPLETED)
    reason_code         VARCHAR(50)    NOT NULL,                        -- 환불 사유 코드 (enum)
    reason_detail       VARCHAR(1000),                                  -- 환불 사유 상세 (자유 텍스트)
    requested_at        TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 환불 요청 시각
    processed_at        TIMESTAMP,                                      -- 환불 처리 완료 시각

    CONSTRAINT fk_refunds_order FOREIGN KEY (order_id) REFERENCES orders(id),
    CONSTRAINT uq_refunds_refund_number UNIQUE (refund_number),
    CONSTRAINT chk_refunds_status CHECK (
        status IN ('REQUESTED', 'APPROVED', 'PROCESSING', 'COMPLETED', 'REJECTED')
    ),
    CONSTRAINT chk_refunds_amount CHECK (amount > 0)
);

-- 환불 대상 아이템 (어느 상품을 몇 개 돌려받는가)
CREATE TABLE refund_items (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,                  -- 내부 PK
    refund_id       BIGINT NOT NULL,                                    -- 부모 환불 건
    order_item_id   BIGINT NOT NULL,                                    -- 환불 대상 주문 아이템
    quantity        INT            NOT NULL,                            -- 이 환불에서 돌려받는 수량
    amount          DECIMAL(12, 2) NOT NULL,                            -- 이 아이템의 환불 금액 (수량 × 단가 기준)

    CONSTRAINT fk_refund_items_refund
        FOREIGN KEY (refund_id) REFERENCES refunds(id) ON DELETE CASCADE,
    CONSTRAINT fk_refund_items_order_item
        FOREIGN KEY (order_item_id) REFERENCES order_items(id),
    CONSTRAINT chk_refund_items_quantity CHECK (quantity > 0),
    CONSTRAINT chk_refund_items_amount   CHECK (amount > 0)
);

-- 환불 결제 수단별 분배 (복합 결제 대응)
CREATE TABLE refund_payments (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,                  -- 내부 PK
    refund_id       BIGINT NOT NULL,                                    -- 부모 환불 건
    payment_id      BIGINT NOT NULL,                                    -- 원 결제 건 (어느 수단으로 돌려줄지)
    amount          DECIMAL(12, 2) NOT NULL,                            -- 이 결제 수단에서 돌려주는 금액
    pg_refund_id    VARCHAR(100),                                       -- PG사 환불 ID (자사 자산이면 NULL)
    status          VARCHAR(20)    NOT NULL DEFAULT 'PENDING',          -- PG 환불 호출 상태 (PENDING → COMPLETED/FAILED)

    CONSTRAINT fk_refund_payments_refund
        FOREIGN KEY (refund_id) REFERENCES refunds(id) ON DELETE CASCADE,
    CONSTRAINT fk_refund_payments_payment
        FOREIGN KEY (payment_id) REFERENCES payments(id),
    CONSTRAINT chk_refund_payments_amount CHECK (amount > 0),
    CONSTRAINT chk_refund_payments_status CHECK (
        status IN ('PENDING', 'COMPLETED', 'FAILED')
    )
);

CREATE INDEX idx_refunds_order_id             ON refunds(order_id);
CREATE INDEX idx_refund_items_refund_id       ON refund_items(refund_id);
CREATE INDEX idx_refund_items_order_item_id   ON refund_items(order_item_id);
CREATE INDEX idx_refund_payments_refund_id    ON refund_payments(refund_id);
CREATE INDEX idx_refund_payments_payment_id   ON refund_payments(payment_id);

8.1 전체 그림

orders (100,000원)
  ├─ order_items
  │    ├─ 상품 A × 5 × 10,000 = 50,000
  │    └─ 상품 B × 5 × 10,000 = 50,000

  ├─ payments
  │    ├─ POINT 10,000 [CONFIRMED]
  │    └─ CARD  90,000 [CONFIRMED]

  └─ refunds (고객이 상품 A 2개 반품, 총 20,000원 환불)
       ├─ refund_items
       │    └─ order_item_id=1 (A) × 2개 × 10,000 = 20,000

       └─ refund_payments (10,000은 포인트, 10,000은 카드로 복구)
            ├─ payment_id=1 (POINT) 10,000
            └─ payment_id=2 (CARD)  10,000

1차 환불이 완료되면 order_items.refunded_quantity0 → 2로 업데이트된다. 이후 고객이 상품 B도 반품하면 새 refunds 행이 생기고, 해당 order_items.refunded_quantity가 또 올라간다.

8.2 “환불 금액의 합이 결제 금액을 초과하면 안 된다”

CHECK로 표현 못 한다(다른 행 참조 필요). 앱 레벨에서 다음을 검증한다:

-- 가능한 최대 환불 금액
SELECT
    (SELECT COALESCE(SUM(amount), 0) FROM payments
     WHERE order_id = ? AND status = 'CONFIRMED')
  - (SELECT COALESCE(SUM(amount), 0) FROM refunds
     WHERE order_id = ? AND status IN ('APPROVED', 'PROCESSING', 'COMPLETED'))
    AS refundable_amount;

배타 락(SELECT ... FOR UPDATE)으로 orders 행을 잠그고 계산 후 INSERT하면 동시성도 안전하다. 이건 FCFS 시리즈 4편에서 다룬 비관적 락의 실전 응용이다.

8.3 아이템 단위 환불 vs 주문 단위 환불

더 단순한 대안은 “환불은 주문 단위 금액만 기록”이다. 하지만:

  • 회계 기준으로 “어느 상품의 환불인지” 요구된다
  • 셀러 정산에서 “어느 상품을 몇 개 돌려줬는지”가 필요하다
  • 재고 복원을 아이템 단위로 해야 한다

이 중 하나라도 해당되면 아이템 단위 환불이 사실상 필수다. MVP 단계가 아니라면 처음부터 refund_items를 두는 게 낫다. 나중에 “주문 단위 환불에서 아이템 단위로” 데이터 마이그레이션하는 건 주문 이력 전체를 복원해야 해서 매우 어렵다. 초반 설계 시점에 결정해야 한다.


9. 설계 원칙 정리

원칙적용
구조 선택2단/3단/형제 중 상황에 맞는 것 선택. 이 글은 3단(orders → order_deliveries → order_items)
배송지 스냅샷orders에 수령인/주소 복사 (user_addresses 참조 X)
상품/옵션 스냅샷order_items에 product_name/unit_price/product_option 복사
멱등성payments.idempotency_key UNIQUE 제약
상태 머신주문/배송 레벨 각각 CHECK + 앱 레벨 전이 규칙
복합 결제payments 1:N (수단별 분리) + refund_payments로 환불 분배
부분 취소/환불order_items.cancelled_quantity / refunded_quantity + refund_items
취소 이력 분리order_cancellations로 주체/사유 코드 기록
외부 ID 분리order_number / delivery_number / refund_number (내부 id 노출 금지)
이력 추적order_status_histories (주문/배송 2 레벨만)
조회 최적화 반정규화order_items.order_id 중복 저장, orders.paid_at 등 타임스탬프

이론과의 연결:

1편 네이밍/타입 → order_number VARCHAR(30), amount DECIMAL(12,2)
2편 정규화/스냅샷 → 상품 스냅샷 + 배송지 스냅샷 + 상태별 타임스탬프
3편 제약조건   → CHECK (cancelled + refunded <= quantity),
                 UNIQUE (idempotency_key, order_number)
4편 관계 패턴  → orders-deliveries-items 1:N 체인,
                 order_status_histories 다형성,
                 refund_items로 order_items N:M 확장

10. 스키마 설계 리뷰 체크리스트

PR에 마이그레이션 파일이 올라왔을 때 이 체크리스트로 리뷰하면 된다.

10.1 네이밍 (1편 참고)

  • 테이블명이 snake_case + 복수형인가?
  • 컬럼명이 snake_case이고 의미가 명확한가?
  • boolean 컬럼에 is_, has_ 접두사가 있는가?
  • FK 컬럼이 참조테이블_id 형식인가? (예: user_id, order_id)

10.2 데이터 타입 (1편 참고)

  • VARCHAR 길이에 근거가 있는가? (무조건 255가 아닌지)
  • 금액에 DECIMAL을 사용했는가? (FLOAT/DOUBLE이 아닌지)
  • PK가 BIGINT인가? (INT의 21억 한계를 고려했는가)
  • TIMESTAMP vs DATETIME 선택에 근거가 있는가?

10.3 정규화/반정규화 (2편 참고)

  • 같은 데이터가 여러 곳에 저장되는 경우, 의도적 반정규화인가 실수인가?
  • 반정규화된 값의 동기화 전략이 있는가?
  • 스냅샷 패턴이 필요한 곳에 적용되었는가? (가격, 주소, 옵션)

10.4 제약조건 (3편 참고)

  • NOT NULL이어야 하는 컬럼이 NOT NULL로 선언되었는가?
  • 비즈니스 규칙을 CHECK로 표현할 수 있는가? (cancelled + refunded <= quantity 등)
  • UNIQUE 제약이 필요한 곳에 걸려 있는가? (order_number, idempotency_key)
  • FK가 필요한 곳에 걸려 있는가?
  • ON DELETE 전략이 적절한가?

10.5 관계 설계 (4편 참고)

  • 주문 구조 선택(2단/3단/형제)에 근거가 명시되어 있는가?
  • 주문(구매 계약)과 풀필먼트(배송)의 경계가 흐리지 않은가?
  • 1:N 체인의 중간 테이블에 양방향 인덱스가 있는가?
  • 다형성 관계의 패턴 선택에 근거가 있는가?

10.6 인덱스

  • FK 컬럼에 인덱스가 있는가? (PostgreSQL은 자동 생성하지 않음)
  • WHERE, ORDER BY, JOIN에 자주 쓰이는 컬럼에 인덱스가 있는가?
  • 불필요한 인덱스는 없는가? (쓰기 성능에 영향)

10.7 주문 도메인 특화 체크

  • 외부 노출용 식별자(order_number 등)와 내부 id가 분리되어 있는가?
  • 스냅샷이 필요한 모든 곳(상품, 가격, 옵션, 배송지)에 적용되었는가?
  • 결제 멱등성이 스키마 레벨에서 보장되는가?
  • 부분 취소/환불이 표현 가능한가?
  • 취소 주체(고객/셀러/관리자/시스템) 구분이 가능한가?
  • 복합 결제(카드 + 포인트 + 쿠폰)가 표현 가능한가?
  • 주문/배송/아이템 레벨 상태가 서로 정합적인가?

10.8 체크리스트 활용법

이 체크리스트를 한 번에 다 적용하려고 하지 말자. 현재 프로젝트에서 가장 문제가 되는 2~3개부터 시작하면 된다.

"우리 팀은 FK 인덱스를 자주 빼먹는다"     → 10.6 집중
"NOT NULL을 안 걸어서 NULL 버그가 많다"   → 10.4 집중
"스냅샷 기준이 없어서 이력이 깨진다"       → 10.3 집중

체크리스트는 규칙이 아니라 대화의 시작점이다. “이거 왜 이렇게 했어?”라는 질문의 근거가 되어야 하고, “이유가 있어서 이렇게 했어”라는 답이 있다면 체크리스트를 무시해도 된다.


정리

이 글에서는 주문 도메인 하나만 끈질기게 팠다. 요점:

  1. 3단 계층(orders → order_deliveries → order_items)은 실무 이커머스의 기본 구조다. 부분 배송, 부분 취소, 부분 환불을 표현하려면 피할 수 없다.
  2. 스냅샷 패턴은 상품 가격에만 쓰는 게 아니다. 배송지, 옵션, 상태별 타임스탬프까지 확장된다.
  3. 멱등성 키는 이중 결제를 스키마 레벨에서 원천 차단하는 가장 중요한 장치다. UNIQUE 제약 하나로 앱 버그와 네트워크 재시도를 한 번에 막는다.
  4. 복합 결제(카드 + 포인트 + 쿠폰)는 payments를 1:N으로 모델링해서 자연스럽게 표현한다. 환불도 refund_payments로 결제 수단별 분배가 추적된다.
  5. 취소와 환불의 이력은 주체/사유 코드까지 남겨야 CS, 정산, 셀러 평가에 쓸 수 있다.

“이론을 아는 것”과 “실무에 적용하는 것”은 다른 능력이다. 주문 도메인 하나만 해도 테이블 8개, 제약 수십 개, 트레이드오프 여러 개가 나온다. 다른 도메인은 또 이만큼씩 있다. 이 글이 “실무 수준 설계의 무게”를 조금이라도 전달했기를 바란다.

다음 편에서는 인덱스 설계와 JOIN 전략 — 커버링 인덱스, 부분 인덱스, JOIN 알고리즘 선택 기준, EXPLAIN ANALYZE 읽는 법까지 다룬다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.