+ 요구된 비용 순서와 쿼리·규모·변경 형식을 잘 지켰다.
- 타임아웃·상태값을 지어냈고 Redis 제안은 범위를 넘었다.
테이블 정의를 주면 나중에 터질 지점을 찾습니다. 되돌리기 비싼 결정부터 짚습니다.
| 분류 | 개발 › 데이터·DB |
|---|---|
| 태그 | 검토분석개발자 |
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.
스키마는 데이터가 쌓이면 못 바꾼다. 이 프롬프트는 "지금 고치면 한 줄, 나중엔 마이그레이션"인 항목을 먼저 올리고, 취향 문제와 구조 문제를 갈라준다.
ChatGPT는 신중하고 정확하지만 분류와 중복이 아쉽다. Gemini는 가장 체계적이나 과장과 추정이 있으며, [C]는 답이 없다.
+ 요구된 비용 순서와 쿼리·규모·변경 형식을 잘 지켰다.
- 타임아웃·상태값을 지어냈고 Redis 제안은 범위를 넘었다.
+ 불확실한 사항을 단정하지 않고 최소 변경을 제안했다.
- 시간대 문제의 비용 분류가 틀리고 인덱스 항목이 중복됐다.
| 기준 | ChatGPT | Gemini | 앞선 모델 |
|---|---|---|---|
| 지시 준수 | 7 | 8 | Gemini +14% |
| 정확성 | 8 | 7 | ChatGPT +14% |
| 구체성 | 9 | 9 | 동점 |
| 구성·가독성 | 7 | 9 | Gemini +29% |
| 분량 적정성 | 7 | 7 | 동점 |
gpt-5.6-sol가 모델 이름을 가린 채 1~10점으로 채점했습니다 (2026-09-25). 측정값이 아니라 AI 심사 결과입니다.
같은 입력을 세 모델에 넣어 받은 답을 그대로 옮겼습니다. 각 CLI(에이전트 하네스)에서 실행했습니다. ChatGPT·Claude 앱이나 웹에서는 답이 다를 수 있습니다.
테이블 정의: 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 ); 주요 조회 패턴: 1) 이메일로 로그인 조회 2) 특정 유저의 최근 주문 목록 조회(user_id, created_at DESC) 3) 상품 상세 진입 시 view_count +1 UPDATE (피크 초당 50건) 4) 주문의 옵션별 매출 집계 예상 규모: 가입자 30만 명, 일 주문 약 5,000건, product_view_count UPDATE가 전체 쓰기 트래픽 중 가장 큼
option_info가 여러 사실을 한 컬럼에 저장옵션이 JSON·문자열로 들어가면 옵션별 매출 집계 시 파싱이 필요하고, 옵션명·옵션값별 필터/그룹화가 인덱스를 효율적으로 사용할 수 없습니다. 주문량과 주문 항목이 늘수록 전체 스캔과 문자열 파싱 비용이 커집니다.
옵션별 매출 집계option_info의 실제 집계 기준이 되는 단일 값만 별도 컬럼으로 분리하거나, 이미 정해진 옵션 식별자를 저장하는 컬럼을 추가product_name은 주문 당시 상품명 스냅샷이라면 의도적인 비정규화일 수 있으므로 문제로 단정하지 않습니다.
SERIAL 기반 정수 PK/FK현재 예상 규모에서는 즉시 문제가 되지 않지만, SERIAL은 32비트 정수라 약 21억 개에서 고갈됩니다. 특히 모든 FK가 같은 INTEGER 타입을 사용하므로 나중에 BIGINT로 바꾸면 전체 PK/FK와 참조 코드의 동시 변경이 필요합니다.
BIGINT PK/FK 사용created_at, updated_at이 TIMESTAMP라 서버 세션 시간대에 따라 해석이 달라질 수 있습니다. 여러 지역의 사용자, 배치 서버, 리포팅 서버가 섞이면 “최근 주문” 경계나 일별 매출 집계가 어긋납니다.
TIMESTAMP WITH TIME ZONE으로 통일하고 UTC 기준으로 저장users.email UNIQUE는 삭제된 사용자까지 포함합니다. 삭제된 계정의 이메일로 재가입하거나 복구하려 하면 계속 실패합니다.
is_deleted = true인 사용자의 이메일 재사용WHERE is_deleted = false 조건의 부분 unique index로 교체email = ? AND is_deleted = false를 사용해야 합니다.현재 is_deleted 자체에는 기본값만 있고 NOT NULL이 없어 NULL이 들어갈 수 있습니다.
is_deleted BOOLEAN NOT NULL DEFAULT falseFLOAT 사용부동소수점 오차로 합계가 정확히 떨어지지 않을 수 있습니다. 특히 option_info별 매출 집계에서 여러 행을 합산하면 오차가 누적됩니다.
NUMERIC(정밀도, scale)로 변경하거나 최소 단위 정수(예: 원 단위)로 변경total_price와 order_items.price의 표현을 동일하게 맞춰야 합니다.현재 두 FK 모두 자식 쪽 인덱스가 없습니다.
orders.user_id REFERENCES users(id)order_items.order_id REFERENCES orders(id)부모 삭제·갱신 시 자식 존재 여부를 확인하는 스캔이 발생할 수 있고, 조인도 대량 데이터에서 느려집니다.
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으로 바꿀 근거는 부족합니다.
요구 쿼리가 다음 형태라면 현재 인덱스로는 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으로 보지 않습니다.
| 대용량 텍스트 변환기 프롬프트 | |
| AI 데이터 추출 도구 기획 프롬프트 | |
| AI2sql 자연어 SQL 생성 프롬프트 | |
| 백엔드 아키텍트 역할을 맡기는 프롬프트 | |
| Base R 프로그래밍 프롬프트 |