첫 마이그레이션 전 PostgreSQL 스키마 검증
PostgreSQL 스키마 검증은 첫 마이그레이션이 데이터를 건드리기 전에 잘못된 매핑, 약한 제약 조건, 누락된 인덱스, 위험한 변경을 찾아냅니다.

AI 빌더는 유효한 PostgreSQL을 만들면서도 잘못된 데이터베이스를 추론할 수 있습니다. 문법은 쉬운 부분입니다. 위험한 실수는 그럴듯합니다. 선택 관계가 필수 관계가 되거나, 상태 문자열에 불완전한 CHECK 제약 조건이 붙거나, 살아남아야 할 레코드까지 삭제가 연쇄 전파되거나, 마이그레이션이 테이블을 다시 만들며 조용히 컬럼 하나를 잃을 수 있습니다.
따라서 PostgreSQL 스키마 검증은 의미, 마이그레이션 동작, 복구를 별개의 문제로 시험해야 합니다. 알려진 데이터셋, 명시적인 불변 조건, 대표 쿼리, 파괴적 변경 검토, 복구 리허설을 모두 통과한 경우에만 추론된 스키마를 승인합니다. 하나라도 빠졌다면 마이그레이션은 아직 제안일 뿐입니다.
운영 데이터베이스가 비어 있어도 첫 마이그레이션은 이 정도 검토가 필요합니다. 초기 스키마 오류는 앱 코드, 시드 데이터, 보고서, 후속 마이그레이션이 이에 의존하기 시작하면서 빠르게 굳어집니다. 첫 실행 전 15분을 검토에 쓰는 편이, 6개월 뒤 서로 다른 두 개념이 NULL을 허용하는 텍스트 컬럼 하나를 공유하는 이유를 설명하는 것보다 대개 저렴합니다.
추론된 스키마는 신뢰할 수 없는 명세입니다
추론된 스키마는 실행 가능한 진실이 아니라 명세 초안으로 다루세요. 빌더는 프롬프트, 샘플 화면, 가져온 레코드, 생성된 앱 코드를 봤습니다. 데이터베이스가 결국 담게 될 모든 업무 예외, 보존 규칙, 대량 가져오기, 지원팀의 수정, 실패한 결제를 목격하지는 못했습니다.
팀이 자주 섞어 생각하는 세 가지 질문부터 분리하세요. 스키마 정확성은 테이블과 제약 조건이 도메인을 모델링하는지 묻습니다. 마이그레이션 안전성은 제안된 작업이 기존 데이터를 보존하고 실행 중 데이터베이스를 쓸 수 있게 하는지 묻습니다. 복구 준비 상태는 부분적으로 실패했거나 의미가 잘못된 변경 뒤 알려진 상태로 돌아갈 수 있는지 묻습니다. 하나를 통과해도 나머지 둘은 거의 말해 주지 않습니다.
CREATE TABLE 문은 의도한 최종 구조를 설명하면서도 위험한 작업을 거쳐 도달할 수 있습니다. 빌더가 customer_name text를 customer_id bigint로 바꾼다고 해 보겠습니다. 최종 외래 키는 타당할 수 있지만, 과거의 이름을 고객과 매칭하기 전에 이름 컬럼을 삭제하는 마이그레이션은 매칭에 필요한 유일한 증거를 없앱니다. 스키마 검토는 목적지를 승인하고, 마이그레이션 검토는 여정을 살핍니다.
제안한 모델을 도메인 언어로 소리 내 읽어 보세요. invoices.customer_id가 customers.id를 참조한다고 말하는 대신, 각 청구서는 정확히 하나의 법적 고객에 속한다고 말하세요. 첫 문장은 유용한 반론을 부릅니다. 고객 선택 전에도 초안이 존재할 수 있고, 가져온 청구서는 보관된 고객을 가리킬 수 있으며, 법적 기록에는 발행 시점의 고객 이름을 고정해야 할 수 있습니다. SQL 용어는 이런 의견 차이를 숨길 수 있습니다.
추론한 각 테이블 옆에 가정 메모를 요구하세요. 한 행이 무엇을 뜻하는지, 행을 어떻게 식별하는지, 누가 소유하는지, 겉보기 부모 없이 존재할 수 있는지, 삭제가 무엇을 뜻하는지를 적어야 합니다. 팀이 이 질문에 답하지 못한다면, 빌더는 팀이 설계하지 않은 데이터베이스를 추측한 것입니다.
알려진 레코드가 잘못된 테이블 매핑을 드러냅니다
알려진 데이터셋에는 의미를 폭넓게 담도록 고른 레코드가 있어야 합니다. 큰 무작위 표본은 같은 쉬운 사례를 반복하는 경우가 많습니다. 신중히 고른 10개 레코드는 거의 동일한 정상 경로 행 1만 개보다 더 많은 문제를 드러낼 수 있습니다.
DDL을 실행하기 전에 매핑 행렬을 만드세요. 행렬의 한 행은 각 원본 개념이 제안된 목적지로 어떻게 가는지와 기대 행 수 또는 값을 추적해야 합니다. 주문 앱이라면 다음과 같을 수 있습니다.
| 알려진 사실 | 제안된 목적지 | 기대 결과 |
|---|---|---|
| 주문 A에는 품목이 두 개 있다 | orders 및 order_items | 주문 행 1개와 자식 행 2개 |
| 주문 B에는 할당된 계정이 없다 | orders.account_id | account가 NULL인 행 1개 |
| 두 사람이 같은 이메일을 쓴다 | contacts.email | 고유성이 명시된 규칙이 아니라면 두 행 모두 보존 |
| 제품 코드에는 앞자리 0이 있다 | products.code | 텍스트 값 00417이 그대로 유지 |
| 취소된 주문은 청구 내역을 보존한다 | orders 및 charges | 취소 뒤에도 청구 행 유지 |
이 과정은 제약 조건의 세부 사항이 검토를 흐리기 전에 테이블 매핑 오류를 잡습니다. AI 빌더는 반복된 객체를 별도 테이블로 정규화하는데, 대체로 타당합니다. 하지만 반복된다고 동일한 대상을 뜻하지는 않습니다. 텍스트가 같은 배송 주소 두 개는 수정 가능한 주소 행 하나를 가리키는 참조가 아니라 과거 스냅샷일 수 있습니다. 이를 합치면 나중의 주소 수정이 과거를 다시 씁니다.
반대 오류도 생깁니다. 빌더는 화면에 고객 필드와 주문 필드가 함께 보인다는 이유로 고객 필드를 모든 주문에 복사할 수 있습니다. 어떤 값은 고객에게 속하고, 다른 값은 주문 스냅샷으로 남아야 합니다. 올바른 설계에는 customer_id와 billing_name 같은 발행 문서 필드가 모두 있을 수 있습니다. 이를 중복으로 여기고 한쪽을 삭제하면 현재 신원 또는 과거의 사실 중 하나를 잃습니다.
앱이 사용할 것과 같은 가져오기 또는 시드 경로로 알려진 데이터셋을 일회용 데이터베이스에 넣으세요. 그다음 단순 행 수가 아니라 사실을 대상으로 단언문을 작성합니다.
SELECT
(SELECT count(*) FROM orders WHERE external_id = 'ORDER-A') AS order_a,
(SELECT count(*) FROM order_items i
JOIN orders o ON o.id = i.order_id
WHERE o.external_id = 'ORDER-A') AS order_a_items,
(SELECT account_id IS NULL FROM orders
WHERE external_id = 'ORDER-B') AS order_b_unassigned;
통과한 결과 형태를 명시하세요.
order_a | order_a_items | order_b_unassigned
---------+---------------+---------------------
1 | 2 | t
생성된 앱 화면이 정상으로 보인다는 이유로 설명되지 않는 차이를 받아들이지 마세요. UI는 중복 부모, 누락된 자식, 잘린 코드, 지어낸 기본값을 숨길 수 있습니다. 운영 배포를 논의하기 전에 의도적으로 만든 모든 픽스처를 대조하세요.
제약 조건은 도메인 사실을 담아야 합니다
데이터베이스 제약 조건은 어떤 화면, API, 가져오기, 수정 스크립트가 행을 쓰더라도 항상 잘못인 상태를 거부해야 합니다. 규칙에 예외가 있거나 바뀌는 외부 사실에 의존한다면, 이를 단순한 제약 조건에 억지로 넣으면 작업을 막거나 부정직한 데이터를 만들기 쉽습니다.
기본 키는 행을 식별하지만 의미 있는 비즈니스 식별자를 자동으로 제공하지는 않습니다. 내부 bigint ID와 테넌트 범위의 고유 주문 번호는 함께 존재할 수 있습니다. 비즈니스 규칙이 테넌트별 주문 번호 고유성을 요구한다면 UNIQUE (tenant_id, order_number)가 이를 표현합니다. 전역 고유 제약 조건은 정상 레코드를 거부하고, 제약 조건이 없으면 재시도 중 모호함을 허용합니다.
CHECK 제약 조건은 quantity > 0 또는 finished_at >= started_at 같은 안정적인 행 사실에 맞습니다. PostgreSQL 설명서는 데이터베이스가 CHECK 표현식이 제약 조건이 존재하는 동안 불변이라고 가정한다고 설명합니다. 따라서 나중에 동작이 바뀌는 함수를 CHECK에서 호출하면 기존 행이 겉보기 규칙을 위반한 상태로 남을 수 있습니다. 고정된 사실에는 고정된 표현식을 쓰세요. 관리자가 제어하는 현재 허용 집합처럼 바뀌는 정책은 참조 테이블이나 앱 워크플로에 두세요.
생성된 상태 제약 조건은 의심해야 합니다. 빌더는 현재 예시를 보고 다음을 만들 수 있습니다.
status text NOT NULL
CHECK (status IN ('draft', 'active', 'closed'))
이는 그것이 완전하고 오래 유지될 상태 집합일 때만 맞습니다. 실패, 취소, 중지, 가져온 데이터, 알 수 없는 레거시 레코드를 물어보세요. 상태 머신이 계속 바뀐다면 조회 테이블로 추가를 명시할 수 있지만, 전이 검증을 대신하지는 못합니다. 행에 closed를 넣을 수 있다는 사실은 draft에서 직접 closed로 바뀌어도 되는지 말해 주지 않습니다.
고유성은 의도적으로 사용하세요. PostgreSQL은 고유 제약 조건을 고유 B-tree 인덱스로 구현하지만, 부분 고유 인덱스는 다른 규칙을 표현합니다. 소프트 삭제에는 보통 살아 있는 행 사이에서만 고유성이 필요합니다.
CREATE UNIQUE INDEX users_tenant_email_live_uq
ON users (tenant_id, lower(email))
WHERE deleted_at IS NULL;
이는 UNIQUE (tenant_id, email, deleted_at)와 바꿔 쓸 수 없습니다. PostgreSQL은 고유성 규칙에 따라 NULL을 다루며, 삭제 시각을 추가하면 강제하는 식별성이 달라집니다. 컬럼 목록만 보고 동작을 추론하지 말고 픽스처로 정확한 중복 사례를 검토하세요.
NULL 허용 여부는 비즈니스 결정입니다
도메인이 모든 정상 행에 값을 요구하고 모든 쓰기 경로가 값을 제공할 수 있을 때만 컬럼을 NOT NULL로 설정하세요. 화면 설계는 약한 증거입니다. 현재 양식의 필수 필드는 가져오기, 초안, 시스템 생성 행, 과거 레코드에 관해 아무것도 말해 주지 않습니다.
네 상태를 따로 검토하세요. 원본이 필드를 생략한 경우, 원본이 명시적으로 null을 보낸 경우, 원본이 빈 값을 보낸 경우, 원본이 의미 있는 값을 제공한 경우입니다. JSON API, 양식, CSV 가져오기, PostgreSQL은 이 상태를 다르게 처리할 수 있습니다. 앱이 삽입 전에 네 상태를 하나로 합친다면 데이터베이스가 해결했다고 가장하지 말고 스키마 검토에서 그 결정을 드러내야 합니다.
기본값도 같은 주의가 필요합니다. 기본값은 INSERT가 컬럼을 생략할 때 값을 넣습니다. 명시적인 NULL을 고치지 않고, 그 값이 사실임을 증명하지도 않습니다. 알 수 없는 국가가 가능하다면 country_code DEFAULT 'US'는 위험합니다. 이제 행에는 보고서와 컴플라이언스 로직이 믿을 수 있는 자신감 있는 거짓말이 들어갑니다.
흔한 생성형 마이그레이션은 한 문으로 필수 컬럼을 추가합니다.
ALTER TABLE customers
ADD COLUMN account_type text NOT NULL DEFAULT 'standard';
문은 실행될 수 있지만, 모든 과거 고객이 근거 없이 standard가 됩니다. 더 안전한 순서는 NULL 허용 컬럼을 추가하고, 알려진 데이터에서 값을 도출하고, 해결되지 않은 행을 측정하고, 앱의 새 쓰기에서 누락을 막은 뒤, 도메인이 뒷받침할 때만 NOT NULL을 추가하는 것입니다. 알 수 없음이 정상이라면 NULL을 유지하고 쿼리와 인터페이스에서 어떻게 표시할지 정하세요.
PostgreSQL은 일부 제약 조건에 유용한 분리를 제공합니다. CHECK나 외래 키는 NOT VALID로 추가할 수 있어 생성 시 기존 행 전체를 검증하지 않고, 나중에 VALIDATE CONSTRAINT로 검사할 수 있습니다. 설명서는 이를 초기 테이블 스캔을 미루는 방법으로 문서화합니다. 오래된 위반을 무시해도 된다는 뜻은 아닙니다. 새 쓰기에는 제약이 적용되고, 승인 전 검증 단계도 통과해야 합니다.
NULL 허용 여부를 강화하기 전에 실제 범주를 보여 주는 분포 쿼리를 실행하세요.
SELECT
count(*) AS total,
count(*) FILTER (WHERE account_type IS NULL) AS nulls,
count(*) FILTER (WHERE account_type = '') AS empty_strings,
count(*) FILTER (WHERE account_type NOT IN
('standard', 'partner', 'internal')) AS unexpected
FROM customers;
AI가 만든 기본값은 마이그레이션 후 이 쿼리를 깨끗하게 보이게 할 수 있습니다. 백필 전에도 실행하고 결과를 보존하세요. 그렇지 않으면 도출한 값과 지어낸 값을 구분할 증거를 잃습니다.
인덱스는 관측한 접근 패턴에 답해야 합니다
인덱스는 알려진 쿼리를 지원하거나, 명시한 고유성 규칙을 강제하거나, 운영 요구 사항을 가능하게 할 때 승인하세요. 식별자처럼 보이는 모든 컬럼을 인덱싱하면 저장 공간과 쓰기 작업을 낭비하고, 중요한 복합 인덱스 하나가 없으면 일상적인 목록 화면이 계속 커지는 스캔이 될 수 있습니다.
생성된 앱이 실제로 실행하는 쿼리부터 시작하세요. 필터 컬럼, 테넌트 경계, 조인 컬럼, 정렬, 예상 결과 크기를 기록합니다. 최근 주문 페이지에서는 테이블 다이어그램보다 이 쿼리 형태가 중요합니다.
SELECT id, order_number, status, created_at
FROM orders
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
tenant_id만 있는 인덱스는 여전히 많은 테넌트 행을 검사하고 정렬할 수 있습니다. (tenant_id, status, created_at DESC) 인덱스는 이 접근 패턴에 더 가깝습니다. 컬럼 순서는 인기투표가 아닙니다. 실제 쿼리의 동등 조건, 범위 조건, 정렬, 선택도를 따릅니다.
대표 데이터에서 EXPLAIN (ANALYZE, BUFFERS)를 실행하되, 작은 픽스처 하나를 성능 증명으로 여기지 마세요. PostgreSQL은 작은 테이블에서 순차 스캔을 선택하는 편이 맞을 수 있습니다. 검증은 의도한 인덱스가 있는지와 운영 규모 리허설이 플래너에게 현실적인 선택지를 주는지를 확인해야 합니다. 승인을 위해 인덱스 스캔을 억지로 만들려고 순차 스캔을 비활성화하지 마세요.
외래 키에는 또 하나의 흔한 문제가 있습니다. PostgreSQL은 참조되는 기본 키나 고유 컬럼은 인덱싱하지만, 참조하는 자식 컬럼에는 자동으로 인덱스를 만들지 않습니다. 따라서 부모를 삭제하거나 갱신하면 참조를 확인하기 위해 자식 테이블을 스캔할 수 있습니다. 자식에서 부모로 조인할 때도 그 자식 인덱스가 필요할 수 있습니다. 예상 읽기와 부모 변경을 기준으로 각 관계를 검사하세요.
초기 제안에서 중복되거나 쓰이지 않는 인덱스는 거부하세요. 적절한 (tenant_id, status, created_at) 인덱스가 있다면 (tenant_id, status)는 중복일 수 있지만, 워크로드 세부 사항에 따라 판단은 달라질 수 있습니다. 이름이 아니라 정의를 비교하세요. AI 빌더는 기능마다 인덱스를 하나씩 만들고, 여러 기능이 같은 선행 컬럼을 요구했다는 점을 놓치기 쉽습니다.
기존의 바쁜 데이터베이스에서는 CREATE INDEX CONCURRENTLY가 트랜잭션 블록 안에서 실행될 수 없고, 더 많은 작업이 필요하며, 실패 뒤 유효하지 않은 인덱스를 남길 수 있음을 기억하세요. PostgreSQL 설명서는 이런 운영상 차이를 분명히 설명합니다. 모든 마이그레이션을 트랜잭션으로 감싸는 프레임워크에는 희망적인 키워드 치환이 아니라 명시적인 예외와 정리 절차가 필요합니다.
외래 키에는 소유권과 삭제 규칙이 필요합니다
외래 키는 관계가 소유, 참조, 선택적 맥락, 과거 귀속 중 무엇을 뜻하는지 팀이 결정한 뒤에야 맞다고 할 수 있습니다. 비슷해 보이는 컬럼도 정반대의 삭제 동작이 필요할 수 있습니다.
projects.owner_user_id, invoices.customer_id, audit_events.actor_user_id를 생각해 보세요. 프로젝트는 소유자를 바꿀 수 있습니다. 청구서는 고객 계정이 닫혀도 살아남아야 할 수 있습니다. 감사 이벤트는 신원 데이터가 제거된 뒤에도 행위자의 이전 식별자를 보존해야 할 수 있습니다. 모두 users를 참조한다는 이유로 ON DELETE CASCADE를 적용하면 파괴적인 허구를 모델링하게 됩니다.
자식이 부모 없이는 의미가 없고 부모 삭제가 실제로 전체 집합의 삭제를 뜻할 때 CASCADE를 사용하세요. 주문 품목은 흔히 여기에 맞습니다. 결제 기록, 발행 문서, 가져오기, 로그, 검토 증거는 대개 그렇지 않습니다. 이런 경우 삭제 거부, 보관, 통제된 익명화 또는 보존된 스냅샷 필드와 짝을 이룬 NULL 허용 참조가 더 맞을 수 있습니다.
SET NULL도 의미 검토가 필요합니다. 자식 행은 보존하지만 직접 관계는 지웁니다. 나중에 직원이 어떤 계정이 보고서를 만들었는지 설명해야 한다면 NULL 참조만으로는 부족할 수 있습니다. 식별성이 없는 과거 토큰이나 스냅샷을 보관하면 모든 개인 정보를 보관하지 않고도 책임 추적을 유지할 수 있지만, 정확한 보존 선택은 AI의 추측이 아니라 제품 정책에 속합니다.
양방향의 카디널리티를 확인하세요. 빌더는 고유 제약 조건 없이 외래 키를 두어 일대일을 모델링하고, 여러 자식 행을 조용히 허용할 수 있습니다. 반대로 이력이 여러 버전을 필요로 하는 곳에 고유성을 강제할 수도 있습니다. 자식이 0개, 1개, 여러 개인 부모의 픽스처를 만들고 어떤 INSERT가 통과해야 하는지 적으세요.
지연 가능한 제약 조건에는 구체적인 이유가 필요합니다. 트랜잭션이 일시적으로 참조 순서를 위반하거나 서로 의존하는 행을 갱신해야 할 때 도움이 될 수 있지만, 모든 외래 키를 기본적으로 지연시키면 오류가 커밋 시점으로 밀리고 실패 원인을 찾기 어려워집니다. 실제 트랜잭션 순서가 연기를 요구하지 않는 한 즉시 강제를 유지하세요.
리허설 데이터베이스에 마이그레이션을 적용한 뒤 카탈로그를 검사하세요.
SELECT
conname,
contype,
convalidated,
pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'public.orders'::regclass
ORDER BY conname;
대표 출력은 다음과 같은 형태입니다.
conname | contype | convalidated | definition
----------------------+---------+--------------+---------------------------------------
orders_pkey | p | t | PRIMARY KEY (id)
orders_customer_fk | f | t | FOREIGN KEY (customer_id) REFERENCES customers(id)
orders_total_check | c | t | CHECK ((total_cents >= 0))
반환된 정의를 승인한 소유권 규칙과 비교하세요. 마이그레이션 성공만으로는 누락된 동작, 예상 밖 지연, 검증되지 않은 제약 조건을 알 수 없습니다.
파괴적 변경은 그럴듯한 SQL 안에 숨어 있습니다
깔끔해 보이는 DDL도 명백한 DROP TABLE 없이 의미를 버릴 수 있으므로, 마이그레이션을 데이터 변환으로 검토하세요. 먼저 직접 파괴를 찾고, 그다음 형 변환, 백필, 재작성, 이름 변경, 제약 조건 교체를 검사합니다.
최종 스키마가 같아 보여도 이름 변경과 삭제는 운영상 다릅니다. surname이 family_name이 된다면 이름 변경이 데이터와 의존성을 더 충실히 보존합니다. 기존 컬럼을 삭제하고 새 컬럼을 추가하면 다이어그램은 같지만 모든 값이 비게 됩니다. 생성된 마이그레이션은 연속성을 이해하지 못한 채 최종 상태를 추론하는 경우가 많습니다.
형 변경에는 변환 표본과 거부 사례가 필요합니다. 텍스트 식별자를 정수로 바꾸면 앞자리 0이 사라지거나 혼합 식별자가 거부될 수 있습니다. 숫자 정밀도를 줄이면 값이 반올림될 수 있습니다. 타임스탬프 변환에는 명시한 시간대 가정이 필요합니다. 컬럼을 바꾸기 전에 최소값, 최대값, NULL, 잘못된 값, 과거의 특이값으로 실제 USING 표현식을 시험하세요.
테이블 또는 컬럼 삭제, 손실 캐스트를 통한 형 변경, 채워진 컬럼 교체, CASCADE 추가, 생성된 백필 뒤 NOT NULL 설정, 다른 컬럼으로 고유성 재구축에는 서면 근거를 요구하세요. 생성된 함수나 마이그레이션 콜백에 들어 있는 원시 SQL도 검사합니다. 텍스트 검색은 시작 필터일 뿐, 검토의 전부가 아닙니다.
한 실패 패턴이 반복됩니다. 알려진 데이터에는 선택적 회사가 있는 연락처가 있지만, 샘플 화면에는 기업 연락처만 보입니다. 빌더는 contacts.company_id를 NOT NULL로 만들고 매칭되지 않은 행에는 Unknown이라는 생성된 회사를 넣습니다. 마이그레이션은 통과하고, 개수는 맞으며, 외래 키도 모두 검증됩니다. 그러나 데이터는 여전히 틀렸습니다. 개인 연락처가 회사에 속한 것처럼 보이고, 보고서는 관련 없는 사람을 함께 묶으며, 플레이스홀더를 삭제하면 실제 연락처까지 연쇄 삭제될 수 있습니다.
해결책은 또 다른 기본값이 아닙니다. 원본 상태를 복원하고, 관계가 NULL을 허용하게 만들고, 증거가 뒷받침하는 매칭만 마이그레이션하고, 매칭되지 않은 집합이 알려진 개인 연락처와 같다는 단언문을 추가하세요. 그래서 의미 픽스처에는 기대 행 수뿐 아니라 기대 관계도 기록해야 합니다.
스키마 차이 도구는 도움이 되지만, 차이만 보고 승인하는 데에는 반대합니다. 차이는 짧고 검토하기 쉬워서 권장되기 쉽습니다. 하지만 백필 값의 출처, 트랜잭션 경계, 잠금 동작, 마이그레이션 후의 사실은 보여 주지 않으므로 유일한 관문으로 삼는 것은 잘못입니다.
리허설은 결과와 실패 동작을 증명해야 합니다
알려진 데이터셋을 일회용으로 복원한 환경에서 전체 마이그레이션을 실행한 뒤, 의도한 결과와 중단되거나 거부되는 경로를 모두 시험하세요. 새 빈 데이터베이스는 순서 오류를 잡는 데 유용하지만, 손실 변환, 잘못된 과거 행, 느린 검증은 드러내지 못합니다.
다음 리허설 순서를 릴리스 산출물로 쓰세요.
- 변경 전 데이터셋을 격리된 데이터베이스에 복원하고 행 수와 의미 단언문을 기록합니다.
- 현재 스키마를 캡처하고, 정확한 마이그레이션 산출물을 적용한 뒤 시간과 트랜잭션 경계를 포함한 모든 출력을 저장합니다.
- 카탈로그 검사, 매핑 단언문, 제약 조건 거부 테스트, 대표 앱 쿼리를 실행합니다.
- 매칭되지 않은 값과 NULL 범주를 포함해 중요한 값을 기록한 기대값과 비교합니다.
- 문서화한 복구 방법을 실행하고, 복구된 데이터베이스에서 마이그레이션 전 단언문을 다시 실행합니다.
전후로 스키마 전용 덤프를 캡처하세요.
pg_dump --schema-only --no-owner --no-privileges \
--dbname "$DATABASE_URL" > schema.sql
앱과 관련된 테이블, 시퀀스, 인덱스, 제약 조건, 함수, 트리거, 확장 기능, 권한을 검토하세요. ORM 모델 차이는 특히 트리거, 표현식 인덱스, 부분 인덱스, 수동 설치 함수처럼 앱 모델에 없는 데이터베이스 객체를 누락할 수 있습니다.
제약 조건이 잘못된 상태를 거부한다는 음성 테스트를 추가하세요. 테스트 트랜잭션은 결과와 관계없이 잘못된 INSERT를 시도하고 롤백할 수 있습니다.
BEGIN;
INSERT INTO order_items (order_id, quantity, unit_price_cents)
VALUES (1001, 0, 2500);
ROLLBACK;
기대 출력에는 위반한 제약 조건 이름이 있어야 합니다.
ERROR: new row for relation "order_items" violates check constraint "order_items_quantity_check"
DETAIL: Failing row contains (..., 0, 2500, ...).
세부 정보는 달라질 수 있으므로 모든 환경에서 오류 전체 텍스트를 비교하지 마세요. 자동 테스트에서는 SQLSTATE나 제약 조건 식별자를 단언하고, 검토자에게는 읽기 쉬운 출력을 보관하세요.
의도한 배포 규모와 비슷한 데이터셋에서 잠금과 시간을 측정하세요. 50행에서는 즉시 끝나는 작업도 수백만 행을 검증할 때는 쓰기를 막을 수 있습니다. 초기 운영 데이터베이스가 비어 있다면 즉각적인 위험은 낮지만, 리허설은 가져온 시드 데이터를 시험하고 이후 변경의 기준선을 만듭니다.
복구에는 역방향 마이그레이션 이상이 필요합니다
복구는 서비스가 감당할 수 있는 시간 안에 데이터와 앱 호환성을 되돌릴 수 있을 때만 믿을 만합니다. 삭제한 컬럼을 다시 만드는 역방향 마이그레이션은 이전 값을 복구하지 못합니다.
실행 전에 복구 단위를 정하세요. 비어 있는 초기 데이터베이스라면 사용자 쓰기가 시작되지 않은 경우 데이터베이스를 삭제하고 다시 만드는 것이 가능할 수 있습니다. 실제 쓰기가 생긴 뒤에는 데이터베이스 스냅샷, 논리 백업, 이전 컬럼 보존, 전진 수정이 필요할 수 있습니다. 올바른 방법은 마이그레이션 중과 후에 얼마나 많은 새 데이터가 들어올 수 있는지에 달려 있습니다.
의존하기 전에 복원 명령과 자격 증명을 시험하세요. 백업이 있어도 배포 담당자가 복원할 수 없다면 복구 계획이 아닙니다. 별도 데이터베이스로 복원하고 소유권과 확장 기능을 확인한 뒤, 마이그레이션 전과 같은 알려진 단언문을 실행하세요.
스냅샷과 트랜잭션 롤백은 서로 다른 실패를 해결합니다. 모든 작업이 트랜잭션에 참여한다면 마이그레이션이 커밋 전에 실패했을 때 트랜잭션은 문을 되돌릴 수 있습니다. 스냅샷은 전체 데이터베이스를 이전 상태로 돌릴 수 있지만, 스냅샷 뒤에 발생한 정상 쓰기도 버릴 수 있습니다. 어느 방법도 그 쓰기를 자동으로 조정하지는 않습니다.
불확실성이 남아 있다면 추가형 변경을 선호하세요. 새 컬럼이나 테이블을 추가하고, 측정 가능한 규칙으로 데이터를 복사하고, 필요하다면 통제된 기간 동안 두 코드 경로를 모두 실행하고, 검증 후에야 이전 구조를 제거하세요. 이 확장 및 축소 방식은 추가 작업이 들지만 증거를 보존합니다. 이름을 바꾼 이전 컬럼을 한 릴리스 동안 유지하는 편이 로그에서 이를 재구성하는 것보다 흔히 저렴합니다.
복구 트리거를 미리 작성하세요. 예로는 의미 단언문 실패, 예상 밖의 매칭되지 않은 레코드, 유효하지 않은 제약 조건, 승인된 잠금 시간을 넘긴 마이그레이션, 버전 불일치로 인한 앱 오류가 있습니다. 사용자가 기다리는 동안 운영자가 결정을 즉흥적으로 만들게 해서는 안 됩니다.
이전 데이터베이스를 복원하려면 이전 앱도 복원해야 하는 시점을 기록하세요. 새 앱은 새 컬럼에 의존할 수 있고, 이전 앱은 새 enum 값을 거부하거나 이전 형태로 쓸 수 있습니다. 데이터베이스와 앱 복구에는 호환되는 버전이 필요합니다.
승인은 자신감이 아니라 증거를 요구합니다
저장한 산출물만으로 다른 사람이 왜 안전한지 재현할 수 있을 때만 첫 마이그레이션을 승인하세요. 깨끗한 코드 검토나 매끈한 생성형 인터페이스에서 나온 자신감은 첫 번째 설명할 수 없는 데이터 불일치를 견디지 못합니다.
승인 기록에는 추론한 가정, 테이블 매핑 행렬, 알려진 데이터셋의 식별 정보, 스키마 차이, 정확한 마이그레이션, 검증 쿼리와 결과, 거부된 입력 테스트, 인덱스 근거, 파괴적 작업의 정당화, 시험한 복구 절차가 들어가야 합니다. 검토자 이름을 기록하고 해결되지 않은 결정은 채팅 기록에 묻지 말고 차단 요인으로 보존하세요.
Koder.ai에서 앱을 생성한다면, 마이그레이션을 허용하기 전에 계획 모드로 이 스키마 결정을 적고, 검토를 위해 소스를 내보내고, 스냅샷과 롤백도 알려진 데이터셋으로 리허설해야 하는 복구 도구로 다루세요.
테스트가 통과할 때까지 코드를 다시 생성하게 하여 빌더가 자신의 추론을 승인하게 두지 마세요. 그 반복은 모델을 고치는 대신 앱이 잘못된 스키마에 맞춰지게 할 수 있습니다. 특히 식별, 삭제, 보존, 알 수 없는 값에서는 데이터베이스가 도메인과 맞는지 사람이 결정해야 합니다.
최종 승인 쿼리는 지루해야 합니다. 알려진 모든 사실은 하나의 기대 결과에 매핑되고, 모든 제약 조건은 의도한 반례를 거부하며, 모든 파괴적 작업에는 이유가 있고, 복원은 마이그레이션 전 단언문을 재현합니다. 불일치를 변명하기 위해 설득력 있는 설명이 필요하다면 마이그레이션을 중단하세요. PostgreSQL은 빌더가 잘못 추측한 부분까지 포함해 스키마를 정확히 강제합니다.
자주 묻는 질문
AI가 생성한 PostgreSQL 스키마에서 무엇을 확인해야 하나요?
생성된 DDL, 마이그레이션 작업, 그리고 그 뒤의 가정을 모두 확인하세요. 최종 스키마가 맞더라도 데이터를 삭제하거나 쓰기를 막거나 오해를 부르는 기본값을 만들어 내는 마이그레이션으로 도달할 수 있습니다.
스키마 검증용 데이터셋은 얼마나 커야 하나요?
일반 행, 경계값, 빠진 관계, 중복, NULL, 빈 문자열, 과거의 예외 사례를 담은 작은 데이터셋을 쓰세요. 목적은 양이 아니라 운영 데이터보다 먼저 빌더의 가정을 반증하는 데 있습니다.
테스트 마이그레이션이 성공하면 스키마가 안전하다는 뜻인가요?
아닙니다. 성공한 마이그레이션은 PostgreSQL이 해당 데이터베이스 상태에서 문을 받아들였다는 뜻일 뿐입니다. 테이블 매핑이 맞는지, 데이터 의미가 보존됐는지, 인덱스가 실제 쿼리를 지원하는지, 복구가 되는지는 증명하지 못합니다.
PostgreSQL 컬럼은 언제 NOT NULL이어야 하나요?
모든 정상 레코드에 값이 있고 모든 쓰기 경로에서 앱이 그 값을 제공할 수 있을 때만 NOT NULL이어야 합니다. 제약 조건을 맞추기 위해 지어낸 기본값을 쓰지 마세요. 눈에 보이는 누락 데이터를 그럴듯한 거짓 데이터로 바꾸게 됩니다.
고유 제약 조건과 고유 인덱스 중 무엇을 써야 하나요?
고유 제약 조건은 다른 데이터베이스 객체가 참조할 수 있는 규칙을 표현하며 PostgreSQL은 이를 인덱스로 뒷받침합니다. 고유 인덱스는 삭제되지 않은 레코드나 정규화한 이메일 주소처럼 특정 행이나 표현식에만 고유성이 적용될 때 유용합니다.
PostgreSQL 외래 키는 자동으로 인덱스를 만들나요?
부모 행을 찾는 컬럼, 자주 쓰는 쿼리의 필터, 대형 테이블 조인, 고유성 강제에 쓰이는 컬럼을 인덱싱하세요. PostgreSQL은 외래 키의 참조하는 쪽을 자동 인덱싱하지 않으므로, 제약 조건이 처리했을 것이라 가정하지 말고 자식 컬럼을 따로 살펴야 합니다.
ON DELETE CASCADE는 언제 안전한가요?
부모가 사라진 뒤 자식 행이 독립적인 의미를 전혀 갖지 않을 때만 CASCADE를 쓰세요. 삭제가 비즈니스 판단이거나 자식이 청구서나 감사 기록 같은 증거라면, 삭제를 거부하거나 명시적인 절차로 처리하세요.
마이그레이션에서 파괴적인 변경을 어떻게 찾나요?
모든 DROP, 손실이 있는 형 변환, 테이블 재작성, 새 필수 컬럼, 교체되는 제약 조건을 잠재적으로 파괴적이라고 보세요. 마이그레이션 텍스트를 검색하되, 파괴적 동작은 생성된 함수와 원시 SQL 안에도 숨어 있을 수 있으므로 함께 검사하세요.
마이그레이션 복구를 가장 안전하게 테스트하는 방법은 무엇인가요?
변경 전 데이터베이스를 별도 위치에 복원하고, 그곳에서 마이그레이션을 실행한 뒤 의미 검증 쿼리를 수행해 기록된 기대값과 비교하세요. 역방향 마이그레이션만 시험하면 삭제된 데이터를 놓치고 잘못된 확신을 얻을 수 있습니다.
스키마를 승인한 뒤 어떤 증거를 보관해야 하나요?
생성된 DDL, 마이그레이션 텍스트, 스키마 차이, 검증 쿼리와 결과, 복구 절차, 검토자 정보를 함께 보관하세요. 이 기록은 무엇을 승인했는지 설명하고, 다음 마이그레이션 검토에서 달라진 가정을 찾게 해 줍니다.