Claude Code로 데이터베이스 설계를 리뷰하는 실전 가이드
Claude Code로 데이터베이스 설계를 검토하는 법: 테이블, 제약, 인덱스, 마이그레이션, 롤백.
데이터베이스 설계는 테이블 이름을 정하는 일이 전부가 아닙니다. 어떤 정보를 같은 테이블에 둘지, 어떤 컬럼은 반드시 값이 있어야 하는지, 어떤 값은 중복되면 안 되는지, 테이블 사이 관계를 어떻게 보장할지, 나중에 검색과 보고서가 느려지지 않을지까지 함께 판단해야 합니다. 처음에는 화면이 잘 보여도, 실제 운영에서는 중복 계정, 깨진 결제 상태, 삭제된 부모 데이터, 되돌릴 수 없는 마이그레이션이 문제가 됩니다.
Claude Code는 이 과정을 도와줄 수 있습니다. 하지만 정답을 대신 내려 주는 도구로 쓰면 위험합니다. 더 좋은 방식은 설계 보조자이자 리뷰어로 쓰는 것입니다. 초안을 만들게 하고, 빠진 제약을 찾게 하고, 테스트 SQL을 만들게 하고, 위험한 마이그레이션을 지적하게 합니다. 최종 판단은 제품 규칙, 실제 쿼리, 공식 문서, 운영 경험을 가진 사람이 해야 합니다.
관련 흐름은 데이터베이스 마이그레이션 자동화와 Prisma ORM 활용도 함께 보면 좋습니다. 제약과 인덱스는 PostgreSQL constraints 문서, PostgreSQL indexes 문서를 기준으로 확인하세요. 감사 로그나 검색 테이블에 민감한 정보가 들어갈 수 있다면 OWASP Top 10 관점도 같이 봐야 합니다.
기본 개념부터 정리하기
테이블은 같은 종류의 데이터를 모아 두는 표입니다. users, teams, projects처럼 책임을 나눕니다. 컬럼은 한 행의 속성입니다. email, status, created_at 같은 값이 여기에 해당합니다. 기본 키는 한 행을 식별하는 값이고, 외래 키는 다른 테이블의 실제 행을 참조한다는 약속입니다. 유니크 제약은 이메일이나 결제 고객 ID처럼 중복되면 안 되는 값을 막습니다. 인덱스는 자주 검색하는 조건을 빠르게 찾기 위한 구조입니다. 마이그레이션은 스키마 변경 절차이고, 롤백은 배포가 잘못됐을 때 되돌리는 절차입니다.
초보자가 흔히 하는 실수는 모든 검증을 애플리케이션 코드에만 두는 것입니다. 폼 검증은 사용자 경험에 필요하지만, 동시 요청이나 배치 작업까지 막지는 못합니다. 데이터베이스 제약은 마지막 방어선입니다. Claude Code에는 이 방어선이 빠진 곳을 찾게 해야 합니다.
claude -p "Read docs/database/schema.sql and migrations/*.sql.
Act as a database design reviewer, not as the owner of truth.
Check for:
- Missing primary keys, foreign keys, unique constraints, and NOT NULL rules
- status or role columns that are too loose
- Indexes needed for WHERE, JOIN, and ORDER BY patterns
- Destructive migrations and rollback gaps
- PII leaking into audit logs or search/reporting tables
- API shapes that may trigger N+1 queries
Return findings first. Include why it matters, how to verify it, and only then suggest a minimal patch."
네 가지 실제 사용 사례
첫 번째는 SaaS의 사용자, 팀, 프로젝트 구조입니다. 사용자는 여러 팀에 들어갈 수 있고, 팀은 여러 프로젝트를 가집니다. 프로젝트 slug는 전체 서비스에서 하나만 있을 필요가 아니라 팀 안에서만 유일하면 되는 경우가 많습니다. 그래서 UNIQUE (team_id, slug)가 자연스럽습니다.
두 번째는 감사 로그입니다. 누가 언제 어떤 대상을 바꿨는지 기록해야 하지만, 로그 테이블을 모든 정보를 넣는 장소로 만들면 안 됩니다. 이메일, 전화번호, 주소를 JSON metadata에 넣는 순간 보관 기간, 접근 권한, 삭제 요청이 어려워집니다.
세 번째는 결제와 구독입니다. 결제 제공자의 고객 ID는 중복되면 안 되고, 한 팀에 활성 구독이 두 개 있으면 권한과 청구가 꼬입니다. 다만 취소된 이력은 남겨야 하므로 부분 유니크 인덱스 같은 설계가 필요합니다.
네 번째는 검색과 보고서 테이블입니다. 매번 여러 운영 테이블을 JOIN해서 검색하면 느려질 수 있습니다. 읽기 전용 테이블을 따로 두면 편하지만, 갱신 시점과 검증 방법을 설계하지 않으면 오래된 결과를 보여 줄 수 있습니다.
바로 실행 가능한 PostgreSQL DDL
아래 SQL은 로컬 PostgreSQL에서 그대로 실행할 수 있는 예제입니다. 전용 schema만 다시 만들기 때문에 다른 schema에는 영향을 주지 않습니다.
DROP SCHEMA IF EXISTS claude_db_design_demo CASCADE;
CREATE SCHEMA claude_db_design_demo;
SET search_path = claude_db_design_demo;
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_email_has_at CHECK (position('@' in email) > 1)
);
CREATE TABLE teams (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teams_slug_unique UNIQUE (slug)
);
CREATE TABLE team_members (
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id)
);
CREATE TABLE projects (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_by BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT projects_team_slug_unique UNIQUE (team_id, slug)
);
CREATE TABLE audit_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
actor_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT audit_action_not_blank CHECK (length(action) > 0),
CONSTRAINT audit_metadata_no_email CHECK (
metadata::text !~* '[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}'
)
);
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'manual')),
provider_customer_id TEXT NOT NULL,
plan_code TEXT NOT NULL CHECK (plan_code IN ('free', 'pro', 'business')),
status TEXT NOT NULL CHECK (status IN ('trialing', 'active', 'past_due', 'canceled')),
current_period_end DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT subscriptions_provider_customer_unique UNIQUE (provider, provider_customer_id)
);
CREATE TABLE project_search_documents (
project_id BIGINT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
title TEXT NOT NULL,
search_text TEXT NOT NULL,
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX subscriptions_one_open_per_team
ON subscriptions(team_id)
WHERE status IN ('trialing', 'active', 'past_due');
CREATE INDEX idx_team_members_user_id ON team_members(user_id);
CREATE INDEX idx_projects_team_status_created ON projects(team_id, status, created_at DESC);
CREATE INDEX idx_audit_logs_team_created ON audit_logs(team_id, created_at DESC);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_project_search_documents_search
ON project_search_documents USING GIN (to_tsvector('simple', search_text));
projects_team_slug_unique는 팀 안에서 같은 slug를 막습니다. subscriptions_one_open_per_team은 취소된 이력은 보존하면서 현재 진행 중인 구독은 팀당 하나로 제한합니다.
시드 데이터와 제약 테스트
스키마가 좋은지는 성공 케이스만으로 알 수 없습니다. 실패해야 하는 INSERT가 실제로 실패하는지 확인해야 합니다.
SET search_path = claude_db_design_demo;
INSERT INTO users (email, display_name, password_hash) VALUES
('[email protected]', 'Masa', '$2b$demo-hash-1'),
('[email protected]', 'Aiko', '$2b$demo-hash-2'),
('[email protected]', 'Ren', '$2b$demo-hash-3');
INSERT INTO teams (name, slug) VALUES
('ClaudeCodeLab', 'clab'),
('Northstar Apps', 'northstar');
INSERT INTO team_members (team_id, user_id, role) VALUES
(1, 1, 'owner'),
(1, 2, 'admin'),
(2, 3, 'owner');
INSERT INTO projects (team_id, name, slug, status, created_by) VALUES
(1, 'Schema Review', 'schema-review', 'active', 1),
(1, 'Billing Console', 'billing-console', 'active', 2),
(2, 'Customer Portal', 'customer-portal', 'active', 3);
INSERT INTO audit_logs (team_id, actor_user_id, action, entity_type, entity_id, metadata) VALUES
(1, 1, 'project.created', 'project', '1', '{"project_slug":"schema-review"}'::jsonb),
(1, 2, 'billing.plan_changed', 'subscription', '1', '{"from":"free","to":"pro"}'::jsonb);
INSERT INTO subscriptions (team_id, provider, provider_customer_id, plan_code, status, current_period_end) VALUES
(1, 'stripe', 'cus_demo_1', 'pro', 'active', DATE '2026-07-31'),
(2, 'manual', 'manual_northstar_1', 'free', 'canceled', NULL);
INSERT INTO project_search_documents (project_id, team_id, title, search_text)
SELECT
id,
team_id,
name,
name || ' project notes for database design, billing, audit, and reporting.'
FROM projects;
SET search_path = claude_db_design_demo;
DO $$
BEGIN
INSERT INTO users (email, display_name, password_hash)
VALUES ('[email protected]', 'Duplicate Masa', '$2b$demo-hash-x');
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'OK duplicate email blocked: %', SQLERRM;
END $$;
DO $$
BEGIN
INSERT INTO projects (team_id, name, slug, created_by)
VALUES (1, NULL, 'bad-null', 1);
EXCEPTION WHEN not_null_violation THEN
RAISE NOTICE 'OK NULL project name blocked: %', SQLERRM;
END $$;
DO $$
BEGIN
INSERT INTO team_members (team_id, user_id, role)
VALUES (999, 1, 'member');
EXCEPTION WHEN foreign_key_violation THEN
RAISE NOTICE 'OK missing team blocked: %', SQLERRM;
END $$;
DO $$
BEGIN
INSERT INTO projects (team_id, name, slug, created_by)
VALUES (1, 'Duplicate Slug', 'schema-review', 1);
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'OK duplicate team slug blocked: %', SQLERRM;
END $$;
DO $$
BEGIN
INSERT INTO audit_logs (team_id, actor_user_id, action, entity_type, entity_id, metadata)
VALUES (1, 1, 'project.invited', 'project', '1', '{"email":"[email protected]"}'::jsonb);
EXCEPTION WHEN check_violation THEN
RAISE NOTICE 'OK email in audit metadata blocked: %', SQLERRM;
END $$;
DO $$
BEGIN
INSERT INTO subscriptions (team_id, provider, provider_customer_id, plan_code, status, current_period_end)
VALUES (1, 'stripe', 'cus_demo_2', 'business', 'active', DATE '2026-08-31');
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'OK second open subscription blocked: %', SQLERRM;
END $$;
SELECT p.id, p.name, count(a.id) AS audit_events
FROM projects p
LEFT JOIN audit_logs a
ON a.entity_type = 'project'
AND a.entity_id = p.id::text
WHERE p.team_id = 1
GROUP BY p.id, p.name
ORDER BY audit_events DESC, p.id;
SELECT project_id, title
FROM project_search_documents
WHERE to_tsvector('simple', search_text) @@ plainto_tsquery('simple', 'billing');
EXPLAIN (COSTS OFF)
SELECT *
FROM audit_logs
WHERE team_id = 1
ORDER BY created_at DESC
LIMIT 20;
구체적인 실패 사례
유니크 제약이 없으면 같은 이메일이나 같은 결제 고객 ID가 들어갈 수 있습니다. 애플리케이션 검증은 UX에는 좋지만 데이터 무결성을 보장하지 못합니다. NULL도 마찬가지입니다. 이름, 역할, 상태가 비어 있으면 화면과 권한 로직이 불안정해집니다. 반대로 deleted_at처럼 비어 있는 것이 의미가 있는 컬럼도 있으므로, 각 NULL의 의미를 설명해야 합니다.
파괴적인 마이그레이션은 특히 조심해야 합니다. 컬럼 삭제, 타입 변경, 기존 데이터가 있는 컬럼에 NOT NULL 추가, 큰 테이블에 기본값 추가는 운영 중 락이나 데이터 손실로 이어질 수 있습니다. Claude Code에는 큰 테이블을 가정하고 단계적 변경안을 요구하세요.
N+1 쿼리는 API 설계와 연결됩니다. 프로젝트 목록을 가져온 뒤 프로젝트마다 감사 로그 수를 따로 조회하면 목록 100개에 SQL 101번이 됩니다. 집계 쿼리, 보고서 테이블, 읽기 모델을 설계 단계에서 검토해야 합니다. 또한 감사 로그와 검색 테이블에는 이메일, 토큰, 전화번호 같은 PII를 쉽게 넣지 말아야 합니다.
마이그레이션과 롤백 체크리스트
# Migration checklist: add projects.archived_at
## Design
- [ ] Why this column is needed is written in the issue or PR.
- [ ] Existing rows and backfill behavior are defined.
- [ ] NOT NULL, default value, and CHECK constraint choices are explained.
- [ ] Index impact was checked against real WHERE / ORDER BY queries.
## Safety
- [ ] This migration does not drop data in the first deploy.
- [ ] Large-table locks were considered.
- [ ] The application can run during the expand-and-contract period.
- [ ] Backfill can be retried safely.
## Up SQL
ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ;
UPDATE projects
SET archived_at = now()
WHERE status = 'archived' AND archived_at IS NULL;
ALTER TABLE projects
ADD CONSTRAINT projects_archived_at_matches_status
CHECK (
(status = 'archived' AND archived_at IS NOT NULL)
OR (status = 'active' AND archived_at IS NULL)
) NOT VALID;
ALTER TABLE projects VALIDATE CONSTRAINT projects_archived_at_matches_status;
## Down SQL
ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_archived_at_matches_status;
ALTER TABLE projects DROP COLUMN IF EXISTS archived_at;
## Verification
- [ ] Constraint test SQL was run locally.
- [ ] Read queries still use expected indexes.
- [ ] Rollback was reviewed, including data-loss notes.
- [ ] Claude Code findings were reviewed by a human owner.
자동 생성된 down migration은 테이블 모양만 되돌리고 데이터는 되돌리지 못할 수 있습니다. 되돌릴 수 없는 변경이라면 PR에 명확히 쓰고 백업, 단계 배포, 복구 절차를 따로 준비해야 합니다.
ER 다이어그램으로 공유하기
erDiagram
users ||--o{ team_members : joins
teams ||--o{ team_members : has
teams ||--o{ projects : owns
users ||--o{ projects : creates
teams ||--o{ audit_logs : records
users ||--o{ audit_logs : acts
teams ||--o{ subscriptions : pays
projects ||--|| project_search_documents : indexes
다이어그램은 관계를 빨리 설명하는 데 좋지만, DDL과 테스트를 대체하지는 않습니다. 보기 좋은 그림보다 중요한 것은 데이터베이스가 나쁜 데이터를 거부하는지입니다.
CTA: 팀 규칙으로 만들기
Claude Code를 데이터베이스 설계에 쓰려면 CLAUDE.md에 규칙을 남기세요. 운영 DB 직접 연결 금지, 파괴적 마이그레이션의 단계적 배포, 제약 테스트 필수, 로그에 PII 금지 같은 문구가 실제 사고를 줄입니다. ClaudeCodeLab은 팀의 Claude Code 도입, 마이그레이션 리뷰 템플릿, PR 체크리스트, 교육을 지원합니다. 실제 저장소에 적용하고 싶다면 Claude Code 교육 및 상담에서 시작하세요. 개인 학습은 무료 치트시트와 제품 템플릿도 도움이 됩니다.
Masa가 이 흐름을 실제 스키마 리뷰에 적용했을 때 가장 효과적이었던 것은 첫 ER 다이어그램이 아니라 실패해야 하는 INSERT였습니다. 중복 이메일, 빈 프로젝트 이름, 없는 팀 참조, 중복 slug, 감사 로그의 이메일, 두 번째 활성 구독을 데이터베이스가 거부하는 것을 보니 설계의 강점과 빈틈이 훨씬 빨리 보였습니다. Claude Code는 체크리스트를 빠르게 만들었고, 확신은 PostgreSQL의 실패 결과에서 나왔습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.