Postgres 스키마 설계 플래닝 모드는 코드 생성 전에 엔티티, 제약, 인덱스, 마이그레이션을 정의해 이후 재작업을 줄여줍니다.

엔드포인트와 모델을 데이터베이스 구조가 확실해지기 전에 만들면 같은 기능을 두 번 고치는 일이 자주 생깁니다. 데모용으로는 동작하던 앱이 실제 데이터와 엣지 케이스를 만나면 모두 취약해 보이기 시작하죠.
대부분의 재작성은 예측 가능한 세 가지 문제에서 옵니다:
각각은 코드, 테스트, 클라이언트 앱 전반에 걸쳐 파급되는 변경을 강요합니다.
Postgres 스키마 계획은 먼저 데이터 계약을 결정하고 그에 맞춰 코드를 생성하는 것을 의미합니다. 실전에서는 엔티티, 관계, 중요한 몇 개의 쿼리를 적고 제약, 인덱스, 마이그레이션 방식을 선택한 다음 어떤 도구도 테이블과 CRUD를 스캐폴드하기 전에 이 과정을 마치는 식입니다.
이것은 Koder.ai 같은 바이브-코딩 플랫폼을 사용할 때 더 중요합니다. 많은 코드를 빠르게 생성할 수 있는 건 좋지만, 스키마가 확정되어 있을 때 훨씬 더 신뢰할 수 있습니다. 생성된 모델과 엔드포인트는 나중에 수정이 적게 필요합니다.
계획을 건너뛰면 흔히 이렇게 문제가 생깁니다:
좋은 스키마 계획은 단순합니다: 엔티티를 평문으로 설명하고, 테이블과 컬럼 초안을 만들고, 핵심 제약과 인덱스를 정한 뒤 제품 성장에 따라 안전하게 변경할 수 있는 마이그레이션 전략을 세우는 것입니다.
스키마 계획은 앱이 무엇을 기억해야 하고 사람들이 그 데이터를 가지고 무엇을 할 수 있어야 하는지를 먼저 정할 때 가장 잘 작동합니다. 목표를 2~3문장으로 적으세요. 간단히 설명할 수 없다면 불필요한 테이블을 만들 가능성이 큽니다.
다음으로 데이터를 생성하거나 변경하는 행동에 집중하세요. 이런 행동들이 실제 행(row)의 근원이며 무엇을 검증해야 하는지 드러냅니다. 명사가 아니라 동사로 생각하세요.
예를 들어 예약 앱은 예약 생성, 일정 변경, 취소, 환불, 고객에게 메시지 전송 같은 동사가 필요할 것입니다. 이런 동사는 시간 슬롯, 상태 변경, 금액 같은 무엇을 저장해야 하는지 빠르게 제시합니다.
읽기 경로도 캡처하세요. 읽기가 구조와 인덱싱을 결정합니다. 사람들이 실제로 사용할 화면이나 리포트를 나열하고 어떻게 데이터를 자르는지 적으세요: 날짜별 정렬된 “내 예약”, 고객 이름이나 예약 참조로 검색하는 관리자 화면, 위치별 일일 매출, 누가 언제 무엇을 변경했는지 보여주는 감사 뷰 등.
마지막으로 감사 기록, 소프트 삭제, 멀티테넌시 분리, 개인정보 규칙(예: 연락처 세부 정보를 누가 볼 수 있는지 제한 등)처럼 스키마 선택에 영향을 주는 비기능적 요구사항을 적어두세요.
이후 코드를 생성할 계획이라면 이 메모들은 강력한 프롬프트가 됩니다. 무엇이 필요한지, 무엇이 변경될 수 있는지, 무엇이 검색 가능해야 하는지를 분명히 적어줍니다. Koder.ai를 사용하는 경우에는 생성 전에 이걸 적어두면 플래닝 모드가 추측 대신 실제 요구사항을 기반으로 작동해 훨씬 효과적입니다.
테이블을 건드리기 전에 앱이 저장하는 내용을 평문으로 적으세요. 반복해서 나오는 명사들을 먼저 나열합니다: user, project, message, invoice, subscription, file, comment. 각 명사는 후보 엔티티입니다.
그다음 각 엔티티에 대해 한 문장으로 답하세요: 그것은 무엇이고 왜 존재하는가? 예: “Project는 사용자가 작업을 그룹화하고 다른 사람을 초대하기 위해 만드는 워크스페이스다.” 이렇게 하면 data, items, misc 같은 모호한 테이블을 방지할 수 있습니다.
소유권도 중요한 결정이며 거의 모든 쿼리에 영향을 줍니다. 각 엔티티에 대해:
이제 레코드를 어떻게 식별할지도 결정하세요. UUID는 웹/모바일/백그라운드 잡 등 여러 곳에서 생성될 수 있거나 예측 가능한 ID를 원하지 않을 때 좋습니다. bigint ID는 더 작고 빠릅니다. 사람이 읽기 쉬운 식별자가 필요하면 기본 키로 강제하지 말고 별도 고유 컬럼(예: 계정 내에서 고유한 project_code)으로 두세요.
마지막으로 관계를 다이어그램하기 전에 말로 적으세요: 사용자 하나가 여러 프로젝트를 가진다, 프로젝트 하나가 여러 메시지를 가진다, 사용자가 여러 프로젝트에 속할 수 있다 등. 각 링크를 필수인지 선택인지로 표시하세요(예: “메시지는 반드시 프로젝트에 속해야 한다” vs “송장은 프로젝트에 속할 수도 있다”). 이 문장들이 나중에 코드 생성의 진실의 원천이 됩니다.
엔티티가 평문으로 명확해지면 각 엔티티를 테이블로 바꾸고 실제로 저장해야 할 사실에 맞는 컬럼을 만드세요.
먼저 이름과 타입은 고수할 수 있는 패턴으로 정하세요. 일관된 규칙을 선택하세요: snake_case 컬럼명, 같은 의미에는 같은 타입, 예측 가능한 기본 키. 타임스탬프는 timestamptz를 선호해 시간대 때문에 놀라지 않게 하세요. 금액은 numeric(12,2)(또는 센트 정수)로 두고 부동소수점은 피하세요.
상태 필드는 Postgres enum을 쓰거나 허용 값을 제어하기 위해 text와 CHECK 제약을 사용할 수 있습니다.
필수와 선택은 규칙을 NOT NULL로 번역해 결정하세요. 값이 행의 의미를 위해 반드시 있어야 하면 필수로, 정말로 알 수 없거나 해당하지 않으면 NULL 허용으로 하세요.
계획에 포함할 실용적인 기본 컬럼 세트:
id (uuid 또는 bigint, 한 가지 접근을 선택하고 일관되게 사용)created_at 와 updated_atdeleted_atcreated_by다대다 관계는 거의 항상 조인 테이블로 만드세요. 예: 여러 사용자가 앱에서 협업할 수 있다면 app_members를 만들어 app_id와 user_id를 두고 쌍에 대해 고유성을 강제하세요.
히스토리를 초기에 고려하세요. 버전 관리가 필요하면 불변 테이블(app_snapshots)을 계획해 각 행을 app_id로 연결하고 created_at으로 타임스탬프를 찍으세요.
제약은 스키마의 가드레일입니다. 어떤 서비스나 스크립트, 관리자 도구가 데이터베이스를 건드려도 반드시 참이어야 할 규칙들을 결정하세요.
아이덴티티와 관계부터 시작하세요. 모든 테이블은 기본 키가 필요하고, 어떤 ‘belongs to’ 필드든 단순 정수가 아니라 실제 외래 키여야 합니다.
그다음 중복이 실제로 해를 끼치는 곳에는 고유성을 추가하세요(예: 같은 이메일을 가진 두 계정, (order_id, product_id)가 중복된 라인 항목).
초기에 계획해야 할 고가치 제약:
amount >= 0, status IN ('draft','paid','canceled'), rating BETWEEN 1 AND 5 같은 저비용 규칙Cascade 동작은 미리 생각하면 나중에 많은 문제를 막습니다. 사람들이 실제로 무엇을 기대하는지 물어보세요. 고객이 삭제되면 주문이 사라져야 할까요? 대개는 그렇지 않으니 제한 삭제(restrict)를 사용하고 기록을 유지하세요. 반면 주문 항목처럼 부모 없이는 의미가 없는 데이터는 부모에서 CASCADE가 적절할 수 있습니다.
나중에 모델과 엔드포인트를 생성하면 이 제약들은 명확한 요구사항이 됩니다: 어떤 오류를 처리해야 하는지, 어떤 필드가 필수인지, 어떤 엣지 케이스가 설계상 불가능한지 알려줍니다.
인덱스는 단 하나의 질문에 답해야 합니다: 실제 사용자에게 무엇이 빨라야 하는가.
먼저 출시할 화면들과 API 호출을 생각하세요. 상태로 필터하고 최신순으로 정렬하는 리스트 페이지는 관련 레이블을 불러오는 상세 페이지와 다른 요구를 가집니다.
인덱스를 선택하기 전에 평문으로 5–10개의 쿼리 패턴을 적으세요. 예: “지난 30일간 내 인보이스를 보여주고, 결제/미결제로 필터하며 created_at으로 정렬” 또는 “프로젝트 열람 시 해당 프로젝트의 작업을 due_date로 정렬해 나열”. 이렇게 하면 인덱스 선택이 실제 사용에 기반하게 됩니다.
좋은 초기사용 인덱스는 보통 조인에 사용되는 외래 키 컬럼, 자주 필터되는 컬럼(예: status, user_id, created_at), 그리고 안정적인 멀티-필터 쿼리를 위한 1~2개의 복합 인덱스를 포함합니다. 예: 항상 account_id로 필터하고 시간순 정렬하면 (account_id, created_at) 같은 인덱스가 유용합니다.
복합 인덱스의 순서는 중요합니다. 가장 자주 필터하고 선택도가 높은 컬럼을 앞에 두세요. 테넌트별로 항상 tenant_id로 필터한다면 많은 인덱스의 맨 앞에 두는 것이 맞습니다.
모든 것을 ‘혹시 모르니’ 인덱싱하지 마세요. 인덱스는 INSERT와 UPDATE 비용을 증가시키며 드문 쿼리를 약간 빠르게 하는 것이 그 비용을 정당화하지 않을 수 있습니다.
텍스트 검색은 별도로 계획하세요. 간단한 포함 검색만 필요하면 처음에는 ILIKE로 충분할 수 있습니다. 검색이 핵심이라면 초기에 풀텍스트 검색(tsvector)을 계획해 나중에 재설계하는 일을 피하세요.
스키마는 최초 테이블 생성으로 ‘완성’되지 않습니다. 기능을 추가하거나 실수를 고치거나 데이터를 더 알게 될 때마다 바뀝니다. 미리 마이그레이션 전략을 정하면 코드 생성 이후 고통스러운 재작업을 피할 수 있습니다.
간단한 규칙: 데이터베이스 변경은 작은 단계로, 기능 단위로 하세요. 각 마이그레이션은 리뷰하기 쉽고 모든 환경에서 안전하게 실행될 수 있어야 합니다.
대부분의 실패는 컬럼 이름 변경, 제거, 타입 변경에서 옵니다. 한 번에 모든걸 하지 말고 안전한 경로를 계획하세요:
이 과정은 단계가 더 많지만 실전에서는 중단과 긴급 패치를 줄여 더 빠릅니다.
시드 데이터도 마이그레이션의 일부입니다. 어떤 참조 테이블(roles, statuses, countries, plan types 등)이 항상 있어야 하는지 정하고 예측 가능하게 만드세요. 이러한 테이블의 INSERT/UPDATE는 전용 마이그레이션에 넣어 모든 개발자와 배포 환경이 같은 결과를 갖게 하세요.
초기에 기대치를 정하세요:
롤백이 항상 완벽한 'down migration'인 것은 아닙니다. 때로는 백업 복원이 더 현실적인 롤백입니다. Koder.ai를 사용한다면 위험한 변경 전 스냅샷과 롤백 시점을 정해 빠른 복구에 활용하는 것도 고려하세요.
팀에 사람들이 가입하고 프로젝트를 만들고 작업을 추적하는 작은 SaaS 앱을 상상해보세요.
먼저 엔티티와 첫날에 필요한 필드만 나열합니다:
관계는 직관적입니다: 팀은 여러 프로젝트를 가지며, 프로젝트는 여러 작업을 갖고 사용자는 team_members를 통해 팀에 참여합니다. 작업은 프로젝트에 속하고 사용자가 배정될 수 있습니다.
몇 가지 제약을 더해 늦게 발견하는 버그를 예방하세요:
citext를 쓰면 대소문자 구분 없는 고유성 적용)인덱스는 실제 화면에 맞추세요. 예: 작업 리스트가 프로젝트와 상태로 필터하고 최신순으로 정렬하면 tasks (project_id, state, created_at DESC) 같은 인덱스를 계획하세요. “내 작업”이 중요하면 tasks (assignee_user_id, state, due_date) 같은 인덱스가 도움됩니다.
마이그레이션은 첫 세트를 안전하고 단순하게 유지하세요: 테이블 생성, 기본 키, 외래 키, 핵심 고유 제약을 먼저 만드세요. 사용 후 증명이 필요한 변경(예: 소프트 삭제 deleted_at 추가)은 추후에 추가하는 것이 좋습니다. 그런 변경은 인덱스를 조정하거나 ‘활성 작업’ 뷰를 업데이트하는 작업을 수반할 수 있습니다.
대부분의 재작성은 최초 스키마에 규칙과 실제 사용 세부사항이 빠져 있기 때문에 발생합니다. 좋은 계획은 완벽한 다이어그램이 아니라 초기 함정을 미리 발견하는 것입니다.
일반적 오류는 중요한 규칙을 애플리케이션 코드에만 두는 것입니다. 값이 고유해야 하거나 존재해야 하거나 범위 안에 있어야 한다면 데이터베이스가 강제해야 합니다. 그렇지 않으면 백그라운드 잡, 새로운 엔드포인트, 수동 임포트가 그 규칙을 우회할 수 있습니다.
또 다른 실수는 인덱스를 나중 문제로 생각하는 것. 출시 후 인덱스를 추가하면 추측에 기반한 작업이 되고 실제 느린 쿼리가 조인이나 상태 필터인 경우 잘못된 것을 인덱싱할 수 있습니다.
조인 테이블도 조용한 버그의 원천입니다. 조인 테이블이 중복을 막지 않으면 같은 관계가 두 번 저장되어 “사용자가 역할을 두 번 가지고 있다” 같은 문제로 몇 시간을 허비할 수 있습니다.
감사 로그, 소프트 삭제, 이벤트 히스토리가 필요하다는 것을 뒤늦게 깨닫고 테이블을 먼저 만든 뒤에 추가하는 일도 흔합니다. 이런 추가는 엔드포인트와 리포트 전반에 영향을 미칩니다.
마지막으로 JSON 컬럼은 유혹적이지만 핵심 비즈니스 필드를 위해 쓰면 검사와 인덱싱이 어려워집니다. JSON은 진짜로 가변적인 페이로드에만 쓰고 핵심 필드는 컬럼으로 승격시키세요.
코드 생성 전에 빠르게 점검할 목록:
NOT NULL, CHECK, UNIQUE, 외래 키user_id + role_id)여기서 잠시 멈추고 플랜이 모델을 생성해도 깜짝 질문을 유발하지 않을 만큼 충분한지 확인하세요. 목표는 완벽이 아니라 나중에 재작성으로 이어지는 간극(빠진 관계, 불명확한 규칙, 실제 사용과 맞지 않는 인덱스)을 잡는 것입니다.
간단한 사전 점검:
amount >= 0 또는 허용 상태)간단한 건선 테스트: 내일 팀원이 합류한다고 가정했을 때 그 사람이 매시간 “이 필드는 NULL일 수 있나요?” 또는 “삭제하면 어떻게 되죠?” 같은 질문을 계속하지 않고도 첫 엔드포인트를 만들 수 있을까요?
플랜이 분명하고 주요 흐름이 문서상에서 타당하면 실행 가능한 형태로 전환하세요: 실제 스키마와 마이그레이션.
초기 마이그레이션은 테이블, 타입(ENUM 사용 시), 필수 제약을 생성하는 것으로 시작하세요. 첫 버전은 작지만 정확하게 만드세요. 약간의 시드 데이터를 넣고 앱이 실제로 필요로 하는 몇 가지 쿼리를 실행해 보세요. 흐름이 어색하면 마이그레이션 기록이 짧을 때 스키마를 바로 고치면 됩니다.
테이블, 키, 네이밍이 충분히 안정적일 때 모델과 엔드포인트를 생성하세요. 그래야 다음 날 모든 걸 이름 바꾸느라 허비하지 않습니다.
재작성 감소를 위한 실용적 루프:
초기에 DB에서 검증할 것과 API 레이어에서 검증할 것을 결정하세요. 영구 규칙(외래 키, 고유성, CHECK)은 DB에 넣고 자주 바뀌는 복잡한 교차 테이블 로직이나 임시 제한은 API에 두는 것이 좋습니다.
Koder.ai를 사용한다면 우선 플래닝 모드에서 엔티티와 마이그레이션에 합의한 뒤 Go + PostgreSQL 백엔드를 생성하는 것이 합리적입니다. 변경이 꼬이면 스냅샷과 롤백으로 알려진 안정 버전으로 빨리 돌아간 뒤 스키마 계획을 조정하세요.
먼저 스키마를 계획하세요. 스키마는 안정적인 데이터 계약(테이블, 키, 제약)을 정해 생성된 모델과 엔드포인트가 나중에 계속 이름 바꾸거나 재작성할 필요가 없게 합니다.
실무 팁: 엔티티, 관계, 핵심 쿼리를 적고 제약, 인덱스, 마이그레이션을 고정한 뒤 코드를 생성하세요.
앱이 무엇을 기억해야 하고 사용자가 무엇을 할 수 있어야 하는지 2–3문장으로 적는 것이 가장 빠른 시작입니다.
그다음에 적으세요:
이 정도면 과도하게 설계하지 않고 테이블을 디자인할 충분한 명확성이 생깁니다.
반복해서 나오는 명사를 먼저 나열하세요 (user, project, invoice, task 등). 각 엔티티마다 ‘무엇이고 왜 존재하는가’ 한 문장을 추가하세요.
명확히 설명할 수 없다면 items나 misc 같은 모호한 테이블이 생길 가능성이 큽니다.
스키마 전반에 일관된 ID 전략을 사용하는 것이 중요합니다.
사람이 읽기 쉬운 식별자가 필요하면 기본 키로 쓰지 말고 별도의 고유 컬럼(예: project_code)으로 두세요.
관계마다 사용자 기대와 보존 필요성을 기준으로 결정하세요.
보편적인 기본값:
RESTRICT/NO ACTION 사용(예: 고객 → 주문)CASCADE 사용(예: 주문 → 주문 항목)삭제 동작은 API 동작과 엣지 케이스에 큰 영향을 주므로 초기에 결정하세요.
데이터베이스에 영구 규칙을 넣어 모든 쓰기 주체(API, 스크립트, 임포트, 관리자 도구)가 같은 규칙을 따르도록 하세요.
우선순위:
추측이 아니라 실제 쿼리 패턴에서 시작하세요.
먼저 5–10개의 평문 쿼리를 적으세요(필터 + 정렬). 그다음 인덱스를 고르세요:
status, user_id, created_at처럼 자주 필터되는 컬럼(account_id, created_at))모두에 인덱스를 다는 것을 피하세요. 인덱스는 INSERT/UPDATE 비용을 늘립니다.
조인 테이블을 만들고 두 외래 키와 복합 고유 제약을 추가하세요.
예시 패턴:
team_members(team_id, user_id, role, joined_at)UNIQUE(team_id, user_id) 추가로 중복 방지이렇게 하면 같은 관계가 두 번 저장되는 것 같은 미묘한 버그를 예방할 수 있습니다.
기본 권장 타입:
timestamptz (시간대 관련 문제를 줄여줌)numeric(12,2) 또는 센트 정수(부동소수점 금지)CHECK로 강제같은 개념에는 같은 타입을 사용해 일관성을 유지하세요.
작고 검토 가능한 마이그레이션을 사용하고 한 번에 큰 파괴적 변경을 피하세요.
안전한 절차:
시드/참조 데이터도 마이그레이션에 포함해 모든 환경이 동일하도록 하세요.
PRIMARY KEYbelongs to 컬럼에 FOREIGN KEYUNIQUE (이메일, (team_id, user_id) 등)CHECK(예: 음수 금액 금지, 허용 상태)NOT NULL