14.1 스키마 안티패턴
데이터 모델은 운영 비용을 결정짓는 가장 큰 변수입니다. 한 번 잘못 잡으면 10년의 운영 비용이 따라옵니다. 자주 보는 스키마 안티패턴과 대안을 정리합니다.
1. EAV (Entity-Attribute-Value)
-- 안티패턴
CREATE TABLE attributes (
entity_id int,
key text,
value text -- 모든 타입을 문자열로
);장점:
- 동적 스키마
단점:
- 타입 검증 불가 —
value가 항상 text - 인덱스 비대 — 어떤 key를 인덱싱할지 결정 어려움
- JOIN 폭증 — 한 entity의 10 속성 = 10 join
- 통계·옵티마이저 작동 안 함
대안:
- 알려진 속성은 컬럼으로
- 가변 속성은 JSONB (구조화된 동적 데이터)
CREATE TABLE items (
id bigserial PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
attrs jsonb DEFAULT '{}'::jsonb
);
CREATE INDEX ON items USING gin (attrs jsonb_path_ops);2. 모든 것을 한 JSONB에 (반대 극단)
-- 안티패턴
CREATE TABLE users (
id bigserial PRIMARY KEY,
data jsonb NOT NULL -- name·email·created_at·preferences 모두
);단점:
- 인덱싱 비효율 — 자주 쓰는 컬럼도 GIN 거쳐야
- 통계 부정확
- 타입 검증 약함 (CHECK constraint로 일부)
- ORM·BI 도구 친화 ↓
대안: 자주 쓰는 핵심 컬럼은 별로 두고, 가변 부분만 JSONB.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL,
created_at timestamptz DEFAULT now(),
preferences jsonb DEFAULT '{}'::jsonb
);3. UUID v4를 PK로
-- 안티패턴
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
...
);단점:
- random insert → B-tree 페이지 분할 폭증
- BLOAT 빨리 자람
- 인덱스 크기 큼 (16 byte vs int 4 byte)
- correlation 0 — BRIN 불가능
대안:
- bigserial /
IDENTITY GENERATED ALWAYS(정수, 순차) 우선 - 외부 노출이 필요하면 UUIDv7(시간 기반, PG 18 native), ULID — 순차성 유지
- UUID 꼭 써야 하면
uuid_generate_v7(ext) 또는 v1
PG 18부터 uuidv7() 함수 추가.
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- PG 18+
...
);4. NULL을 두려워하기 — 빈 문자열·0 대체
-- 안티패턴
phone text DEFAULT ''
last_login timestamptz DEFAULT '1970-01-01'단점:
- 의미 모호 — 값 없음 vs 값 있음
- index에서 partial 활용 불가
- 통계 분포 이상
대안: 진짜 NULL을 쓰고, 필요한 곳만 partial index.
CREATE INDEX ON users(last_login) WHERE last_login IS NOT NULL;5. boolean 대신 enum text
-- 안티패턴
status text -- 'active' / 'inactive' / 'pending' / 'paid' / ...다양한 값 + 자유 입력 = 오타. 대문자/소문자 혼재·새 값 추가가 코드 변경 없이 가능합니다.
대안: ENUM 타입 또는 CHECK constraint.
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'cancelled', 'refunded');
CREATE TABLE orders (
status order_status NOT NULL DEFAULT 'pending'
);ENUM 단점: 값 제거가 ALTER TYPE으로 어렵습니다. 변경 잦으면 lookup table:
CREATE TABLE order_statuses (
status text PRIMARY KEY,
description text
);
ALTER TABLE orders ADD CONSTRAINT fk_status FOREIGN KEY (status) REFERENCES order_statuses(status);6. 자연키 vs 대리키
-- 안티패턴 (일부)
CREATE TABLE products (
sku text PRIMARY KEY, -- 자연키
...
);자연키 단점:
- 변경 시 모든 FK 갱신 (cascade로 처리되지만 비용)
- 길어서 인덱스 비대
- 외부 노출 시 정보 누출
대안: bigserial 대리키 + 자연키에 UNIQUE 제약.
CREATE TABLE products (
id bigserial PRIMARY KEY,
sku text NOT NULL UNIQUE,
...
);7. 매우 wide 테이블 (수십~수백 컬럼)
장단점이 있지만:
단점:
- HOT update 어려움 — 한 컬럼 갱신도 페이지 가득
- 한 row 크기 폭증
- SELECT 모든 컬럼 → 메모리 폭증
대안: 자주 함께 쓰는 컬럼 묶음으로 분리, vertical partitioning.
-- 사용자 기본
CREATE TABLE users (
id bigserial PRIMARY KEY, name text, email text, ...
);
-- 자주 갱신되는 별 테이블
CREATE TABLE user_metrics (
user_id bigint PRIMARY KEY REFERENCES users(id),
last_login timestamptz,
login_count int,
...
);8. timestamptz vs timestamp
-- 안티패턴
created_at timestamp -- timezone 없음PostgreSQL은 timezone aware가 표준입니다. timestamp 사용 시 timezone 정보 누락 + 비교 시 혼란.
대안: 항상 timestamptz.
created_at timestamptz NOT NULL DEFAULT now()9. 외래 키 회피
-- 안티패턴
order_id bigint NOT NULL -- FK 없음, app에서 검증단점:
- 무결성 깨질 가능성
- cascade delete 등 활용 못 함
- 옵티마이저 plan에 join 정보 부족
대안: 정말 쓰기 성능 이슈가 있을 때만 FK 생략. 대부분의 OLTP는 FK 부담 무시할 만함.
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE10. 컬럼 순서 무시 — 정렬 패딩 손해
PostgreSQL은 컬럼 정렬 규칙에 따라 패딩을 넣습니다. 8-byte → 4-byte → 1-byte 순서면 패딩 최소화.
-- 안티패턴
CREATE TABLE bad (
flag boolean, -- 1B
big bigint, -- 8B (7B 패딩 추가)
small smallint, -- 2B
another bigint -- 8B (6B 패딩 추가)
);
-- 총 8 + 8 + 2 + 6 + 8 = 32B 가까이
-- 권장
CREATE TABLE good (
big bigint,
another bigint,
small smallint,
flag boolean
);
-- 총 8 + 8 + 2 + 1 + 패딩 = 약 24B작은 절약이지만 수억 row면 GB 단위 차이입니다.
11. soft delete + 인덱스 미사용
deleted_at timestamptz
-- 모든 쿼리에 WHERE deleted_at IS NULL99%가 NULL인데 full 인덱스를 두면 비효율.
대안: partial.
CREATE INDEX ON users(email) WHERE deleted_at IS NULL;unique constraint도 partial:
CREATE UNIQUE INDEX ON users(email) WHERE deleted_at IS NULL;
-- 동일 email로 재가입 가능 (옛 row는 deleted_at != NULL)12. 너무 많은 인덱스
10개 이상 인덱스는 대부분 안티패턴. INSERT 비용 폭증 + HOT update 거의 불가능 + BLOAT 빠릅니다.
대안: WHERE 패턴 분석 후 꼭 필요한 인덱스만. 정기 점검(6.7)으로 정리합니다.
정리
- EAV는 거의 항상 안티 — 컬럼 + JSONB 조합으로 대체
- UUIDv4 PK는 성능 큰 손해. v7 또는 bigserial 권장
- NULL을 두려워 말 것 — partial index 활용
- 자유 텍스트 status는 ENUM/lookup
- 자연키 + 대리키 조합
- timestamp 대신 timestamptz
- 외래 키는 가능하면 사용
- 컬럼 순서로 정렬 패딩 줄임
- soft delete는 partial unique
- 인덱스는 필요한 만큼
다음 절(14.2)에서는 쿼리 작성의 흔한 함정 — 쿼리 안티패턴을 봅니다.