← posts/b.log()

blog92@web:~$ cat posts/db-design-25-capstone.md

DATABASE10 min read

DB 설계 A to Z 25강 — 종합 실습

마켓플레이스 출고 관리 시스템 하나를 요구사항 분석부터 개념·논리·물리 설계, 보안, 분석 모델, 진화 시나리오, 리뷰까지 관통한다. 25강 전체 요약과 설계자가 기억할 원칙, 참고 자료로 시리즈를 마친다.

지금까지의 과정을 하나의 요구사항에 처음부터 끝까지 적용합니다.

1. 요구사항

쇼핑몰의 출고 작업을 관리하는 시스템을 만든다.

  • 주문 하나에 여러 주문항목이 있고, 주문항목마다 출고 품목(의류, 전자, 도서)이 붙는다.
  • 출고 품목은 SKU 코드로 식별되며, 같은 SKU 코드의 상품이 여러 주문에 쓰인다. 상품 이름과 판매 금액은 바뀐다. 그래도 과거 주문은 당시 값으로 재현되어야 한다.
  • 출고 처리는 계획일, 실적일, 담당 셀러를 관리한다. 검수에 불합격하면 재처리를 한다.
  • 상품 유형별로 관리 속성이 다르다 (의류: 크기·재질 / 전자: 보장 기간·모델 번호 / 도서: ISBN).
  • 셀러는 자기 회사의 출고 작업만 조회할 수 있다.
  • 경영진은 주문·주문항목·유형별 처리율과 월별 투입 시수를 대시보드로 본다. 과거 월 보고서는 당시 기준으로 재현되어야 한다.
  • 포장 차수는 일 6만 건, 5년 보관.

2. 요구사항 분석 (2강)

모호성 질문과 가정된 답변

질문가정 답변설계 영향
한 주문에 같은 상품이 여러 개 있을 수 있나?예, 주문항목에 수량이 있음출고 단위 = 상품 × 주문 × 일련번호
출고 창고가 바뀔 수 있나?예, 재고 이전 시출고 창고는 출고 단위의 비식별 속성
재처리 시 이전 처리 기록을 보존하나?예, 검수 이력포장 차수
셀러가 중간에 바뀌나?드물지만 있음차수별로 셀러 기록
과거 보고서 재현의 기준은?월말 마감 시점의 상태주기적 스냅샷

업무 규칙

규칙유형구현
실적일은 계획일 이후 7일 이내행 간 (다른 테이블 참조)트리거 또는 서비스 계층
같은 출고 단위의 차수는 유일유일성PK (단, 파티션 키 포함 → 아래 한계 참고)
진행 중인 차수는 출고 단위 하나에 하나행 간서비스 계층 행 락 (파티셔닝 제약)
의류는 크기 필수값 (서브타입)서브타입 NOT NULL
셀러는 자기 데이터만보안RLS

볼륨

엔티티규모결정
포장 차수일 6만 × 5년 ≈ 1억 1,000만월 파티셔닝
상품 마스터수십만일반 테이블

3. 개념 모델 (3~4강)

text
[주문] ┼┼───┼< [주문항목]
[상품] ┼┼───○< [출고 단위] >○───┼┼ [주문]
                   │
              >○───┼┼ [주문항목]  (소속 주문항목)
              >○───┼┼ [출고 창고]  (현재 출고 창고, 가변)
[셀러] ┼┼───○< [포장 차수] >┼───┼┼ [출고 단위]
[포장 차수] ┼┼───○< [검수]
[상품] ── 슈퍼타입 {의류, 전자, 도서} (배타, 완전)
  • 상품: 주문과 무관한 마스터 정보 (여러 주문에서 공유) → 주문과 분리한 것이 핵심 판단
  • 출고 단위: 특정 주문에 들어가는 개별 상품 (상품 × 주문 × 일련번호)
  • 포장 차수: 약한 엔티티, 재처리 이력

주문 시점 스냅샷 — 마스터를 분리한 대가

상품을 주문에서 분리한 설계는 대가가 있습니다. 상품 이름과 판매 금액은 계속 바뀝니다. 출고 단위에 상품 마스터 참조만 있으면, 주문을 조회할 때마다 오늘의 상품 이름과 오늘의 판매 금액이 따라 들어갑니다. 과거 주문이 조용히 다시 쓰인다는 것입니다.

그래서 마스터 참조와 주문 시점 스냅샷을 동시에 둡니다. 상품의 현재 모습은 상품 마스터가 갖고, 주문이 성립하면 그때의 상품 이름과 판매 금액을 주문항목의 product_name_snapshot·unit_price_snapshot에 복제로 남깁니다. 모습은 10강 반정규화와 같습니다. 하지만 동기화 수단은 반대입니다. 10강의 복제 컬럼은 원본이 바뀌면 따라 갱신해 정합성을 맞춥니다. 주문 시점 스냅샷은 원본이 바뀌어도 갱신하지 않는 것이 정합성입니다. 복제 컬럼마다 갱신 주체를 함께 적습니다. 그 기록이 없으면 나중에 누가 좋은 의도로 동기화 배치를 붙여 과거 주문이 깨집니다.

18강의 선분 이력을 상품에 두면 과거 값을 다시 만들 수 있습니다. 스냅샷 없이도 재현은 됩니다. 다만 주문을 조회할 때마다 시점 조인을 해야 하고, 주문 조회는 이 시스템에서 가장 잦은 조회입니다. 그래서 보통 두 가지를 함께 씁니다. 상품 마스터의 변경은 이력으로 남깁니다. 주문 문서는 스냅샷으로 둡니다. 어느 쪽에도 비용이 있습니다. 선택한 근거를 함께 남깁니다. 여기까지가 설계입니다.

4. 논리·물리 설계 (6~16강, PostgreSQL 기준)

sql
-- 입점 셀러 (코드 마스터, 자연키)
CREATE TABLE seller (
    seller_code VARCHAR(10) PRIMARY KEY,
    seller_name VARCHAR(100) NOT NULL
);
 
-- 출고 창고 (코드 마스터)
CREATE TABLE fulfillment_center (
    fc_id   INT PRIMARY KEY,
    fc_name VARCHAR(50) NOT NULL
);
 
CREATE TABLE orders (
    order_no   VARCHAR(10) PRIMARY KEY,
    order_type VARCHAR(20) NOT NULL
);
 
CREATE TABLE order_item (
    item_id               BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_no              VARCHAR(10) NOT NULL REFERENCES orders,
    item_seq              INT NOT NULL,                 -- 주문 내 항목 순번
    item_name             VARCHAR(20) NOT NULL,         -- 주문 한 줄에 표시되는 표기 — 상품명에 옵션·수량을 붙인 값. 상품명 단독 값은 아래 스냅샷이 갖는다
    product_name_snapshot VARCHAR(200) NOT NULL,        -- 주문 시점 스냅샷 (원본이 바뀌어도 갱신하지 않는다)
    unit_price_snapshot   NUMERIC(12,2) NOT NULL,       -- 주문 시점 스냅샷 (원본이 바뀌어도 갱신하지 않는다)
    UNIQUE (order_no, item_seq),
    UNIQUE (item_id, order_no)                          -- 복합 FK 대상
);
 
-- 슈퍼타입 + 서브타입 (11강 전략 C)
CREATE TABLE product (
    product_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku_code     VARCHAR(30) NOT NULL UNIQUE,           -- 자연키 보호
    product_type CHAR(2) NOT NULL CHECK (product_type IN ('AP','EL','BK')),  -- 유형코드(2자). 19·20강의 product_type은 유형명이다
    description  VARCHAR(200),
    UNIQUE (product_id, product_type)
);
 
CREATE TABLE apparel (
    product_id   BIGINT PRIMARY KEY,
    product_type CHAR(2) NOT NULL DEFAULT 'AP' CHECK (product_type = 'AP'),
    size_label   VARCHAR(10) NOT NULL,
    material     VARCHAR(20) NOT NULL,
    FOREIGN KEY (product_id, product_type) REFERENCES product (product_id, product_type)
);
 
CREATE TABLE electronics (
    product_id      BIGINT PRIMARY KEY,
    product_type    CHAR(2) NOT NULL DEFAULT 'EL' CHECK (product_type = 'EL'),
    warranty_months SMALLINT NOT NULL CHECK (warranty_months > 0),
    model_no        VARCHAR(30) NOT NULL,
    FOREIGN KEY (product_id, product_type) REFERENCES product (product_id, product_type)
);
 
CREATE TABLE book (
    product_id   BIGINT PRIMARY KEY,
    product_type CHAR(2) NOT NULL DEFAULT 'BK' CHECK (product_type = 'BK'),
    isbn         VARCHAR(13) NOT NULL,
    author       VARCHAR(100) NOT NULL,
    FOREIGN KEY (product_id, product_type) REFERENCES product (product_id, product_type)
);
 
-- 출고 단위
CREATE TABLE ship_unit (
    unit_id    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_id BIGINT NOT NULL REFERENCES product,
    order_no   VARCHAR(10) NOT NULL,
    serial_no  SMALLINT NOT NULL,
    item_id    BIGINT NOT NULL,
    fc_id      INT NOT NULL REFERENCES fulfillment_center,  -- 현재 출고 창고 (가변)
    plan_date  DATE NOT NULL,
    UNIQUE (product_id, order_no, serial_no),               -- 자연키
    FOREIGN KEY (item_id, order_no) REFERENCES order_item (item_id, order_no)  -- 주문항목이 같은 주문 소속임을 보장
);
CREATE INDEX ix_unit_item  ON ship_unit (item_id);
CREATE INDEX ix_unit_order ON ship_unit (order_no);
 
-- 포장 차수 (약한 엔티티, 월 파티셔닝)
CREATE TABLE pack_round (
    unit_id     BIGINT NOT NULL,
    round_no    SMALLINT NOT NULL,
    seller_code VARCHAR(10) NOT NULL REFERENCES seller,
    status      VARCHAR(10) NOT NULL
                CHECK (status IN ('ASSIGNED','PACKED','PASSED','FAILED')),
    packed_on   DATE,
    man_hours   NUMERIC(6,2) CHECK (man_hours >= 0),
    work_month  DATE NOT NULL,                          -- 파티션 키 (배정 월 1일)
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (unit_id, round_no, work_month),
    CHECK (EXTRACT(DAY FROM work_month) = 1),
    CHECK (status = 'ASSIGNED' OR packed_on IS NOT NULL)
) PARTITION BY RANGE (work_month);
 
CREATE INDEX ix_round_seller ON pack_round (seller_code, work_month);
CREATE INDEX ix_round_unit   ON pack_round (unit_id);

설계 판단 기록

판단근거관련 강의
상품 마스터와 출고 단위 분리여러 주문에서 공유 → 상품 속성이 여러 주문에 반복되는 갱신 이상 방지7, 8강
슈퍼+서브타입출고 단위가 상품 마스터를 참조해야 하므로 서브타입별 테이블 불가11강
(item_id, order_no) 복합 FK다른 주문의 주문항목을 지정하는 오류를 DB가 차단10강
포장 차수 PK에 work_month파티셔닝 제약, 월 단위 보관 관리16강
주문 시점 스냅샷 컬럼상품 이름·판매 금액이 바뀌어도 과거 주문은 당시 값으로 재현10, 18강
상태 단일 컬럼불리언 조합의 모순 방지12강
실적일 범위 규칙계획일은 다른 테이블 → CHECK 불가, 앱 또는 트리거로 검증13강

마지막 행처럼 DB 제약으로 표현할 수 없는 규칙을 명시적으로 기록하는 것도 설계의 일부입니다. 이 규칙은 트리거로 구현하거나, 출고 단위에 계획일이 바뀔 때의 처리까지 포함해 앱 서비스 계층에서 검증합니다.

"차수 유일"과 "진행 중인 차수는 출고 단위 하나에 하나" 규칙은, 파티션 테이블의 PK·UNIQUE가 파티션 키(work_month)를 포함해야 하므로 월이 다른 행끼리는 DB 제약만으로 막을 수 없습니다. 이 한계는 파티셔닝과 무결성의 트레이드오프이며, 서비스 계층의 행 락(SELECT ... FOR UPDATE on ship_unit)으로 보완합니다.

5. 보안 (21강)

sql
ALTER TABLE pack_round ENABLE ROW LEVEL SECURITY;
CREATE POLICY seller_own_rows ON pack_round
  FOR SELECT TO seller_role
  USING (seller_code = current_setting('app.seller_code'));

사내 사용자 역할에는 별도 정책(USING (true))을 부여합니다.

6. 분석 모델 (20강)

버스 매트릭스

업무 \ 차원월주문주문항목상품 유형셀러
포장 실적 (트랜잭션)OOOOO
월말 포장 현황 (주기적 스냅샷)OOOO
sql
CREATE TABLE fact_pack_monthly_snapshot (
    month_key    INT NOT NULL,              -- 202609
    item_sk      BIGINT NOT NULL,           -- SCD Type 2 대리키
    product_type CHAR(2) NOT NULL,
    planned_cnt  INT NOT NULL,              -- 분모
    packed_cnt   INT NOT NULL,              -- 분자
    passed_cnt   INT NOT NULL,
    man_hours    NUMERIC(12,2) NOT NULL,
    closed_at    TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (month_key, item_sk, product_type)
);
  • 처리율은 저장하지 않고 packed_cnt / planned_cnt로 계산 (비가산 측정값)
  • 월말 마감 배치가 스냅샷을 삽입만 하므로, 과거 월 보고서가 그대로 재현됨 (18강 요구)
  • man_hours는 가산이지만, 개수 컬럼들은 월 차원으로 합산하면 안 되는 준가산 값

7. 진화 시나리오 (22강)

요구 변경: "의류 상품에 색상 사양을 추가하고, 향후 유형별 속성이 자주 늘어날 예정"

단계작업
1product에 extra_spec JSONB NOT NULL DEFAULT '{}' 추가 (즉시 적용)
2새 속성은 JSON에 저장 (19강 하이브리드)
33개월 후 color가 대시보드 필터로 쓰이기 시작 → 컬럼 승격 결정
4apparel.color 추가 → 앱 이중 쓰기 → Backfill (JSON에서 추출)
5읽기 전환 → 검증 → JSON 키 쓰기 중단 → JSON에서 키 제거

8. 리뷰 (24강)

항목결과
대리키 테이블의 자연키 UNIQUE모두 있음
FK 인덱스ship_unit.product_id는 UNIQUE (product_id, order_no, serial_no)의 선두 컬럼이라 커버됨, pack_round.seller_code는 복합 인덱스 선두로 커버됨. ship_unit.fc_id만 전용 인덱스 없음 → 중간
금액·시수 타입NUMERIC
DB로 표현 못한 규칙3건 (실적일 범위, 월 간 차수 유일, 진행 차수 단일), 기록 및 서비스 계층 구현 확인
파티션 선 생성 배치운영 계획에 포함 필요 → 높음
안티패턴 역추적해당 없음

25강 정리

하나의 요구사항이 25개 강의의 판단을 모두 거쳐 스키마가 됩니다.

  1. 요구사항의 모호성 질문이 엔티티 분리(상품 마스터 vs 출고 단위)를 결정했다.
  2. 슈퍼/서브타입과 복합 FK로 업무 규칙을 구조로 강제했다.
  3. 파티셔닝은 보관 관리를 얻는 대신 일부 유일성 보장을 포기하게 했고, 그 한계를 기록했다.
  4. 분석 모델은 분자·분모 스냅샷으로 과거 재현 요구를 충족했다.
  5. 변화 요구는 JSON 하이브리드와 Expand–Contract로 흡수했다.

마치며

강의 전체 요약

Part핵심 질문핵심 도구
1. 기초업무를 어떻게 정확히 이해하고 표현하나?요구사항 분석, ER 모델, 키
2. 논리 설계사실을 어떻게 중복 없이 배치하나?변환 규칙, 함수 종속성, 정규화
3. 물리 설계DBMS 위에서 어떻게 효율적으로 저장하나?타입, 제약, 키 생성, 인덱스, 파티션
4. 심화 모델링계층·시간·유연성·분석·테넌트를 어떻게 다루나?클로저 테이블, 선분 이력, JSON, 차원 모델, RLS
5. 시니어 레벨운영 중인 설계를 어떻게 바꾸고 나누고 검증하나?Expand–Contract, 바운디드 컨텍스트, 리뷰, ADR

설계자가 기억할 원칙

  1. 업무 규칙이 먼저, 테이블은 나중이다. FD도, 제약도, 인덱스도 업무 규칙과 쿼리에서 나온다.
  2. "같은 일이 두 번 일어날 수 있는가?" 이 질문 하나가 키 설계를 바꾼다.
  3. DB가 보장할 수 있는 것은 DB에 맡긴다. 앱 검증은 우회된다.
  4. 중복은 동기화 수단과 함께 설계한다. 가능하면 DB가 보장하는 수단을 쓴다.
  5. 나누기는 쉽고 합치기는 어렵다. 스키마도, 테이블도, DB도 필요가 확인될 때 나눈다.
  6. 표현하지 못한 규칙은 기록한다. 설계의 한계를 아는 것도 설계다.
  7. 스키마는 변한다. 모든 변경은 되돌릴 수 있는 단계로 나눈다.

참고 자료

  • Martin Kleppmann, 데이터 중심 애플리케이션 설계 (Designing Data-Intensive Applications)
  • Alex Petrov, Database Internals
  • Bill Karwin, SQL AntiPatterns
  • Ralph Kimball, Margy Ross, The Data Warehouse Toolkit
  • Richard T. Snodgrass, Developing Time-Oriented Database Applications in SQL
  • CMU 15-445/645 Database Systems
  • 백은빈·이승현, Real MySQL 8.0
  • 각 DBMS 공식 문서 (PostgreSQL, MySQL, Oracle, SQL Server)

시리즈를 마치며 — 25강 전체 목록

강주제
1강설계란 무엇인가
2강요구사항 분석
3강개념 모델링 (ER 모델)
4강ERD 표기법
5강키의 종류
6강ER → 관계형 변환
7강이상현상과 함수 종속성
8강정규화 1NF ~ BCNF
9강4NF, 5NF
10강반정규화
11강식별/비식별 관계, 슈퍼타입/서브타입
12강데이터 타입 설계
13강제약조건 설계
14강키 생성 전략
15강인덱스 설계
16강파티셔닝 설계
17강계층 구조 모델링
18강이력·시간 모델링
19강유연한 구조: 다형 관계, EAV, JSON
20강차원 모델링
21강멀티테넌시 설계
22강스키마 진화
23강도메인 경계와 DB 분리
24강설계 리뷰 방법론
25강종합 실습
COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..