본문으로 건너뛰기

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 CASCADE

10. 컬럼 순서 무시 — 정렬 패딩 손해

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 NULL

99%가 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)에서는 쿼리 작성의 흔한 함정 — 쿼리 안티패턴을 봅니다.