앞의 두 강이 구조가 정해져 있을 때의 모델링이었다면, 이번에는 구조 자체가 고정되지 않는 경우를 다룹니다.
1. 문제 상황
- 첨부파일·코멘트가 주문항목, 검수, 출고지시 여러 엔티티에 붙는다
- 상품 유형(의류, 전자, 도서)마다 관리 속성이 다르고 자주 추가된다
- 외부 시스템에서 받은 상품 데이터의 형식이 서로 다르다
2. 다형 관계 (Polymorphic Association)
-- 안티패턴 형태
CREATE TABLE attachment (
attachment_id BIGINT PRIMARY KEY,
target_type VARCHAR(20) NOT NULL, -- 'ORDER_ITEM', 'INSPECTION', 'PICK_ORDER'
target_id BIGINT NOT NULL, -- FK를 걸 수 없음
file_path VARCHAR(500) NOT NULL
);문제: target_id가 어느 테이블을 참조하는지 DB가 모르므로 FK를 걸 수 없고, 고아 레코드가 생기며, 조인할 때마다 CASE나 UNION이 필요합니다.
대안 1: 대상별 교차 테이블
CREATE TABLE item_attachment (item_id BIGINT REFERENCES order_item, attachment_id BIGINT REFERENCES attachment, PRIMARY KEY (item_id, attachment_id));
CREATE TABLE inspection_attachment (inspection_id BIGINT REFERENCES inspection, attachment_id BIGINT REFERENCES attachment, PRIMARY KEY (inspection_id, attachment_id));대안 2: 배타적 아크 (Exclusive Arc)
CREATE TABLE attachment (
attachment_id BIGINT PRIMARY KEY,
item_id BIGINT REFERENCES order_item(item_id),
inspection_id BIGINT REFERENCES inspection(inspection_id),
pick_order_id BIGINT REFERENCES pick_order(pick_order_id),
file_path VARCHAR(500) NOT NULL,
CHECK (num_nonnulls(item_id, inspection_id, pick_order_id) = 1) -- PG
);MySQL·Oracle·SQL Server에서는 (CASE WHEN item_id IS NOT NULL THEN 1 ELSE 0 END + ...) = 1 형태로 작성합니다.
대안 3: 공통 슈퍼타입 주문항목, 검수, 출고지시를 "첨부 가능 대상(attachable)" 슈퍼타입의 서브타입으로 두고, attachment는 슈퍼타입을 참조합니다 (11강 전략 C).
| 대안 | FK 보장 | 대상 추가 시 | 적합한 경우 |
|---|---|---|---|
| 교차 테이블 | O | 테이블 추가 | 대상이 적당히 많고 N:M |
| 배타적 아크 | O | 컬럼 추가 | 대상이 소수로 고정 |
| 공통 슈퍼타입 | O | 서브타입 연결 | 대상들이 공통 식별 체계를 가질 수 있음 |
3. EAV (Entity-Attribute-Value)
CREATE TABLE product_attr (
product_id BIGINT NOT NULL,
attr_name VARCHAR(50) NOT NULL,
attr_value VARCHAR(4000),
PRIMARY KEY (product_id, attr_name)
);문제
| 항목 | 설명 |
|---|---|
| 타입 | 모든 값이 문자열 → 숫자 비교, 날짜 계산 불가 |
| 제약 | NOT NULL, CHECK, FK를 속성별로 걸 수 없음 |
| 조회 | 속성 N개를 한 행으로 만들려면 N번 조인 또는 피벗 |
| 옵티마이저 | 통계가 속성 구분 없이 섞여 계획이 부정확 |
EAV가 정당한 경우
- 속성 정의를 최종 사용자가 런타임에 추가하는 제품 (설정형 폼, 의료 관찰 데이터 같은 도메인)
- 속성이 수천 종이고 행마다 극히 일부만 사용
이때도 속성 정의 테이블(타입, 필수 여부, 허용값)을 두고 값 컬럼을 타입별로 나누면 문제를 줄일 수 있습니다.
CREATE TABLE attr_def (
attr_id INT PRIMARY KEY,
attr_name VARCHAR(50) NOT NULL UNIQUE,
data_type VARCHAR(10) NOT NULL CHECK (data_type IN ('NUM','TEXT','DATE'))
);
CREATE TABLE product_attr (
product_id BIGINT NOT NULL,
attr_id INT NOT NULL REFERENCES attr_def,
num_val NUMERIC,
text_val VARCHAR(4000),
date_val DATE,
PRIMARY KEY (product_id, attr_id),
CHECK (num_nonnulls(num_val, text_val, date_val) = 1)
);4. JSON 컬럼
-- PostgreSQL
CREATE TABLE product (
product_id BIGINT PRIMARY KEY,
product_type VARCHAR(20) NOT NULL,
weight_kg NUMERIC(8,2) NOT NULL,
spec JSONB NOT NULL DEFAULT '{}'
);
-- 포함 검색 인덱스
CREATE INDEX ix_product_spec ON product USING gin (spec jsonb_path_ops);
SELECT * FROM product WHERE spec @> '{"color": "BLACK"}';
-- 특정 키 조회용 표현식 인덱스
CREATE INDEX ix_product_spec_size ON product (((spec->>'size')::numeric));-- MySQL 8.0: 생성 컬럼 또는 함수 인덱스로 JSON 필드 인덱싱
ALTER TABLE product
ADD COLUMN color VARCHAR(20) GENERATED ALWAYS AS (spec->>'$.color') VIRTUAL,
ADD INDEX ix_product_color (color);JSON 스키마 검증
-- PostgreSQL: 최소 구조 검증
ALTER TABLE product ADD CONSTRAINT ck_spec_type
CHECK (jsonb_typeof(spec) = 'object'
AND (NOT spec ? 'size' OR jsonb_typeof(spec->'size') = 'number'));MySQL 8.0.17+은 JSON_SCHEMA_VALID()를 CHECK에 사용할 수 있습니다.
5. 컬럼으로 뺄 것 vs JSON에 둘 것
| 기준 | 정규 컬럼 | JSON |
|---|---|---|
| 모든 행에 존재 | O | |
| 조인·FK 대상 | O | |
| 집계·정렬·범위 검색의 주 대상 | O | |
| 업무 규칙(NOT NULL, CHECK) 필요 | O | 제한적 |
| 유형별로 다르고 조회만 함 | O | |
| 외부에서 받은 원본 보관 | O | |
| 스키마 변경이 잦음 | O |
하이브리드가 정답인 경우가 많습니다. 핵심 속성은 컬럼, 유형별·부가 속성은 JSON. 그리고 JSON 속성이 자주 검색되기 시작하면 컬럼으로 승격합니다 (22강 스키마 진화).
6. JSON의 물리적 비용
- PostgreSQL JSONB는 큰 문서를 TOAST로 분리 저장합니다. 작은 필드 하나를 갱신해도 문서 전체를 다시 씁니다 → 쓰기 증폭, WAL 증가, MVCC dead tuple 크기 증가
- 통계가 JSON 내부 값에는 기본적으로 없으므로 선택도 추정이 부정확합니다.
- 자주 갱신되는 카운터나 상태를 JSON 안에 두지 않습니다.
19강 정리
- 다형 관계는 교차 테이블, 배타적 아크, 공통 슈퍼타입으로 FK를 되찾는다.
- EAV는 런타임 속성 정의가 필수인 경우에만, 속성 정의 테이블과 함께 쓴다.
- JSON은 핵심 속성이 아닌 부가·유형별 속성에 쓰고, 검색이 늘면 컬럼으로 승격한다.
- JSON 부분 갱신은 문서 전체 재작성이므로 갱신이 잦은 값은 컬럼에 둔다.