본문으로 이동
·27분 읽기

데이터베이스 스키마 설계: 시간이 지나도 견디는 패턴들

정규화 규칙, 명명 규칙, 소프트 삭제, 감사 추적, 멀티테넌시 패턴, 버전 관리 전략, 그리고 후회한 스키마 결정들. PostgreSQL 중심.

공유:X / TwitterLinkedIn

스키마는 미래의 자신과 맺는 계약이다. 추가하는 모든 컬럼, 건너뛰는 모든 제약 조건, "나중에 정리하겠다"는 모든 결정이 복리로 쌓인다. 3년 전의 잘못된 스키마 결정 하나 때문에 매 분기마다 팀이 우회 작업에 풀 스프린트를 소비하는 시스템에서 일한 적이 있다.

데이터베이스는 모든 것보다 오래 산다. 프론트엔드 프레임워크는 바뀔 것이다. API 계층은 다시 작성될 것이다. 배포 전략은 진화할 것이다. 하지만 데이터는? 데이터는 남는다. 첫날 정해준 형태가 영원히 따라다닌다. 2억 행이 있는 테이블을 마이그레이션하는 것은 React 컴포넌트를 리팩토링하는 것과는 차원이 다르기 때문이다.

이 글은 나중에 후회하지 않을 스키마 결정을 내리는 법에 대해 내가 배운 것들이다. PostgreSQL 중심인데, 내가 쓰고 신뢰하는 DB이기 때문이다. 하지만 대부분의 패턴은 어떤 관계형 데이터베이스에도 적용된다.

명명 규칙: 지루하지만 가장 중요한 것#

명명 규칙에 대한 논쟁을 실제 아키텍처 결정에 대한 논쟁보다 더 많이 봤다. 수년간 프로젝트를 오가며 정착한 규칙은 이렇다:

모든 곳에 snake_case. 테이블, 컬럼, 인덱스, 제약 조건. camelCase도 PascalCase도 안 된다. PostgreSQL은 따옴표 없는 식별자를 어차피 소문자로 변환하기 때문에 createdAt은 어디서나 쌍따옴표로 감싸지 않으면 createdat이 된다. 데이터베이스와 싸우지 마라.

테이블 이름은 복수형. 테이블에는 여러 행이 들어간다. user가 아니라 users. order가 아니라 orders. order_item이 아니라 order_items. 쿼리에서 자연스럽게 읽힌다: SELECT * FROM users WHERE ... — 컬렉션에서 조회하는 것이다.

외래 키에는 _id 접미사. user_id, order_id, tenant_id. 기본 키는 그냥 id. 모호하지 않다. 테이블을 조인할 때 users.id = orders.user_id가 영어처럼 읽힌다.

타임스탬프에는 _at 접미사. created_at, updated_at, deleted_at, published_at, expires_at. 시점이라는 것을 항상 알 수 있다.

불리언에는 is_ 접두사. is_active, is_verified, is_published. 소유 관계 불리언에 has_를 쓰는 사람도 있지만(has_mfa_enabled), 나는 단순하게 모든 곳에 is_를 쓴다.

비정규화된 카운터에는 _count 접미사. comment_count, follower_count. 실시간 계산이 아니라 캐시된 숫자라는 것을 명확히 한다.

핵심은 이것이다: 일관성이 완벽함을 이긴다. email_address로 할지 email로 할지 email_addr로 할지 논쟁하며 몇 주를 보내는 팀을 봤다. 하나의 패턴을 정하고 모든 곳에 적용하라. 최악의 명명 규칙은 일관성 없이 적용된 규칙이다.

sql
-- Good: consistent, readable, no surprises
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    login_count     INTEGER NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Bad: inconsistent casing, mixed conventions, ambiguous names
CREATE TABLE User (
    UserID          SERIAL PRIMARY KEY,
    EmailAddress    VARCHAR(255),
    name            VARCHAR(100),
    passwd          VARCHAR(255),
    active          BOOLEAN,
    verified        INT,             -- is this 0/1? a verification level?
    lastLogin       TIMESTAMP,       -- camelCase in a snake_case world
    created         TIMESTAMP        -- created what? when?
);

한 가지 더: 예약어를 컬럼 이름으로 쓰지 마라. type, order, user, group, table — 따옴표로 감싸면 다 동작하지만, ORM, 쿼리 빌더, 그리고 모든 동적 SQL 생성 도구에서 문제를 일으킨다. type 대신 kind를, order 대신 sort_order를 써라. 미래의 자신이 감사할 것이다.

표준 컬럼: 모든 테이블이 갖춰야 할 것#

내 스키마에서 모든 테이블은 동일한 뼈대로 시작한다. 예외 없이.

sql
CREATE TABLE things (
    id          BIGSERIAL PRIMARY KEY,
    -- ... domain columns ...
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

UUID vs BIGSERIAL 논쟁#

실제보다 훨씬 많은 논쟁을 불러일으키는 결정 중 하나다. 실제 트레이드오프는 이렇다:

BIGSERIAL (자동 증가 정수):

  • 8바이트. 컴팩트하다. 인덱싱과 조인이 빠르다.
  • 순서가 있다 — id로 정렬하면 삽입 순서를 얻는다.
  • 예측 가능하다 — 사용자가 자신의 ID를 증가시켜 다른 ID를 추측할 수 있다.
  • 분산 시스템에서 잘 동작하지 않는다(조정이 필요하다).

UUID v4 (랜덤):

  • 16바이트. 인덱스가 크고 조인이 느리다(하지만 병목이 되는 경우는 드물다).
  • 예측 불가능하다 — 정보 누출이 없다.
  • 분산 시스템에서 조정 없이 동작한다.
  • B-tree 인덱스 지역성이 매우 나쁘다 — 랜덤 UUID가 인덱스를 단편화한다.

UUID v7 (시간 정렬, RFC 9562):

  • 16바이트이지만 시간순으로 정렬되므로 B-tree 지역성이 우수하다.
  • 외부 사용에 충분히 예측 불가능하다.
  • 비교적 새롭지만, PostgreSQL 17+에는 gen_random_uuid()가 있고 확장을 통해 uuid_generate_v7()을 쓸 수 있다.

현재 내 입장: 내부 테이블에는 BIGSERIAL, 외부에 노출되는 모든 것에는 UUID v7. ID가 URL, API 응답, 또는 웹훅 페이로드에 나타나면 UUID를 써라. 사용자가 절대 보지 않는 순수 조인 테이블이라면 BIGSERIAL이면 충분하다.

sql
-- For an API-facing resource
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- For an internal mapping table
CREATE TABLE project_members (
    id          BIGSERIAL PRIMARY KEY,
    project_id  UUID NOT NULL REFERENCES projects(id),
    user_id     UUID NOT NULL REFERENCES users(id),
    role        TEXT NOT NULL DEFAULT 'member',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (project_id, user_id)
);

타임스탬프: 항상 TIMESTAMPTZ#

TIMESTAMP이 아니라 TIMESTAMPTZ를 써라. "TZ" 버전은 값을 UTC로 저장하고 세션 시간대에 따라 읽을 때 변환한다. TZ가 없는 버전은 주어진 대로 시간대 컨텍스트 없이 저장한다 — 즉, 서로 다른 시간대에 있는 두 서버가 같은 테이블에 쓰면 조용한 데이터 손상이 발생한다.

sql
-- Always this
created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
-- Never this
created_at  TIMESTAMP NOT NULL DEFAULT NOW()

updated_at 트리거#

PostgreSQL에는 MySQL의 ON UPDATE CURRENT_TIMESTAMP가 없다. 트리거가 필요하다:

sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Apply to every table that has updated_at
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
 
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON projects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

그렇다, 테이블마다 하나의 트리거가 필요하다. 번거롭긴 하다. 테이블을 추가할 때 자동으로 트리거를 생성하는 마이그레이션 헬퍼를 작성하라. 그럴 만한 가치가 있다. 대안은 애플리케이션 전체의 모든 UPDATE 쿼리에서 updated_at = NOW()를 기억하는 것인데 — 분명 잊게 될 것이다.

정규화: 규칙을 깰 때#

모든 CS 과정에서 3NF(제3 정규형)까지 정규화를 가르친다. 규칙은 이렇다:

  • 1NF: 모든 컬럼은 단일 원자 값을 보유한다. 배열도, 쉼표로 구분된 목록도 안 된다.
  • 2NF: 모든 비키 컬럼은 전체 기본 키에 의존한다(복합 키에 관련됨).
  • 3NF: 이행적 의존성이 없다. 컬럼 A가 컬럼 B를 결정하고, B가 C를 결정한다면, C는 A와 같은 테이블에 있으면 안 된다.

실제로는 트랜잭션 테이블에 3NF가 최적점이다. 거기서 시작하고 구체적이고 측정 가능한 이유가 있을 때만 벗어나라.

제대로 정규화된 주문 시스템은 이렇다:

sql
CREATE TABLE customers (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL UNIQUE,
    display_name    TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    sku             TEXT NOT NULL UNIQUE,
    name            TEXT NOT NULL,
    price_cents     INTEGER NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT NOT NULL REFERENCES customers(id),
    status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
    total_cents     INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE order_items (
    id              BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    quantity        INTEGER NOT NULL CHECK (quantity > 0),
    unit_price_cents INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

order_itemsunit_price_cents를 주목하라. 이것은 의도적인 비정규화다. 주문 시점의 가격을 스냅샷으로 저장하는 것인데, 상품의 가격이 나중에 바뀔 수 있기 때문이다. 이것은 가장 흔하고 올바른 비정규화 이유 중 하나다.

비정규화가 올바른 선택일 때#

리포팅 테이블. 분석 대시보드가 렌더링하려면 8개 테이블을 조인해야 한다면, 비정규화된 리포팅 테이블을 만들고 백그라운드 작업으로 채워라. 트랜잭션 스키마는 깔끔하게 유지하고, 리포팅 쿼리는 빠르게 유지한다.

sql
-- Denormalized reporting table, populated by a cron job
CREATE TABLE order_reports (
    id                  BIGSERIAL PRIMARY KEY,
    order_id            BIGINT NOT NULL,
    customer_email      TEXT NOT NULL,
    customer_name       TEXT NOT NULL,
    product_names       TEXT[] NOT NULL,        -- array is fine here
    item_count          INTEGER NOT NULL,
    total_cents         INTEGER NOT NULL,
    order_status        TEXT NOT NULL,
    ordered_at          TIMESTAMPTZ NOT NULL,
    report_generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Materialized View. PostgreSQL의 MATERIALIZED VIEW는 과소평가되어 있다. 필요할 때 새로 고칠 수 있는 비정규화된 스냅샷이다. 대시보드에 완벽하다.

sql
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
    DATE_TRUNC('month', o.created_at) AS month,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(o.total_cents) AS revenue_cents,
    COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
 
-- Refresh it nightly
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

동적 속성을 위한 JSON 컬럼. 서로 다른 행이 서로 다른 필드가 필요할 때 — 상품 변형, 폼 제출, 사용자 환경설정 — JSONB 컬럼이 악몽 같은 EAV(Entity-Attribute-Value) 테이블보다 나은 경우가 많다. 이에 대해서는 뒤에서 더 다룬다.

절대 깨지 않는 한 가지 규칙#

원본 데이터 테이블은 절대 비정규화하지 마라. 복사본, 스냅샷, 리포트, 캐시를 비정규화하라. 정식 데이터는 정규화된 상태를 유지한다. 비정규화된 복사본이 오래되거나 손상되면(그리고 그렇게 될 것이다) 정규화된 원본에서 재구축한다.

외래 키와 제약 조건: 작성할 필요 없는 최고의 코드#

외래 키를 건너뛰는 모든 변명을 들어봤다. "쓰기가 느려진다." "애플리케이션에서 처리한다." "유연성이 필요하다."

전부 틀렸다.

외래 키는 스키마에 추가할 수 있는 가장 영향력 있는 것이다. 애플리케이션 코드로는 절대 잡을 수 없는 전체 범주의 버그를 방지한다 — 경쟁 조건, 부분 실패, 실패한 트랜잭션으로 인한 고아 행. 외래 키는 스토리지 수준에서 적용되는 데이터베이스 엔진 자체의 보장이다. ORM의 beforeDelete 훅은 제안일 뿐이다.

sql
-- Always do this
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id);
 
-- The "we'll handle it in code" alternative:
-- Hope. Hope is not a strategy.

ON DELETE 전략#

여기서부터 미묘해진다. 부모 행을 삭제하면 무슨 일이 벌어지는가?

RESTRICT (기본값): 자식 행이 있으면 삭제가 실패한다. 대부분의 관계에서 이것을 써라. 주문이 있는 고객은 삭제할 수 없다 — 이것이 스키마에 인코딩된 비즈니스 로직이다.

sql
-- Customer can't be deleted while they have orders
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id)
    ON DELETE RESTRICT;

CASCADE: 자식 행이 자동으로 삭제된다. 아껴서 의도적으로 써라. 부모 없이 자식이 의미가 없는 "일부분" 관계에 좋다.

sql
-- Deleting an order deletes its line items — they're meaningless alone
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id) REFERENCES orders(id)
    ON DELETE CASCADE;
 
-- Deleting a project deletes its memberships
ALTER TABLE project_members
    ADD CONSTRAINT fk_project_members_project
    FOREIGN KEY (project_id) REFERENCES projects(id)
    ON DELETE CASCADE;

SET NULL: 외래 키 컬럼이 NULL로 설정된다. 관계가 선택적이고 자식 행이 단독으로도 의미가 있을 때 써라.

sql
-- If a manager leaves, their reports still exist — just unassigned
ALTER TABLE employees
    ADD CONSTRAINT fk_employees_manager
    FOREIGN KEY (manager_id) REFERENCES employees(id)
    ON DELETE SET NULL;

내 경험칙: 기본은 RESTRICT, 구성 관계에는 CASCADE, 선택적 연관에는 SET NULL. 확실하지 않으면 RESTRICT가 항상 안전한 선택이다 — 제약을 완화하는 것이 삭제된 데이터를 복구하는 것보다 쉽다.

Check 제약 조건: 저렴한 보험#

Check 제약 조건은 쓰기 시 거의 비용이 들지 않으며 쓰레기 데이터가 시스템에 영원히 들어오는 것을 방지한다:

sql
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    discount_pct    NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
    weight_kg       NUMERIC(10,3) CHECK (weight_kg > 0),
    status          TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

추가하는 모든 check 제약 조건은 프로덕션에서 디버그할 필요가 없는 검증 버그 하나다. 데이터베이스가 최후의 방어선이다. 활용하라.

유니크 제약 조건과 부분 유니크 인덱스#

유니크 제약 조건은 단순한 경우에 직관적이다:

sql
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

하지만 부분 인덱스 — 특정 행에만 적용되는 유니크 제약 조건 — 와 함께 쓰면 흥미로워진다:

sql
-- Only one active subscription per user (but they can have many cancelled ones)
CREATE UNIQUE INDEX uq_active_subscription
    ON subscriptions (user_id)
    WHERE status = 'active';
 
-- Only one primary address per user
CREATE UNIQUE INDEX uq_primary_address
    ON addresses (user_id)
    WHERE is_primary = true;

이것은 PostgreSQL의 킬러 기능 중 하나다. 활용하라.

소프트 삭제: 모두가 미워하면서도 좋아하는 패턴#

소프트 삭제는 개념이 단순하다: DELETE FROM users WHERE id = 42 대신 UPDATE users SET deleted_at = NOW() WHERE id = 42를 한다. 행은 데이터베이스에 남지만 일반 쿼리에서 필터링된다.

sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT NOT NULL,
    display_name TEXT NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- The partial index: filter out deleted rows efficiently
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;

이제 애플리케이션의 모든 쿼리에 WHERE deleted_at IS NULL이 필요하다. 모든. 단일. 쿼리에. ORM이 도움이 된다(Prisma에는 미들웨어가 있고, Drizzle에는 .where(isNull(deletedAt))가 있다). 하지만 모든 읽기 작업에 대한 세금이다. 한 번이라도 빠뜨리면 사용자에게 "삭제된" 데이터를 보여주게 된다.

유니크 제약 조건 문제#

소프트 삭제가 지저분해지는 지점이다. UNIQUE (email) 제약 조건이 있고 사용자가 계정을 소프트 삭제하면, 이메일이 여전히 테이블에 있다. 같은 이메일로 재가입할 수 없다. 그 이메일을 가진 새로운 사용자도 가입할 수 없다.

해결책은 부분 유니크 인덱스다:

sql
-- Email must be unique, but only among non-deleted users
CREATE UNIQUE INDEX uq_users_email_active
    ON users (email)
    WHERE deleted_at IS NULL;

동작하긴 하지만, 이제 소프트 삭제 가능한 모든 테이블의 모든 유니크 컬럼에 대해 이 패턴을 기억해야 한다. 규율만 있으면 관리할 수 있지만, 스스로 떠안기로 선택한 복잡성이다.

외래 키 문제#

소프트 삭제는 외래 키와 잘 어울리지 않는다. orders.user_idON DELETE RESTRICTusers.id를 참조하고, 사용자를 소프트 삭제하면... 아무 일도 일어나지 않는다. 실제로 행을 삭제하지 않았기 때문에 FK가 발동하지 않는다. 사용자는 애플리케이션 관점에서는 "사라졌지만" 데이터베이스에는 여전히 존재한다.

이는 애플리케이션 코드가 참조된 엔티티가 소프트 삭제된 경우를 처리해야 한다는 뜻이다. 모든 조인, 모든 조회, 외래 키를 따라갈 때마다 — 참조된 테이블에서도 deleted_at IS NULL을 확인해야 한다. 아니면 하지 않고, 애플리케이션이 "삭제된 사용자의 주문"을 표시하게 되는데, 누구한테 물어보느냐에 따라 버그이기도 하고 기능이기도 하다.

내 접근법#

소프트 삭제는 복구가 비즈니스 요구사항인 사용자 대면 엔티티에만 쓴다 — 사용자 계정, 프로젝트, 문서. 지원 담당자가 삭제를 복원해야 할 수 있는 것들. 나머지는 감사 추적을 곁들인 하드 삭제를 쓴다(다음에 자세히 다룬다).

sql
-- Soft delete: user-facing, recoverable
CREATE TABLE documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
 
-- Hard delete: internal, not recoverable from UI (but audited)
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    key_hash    TEXT NOT NULL,
    last_used_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- When deleted, it's gone. The audit_log records that it existed.

감사 추적: 무엇이 바뀌었고 누가 했는지 파악하기#

모든 비자명한 애플리케이션은 어느 시점에서 "이 레코드에 무슨 일이 일어났는가?"에 대한 답이 필요하다. 감사 추적은 애플리케이션 로그를 뒤지지 않고도 그 답을 제공하는 방법이다.

별도의 감사 테이블 패턴#

가장 간단한 접근법: 모든 변경을 기록하는 단일 audit_log 테이블.

sql
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,          -- text to handle both UUID and BIGINT PKs
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,                  -- NULL for INSERT
    new_values      JSONB,                  -- NULL for DELETE
    changed_fields  TEXT[],                 -- which columns changed (for UPDATE)
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT
);
 
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

과제는 이것을 채우는 것이다. 애플리케이션 코드에서 할 수 있고(명시적이지만 잊기 쉬움), 트리거로도 할 수 있다(자동이지만 changed_by 같은 컨텍스트를 전달하기 어려움).

트리거 기반 접근법#

트리거는 원시 SQL이나 데이터베이스 관리 작업을 포함한 모든 변경을 자동으로 캡처한다:

sql
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    old_data JSONB;
    new_data JSONB;
    changed  TEXT[];
    col      TEXT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        old_data := to_jsonb(OLD);
        INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
        VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
        RETURN OLD;
    ELSIF TG_OP = 'UPDATE' THEN
        old_data := to_jsonb(OLD);
        new_data := to_jsonb(NEW);
        -- Find changed fields
        FOR col IN SELECT key FROM jsonb_each(new_data)
        LOOP
            IF old_data->col IS DISTINCT FROM new_data->col THEN
                changed := array_append(changed, col);
            END IF;
        END LOOP;
        -- Only log if something actually changed
        IF array_length(changed, 1) > 0 THEN
            INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
            VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
        END IF;
        RETURN NEW;
    ELSIF TG_OP = 'INSERT' THEN
        new_data := to_jsonb(NEW);
        INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
        VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
 
-- Apply to tables you want audited
CREATE TRIGGER audit_users
    AFTER INSERT OR UPDATE OR DELETE ON users
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
 
CREATE TRIGGER audit_orders
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();

단점: 트리거는 어떤 애플리케이션 사용자가 변경했는지 모른다. 세션 변수로 우회할 수 있다:

sql
-- In your application, before the query:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- In the trigger function:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

동작하긴 하지만 취약하다는 느낌이다. 실제로는 하이브리드 접근법을 쓴다: 데이터 캡처에는 트리거를, 세션 컨텍스트 설정에는 애플리케이션 코드를 쓴다.

히스토리 테이블 패턴#

단순히 "무엇이 바뀌었는가"가 아니라 "시점 T에서의 상태는 무엇이었는가"라는 전체 버전 히스토리가 필요한 테이블에는, 전용 히스토리 테이블이 더 깔끔하다:

sql
CREATE TABLE documents (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL DEFAULT 1,
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE document_history (
    id              BIGSERIAL PRIMARY KEY,
    document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL,
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (document_id, version)
);

documents에 업데이트하기 전에 현재 상태를 document_history에 복사하고 버전을 증가시킨다. 이제 어떤 시점에서든 문서를 재구성할 수 있고, 버전 간 차이를 보여줄 수 있고, 이전 버전을 복원할 수도 있다.

트레이드오프는 저장 공간이다. content 컬럼이 크고 자주 변경되면 히스토리 테이블이 빠르게 커질 수 있다. 대부분의 애플리케이션에서는 괜찮다 — 저장 공간은 저렴하고 필요하면 오래된 버전을 콜드 스토리지로 아카이브할 수 있다.

멀티테넌시: 세 가지 접근법, 고통을 골라라#

멀티테넌시는 처음에 추가하기 쉽고 나중에 추가하기 거의 불가능한 것 중 하나다. 애플리케이션이 여러 조직을 서비스할 가능성이 조금이라도 있다면, 첫날부터 구축하라.

행 수준: 모든 테이블에 tenant_id#

가장 일반적인 접근법. 모든 테이블에 tenant_id 컬럼이 있고, 모든 쿼리가 이를 기준으로 필터링한다.

sql
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    slug        TEXT NOT NULL UNIQUE,
    plan        TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE tasks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    project_id  UUID NOT NULL REFERENCES projects(id),
    title       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'todo',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Every index should include tenant_id for query performance
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);

위험은 데이터 누출이다 — WHERE tenant_id = ...를 한 번 빠뜨리면 테넌트 A의 데이터를 테넌트 B에게 보여주게 된다. PostgreSQL의 Row-Level Security(RLS)는 이 범주의 버그를 제거한다:

sql
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Create a policy based on a session variable
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Force RLS even for table owners
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

이제 애플리케이션 코드가 WHERE tenant_id = ... 절을 잊더라도, PostgreSQL이 자동으로 추가한다. 이것이 심층 방어이며, 멀티테넌트 시스템에서 PostgreSQL을 선택하는 가장 강력한 근거 중 하나다.

sql
-- In your application's connection middleware:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
 
-- Now all queries on RLS-enabled tables are automatically filtered
SELECT * FROM projects;
-- PostgreSQL internally adds: WHERE tenant_id = 'tenant-uuid-here'

장점: 단일 데이터베이스, 간단한 운영, 효율적인 리소스 사용, 관리자를 위한 쉬운 교차 테넌트 쿼리. 단점: 규율(또는 RLS)이 필요, 모든 쿼리가 tenant_id를 다룸, 테넌트별 백업/복원이 더 어려움.

스키마 단위 테넌트#

각 테넌트가 자체 PostgreSQL 스키마를 갖는다. 모든 스키마가 같은 데이터베이스를 공유하지만, 테이블은 네임스페이스로 격리된다.

sql
-- Create a schema for each tenant
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
 
-- Tables live in the tenant's schema
CREATE TABLE tenant_acme.projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Set the search_path for the current tenant
SET search_path TO tenant_acme, public;
 
-- Now unqualified queries hit the tenant's schema
SELECT * FROM projects;  -- queries tenant_acme.projects

장점: 강력한 격리, 교차 테넌트 데이터 누출 위험 없음, 쉬운 테넌트별 백업/복원, 테넌트별 스키마 변형 가능. 단점: 스키마 마이그레이션 복잡성(N개 스키마를 마이그레이션해야 함), 커넥션 풀 관리, PostgreSQL은 ~10,000개 스키마 정도에서 실질적 한계가 있음.

데이터베이스 단위 테넌트#

각 테넌트가 자체 데이터베이스를 갖는다. 최대 격리.

장점: 완전한 격리, 독립적 확장, 쉬운 백업/복원, 대형 테넌트를 전용 하드웨어에 배치 가능. 단점: 커넥션 관리 악몽, 교차 테넌트 쿼리 불가능, 마이그레이션을 N번 실행해야 함, 상당한 운영 오버헤드.

어떤 것을 선택해야 하는가?#

대부분의 SaaS 애플리케이션: 행 수준 + RLS로 시작하라. 운영이 가장 간단하고, RLS가 대다수 사용 사례에 충분히 강한 격리를 제공한다. 계약상 격리 요구사항(엔터프라이즈 고객, 규제 산업)이 있을 때만 스키마 단위 테넌트로 이동하라. 데이터베이스 단위 테넌트는 물리적 격리를 반드시 보장해야 할 때를 위한 것이다 — 그때도 운영 부담을 대신 처리해주는 매니지드 데이터베이스를 고려하라.

JSON/JSONB 컬럼: 탈출구#

PostgreSQL의 JSONB는 놀랍다. 관계형 시스템 안에서 문서 데이터베이스의 유연성을 제공한다. 하지만 어떤 강력한 도구든 그렇듯, 남용하기 쉽다.

JSONB가 올바른 선택일 때#

동적 사용자 정의 속성. 각 상품 카테고리마다 다른 속성이 있는 전자상거래 플랫폼 — 신발에는 sizecolor, 전자제품에는 voltagewattage. 모든 가능한 속성에 대한 컬럼이나 EAV 테이블 대신:

sql
CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL,
    category_id     UUID NOT NULL REFERENCES categories(id),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    attributes      JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Example data:
-- Shoe: {"size": "10", "color": "black", "material": "leather"}
-- Laptop: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}

애플리케이션 설정과 구성. 사용자 환경설정, 기능 플래그, 알림 설정 — 형태가 자주 바뀌고 관계형 무결성이 필요 없는 것들.

sql
CREATE TABLE user_settings (
    user_id     UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    preferences JSONB NOT NULL DEFAULT '{
        "theme": "system",
        "notifications": {"email": true, "push": false},
        "locale": "en",
        "timezone": "UTC"
    }',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

웹훅 페이로드, API 응답, 이벤트 데이터. 외부 시스템이 스키마를 제어하고 예고 없이 변경될 수 있는 모든 것.

JSONB의 GIN 인덱스#

인덱스가 없으면 JSONB 내부 쿼리에 전체 테이블 스캔이 필요하다. GIN 인덱스가 빠르게 만들어준다:

sql
-- Index the entire JSONB column (good for @> containment queries)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Query: find all products with color = "black"
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
 
-- Query: find products with any of these attributes
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];

자주 쿼리하는 특정 경로에는 타겟 인덱스가 더 효율적이다:

sql
-- Index a specific path
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- Now this is a regular B-tree lookup
SELECT * FROM products
WHERE attributes->>'color' = 'black';

마이그레이션 함정#

JSONB가 물어뜯는 지점이다: JSON 내부 필드에 NOT NULL 제약 조건이나 기본값을 쉽게 추가할 수 없다. 상품 속성에 새로운 필수 필드를 추가하면 기존의 모든 행을 백필해야 한다. 일반 컬럼은 마이그레이션이 원자적으로 처리한다. JSONB에서는 모든 행을 건드리는 UPDATE를 작성하고, 백필이 완료될 때까지 애플리케이션 코드가 누락된 필드를 우아하게 처리하기를 바라야 한다.

sql
-- Adding a new column: clean, atomic, one statement
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
 
-- Adding a new JSONB field: messy, requires a backfill
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Plus update application code to handle missing weight_kg
-- Plus update validation logic
-- Plus update every API response that includes attributes

내 규칙: JSONB 필드를 WHERE 절에서 일주일에 두 번 이상 쿼리한다면, 아마 컬럼이어야 한다. JSONB는 훌륭한 탈출구다. 끔찍한 기본값이다.

스키마 마이그레이션: 새벽 3시에 프로덕션을 멈추지 마라#

스키마 마이그레이션은 이론이 현실을 만나는 곳이다. 스키마는 종이 위에서는 멋져 보이지만, 이제 업무 시간 중에 5천만 행이 있는 테이블을 다운타임 없이 변경해야 한다.

마이그레이션 도구#

대부분 써봤다. 간단한 평가:

Drizzle (TypeScript): 현재 내 취향. 코드로서의 스키마, 스키마에서 생성된 타입 안전 쿼리, 깔끔한 마이그레이션 SQL. 개발용 push 명령이 빠르다.

Prisma (TypeScript): 단순한 스키마에서 훌륭한 DX. 고급 PostgreSQL 기능(부분 인덱스, 커스텀 타입, RLS)에서 어려움. 마이그레이션 엔진이 놀라운 결정을 내릴 수 있다.

Flyway (Java/CLI): 견고하고, 전투에서 검증되었으며, SQL 우선. 원시 SQL 마이그레이션을 작성한다면 Flyway가 신뢰성 있게 추적한다. 마법도 없고 놀라움도 없다.

golang-migrate (Go/CLI): Flyway와 비슷하지만 더 가볍다. Go 프로젝트나 단순한 up/down 마이그레이션 러너만 원할 때 좋다.

무중단 문제#

가장 위험한 스키마 변경은 테이블을 잠그는 것이다. PostgreSQL에서 기본값이 있는 ALTER TABLE ... ADD COLUMN은 예전에는 다시 쓰는 동안 전체 테이블을 잠갔다. PostgreSQL 11부터 단순한 기본값(상수)은 메타데이터만 변경되어 즉시 완료된다. 하지만 다른 작업은 여전히 잠근다:

sql
-- SAFE: metadata-only, instant (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
 
-- DANGEROUS: rewrites the entire table, full table lock
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
 
-- DANGEROUS: scans the entire table to validate
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);

NOT NULL 컬럼을 안전하게 추가하기#

데이터가 있는 기존 테이블에 NOT NULL을 그냥 추가할 수 없다 — 기존 행에 값이 없어서 실패한다. 순진한 접근:

sql
-- This locks the table and rewrites it. Don't do this on a big table.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

PostgreSQL 11부터 상수 기본값은 실제로 안전하다 — 메타데이터만 변경된다. 하지만 기본값이 함수이거나 계산된 값으로 백필해야 하면, 확장-수축 패턴을 써라.

확장-수축 패턴#

무중단 스키마 변경의 금본위제다. 세 단계:

단계 1: 확장 — 새 컬럼을 nullable로 추가한다. 이전 컬럼과 새 컬럼 모두에 쓰는 애플리케이션 코드를 배포한다.

sql
-- Migration 1: Add the nullable column
ALTER TABLE users ADD COLUMN normalized_email TEXT;

단계 2: 마이그레이션 — 기존 행을 배치로 백필한다. 애플리케이션은 새 데이터에 대해 이미 새 컬럼에 쓰고 있다.

sql
-- Migration 2: Backfill in batches (don't do this in one statement for large tables)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Repeat until all rows are backfilled

단계 3: 수축 — 모든 행이 백필되면 NOT NULL 제약 조건을 추가하고 이전 컬럼을 제거한다(해당하는 경우).

sql
-- Migration 3: Add constraint (use NOT VALID to avoid full table scan, then validate separately)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
    CHECK (normalized_email IS NOT NULL) NOT VALID;
 
-- Migration 4: Validate the constraint (takes a ShareUpdateExclusiveLock, not an AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;

NOT VALID + VALIDATE CONSTRAINT 트릭이 핵심이다. 체크 제약 조건을 보통으로 추가하면 무거운 잠금을 잡고 전체 테이블을 스캔한다. NOT VALID는 스캔 없이 제약 조건을 추가하고(새로운 쓰기에만 적용), VALIDATE CONSTRAINT는 읽기나 쓰기를 차단하지 않는 가벼운 잠금으로 스캔한다.

인덱스 생성#

큰 테이블에 인덱스를 생성하면 기본적으로 쓰기가 차단된다. 항상 CONCURRENTLY를 써라:

sql
-- BLOCKS WRITES: don't do this on a live table
CREATE INDEX idx_users_email ON users (email);
 
-- NON-BLOCKING: use this instead
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY는 더 오래 걸리고 트랜잭션 안에서 실행할 수 없지만, 테이블을 잠그지 않는다. 프로덕션에서는 항상 그 트레이드오프가 가치 있다.

후회한 결정들#

모든 스키마에는 후회가 있다. 내 후회들은 이렇다.

외부 ID에 SERIAL 대신 UUID를 썼어야 했다#

프로젝트 초기에 SERIAL 기본 키를 쓰고 URL에 직접 노출했다: /users/42, /orders/1337. 이것은 정보를 누출했고(경쟁사가 사용자 수를 추측할 수 있었다), 페이지네이션이 추측 가능했고, 두 지역의 데이터베이스를 병합해야 할 때 깨졌다. 외부 대면 ID를 UUID로 전환하려면 몇 달에 걸친 마이그레이션이 필요했다.

교훈: 데이터베이스 외부에 노출되는 모든 것에 UUID를 써라. 내부 조인 테이블에는 원하면 SERIAL/BIGSERIAL을 쓰되, 자동 증가 정수가 URL에 나타나게 하지 마라.

제약 조건 없이 ("나중에 추가하겠다")#

"빠르게 움직이고 있으니까 나중에 추가하겠다"며 체크 제약 조건 없이 테이블을 출시했다. 2주 안에 데이터에 음수 가격, 빈 이름, 그리고 테스트 중 누군가가 입력하고 정리하지 않은 "oof"라는 order_status 값이 있었다.

사후에 제약 조건을 추가하려면:

  1. 모든 잘못된 데이터 찾기
  2. 그것을 어떻게 할지 결정하기(수정, 삭제, 또는 기존 것 그대로 두기)
  3. 데이터를 백필/수정하고 동시에 제약 조건을 추가하는 마이그레이션 작성하기

이것은 첫날에 제약 조건을 추가하는 것보다 오래 걸렸다. 제약 조건부터 시작하라. 필요하면 완화하라. 그 반대는 절대 안 된다.

배열이나 조인 테이블 대신 쉼표 구분 문자열#

태그를 쉼표 구분 문자열로 저장한 적이 있다: "javascript,react,nextjs". 쿼리가 악몽이었다:

sql
-- This is how you query comma-separated values. Don't do this.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- This also matches "react-native" and "preact"
 
-- What I should have done:
CREATE TABLE post_tags (
    post_id     UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

또는 최소한 PostgreSQL의 네이티브 배열 타입을 써라:

sql
CREATE TABLE posts (
    id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title   TEXT NOT NULL,
    tags    TEXT[] NOT NULL DEFAULT '{}',
    -- ...
);
 
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
 
-- Clean queries
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];

배열은 자체 속성이 필요 없는 단순한 목록에는 적합하다. 관계에 메타데이터가 필요한 순간("누가 이 태그를 추가했는가" 또는 "언제 추가되었는가"), 조인 테이블이 필요하다.

"type"을 컬럼 이름으로 사용#

sql
-- Seemed fine at the time
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- Then you try to query it in literally any ORM:
-- notification.type  ← conflicts with every language's type system
-- "type" is a reserved word in most SQL dialects
-- You end up quoting it everywhere: SELECT "type" FROM notifications

kind, category, 또는 notification_type을 써라. type만 빼고 아무거나. 마찬가지로 order(대신 sort_order 또는 position), user(대신 account 또는 접두사를 붙여라), group(대신 team 또는 group_name)을 피하라.

처음부터 created_at을 추가하지 않은 것#

한 테이블이 "필요 없으니까" created_at을 받지 못했다. 3개월 후 이슈를 디버그해야 했는데 레코드가 언제 생성되었는지 알 수 없었다. 소급해서 추가하면 모든 기존 행이 같은 타임스탬프(마이그레이션 타임스탬프)를 갖게 되어, 과거 데이터가 쓸모없어졌다.

모든 테이블에 created_at을 넣어라. 예외 없다. 비용은 컬럼 하나다. 없었을 때의 비용은 필요할 때까지 알 수 없다.

비즈니스 로직을 데이터베이스 뷰에 넣기#

뷰 체인을 만든 적이 있다 — active_usersusers를 필터링하고, premium_active_usersactive_users를 필터링하고, 리포팅 뷰가 이 모든 것을 조인했다. 누군가 users 테이블을 변경할 때까지 잘 동작했고, 그러자 세 개의 뷰가 조용히 깨졌다. 쿼리 플래너도 여러 뷰 계층을 통해 최적화하는 데 어려움을 겪었다.

뷰는 편의성과 읽기 전용 접근 패턴에 훌륭하다. 변경되는 비즈니스 로직을 인코딩하는 장소로는 끔찍하다. 비즈니스 규칙은 버전 관리되고, 테스트되고, 배포 가능한 애플리케이션 코드에 두라. 뷰는 리포팅 단축키로 쓰되, 아키텍처의 구성 요소로 쓰지 마라.

모두 합치기#

이 패턴들을 결합하면 잘 설계된 스키마가 어떤 모습인지 보여준다. 단순화된 프로젝트 관리 시스템이다:

sql
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- for gen_random_uuid()
 
-- Updated_at trigger function (reusable)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Tenants
CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    slug            TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
    plan            TEXT NOT NULL DEFAULT 'free'
                    CHECK (plan IN ('free', 'pro', 'enterprise')),
    settings        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Users
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    last_login_at   TIMESTAMPTZ,
    deleted_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE UNIQUE INDEX uq_users_email_per_tenant
    ON users (tenant_id, email)
    WHERE deleted_at IS NULL;
 
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Projects
CREATE TABLE projects (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'archived', 'deleted')),
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Tasks
CREATE TABLE tasks (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    project_id      UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title           TEXT NOT NULL CHECK (LENGTH(title) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'todo'
                    CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
    priority        INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
    assignee_id     UUID REFERENCES users(id) ON DELETE SET NULL,
    due_at          TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Task comments
CREATE TABLE task_comments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    task_id         UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    author_id       UUID NOT NULL REFERENCES users(id),
    body            TEXT NOT NULL CHECK (LENGTH(body) > 0),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Audit log (no RLS — admin-only table)
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,
    new_values      JSONB,
    changed_fields  TEXT[],
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

패턴들을 주목하라:

  • 모든 테이블에 tenant_id와 RLS가 활성화되어 있다(tenantsaudit_log 제외).
  • 모든 테이블에 created_atupdated_at이 트리거와 함께 있다.
  • 모든 기본 키에 UUID를 사용한다(외부 대면 엔티티).
  • 상태 열거형, 길이, 범위에 대한 체크 제약 조건.
  • 적절한 ON DELETE 동작이 있는 외래 키.
  • 접근 패턴에 맞게 설계된 인덱스(테넌트 + 프로젝트, 테넌트 + 상태).
  • 소프트 삭제는 users에만(계정 복구가 필요한 곳), projects에는 상태 기반 아카이빙.

마치며#

스키마 설계는 화려하지 않다. "모든 테이블에 Check 제약 조건을 추가했더니 6개월의 디버깅이 절약되었다"라는 제목의 컨퍼런스 발표를 한 사람은 없다. 하지만 바로 그것이 좋은 스키마 설계가 하는 일이다 — 문제가 존재했을 것이라는 사실조차 모를 정도로 조용히 문제를 예방한다.

이 글의 패턴들은 새롭지 않다. 수년간 마이그레이션을 작성하고, 데이터 손상을 디버그하고, 프로덕션 부하 속에서 스키마를 리팩토링한 결과물이다. 모든 패턴은 나 또는 나와 함께 일한 누군가가 먼저 다른 방법으로 했다가 대가를 치렀기에 존재한다.

제약 조건부터 시작하라. 외래 키를 써라. 모든 곳에 created_at을 추가하라. 명명 규칙을 정하고 엄격하게 적용하라. 멀티테넌시에 RLS를 써라. JSONB를 조심하라. 배포 전에 프로덕션 크기의 데이터에 대해 마이그레이션을 테스트하라.

데이터베이스는 기초다. 올바르게 하면 그 위에 쌓는 모든 것이 더 단순해진다. 잘못하면 어떤 영리한 애플리케이션 코드도 당신을 구할 수 없다.

관련 게시물