☰ Categories

Review a database schema before it ships

Finds what will break later from your DDL, leading with the decisions that are expensive to reverse.

CategoryDevelopment › Data & databases
TagsReviewingAnalyzingDeveloper
Prompt
Review this database schema.

Order findings by **how expensive they are to change after data exists**, not by severity:
1. Irreversible-ish — primary key type and shape, partitioning, denormalization, column that should have been a table
2. Costly — nullability, uniqueness across existing rows, type widening or narrowing, timezone handling
3. Cheap — index changes, names, defaults, comments

For each finding: what breaks, under what query or volume, and the change.

Check specifically:
- Does every foreign key have an index on the child side? Missing ones make parent deletes and joins collapse at scale.
- Soft delete combined with a full-table unique constraint — that permanently locks the deleted value.
- Columns storing more than one fact, and facts split across columns that should be rows.
- Money as float. Timestamps without timezone. Enum as a database type where values will change.
- Counters updated on the same row that carries indexed columns.
- What the access patterns need that no index covers.

Rules:
- *Separate structural problems from preferences* and label them. A naming opinion is not a finding.
- Where the access patterns do not tell you enough to judge an index, say what query you would need to see.
- Do not propose a redesign. Name the smallest change per finding.
After pasting, fill in the fields at the bottom (DDL · Access patterns · Expected scale)

What this prompt does

Schemas freeze once data accumulates. This surfaces "one line now, a migration later" items first and separates structural problems from matters of taste.

Model comparison

ChatGPT is cautious but has ordering and duplication issues. Gemini is best structured but includes speculation; [C] is missing.

GeminiTop overall
40/ 50

+ Clearly follows the requested cost and finding structure.

- Invents details and exceeds scope with a Redis redesign.

ChatGPT
38/ 50

+ Carefully avoids assumptions and proposes minimal changes.

- Misclassifies timezone cost and duplicates index findings.

CriterionChatGPTGeminiLeader
Instruction following78Gemini +14%
Accuracy87ChatGPT +14%
Specificity99Tie
Structure79Gemini +29%
Right length77Tie

Scored 1–10 by gpt-5.6-sol with model names hidden (2026-09-25). This is an AI review, not a measurement.

Read full answers

We gave three models the same input and copied their answers unedited. Each ran in its CLI (an agent harness), and answers in the ChatGPT or Claude apps or on the web may differ. Outputs are in Korean.

Input used
DDL: CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  phone VARCHAR(20),
  is_deleted BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT now()
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INTEGER REFERENCES users(id),
  total_price FLOAT NOT NULL,
  status VARCHAR(20),
  created_at TIMESTAMP DEFAULT now()
);

CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INTEGER REFERENCES orders(id),
  product_name VARCHAR(255),
  option_info VARCHAR(500),
  price FLOAT
);

CREATE TABLE product_view_count (
  product_id INTEGER PRIMARY KEY,
  view_count INTEGER DEFAULT 0,
  updated_at TIMESTAMP
);

Access patterns: 1) 이메일로 로그인 조회 2) 특정 유저의 최근 주문 목록 조회(user_id, created_at DESC) 3) 상품 상세 진입 시 view_count +1 UPDATE (피크 초당 50건) 4) 주문의 옵션별 매출 집계

Expected scale: 가입자 30만 명, 일 주문 약 5,000건, product_view_count UPDATE가 전체 쓰기 트래픽 중 가장 큼
gpt-5.6-luna (codex CLI · 에이전트 하네스) · Run on 2026-09-24

1. 되돌리기 어려운 변경

[구조적 문제] option_info가 여러 사실을 한 컬럼에 저장

옵션이 JSON·문자열로 들어가면 옵션별 매출 집계 시 파싱이 필요하고, 옵션명·옵션값별 필터/그룹화가 인덱스를 효율적으로 사용할 수 없습니다. 주문량과 주문 항목이 늘수록 전체 스캔과 문자열 파싱 비용이 커집니다.

  • 깨지는 접근: 옵션별 매출 집계
  • 최소 변경: option_info의 실제 집계 기준이 되는 단일 값만 별도 컬럼으로 분리하거나, 이미 정해진 옵션 식별자를 저장하는 컬럼을 추가
  • 옵션이 여러 개의 독립된 행으로 조회·집계되어야 한다면 테이블화가 필요하지만, 이는 현재 정보만으로 확정할 수 없습니다.

product_name은 주문 당시 상품명 스냅샷이라면 의도적인 비정규화일 수 있으므로 문제로 단정하지 않습니다.

[구조적 문제] SERIAL 기반 정수 PK/FK

현재 예상 규모에서는 즉시 문제가 되지 않지만, SERIAL은 32비트 정수라 약 21억 개에서 고갈됩니다. 특히 모든 FK가 같은 INTEGER 타입을 사용하므로 나중에 BIGINT로 바꾸면 전체 PK/FK와 참조 코드의 동시 변경이 필요합니다.

  • 깨지는 규모: 장기적으로 users/orders/order_items가 21억 행에 접근할 때
  • 최소 변경: 신규 시스템이면 처음부터 BIGINT PK/FK 사용
  • 현재 데이터가 이미 있고 규모가 충분히 작다면 즉시 변경할 필요는 없습니다.

[구조적 문제] 시간대 없는 timestamp

created_at, updated_at이 TIMESTAMP라 서버 세션 시간대에 따라 해석이 달라질 수 있습니다. 여러 지역의 사용자, 배치 서버, 리포팅 서버가 섞이면 “최근 주문” 경계나 일별 매출 집계가 어긋납니다.

  • 깨지는 접근: 시간대별 최근 주문, 일/월 매출 집계, 해외 사용자 지원
  • 최소 변경: TIMESTAMP WITH TIME ZONE으로 통일하고 UTC 기준으로 저장

2. 비용이 큰 변경

[구조적 문제] soft delete와 전체 UNIQUE 제약의 충돌

users.email UNIQUE는 삭제된 사용자까지 포함합니다. 삭제된 계정의 이메일로 재가입하거나 복구하려 하면 계속 실패합니다.

  • 깨지는 접근: is_deleted = true인 사용자의 이메일 재사용
  • 최소 변경: 전체 UNIQUE를 삭제하고 WHERE is_deleted = false 조건의 부분 unique index로 교체
  • 단, 로그인 조회도 반드시 email = ? AND is_deleted = false를 사용해야 합니다.

현재 is_deleted 자체에는 기본값만 있고 NOT NULL이 없어 NULL이 들어갈 수 있습니다.

  • 최소 변경: is_deleted BOOLEAN NOT NULL DEFAULT false

[구조적 문제] 금액에 FLOAT 사용

부동소수점 오차로 합계가 정확히 떨어지지 않을 수 있습니다. 특히 option_info별 매출 집계에서 여러 행을 합산하면 오차가 누적됩니다.

  • 깨지는 접근: 주문 합계 검증, 환불/정산, 옵션별 매출 집계
  • 최소 변경: 금액 컬럼을 NUMERIC(정밀도, scale)로 변경하거나 최소 단위 정수(예: 원 단위)로 변경
  • total_price와 order_items.price의 표현을 동일하게 맞춰야 합니다.

[구조적 문제] FK 자식 컬럼 인덱스 누락

현재 두 FK 모두 자식 쪽 인덱스가 없습니다.

  • orders.user_id REFERENCES users(id)
  • order_items.order_id REFERENCES orders(id)

부모 삭제·갱신 시 자식 존재 여부를 확인하는 스캔이 발생할 수 있고, 조인도 대량 데이터에서 느려집니다.

  • 깨지는 규모: orders/order_items가 수십만~수백만 행 이상일 때, 사용자·주문 삭제나 부모-자식 조인이 발생할 때
  • 최소 변경:
    • orders(user_id) 인덱스
    • order_items(order_id) 인덱스

특정 유저의 최근 주문 조회에는 단순 인덱스보다 다음 복합 인덱스가 필요합니다.

CREATE INDEX ON orders (user_id, created_at DESC);

현재 제시된 패턴만으로는 삭제된 주문 제외, 상태 필터, 페이지네이션 여부를 알 수 없습니다. 실제 쿼리에 status 또는 is_deleted 조건이 있다면 그 조건까지 포함한 인덱스가 필요한지 쿼리를 봐야 판단할 수 있습니다.

[구조적 문제] status가 변경 가능한 값인데 데이터베이스 enum을 사용하지 않음

현재는 VARCHAR(20)이므로 enum 타입 자체의 변경 비용 문제는 없습니다. 오히려 상태 값이 자주 바뀐다면 현재 방식이 변경에는 유리합니다.

다만 유효하지 않은 상태 문자열을 막아야 한다면 최소 변경은 CHECK 제약입니다. 상태 목록과 변경 빈도를 모르면 별도 테이블이나 enum으로 바꿀 근거는 부족합니다.

3. 저비용 변경

[구조적 문제] 주문 최근 목록 조회를 지원하는 인덱스 없음

요구 쿼리가 다음 형태라면 현재 인덱스로는 user_id 기준 필터와 created_at DESC 정렬을 모두 지원하지 못합니다.

SELECT ...
FROM orders
WHERE user_id = ?
ORDER BY created_at DESC;

최소 변경:

CREATE INDEX ON orders (user_id, created_at DESC);

[구조적 문제] 이메일 로그인 조회는 인덱스가 있음

email VARCHAR(255) UNIQUE NOT NULL이므로 이메일 조회용 unique index는 이미 생성됩니다. 별도 인덱스는 필요하지 않습니다.

단, soft delete를 반영해 부분 unique index로 바꾸면 그 인덱스가 로그인 조회도 담당하게 해야 합니다.

[구조적 문제] 조회수 카운터의 행 경합

product_view_count는 product_id가 PK이므로 조회수 증가 때 인덱스가 있는 동일 행을 갱신합니다. 같은 상품에 초당 50건이 집중되면 해당 행의 row lock 경합과 WAL/페이지 갱신이 병목이 될 수 있습니다.

  • 깨지는 규모: 전체 트래픽이 아니라 특정 인기 상품에 증가 요청이 집중될 때
  • 최소 변경: 반드시 원자적 증가를 사용
UPDATE product_view_count
SET view_count = view_count + 1,
    updated_at = now()
WHERE product_id = ?;

이 방식으로도 경합이 확인되면 카운터를 여러 행으로 분산하거나 비동기 집계해야 하지만, 이는 실제 hot product의 락 대기·TPS 측정 후 결정할 사안입니다.

[선호에 가까운 사항] 명시적 기본값·이름·코멘트

created_at DEFAULT now(), view_count DEFAULT 0 등은 기능상 큰 문제는 없습니다. 컬럼명이나 제약 이름을 바꾸는 것은 데이터가 존재한 뒤에도 비교적 저렴하므로, 일관성이 목적이 아니라면 finding으로 보지 않습니다.

More in this category

Advanced Text Converter for Large Datasets
AI-powered data extraction and organization tool
AI2sql SQL Model — Query Generator
Backend Architect
base-R