코드와 테스트에 앞서 PostgreSQL의 NOT NULL, CHECK, UNIQUE, FOREIGN KEY 제약을 활용해 AI가 생성한 앱을 더 안전하게 배포하세요.

AI가 작성한 코드는 종종 정상 흐름(happy path)을 잘 처리해서 올바르게 보입니다. 하지만 실제 애플리케이션은 중간의 지저분한 상황에서 실패합니다: 폼이 null 대신 빈 문자열을 보내거나, 백그라운드 작업이 재시도되어 동일한 기록을 두 번 만들거나, 삭제가 부모 행을 지우고 자식 레코드를 고아로 남기기도 합니다. 이런 문제들은 희귀한 버그가 아닙니다. 필수 필드가 비어 보이거나, “고유” 값이 중복되거나, 아무것도 가리키지 않는 고아 행으로 나타납니다.
이런 문제들은 코드 리뷰나 기본 테스트를 통과하기 쉽습니다. 그 이유는 단순합니다: 리뷰어는 의도를 읽지, 모든 엣지 케이스를 확인하지 않습니다. 테스트는 보통 몇 가지 전형적인 예만 커버하고, 수 주간의 실제 사용자 행동, CSV에서의 임포트, 불안정한 네트워크 재시도, 동시 요청 등은 다루지 않습니다. 어시스턴트가 생성한 코드라면 공백 제거, 범위 검증, 레이스 컨디션 방지 같은 작은 하지만 치명적인 검사들을 놓치기 쉽습니다.
“제약 우선, 코드 차선” 접근은 데이터베이스에 불가역적 규칙을 넣어 어떤 코드 경로가 쓰기를 시도하든 잘못된 데이터가 저장되지 않게 하는 뜻입니다. 사용자에게는 더 나은 오류 메시지를 주기 위해 애플리케이션에서 입력을 검증하는 것이 여전히 필요하지만, 데이터베이스가 최종 진실을 강제해야 합니다. PostgreSQL 제약은 바로 이런 상황에서 빛을 발합니다. 전체 카테고리의 실수를 방지해 줍니다.
간단한 예를 들어보면: 작은 CRM이 있다고 가정합시다. AI가 생성한 임포트 스크립트가 연락처를 만듭니다. 어떤 행은 이메일이 ""(빈 문자열)이고, 두 행은 서로 다른 대소문자로 같은 이메일을 반복하며, 한 연락처는 계정이 다른 프로세스에서 삭제돼 존재하지 않는 account_id를 참조합니다. 제약이 없다면 이런 데이터는 프로덕션에 그대로 쌓여 나중에 리포트를 망가뜨립니다.
적절한 데이터베이스 규칙이 있으면 그런 쓰기는 즉시 실패합니다. 발생 지점에 가깝게 차단됩니다. 필수 필드는 빠져 있을 수 없고, 재시도 중 중복이 생길 수 없으며, 관계는 삭제되었거나 존재하지 않는 레코드를 가리킬 수 없고, 값은 허용 범위를 벗어날 수 없습니다.
제약이 모든 버그를 막지는 못합니다. 혼란스러운 UI나 잘못된 할인 계산, 느린 쿼리를 고쳐주진 않습니다. 하지만 잘못된 데이터가 조용히 쌓이는 것을 막아 주며, 그게 종종 “AI가 만든 엣지 케이스 버그”가 비용으로 이어지는 지점입니다.
애플리케이션은 흔히 하나의 코드베이스가 하나의 사용자와만 통신하는 구조가 아닙니다. 일반적인 제품에는 웹 UI, 모바일 앱, 관리자 화면, 백그라운드 작업, CSV 임포트, 그리고 때로는 서드파티 통합이 포함됩니다. 각 경로는 데이터를 생성하거나 변경할 수 있습니다. 모든 경로가 동일한 규칙을 기억해야 한다면, 어느 하나는 반드시 잊게 됩니다.
데이터베이스는 그 모든 경로가 공유하는 장소입니다. 데이터베이스를 최종 심사관으로 다루면 규칙이 자동으로 모두에게 적용됩니다. PostgreSQL 제약은 “우리는 이게 항상 참이라고 가정한다”를 “이것이 반드시 참이어야 하며, 아니면 쓰기가 실패한다”로 바꿔줍니다.
AI가 생성한 코드는 이 점을 더 중요하게 만듭니다. 모델은 React UI에서 폼 유효성 검사를 추가할 수 있지만 백그라운드 작업의 구석 케이스를 놓칠 수 있습니다. 또는 정상 경로의 데이터는 잘 처리하더라도 실제 고객이 예기치 않은 값을 입력하면 무너질 수 있습니다. 제약은 잘못된 데이터가 들어가려는 순간을 잡아냅니다. 수 주 후 이상한 리포트를 디버깅할 때가 아니라요.
제약을 건너뛰면 잘못된 데이터는 종종 소리 없이 들어갑니다. 저장은 성공하고 앱은 넘어가며, 문제는 지원 티켓, 청구 불일치, 또는 아무도 신뢰하지 않는 대시보드로 나타납니다. 이력을 고치는 데에는 비용이 많이 듭니다. 왜냐하면 과거 전체를 고치는 것이기 때문입니다.
잘못된 데이터는 보통 일상적인 상황을 통해 들어옵니다: 새 클라이언트 앱 버전이 필드를 누락 대신 빈 값으로 보내거나, 재시도가 중복을 만들거나, 관리자 편집이 UI 검사를 우회하거나, 임포트 파일 포맷이 일관되지 않거나, 두 사용자가 관련 레코드를 동시에 업데이트하는 경우 등입니다.
유용한 사고 모델은 경계에서만 데이터를 받아들이는 것입니다. 실제로 그 경계에는 데이터베이스가 포함되어야 합니다. 데이터베이스는 모든 쓰기를 보기 때문입니다.
NOT NULL은 가장 단순한 PostgreSQL 제약이며 놀랄 만큼 많은 버그를 예방합니다. 행이 의미를 가지려면 값이 반드시 있어야 한다면 데이터베이스에 이를 강제하세요.
NOT NULL은 식별자, 필수 이름, 타임스탬프에 보통 적합합니다. 유효한 레코드를 생성할 수 없다면 비어 있게 하지 마세요. 작은 CRM에서 소유자나 생성 시간이 없는 리드는 "부분 리드"가 아니라, 나중에 이상한 행동을 일으키는 손상된 데이터입니다.
NULL은 AI가 생성한 코드에서 더 자주 들어옵니다. 왜냐하면 "옵션" 경로를 무심코 만들기 쉽기 때문입니다. 폼 필드가 UI에서 선택적일 수 있고, API가 누락된 키를 허용할 수 있으며, 생성 함수의 한 분기에서 값을 할당하지 않을 수 있습니다. 모든 것이 컴파일되고 정상 흐름의 테스트는 통과합니다. 그다음 실제 사용자가 빈 셀을 가진 CSV를 임포트하거나 모바일 클라이언트가 다른 페이로드를 보내면 NULL이 데이터베이스에 들어오게 됩니다.
좋은 패턴은 NOT NULL을 시스템이 소유하는 필드의 합리적인 기본값과 결합하는 것입니다:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT true기본값이 항상 정답은 아닙니다. email이나 company_name처럼 사용자가 제공하는 필드를 NOT NULL을 만족시키려고 기본값으로 채우지 마세요. 빈 문자열은 NULL보다 "더 유효"하지 않습니다. 문제를 숨기는 것일 뿐입니다.
확실하지 않을 때는 그 값이 정말로 "알 수 없음(unknown)"인지, 아니면 다른 상태를 나타내는지 결정하세요. "아직 제공되지 않음"이 의미가 있다면 모든 곳에서 NULL을 허용하는 대신 별도의 상태 컬럼을 고려하세요. 예를 들어 phone은 nullable로 두고 phone_status를 missing, requested, verified 같은 값으로 관리하면 의미를 코드 전반에 걸쳐 일관되게 유지할 수 있습니다.
CHECK 제약은 테이블이 지키는 약속입니다: 모든 행은 항상 어떤 규칙을 만족해야 합니다. 이는 코드 상에서는 괜찮아 보이지만 실제로는 말이 안 되는 레코드가 조용히 생성되는 것을 막는 가장 쉬운 방법 중 하나입니다.
CHECK 제약은 같은 행의 값만으로 판별할 수 있는 규칙에 가장 적합합니다: 숫자 범위, 허용값, 컬럼 간의 간단한 관계 등입니다.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents >= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date >= start_date);
좋은 CHECK 제약은 한눈에 읽을 수 있어야 합니다. 그것을 데이터에 대한 문서처럼 취급하세요. 짧은 표현, 명확한 제약 이름, 예측 가능한 패턴을 선호하세요.
CHECK가 모든 상황에 맞는 도구는 아닙니다. 규칙이 다른 행을 조회하거나 집계 데이터, 또는 테이블 간 비교를 필요로 하면(예: "계정이 플랜 한도를 초과할 수 없다" 등) 그 로직은 애플리케이션 코드, 트리거, 또는 제어된 백그라운드 작업에 두세요.
UNIQUE 규칙은 단순합니다: 데이터베이스는 제약된 컬럼(또는 여러 컬럼의 조합)에 동일한 값을 가진 두 행을 저장하지 않습니다. 이는 "생성" 경로가 두 번 실행되거나 재시도가 발생하거나 두 사용자가 동시에 같은 것을 제출하는 경우에 발생하는 많은 버그를 제거합니다.
UNIQUE는 정의한 정확한 값에 대해 중복이 없음을 보장합니다. 하지만 값이 존재하는지(NOT NULL), 형식을 따르는지(CHECK), 또는 대소문자·공백·구두점의 차이를 어떻게 처리할지는(동등성의 정의) 보장하지 않습니다. 그 부분은 직접 정의해야 합니다.
일반적으로 고유성을 원할 만한 곳은 사용자 테이블의 이메일, 외부 시스템에서 온 external_id, 또는 (account_id, name)처럼 계정 내에서 고유해야 하는 이름 등입니다.
한 가지 주의점: NULL과 UNIQUE. PostgreSQL에서 NULL은 "알 수 없음"으로 취급되어 UNIQUE 제약 아래에서 여러 NULL 값이 허용됩니다. "값이 존재해야 하고 고유해야 한다"면 UNIQUE와 NOT NULL을 같이 사용하세요.
사용자 대면 식별자에 대한 실용적 패턴은 대소문자 구분 없는 고유성입니다. 사람들은 "[email protected]"을 입력하고 나중에 "[email protected]"을 입력하며 같은 것으로 기대합니다.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
사용자 관점에서 "중복"이 무엇을 의미하는지(대소문자, 공백, 계정 단위 vs 전역)를 정의하고, 한 번만 규칙을 인코딩하세요. 그러면 모든 코드 경로가 같은 규칙을 따릅니다.
FOREIGN KEY는 "이 행은 저쪽에 실제 행을 가리켜야 한다"고 말합니다. 이 제약이 없으면 코드가 고아 레코드를 조용히 생성해 나중에 앱을 깨뜨릴 수 있습니다. 예를 들어 삭제된 고객을 가리키는 노트, 또는 존재하지 않는 사용자 ID를 가리키는 인보이스 등입니다.
외래 키는 삭제와 생성이 근접하게 일어날 때 가장 중요합니다: 삭제와 생성, 타임아웃 후 재시도, 또는 오래된 데이터로 백그라운드 작업이 실행되는 경우 등. 데이터베이스는 일관성을 강제하는 데 있어 모든 앱 경로가 규칙을 기억하게 하는 것보다 더 낫습니다.
ON DELETE 옵션은 관계의 실제 의미와 일치해야 합니다. 질문하세요: "부모가 사라지면 자식은 여전히 존재해야 하는가?"
RESTRICT (또는 NO ACTION): 자식이 있으면 부모 삭제를 차단합니다.CASCADE: 부모를 삭제하면 자식도 삭제합니다.SET NULL: 자식은 남기되 링크만 제거합니다.CASCADE는 조심해서 사용하세요. 올바를 수 있지만, 버그나 관리자 실수로 부모 레코드를 삭제하면 예상보다 많은 데이터를 지울 수 있습니다.
멀티테넌트 앱에서 외래 키는 단순한 정확성 이상의 역할을 합니다. 계정 간 누수를 방지합니다. 일반적인 패턴은 테넌트 소유 테이블마다 account_id를 포함하고 관계를 통해 소유권을 묶는 것입니다.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
이렇게 하면 스키마에서 "누가 무엇을 소유하는가"를 강제합니다: 앱 코드(또는 LLM이 생성한 쿼리)가 시도하더라도 노트는 다른 계정의 연락처를 가리킬 수 없습니다.
먼저 불변성 목록을 짧게 작성하세요: 항상 참이어야 하는 사실들입니다. 평이하게 작성하세요. "모든 연락처는 이메일이 필요하다." "상태는 몇 가지 허용된 값 중 하나여야 한다." "인보이스는 실제 고객에 속해야 한다." 이런 규칙들이 데이터베이스가 매번 강제하길 원하는 규칙입니다.
변경은 작은 마이그레이션으로 롤아웃해 프로덕션을 놀라게 하지 마세요:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).문제는 기존의 잘못된 데이터입니다. 이를 계획하세요. 중복의 경우 우승자 행을 선택해 나머지를 병합하고 소규모 감사 노트를 남기세요. 필수 필드가 누락된 경우 안전한 기본값을 선택할 수 있을 때만 사용하고, 그렇지 않으면 격리하세요. 깨진 관계의 경우 자식 행을 올바른 부모로 재할당하거나 잘못된 행을 제거하세요.
각 마이그레이션 후에는 실패해야 할 몇 가지 쓰기를 직접 검증하세요: 필수 값이 없는 행 삽입, 중복 키 삽입, 범위를 벗어난 값 삽입, 없는 부모 행을 참조하는 삽입 등. 실패하는 쓰기는 유용한 신호입니다. 그것은 앱이 "최선의 노력" 동작에 조용히 의존하던 지점을 보여줍니다.
작은 CRM을 떠올려 보세요: 고객(당신의 SaaS 고객), 그들이 일하는 회사들, 그 회사의 연락처, 회사에 연결된 딜들.
이런 앱은 채팅 도구로 빠르게 생성되기 쉽습니다. 데모에서는 괜찮아 보이지만 실제 데이터는 빠르게 지저분해집니다. 초기에 흔히 나타나는 두 가지 버그는 연락처 중복(미세하게 다른 방식으로 같은 이메일이 입력됨)과 회사 없이 생성된 딜(어떤 경로에서 company_id를 설정하지 않음)입니다. 또 다른 고전적인 예는 리팩터나 파싱 실수로 음수 딜 값이 생기는 경우입니다.
해결책은 더 많은 if 문이 아닙니다. 잘 선택한 몇 가지 제약으로 잘못된 데이터가 저장되는 것을 불가능하게 만드는 것입니다.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
이것은 단지 엄격해지기 위한 것이 아닙니다. 모호한 기대를 데이터베이스가 항상 강제할 수 있는 규칙으로 바꾸는 것입니다. 어떤 앱 부분이 쓰기를 하든 동일하게 적용됩니다.
이 제약들이 적용되면 앱은 더 단순해집니다. 사후에 중복을 감지하려는 방어적 검사들을 많이 제거할 수 있습니다. 실패는 더 명확하고 조치 가능해집니다(예: "이 계정에 이미 존재하는 이메일입니다" 같은 메시지). 생성된 API 경로가 필드를 빼먹거나 값을 잘못 처리하면 쓰기가 즉시 실패하여 데이터베이스를 조용히 손상시키는 것을 막습니다.
제약은 비즈니스가 실제로 작동하는 방식과 일치할 때 가장 잘 작동합니다. 대부분의 골칫거리는 순간적으로는 "안전해 보이는" 규칙을 추가했지만 나중에 놀라움이 되는 경우입니다.
흔한 함정은 ON DELETE CASCADE를 모든 곳에 사용하는 것입니다. 깔끔해 보이지만 누군가 부모를 삭제하면 시스템 절반이 삭제되는 상황을 초래할 수 있습니다. CASCADE는 진정으로 소유된 데이터(단독으로 존재해서는 안 되는 임시 항목 등)에 적절할 수 있지만 고객, 인보이스, 티켓 같은 중요한 레코드에 대해서는 위험합니다. 확실하지 않다면 RESTRICT를 선호하고 삭제를 의도적으로 처리하세요.
또 다른 문제는 너무 좁은 CHECK 규칙을 쓰는 것입니다. "status는 'new', 'won', 'lost'여야 한다"는 말은 나중에 "paused"나 "archived"가 필요해질 때 문제가 됩니다. 좋은 CHECK는 일시적 UI 선택이 아니라 안정적인 진실을 묘사해야 합니다. "amount >= 0"은 오래 견딥니다. "country in (...)"는 자주 바뀝니다.
팀이 이미 생성된 코드를 운영 중일 때 제약을 추가하면 반복적으로 나타나는 문제들:
CASCADE를 정리 도구로 취급했다가 의도보다 많은 데이터를 삭제함.성능에 관해: PostgreSQL은 UNIQUE에 대해 자동으로 인덱스를 생성하지만, 외래 키의 참조 컬럼은 자동으로 인덱스가 생기지 않습니다. 해당 컬럼에 인덱스가 없으면 부모의 업데이트나 삭제 시 Postgres가 자식 테이블을 스캔해야 해서 느려질 수 있습니다.
규칙을 강화하기 전에 실패할 기존 행들을 찾아 고칠 것인지 격리할 것인지 결정하고, 단계적으로 변경을 롤아웃하세요.
배포 전에 테이블별로 5분만 투자해 항상 참이어야 할 것을 적어보세요. 평범한 영어로 말할 수 있으면 보통 제약으로 강제할 수 있습니다.
각 테이블에 대해 다음 질문을 하세요:
채팅 기반 빌드 도구를 사용한다면 이러한 불변성을 데이터에 대한 수용 기준(acceptance criteria)으로 다루세요. 예: "딜 금액은 음수일 수 없다", "연락처 이메일은 워크스페이스별로 고유해야 한다", "작업은 실제 연락처를 참조해야 한다" 등. 규칙을 명확히 할수록 우연한 엣지 케이스의 여지가 줄어듭니다.
Koder.ai (koder.ai)는 플래닝 모드, 스냅샷과 롤백, 소스 코드 내보내기 같은 기능을 포함하여 제약을 점진적으로 강화하면서 스키마 변경을 안전하게 반복하기 쉽게 만듭니다.
실무에서 효과적인 간단한 롤아웃 패턴: 가치가 큰 테이블 한 개(사용자, 주문, 인보이스, 연락처 등)를 골라 1~2개의 제약(NOT NULL, UNIQUE인 경우가 많음)을 추가하고, 실패하는 쓰기를 고친 뒤 반복하세요. 한 번에 큰 위험을 감수하는 것보다 규칙을 점차 강화하는 것이 더 낫습니다.