Split View: 데이터 모델링 완전 가이드: 논리 모델에서 PostgreSQL 물리 모델까지
데이터 모델링 완전 가이드: 논리 모델에서 PostgreSQL 물리 모델까지
- 들어가며
- 1. 논리 모델을 물리 모델로 옮길 때 결정되는 것들
- 2. 키 설계 — 무엇으로 행을 식별할 것인가
- 3. 타입 선택이 만드는 차이
- 4. 제약 조건은 문서가 아니라 코드다
- 5. jsonb의 경계선
- 6. 시간을 표현하는 세 가지 방식
- 7. 정규화를 깨는 순간과 그 대가
- 8. 물리 배치 — 컬럼 순서와 TOAST
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
데이터 모델링을 다루는 글은 대개 정규화에서 시작해 정규화에서 끝납니다. 이 블로그의 데이터베이스 기초 완전 가이드도 그 계보에 있고, 그 자체로 유용합니다.
이 글은 정규화가 끝난 다음부터 시작합니다. 엔티티와 관계를 다 그렸다고 해서 스키마가 정해지지 않기 때문입니다. 식별자를 bigint로 할지 uuid로 할지, 금액을 numeric으로 할지, 시각을 timestamptz로 할지 timestamp로 할지, 어떤 규칙을 데이터베이스 제약으로 옮기고 어떤 규칙을 애플리케이션에 남길지, jsonb는 어디까지 허용할지. 이 결정들이 논리 모델보다 오래 남고, 바꾸기가 더 어렵습니다. 컬럼 타입 변경은 대부분 테이블 전체 재작성이니까요.
기준 엔진은 PostgreSQL 18 이며, 타입의 저장 크기와 동작은 모두 PostgreSQL 18 문서에서 확인했습니다. 다른 엔진에서는 같은 판단이 다르게 나올 수 있습니다.
1. 논리 모델을 물리 모델로 옮길 때 결정되는 것들
논리 모델은 "무엇이 있고 어떻게 연결되는가"를 말합니다. 물리 모델은 거기에 네 가지를 더합니다.
타입 — 각 속성이 어떤 데이터 타입으로 저장되는가. 저장 크기, 연산 정확도, 비교 규칙이 여기서 결정됩니다.
제약 — 어떤 규칙을 데이터베이스가 강제하는가. 강제하지 않기로 한 규칙은 언젠가 깨진 데이터로 나타납니다.
접근 경로 — 어떤 인덱스가 있는가. 이것은 쿼리 패턴에서 역산되며, 스키마 설계와 함께 정해져야 합니다.
변경 가능성 — 나중에 바꿀 수 있는가. 이 관점이 가장 자주 빠집니다.
마지막 항목을 먼저 짚습니다. PostgreSQL에서 컬럼 타입 변경은 문서 표현으로 "보통 테이블과 인덱스 전체를 재작성"하고, 그동안 ACCESS EXCLUSIVE 잠금을 잡습니다. 예외는 USING 절이 내용을 바꾸지 않고 기존 타입이 새 타입으로 이진 호환이거나 제약 없는 도메인인 경우뿐입니다.
즉 타입 선택은 사실상 되돌리기 어려운 결정입니다. 반면 인덱스는 언제든 추가하고 지울 수 있고, 제약도 NOT VALID로 단계적으로 추가할 수 있습니다. 이 비대칭이 설계 우선순위를 정해 줍니다. 타입에 시간을 쓰고, 인덱스는 나중에 데이터를 보고 정하세요.
2. 키 설계 — 무엇으로 행을 식별할 것인가
첫 번째 갈림길은 자연 키와 대리 키입니다.
자연 키는 도메인에 이미 존재하는 식별자를 씁니다. 사업자등록번호, ISBN, 이메일 같은 것들입니다. 조인 시 추가 조회가 필요 없다는 장점이 있지만, 세 가지 문제를 데려옵니다. 값이 바뀔 수 있고(사람은 이메일을 바꿉니다), 길이가 길면 모든 참조 테이블의 인덱스가 커지고, 도메인 규칙이 바뀌면 스키마가 무너집니다.
대리 키는 의미 없는 식별자를 별도로 둡니다. 대부분의 실무 스키마가 이쪽을 택합니다. 그렇더라도 자연 키가 있다면 유일 제약으로 반드시 표현하세요. 대리 키를 쓴다는 것이 자연 키의 유일성을 포기한다는 뜻은 아닙니다.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 대리 키를 쓰더라도 자연 키의 유일성은 제약으로 남긴다
CREATE UNIQUE INDEX uq_users_email ON users (lower(email));
GENERATED ALWAYS AS IDENTITY는 SQL 표준 문법이고 serial보다 권장됩니다. serial은 내부적으로 시퀀스를 만들고 기본값을 거는 매크로에 가까워서 소유권 관계가 헷갈리기 쉽습니다.
두 번째 갈림길은 정수냐 UUID냐입니다.
bigint는 8바이트이고 순차적으로 증가하므로 B-tree 인덱스에서 삽입 지역성이 좋습니다. 단점은 값을 추측할 수 있다는 점(URL에 노출하면 안 됩니다)과 분산 환경에서 전역 유일성을 보장하기 어렵다는 점입니다.
uuid는 16바이트이고 전역적으로 유일합니다. 단점은 무작위 UUID의 삽입 지역성이 나쁘다는 것입니다. 새 값이 인덱스 전체에 흩뿌려지므로 인덱스 페이지가 계속 분할되고 캐시 적중률이 떨어집니다.
PostgreSQL 18에는 이 문제를 겨냥한 함수가 있습니다. 문서는 uuidv7()에 대해 "버전 7(시간 순서) UUID를 생성한다. 타임스탬프는 밀리초 정밀도의 UNIX 타임스탬프와 밀리초 미만 타임스탬프, 그리고 무작위 값으로 계산된다"라고 설명합니다. 즉 UUID의 전역 유일성과 정수의 삽입 지역성을 함께 얻습니다.
-- PostgreSQL 18: 시간 순서를 담은 UUID
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_id bigint NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now()
);
같은 문서 페이지에 gen_random_uuid()와 uuidv4()도 있습니다. uuidv7()의 가용 여부는 사용 중인 버전의 문서에서 확인하세요. 구버전에서는 확장이나 애플리케이션 쪽 생성이 필요합니다.
3. 타입 선택이 만드는 차이
문자열. PostgreSQL 문서의 팁은 명확합니다. "이 세 타입 사이에 성능 차이는 없다. 공백으로 채워지는 타입의 저장 공간이 늘어나는 것과, 길이 제약이 있는 컬럼에 저장할 때 길이를 검사하는 약간의 CPU 사이클을 제외하면 그렇다. 다른 데이터베이스 시스템에서는 character(n)이 성능 이점을 갖는 경우가 있지만 PostgreSQL에는 그런 이점이 없다. 사실 character(n)은 추가 저장 비용 때문에 셋 중 대개 가장 느리다. 대부분의 상황에서는 text나 character varying을 써야 한다."
즉 PostgreSQL에서 varchar(n)의 n은 성능이 아니라 제약입니다. 길이 제한이 도메인 규칙이면 붙이고, 그저 습관이면 붙이지 마세요. 나중에 늘리는 것은 재작성 없이 되지만 줄이는 것은 재작성입니다. 문서에 따르면 n은 10,485,760을 넘을 수 없고, 저장 가능한 최장 문자열은 약 1GB입니다.
저장 오버헤드도 문서에 있습니다. "짧은 문자열(126바이트까지)의 저장 요구량은 1바이트에 실제 문자열을 더한 것이고, 더 긴 문자열은 1바이트 대신 4바이트의 오버헤드를 갖습니다."
숫자. 문서의 권고는 단호합니다. numeric은 "매우 많은 자릿수의 숫자를 저장할 수 있고, 금액이나 정확성이 요구되는 수량을 저장하는 데 특히 권장"됩니다. 그리고 부동소수점에 대해서는 "정확한 저장과 계산이 필요하다면(금액 등) 대신 numeric 타입을 쓰라"고 명시합니다.
동시에 대가도 적혀 있습니다. "numeric 값에 대한 계산은 정수 타입이나 다음 절에서 설명하는 부동소수점 타입에 비해 매우 느리다." 그러므로 금액과 수량에는 numeric, 통계·과학 계산에는 부동소수점이 기준입니다. 부동소수점에 대한 문서의 경고 두 줄도 기억하세요. "부정확하다는 것은 어떤 값이 내부 형식으로 정확히 변환되지 못하고 근사치로 저장된다는 뜻"이며 "두 부동소수점 값을 동등 비교하는 것이 항상 기대대로 동작하지는 않을 수 있다."
저장 크기는 smallint 2바이트, integer 4바이트, bigint 8바이트, real 4바이트, double precision 8바이트입니다.
시각. 이것이 가장 자주 틀리는 항목입니다. 문서에 따르면 timestamp with time zone은 내부적으로 UTC로 저장되며, 입력 문자열에 시간대가 명시되어 있으면 그 오프셋으로 UTC로 변환하고, 없으면 TimeZone 파라미터가 가리키는 시간대로 가정해 변환합니다. 그리고 "원래 명시되었거나 가정된 시간대는 유지되지 않습니다." 출력할 때는 항상 UTC에서 현재 TimeZone으로 변환해 지역 시각으로 보여 줍니다.
두 타입 모두 저장 크기는 8바이트입니다. timestamptz가 더 크지 않습니다. 그러므로 "공간을 아끼려고 timestamp를 쓴다"는 근거는 성립하지 않습니다.
기본 규칙은 이렇습니다. 어떤 순간을 가리키는 값에는 timestamptz를 쓰세요. 주문 시각, 로그 시각, 생성 시각이 여기 속합니다. timestamp without time zone은 시간대와 무관한 벽시계 값(예: "매일 오전 9시에 알림")에만 씁니다. 참고로 문서는 "SQL 표준은 그냥 timestamp라고 쓰면 timestamp without time zone과 동등할 것을 요구하며 PostgreSQL은 그 동작을 따른다"라고 하므로, 아무 생각 없이 timestamp라고 쓰면 시간대 없는 타입이 됩니다.
4. 제약 조건은 문서가 아니라 코드다
"이 값은 항상 0보다 커야 한다"는 규칙을 애플리케이션에만 두면 세 가지 경로로 깨집니다. 배치 스크립트, 운영 중 수동 SQL, 그리고 새로 추가된 다른 서비스.
데이터베이스 제약은 이 세 경로를 모두 막습니다. 넣을 수 있는 제약을 정리하면 이렇습니다.
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
sku text NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
status text NOT NULL
CHECK (status IN ('PENDING', 'SHIPPED', 'CANCELLED')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (order_id, sku)
);
몇 가지 실무 지침입니다.
외래 키의 자식 측에는 인덱스를 만드세요. 부모 행을 삭제하거나 키를 갱신할 때 자식 테이블을 검사해야 하는데, 인덱스가 없으면 매번 전체 스캔이 발생합니다. PostgreSQL은 이 인덱스를 자동으로 만들어 주지 않습니다.
열거형은 CHECK와 enum 타입 중에 고릅니다. CHECK는 값 추가가 제약 교체로 끝나 단순하고, enum 타입은 타입 시스템에 편입되지만 값 제거가 까다롭습니다. 값이 자주 바뀐다면 참조 테이블과 외래 키가 가장 유연합니다.
운영 테이블에 제약을 추가할 때는 두 단계로 나누세요. 문서가 명시하듯 NOT VALID를 쓰면 ADD CONSTRAINT가 테이블을 스캔하지 않고 즉시 커밋되고, 이후 VALIDATE CONSTRAINT는 SHARE UPDATE EXCLUSIVE 잠금만 잡으므로 동시 갱신을 막지 않습니다.
ALTER TABLE order_items
ADD CONSTRAINT chk_qty_positive CHECK (quantity > 0) NOT VALID;
ALTER TABLE order_items VALIDATE CONSTRAINT chk_qty_positive;
5. jsonb의 경계선
jsonb는 강력하고 그래서 남용됩니다. 경계선을 정하는 기준 세 가지를 제안합니다.
기준 1 — 이 필드로 조회하거나 정렬하는가. 그렇다면 컬럼으로 빼세요. jsonb 안의 값도 인덱스를 걸 수 있지만, 타입 검사가 없고 통계 추정이 부정확해 실행 계획이 나빠지기 쉽습니다.
기준 2 — 이 필드에 제약이 필요한가. NOT NULL, 외래 키, 유일 제약이 필요하면 컬럼이어야 합니다. jsonb 안에는 이런 제약을 걸 수 없습니다.
기준 3 — 스키마가 정말로 예측 불가능한가. "나중에 필드가 추가될 수도 있어서"는 근거가 되지 않습니다. 컬럼 추가는 비휘발성 기본값이면 재작성 없이 즉시 끝나기 때문입니다.
jsonb가 정당한 경우는 분명합니다. 외부 시스템이 보낸 원본 페이로드를 감사 목적으로 보관할 때, 사용자가 정의하는 임의 속성을 담을 때, 스키마가 테넌트마다 다른 설정값을 담을 때입니다.
CREATE TABLE webhook_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- 조회·조인·제약에 쓰이는 값은 컬럼으로 승격한다
provider text NOT NULL,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
-- 나머지 원본은 그대로 보존
payload jsonb NOT NULL
);
-- 특정 키로 자주 조회한다면 표현식 인덱스를 만든다
CREATE INDEX idx_webhook_payload_orderid
ON webhook_events ((payload ->> 'order_id'));
-- 포함 관계 검색이 필요하면 GIN
CREATE INDEX idx_webhook_payload_gin ON webhook_events USING gin (payload);
GIN 인덱스는 한 행이 여러 값을 갖는 자료에 최적화된 역인덱스입니다. jsonb 포함 연산자 검색에 유용하지만 인덱스가 크고 갱신 비용이 높다는 점을 감안하세요.
6. 시간을 표현하는 세 가지 방식
시간과 관련된 요건은 세 가지로 나뉘고, 각각 다른 모델이 맞습니다.
첫째, 감사 로그(무슨 일이 언제 있었는가). 별도 이력 테이블에 변경 사건을 추가만 합니다. 갱신도 삭제도 하지 않습니다.
CREATE TABLE order_status_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id),
old_status text,
new_status text NOT NULL,
changed_by bigint,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_osh_order_time ON order_status_history (order_id, changed_at DESC);
둘째, 유효 기간(언제부터 언제까지 이 값이 유효했는가). 가격 이력이나 계약 기간이 여기 속합니다. 범위 타입과 배제 제약을 함께 쓰면 기간이 겹치지 않는다는 규칙을 데이터베이스가 강제합니다.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE product_prices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL REFERENCES products (id),
price numeric(12, 2) NOT NULL CHECK (price >= 0),
valid tstzrange NOT NULL,
-- 같은 상품의 유효 기간은 겹칠 수 없다
EXCLUDE USING gist (product_id WITH =, valid WITH &&)
);
배제 제약은 GiST 인덱스로 구현되며, 정수 컬럼을 동등 비교에 함께 쓰려면 btree_gist 확장이 필요합니다. 이 규칙을 애플리케이션에서 검사하면 동시 요청 두 개가 모두 통과하는 구멍이 생기지만, 제약은 데이터베이스가 원자적으로 보장합니다.
셋째, 소프트 삭제(지운 것처럼 보이지만 남아 있는 행). 가장 흔하고 가장 문제가 많은 패턴입니다. 모든 조회에 WHERE deleted_at IS NULL을 붙여야 하고, 하나라도 빠뜨리면 지운 데이터가 보입니다. 그리고 유일 제약이 깨집니다. 삭제된 행의 이메일이 여전히 자리를 차지하기 때문입니다.
부분 인덱스가 두 번째 문제의 정답입니다.
-- 살아 있는 행끼리만 유일하다
CREATE UNIQUE INDEX uq_users_email_alive
ON users (lower(email))
WHERE deleted_at IS NULL;
첫 번째 문제는 뷰나 행 수준 보안으로 다룰 수 있습니다. 다만 근본적으로는 소프트 삭제가 정말 필요한지 다시 묻는 편이 낫습니다. 감사가 목적이라면 이력 테이블이, 복구가 목적이라면 백업과 시점 복구가 더 적합한 도구입니다.
7. 정규화를 깨는 순간과 그 대가
정규화는 기본값이지 종교가 아닙니다. 깨야 할 때가 있고, 그때 무엇을 대가로 내는지 알고 깨야 합니다.
역정규화가 정당화되는 전형적인 경우는 집계값의 물리화입니다. 게시글의 댓글 수를 매번 세는 대신 컬럼에 유지하는 식입니다. 대가는 명확합니다. 두 곳의 값이 어긋날 수 있고, 그것을 막는 책임이 애플리케이션으로 넘어옵니다.
-- 역정규화를 하기로 했다면 갱신 경로를 하나로 고정한다
CREATE OR REPLACE FUNCTION bump_comment_count() RETURNS trigger AS $fn$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id;
END IF;
RETURN NULL;
END;
$fn$ LANGUAGE plpgsql;
CREATE TRIGGER trg_comment_count
AFTER INSERT OR DELETE ON comments
FOR EACH ROW EXECUTE FUNCTION bump_comment_count();
경고:
CREATE TRIGGER는 대상 테이블에SHARE ROW EXCLUSIVE잠금을 잡습니다. 이 잠금은ROW EXCLUSIVE(즉 INSERT, UPDATE, DELETE, MERGE)와 충돌하므로 트리거를 만드는 동안 그 테이블의 쓰기가 막힙니다. 읽기는 통과합니다. 트래픽이 낮은 시간에SET LOCAL lock_timeout을 걸고 실행하세요.
트리거로 유지하는 방식에는 또 다른 대가가 있습니다. 인기 게시글에 댓글이 몰리면 같은 posts 행을 모두가 갱신하려 해 잠금 경합이 생깁니다. 대안은 증분을 별도 테이블에 추가만 하고 주기적으로 합산하거나, 애초에 정확한 실시간 값이 필요한지 다시 묻는 것입니다. 대부분의 카운터는 5초 늦어도 아무 일도 일어나지 않습니다.
역정규화를 결정할 때 남겨야 할 것은 세 가지입니다. 왜 깼는지, 정합성을 무엇이 보장하는지, 그리고 어긋났을 때 바로잡는 재계산 쿼리입니다. 세 번째를 미리 써 두지 않으면 사고 대응 중에 급조하게 됩니다.
8. 물리 배치 — 컬럼 순서와 TOAST
마지막으로 눈에 잘 보이지 않는 층입니다.
컬럼 순서와 정렬 패딩. PostgreSQL은 각 컬럼을 타입의 정렬 요구에 맞춰 배치하므로, 순서에 따라 행 크기에 패딩이 끼어듭니다. 크기가 큰 고정폭 타입부터 작은 타입 순으로 배열하면 낭비가 줄어듭니다. 수억 행 테이블에서는 이 차이가 기가바이트 단위가 됩니다. 다만 가독성을 크게 해치면서까지 최적화할 가치는 대개 없습니다. 매우 큰 테이블에서만 고려하세요.
TOAST. 큰 값은 행에 그대로 들어가지 않고 별도 저장 영역으로 나가 압축되거나 분할됩니다. 이 동작의 이점은 큰 text나 jsonb 컬럼이 있어도 그 컬럼을 읽지 않는 쿼리는 비용을 거의 내지 않는다는 것입니다. 반대로 SELECT *로 습관적으로 전부 읽으면 그 이점이 사라집니다. 넓은 테이블에서는 필요한 컬럼만 나열하는 것이 실질적인 성능 차이를 만듭니다.
파티션 키는 스키마의 일부입니다. 5절과 6절에서 본 유일 제약, 배제 제약, 부분 인덱스는 모두 파티션 키와 상호작용합니다. 파티션된 테이블에서 유일 제약을 만들려면 문서 규정대로 제약의 컬럼이 파티션 키의 모든 컬럼을 포함해야 합니다. 파티셔닝을 나중에 도입할 가능성이 있다면 키 설계 단계에서 미리 검토하세요. 나중에 발견하면 스키마를 갈아엎게 됩니다.
스키마 자체도 설계 대상입니다. 멀티테넌트에서 테넌트마다 스키마를 두는 방식은 격리가 좋아 보이지만, 테넌트가 수천이면 카탈로그가 커지고 마이그레이션이 수천 번 반복되며 커넥션 풀의 search_path 전환 문제까지 생깁니다. 대부분의 경우 tenant_id 컬럼과 행 수준 보안이 더 잘 확장됩니다.
퀴즈: 실력을 확인해 보세요
퀴즈 1: 금액 컬럼을 double precision으로 만들었습니다. 무엇이 문제인가요?
정답: 부동소수점은 근사값이므로 금액에 쓰면 안 됩니다. numeric을 써야 합니다.
설명: 문서는 부동소수점에 대해 "부정확하다는 것은 어떤 값이 내부 형식으로 정확히 변환되지 못하고 근사치로 저장된다는 뜻"이며 "두 부동소수점 값을 동등 비교하는 것이 항상 기대대로 동작하지는 않을 수 있다"라고 경고하고, "정확한 저장과 계산이 필요하다면(금액 등) 대신 numeric 타입을 쓰라"고 명시합니다. 합계가 1원씩 어긋나거나 반올림 결과가 회계와 맞지 않는 문제가 여기서 나옵니다. 대가는 문서가 함께 밝힌 성능입니다. "numeric 값에 대한 계산은 정수 타입이나 부동소수점 타입에 비해 매우 느리다." 그래서 대안으로 금액을 최소 단위 정수(원, 센트)로 저장하는 방식도 널리 쓰입니다. 다만 이 경우 나누기와 비율 계산에서 반올림 규칙을 코드로 명시해야 합니다.
퀴즈 2: created_at 컬럼을 timestamp로 만들었더니 해외 사용자 데이터의 시각이 이상합니다.
정답: timestamp without time zone은 시간대 정보를 담지도 변환하지도 않습니다. timestamptz를 써야 합니다.
설명: 문서에 따르면 SQL 표준은 그냥 timestamp라고 쓰면 timestamp without time zone과 동등할 것을 요구하고 PostgreSQL도 그렇게 동작합니다. 즉 아무 생각 없이 timestamp라고 쓰면 시간대 없는 타입이 됩니다. 이 타입에 저장된 값은 어느 시간대의 벽시계인지 알 수 없으므로, 서버 시간대나 클라이언트 시간대가 다르면 해석이 어긋납니다. timestamp with time zone은 문서 설명대로 값을 내부적으로 UTC로 저장하고 출력 시 현재 TimeZone으로 변환합니다. 두 타입의 저장 크기는 모두 8바이트로 같으므로 공간상의 이유로 timestamp를 고를 근거는 없습니다. 이미 잘못 만들었다면 타입 변경은 테이블 재작성을 유발하므로, 새 컬럼을 추가해 백필하고 전환하는 절차로 처리해야 합니다.
퀴즈 3: 상품별 가격 이력에서 기간이 겹치는 행이 가끔 생깁니다. 애플리케이션에서 검사하는데도 그렇습니다.
정답: 애플리케이션 검사는 동시 요청에 대해 원자적이지 않습니다. 배제 제약으로 데이터베이스에 맡겨야 합니다.
설명: "겹치는 기간이 있는지 SELECT로 확인하고 없으면 INSERT"는 두 요청이 동시에 들어오면 둘 다 통과합니다. 격리 수준을 Serializable로 올리면 막을 수 있지만 재시도 처리가 필요합니다. 더 직접적인 해법은 배제 제약입니다.
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE product_prices
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (product_id WITH =, valid WITH &&);
정수 컬럼을 동등 비교에 함께 쓰려면 btree_gist 확장이 필요합니다. 그리고 파티션된 테이블이라면 문서 규정에 따라 배제 제약이 파티션 키 컬럼을 모두 포함하고 그 컬럼들을 동등 비교해야 한다는 제약이 추가로 걸립니다.
퀴즈 4: 설정값이 자주 바뀔 것 같아 모든 속성을 jsonb 컬럼 하나에 담았습니다. 어떤 문제가 생길까요?
정답: 제약을 걸 수 없고, 조회 성능과 실행 계획이 나빠지며, 오히려 변경이 더 어려워집니다.
설명: 세 가지가 동시에 무너집니다. 첫째, NOT NULL과 외래 키와 유일 제약을 걸 수 없으므로 잘못된 값이 조용히 들어옵니다. 둘째, jsonb 필드에는 정확한 통계가 없어 플래너의 행 수 추정이 어긋나고, 그 결과 조인 방식 선택이 틀어집니다. 셋째, 역설적으로 변경이 더 어렵습니다. 컬럼이라면 타입 시스템과 마이그레이션 도구가 변경을 추적해 주지만, jsonb 안의 필드는 어디서 무엇을 쓰는지 코드 전체를 뒤져야 알 수 있습니다. 기준은 이렇습니다. 조회하거나 정렬하거나 제약이 필요한 필드는 컬럼으로 승격하고, 나머지 원본만 jsonb에 남깁니다. "나중에 필드가 추가될 수 있어서"는 근거가 되지 않습니다. 비휘발성 기본값을 가진 컬럼 추가는 문서에 따르면 테이블 재작성 없이 즉시 끝나기 때문입니다.
퀴즈 5: 게시글의 comment_count 컬럼과 실제 댓글 수가 어긋났습니다. 무엇이 빠져 있었을까요?
정답: 재계산(정합성 복구) 쿼리와, 갱신 경로를 하나로 고정하는 장치입니다.
설명: 역정규화는 정합성 책임을 데이터베이스에서 애플리케이션으로 옮기는 거래입니다. 어긋나는 경로는 여러 개입니다. 트리거를 우회한 대량 삭제, 트리거를 잠시 껐던 마이그레이션, 여러 코드 경로 중 하나의 누락. 그래서 역정규화를 도입할 때는 세 가지를 함께 남겨야 합니다. 왜 깼는지, 무엇이 정합성을 보장하는지, 그리고 어긋났을 때 바로잡는 재계산 쿼리입니다.
UPDATE posts p
SET comment_count = c.cnt
FROM (SELECT post_id, count(*) AS cnt FROM comments GROUP BY post_id) c
WHERE p.id = c.post_id AND p.comment_count IS DISTINCT FROM c.cnt;
이 쿼리를 정기적으로 돌려 어긋난 건수를 지표로 남기면, 정합성이 언제부터 깨졌는지 사후에 추적할 수 있습니다.
마치며
데이터 모델링에서 가장 값비싼 실수는 잘못된 정규화 수준이 아닙니다. 되돌리기 어려운 결정을 근거 없이 내리는 것입니다. 타입 변경은 테이블 재작성이고, 키 설계 변경은 모든 참조 테이블에 파급되며, 파티션 키는 유일 제약의 형태까지 바꿉니다.
그래서 순서를 이렇게 잡기를 권합니다. 먼저 타입과 키를 결정하고 그 근거를 남깁니다. 그다음 도메인 규칙 중 데이터베이스가 강제할 수 있는 것을 전부 제약으로 옮깁니다. 인덱스는 실제 쿼리 패턴이 보이면 그때 추가합니다. 역정규화는 측정된 병목이 있을 때만 하고, 할 때는 재계산 쿼리를 함께 커밋합니다.
스키마와 쿼리를 직접 실험해 보려면 Postgres 놀이터와 SQL 놀이터를, 설계한 스키마에 테스트 데이터를 채우려면 목업 데이터 생성기를 활용하세요.
참고 자료
- PostgreSQL 18, Character Types: https://www.postgresql.org/docs/18/datatype-character.html (2026-08-15 확인)
- PostgreSQL 18, Numeric Types: https://www.postgresql.org/docs/18/datatype-numeric.html (2026-08-15 확인)
- PostgreSQL 18, Date/Time Types: https://www.postgresql.org/docs/18/datatype-datetime.html (2026-08-15 확인)
- PostgreSQL 18, UUID Functions: https://www.postgresql.org/docs/18/functions-uuid.html (2026-08-15 확인)
- PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/18/sql-altertable.html (2026-08-15 확인)
- PostgreSQL 18, Explicit Locking: https://www.postgresql.org/docs/18/explicit-locking.html (2026-08-15 확인)
- PostgreSQL 18, Table Partitioning: https://www.postgresql.org/docs/18/ddl-partitioning.html (2026-08-15 확인)
- PostgreSQL 18, Index Types: https://www.postgresql.org/docs/18/indexes-types.html (2026-08-15 확인)
이어서 읽기
- 이전 편: 대용량 데이터 처리 완전 가이드 — COPY와 청크 배치
- 다음 편: DB 성능 튜닝 완전 가이드 — 측정하는 순서
- 데이터베이스 기초 완전 가이드 — 정규화와 기본 개념
- PostgreSQL 인덱스 완전 가이드 — 스키마에 붙일 인덱스 설계
- Postgres 놀이터 — 스키마와 제약 실험
- SQL 놀이터 — 쿼리 문법 실험
- 목업 데이터 생성기 — 설계한 스키마 채우기
The Complete Guide to Data Modeling: From Logical Model to PostgreSQL Physical Schema
- Introduction
- 1. What Gets Decided When a Logical Model Becomes Physical
- 2. Key Design — What Identifies a Row
- 3. What Type Choices Actually Change
- 4. Constraints Are Code, Not Documentation
- 5. Where the jsonb Boundary Sits
- 6. Three Ways to Represent Time
- 7. When to Break Normalization, and What It Costs
- 8. Physical Layout — Column Order and TOAST
- Quiz: Check Your Understanding
- Closing Thoughts
- References
- Further Reading
Introduction
Articles about data modeling usually start with normalization and end with normalization. This blog's The Complete Guide to Database Fundamentals belongs to that lineage, and it is useful in its own right.
This guide starts where normalization ends. Drawing all the entities and relationships does not settle the schema. Should the identifier be a bigint or a uuid? Should the amount be numeric? Should the timestamp be timestamptz or timestamp? Which rules move into database constraints and which stay in the application? How far does jsonb go? These decisions outlive the logical model and are harder to change — a column type change is, in most cases, a full table rewrite.
The reference engine is PostgreSQL 18, and every storage size and behavior was confirmed in the PostgreSQL 18 documentation. On another engine the same judgment can come out differently.
1. What Gets Decided When a Logical Model Becomes Physical
The logical model says what exists and how things connect. The physical model adds four things on top.
Types — which data type each attribute is stored as. Storage size, arithmetic exactness, and comparison rules are all decided here.
Constraints — which rules the database enforces. Any rule you choose not to enforce will eventually show up as broken data.
Access paths — which indexes exist. These are derived from query patterns and should be settled alongside the schema.
Changeability — whether you can change it later. This perspective is the one most often missing.
Take the last one first. In PostgreSQL, changing a column type, in the documentation's words, "will normally cause the entire table and its indexes to be rewritten," holding an ACCESS EXCLUSIVE lock throughout. The only exceptions are when the USING clause does not change the column contents and the old type is binary coercible to the new type or is an unconstrained domain over it.
In other words, a type choice is effectively an irreversible decision. Indexes, by contrast, can be added and dropped at any time, and constraints can be added in stages with NOT VALID. That asymmetry sets the design priority. Spend the time on types; decide indexes later, once you can see the data.
2. Key Design — What Identifies a Row
The first fork is natural key versus surrogate key.
A natural key uses an identifier that already exists in the domain — a business registration number, an ISBN, an email address. The advantage is that joins need no extra lookup, but it brings three problems: the value can change (people change their email), a long value inflates the index of every referencing table, and a change to the domain rules breaks the schema.
A surrogate key adds a separate, meaningless identifier. Most production schemas take this route. Even so, if a natural key exists, express its uniqueness as a constraint. Using a surrogate key does not mean abandoning the natural key's uniqueness.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Even with a surrogate key, keep the natural key's uniqueness as a constraint
CREATE UNIQUE INDEX uq_users_email ON users (lower(email));
GENERATED ALWAYS AS IDENTITY is standard SQL syntax and is preferred over serial. serial is closer to a macro that creates a sequence and attaches a default, which makes ownership relationships easy to confuse.
The second fork is integer versus UUID.
bigint is 8 bytes and increases monotonically, so insertion locality in a B-tree index is excellent. The downsides are that values are guessable (do not expose them in URLs) and that global uniqueness is hard to guarantee in a distributed setup.
uuid is 16 bytes and globally unique. The downside is that random UUIDs have poor insertion locality. New values scatter across the whole index, so index pages split constantly and cache hit rates drop.
PostgreSQL 18 has a function aimed squarely at this problem. The documentation describes uuidv7() as generating "a version 7 (time-ordered) UUID. The timestamp is computed using UNIX timestamp with millisecond precision + sub-millisecond timestamp + random." In other words, you get UUID global uniqueness together with integer-like insertion locality.
-- PostgreSQL 18: a UUID that carries time ordering
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_id bigint NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now()
);
The same documentation page also lists gen_random_uuid() and uuidv4(). Confirm the availability of uuidv7() in the documentation for the version you run. On older versions you need an extension or application-side generation.
3. What Type Choices Actually Change
Strings. The PostgreSQL documentation's tip is unambiguous: "There is no performance difference among these three types, apart from increased storage space when using the blank-padded type, and a few extra CPU cycles to check the length when storing into a length-constrained column. While character(n) has performance advantages in some other database systems, there is no such advantage in PostgreSQL; in fact character(n) is usually the slowest of the three because of its additional storage costs. In most situations text or character varying should be used instead."
So in PostgreSQL, the n in varchar(n) is a constraint, not a performance choice. Attach it when the length limit is a domain rule; leave it off when it is just habit. Widening it later works without a rewrite; narrowing it is a rewrite. Per the documentation, n cannot exceed 10,485,760, and the longest storable string is about 1 GB.
The storage overhead is documented too: "The storage requirement for a short string (up to 126 bytes) is 1 byte plus the actual string, which includes the space padding in the case of character. Longer strings have 4 bytes of overhead instead of 1."
Numbers. The documentation's recommendation is firm. numeric "can store numbers with a very large number of digits" and "is especially recommended for storing monetary amounts and other quantities where exactness is required." For floating point it states: "If you require exact storage and calculations (such as for monetary amounts), use the numeric type instead."
The price is stated just as plainly: "calculations on numeric values are very slow compared to the integer types, or to the floating-point types." So the rule is numeric for money and quantities, floating point for statistical and scientific computation. Remember the documentation's two warnings about floating point as well: "Inexact means that some values cannot be converted exactly to the internal format and are stored as approximations," and "Comparing two floating-point values for equality might not always work as expected."
Storage sizes are smallint 2 bytes, integer 4 bytes, bigint 8 bytes, real 4 bytes, and double precision 8 bytes.
Timestamps. This is the item people get wrong most often. Per the documentation, timestamp with time zone values are stored internally in UTC; an input string with an explicit time zone is converted to UTC using that offset, and one without is assumed to be in the zone named by the TimeZone parameter and converted. And "the originally stated or assumed time zone is not retained." On output the value "is always converted from UTC to the current timezone zone, and displayed as local time in that zone."
Both types occupy 8 bytes. timestamptz is not larger. So "we use timestamp to save space" is not a valid argument.
The base rule is this: use timestamptz for any value that denotes an instant. Order times, log times, creation times. Use timestamp without time zone only for wall-clock values that are independent of any zone (for example, "notify at 9 a.m. every day"). Note also that the documentation states "the SQL standard requires that writing just timestamp be equivalent to timestamp without time zone, and PostgreSQL honors that behavior" — so writing timestamp without thinking gives you the zone-less type.
4. Constraints Are Code, Not Documentation
Put the rule "this value must always be greater than zero" only in the application and it breaks through three paths: batch scripts, ad-hoc SQL run in production, and a newly added service.
Database constraints block all three. Here is the set of constraints worth putting in.
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
sku text NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
status text NOT NULL
CHECK (status IN ('PENDING', 'SHIPPED', 'CANCELLED')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (order_id, sku)
);
A few practical guidelines.
Index the child side of a foreign key. Deleting or updating a parent key requires checking the child table, and without an index that means a full scan every time. PostgreSQL does not create this index for you.
Choose between CHECK and an enum type for enumerations. A CHECK is simple because adding a value is just replacing the constraint; an enum type joins the type system but makes removing a value awkward. If values change often, a lookup table plus a foreign key is the most flexible.
Add constraints to production tables in two steps. As the documentation states, with NOT VALID the ADD CONSTRAINT command "does not scan the table and can be committed immediately," and the subsequent VALIDATE CONSTRAINT "acquires only a SHARE UPDATE EXCLUSIVE lock on the table being altered," so it does not block concurrent updates.
ALTER TABLE order_items
ADD CONSTRAINT chk_qty_positive CHECK (quantity > 0) NOT VALID;
ALTER TABLE order_items VALIDATE CONSTRAINT chk_qty_positive;
5. Where the jsonb Boundary Sits
jsonb is powerful, and therefore overused. Here are three criteria for drawing the boundary.
Criterion 1 — do you query or sort on this field? If so, promote it to a column. You can index values inside jsonb, but there is no type checking and estimation is imprecise, which makes plans go bad.
Criterion 2 — does this field need a constraint? NOT NULL, foreign keys, and uniqueness require a column. None of them can be attached inside jsonb.
Criterion 3 — is the schema genuinely unpredictable? "A field might get added later" is not a reason. Adding a column with a non-volatile default finishes immediately without a rewrite.
The cases where jsonb is justified are clear: preserving the raw payload an external system sent for audit purposes, holding arbitrary user-defined attributes, and holding configuration whose shape differs per tenant.
CREATE TABLE webhook_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- Values used for querying, joining, or constraints get promoted to columns
provider text NOT NULL,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
-- The rest of the original payload is preserved as-is
payload jsonb NOT NULL
);
-- If you query a specific key often, build an expression index
CREATE INDEX idx_webhook_payload_orderid
ON webhook_events ((payload ->> 'order_id'));
-- If you need containment searches, use GIN
CREATE INDEX idx_webhook_payload_gin ON webhook_events USING gin (payload);
A GIN index is an inverted index optimized for data where one row holds many values. It is useful for jsonb containment operators, but account for the fact that it is large and expensive to update.
6. Three Ways to Represent Time
Time-related requirements split into three kinds, and each wants a different model.
First, an audit log (what happened and when). Append change events to a separate history table. Never update, never delete.
CREATE TABLE order_status_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id),
old_status text,
new_status text NOT NULL,
changed_by bigint,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_osh_order_time ON order_status_history (order_id, changed_at DESC);
Second, a validity period (from when until when was this value in force). Price history and contract terms belong here. A range type plus an exclusion constraint makes the database enforce the rule that periods must not overlap.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE product_prices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL REFERENCES products (id),
price numeric(12, 2) NOT NULL CHECK (price >= 0),
valid tstzrange NOT NULL,
-- Validity periods for the same product cannot overlap
EXCLUDE USING gist (product_id WITH =, valid WITH &&)
);
An exclusion constraint is implemented with a GiST index, and using an integer column for equality inside it requires the btree_gist extension. Checking this rule in the application leaves a hole where two concurrent requests both pass, but a constraint is enforced atomically by the database.
Third, soft deletes (rows that look deleted but are still there). The most common pattern and the most problematic. Every query needs WHERE deleted_at IS NULL, and missing it even once exposes deleted data. Uniqueness also breaks, because a deleted row's email still occupies the slot.
A partial index is the answer to the second problem.
-- Unique only among live rows
CREATE UNIQUE INDEX uq_users_email_alive
ON users (lower(email))
WHERE deleted_at IS NULL;
The first problem can be handled with views or row-level security. But fundamentally, it is better to ask again whether soft deletes are really needed. If the goal is audit, a history table fits better; if the goal is recovery, backups and point-in-time recovery are the right tools.
7. When to Break Normalization, and What It Costs
Normalization is a default, not a religion. There are times to break it, and you should know what you pay when you do.
The classic justification for denormalization is materializing an aggregate — keeping a comment count in a column instead of counting every time. The cost is explicit: the two values can drift apart, and the responsibility for preventing that moves to the application.
-- If you decide to denormalize, pin the update path to exactly one place
CREATE OR REPLACE FUNCTION bump_comment_count() RETURNS trigger AS $fn$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id;
END IF;
RETURN NULL;
END;
$fn$ LANGUAGE plpgsql;
CREATE TRIGGER trg_comment_count
AFTER INSERT OR DELETE ON comments
FOR EACH ROW EXECUTE FUNCTION bump_comment_count();
Warning:
CREATE TRIGGERtakes aSHARE ROW EXCLUSIVElock on the target table. That lock conflicts withROW EXCLUSIVE— that is, INSERT, UPDATE, DELETE, and MERGE — so writes to that table are blocked while the trigger is being created. Reads pass through. Run it during low traffic withSET LOCAL lock_timeoutin place.
Maintaining it with a trigger carries another cost. When comments pile onto a popular post, everyone tries to update the same posts row and lock contention appears. Alternatives are to append increments to a separate table and total them periodically, or to ask again whether an exact real-time value is needed at all. Most counters do not care about being five seconds late.
Three things must be left behind when you decide to denormalize: why you broke it, what guarantees consistency, and the recomputation query that fixes it when it drifts. Fail to write the third one in advance and you will improvise it in the middle of an incident.
8. Physical Layout — Column Order and TOAST
Finally, the layer that is hard to see.
Column order and alignment padding. PostgreSQL lays out each column according to its type's alignment requirement, so ordering changes how much padding creeps into the row. Arranging large fixed-width types before small ones reduces the waste. On a table with hundreds of millions of rows that difference reaches gigabytes. That said, it is rarely worth badly hurting readability for. Consider it only on very large tables.
TOAST. Large values do not sit inline in the row; they move to a separate storage area where they are compressed or split. The benefit is that a query which does not read a big text or jsonb column pays almost nothing for it. Conversely, habitually reading everything with SELECT * throws that benefit away. On wide tables, listing only the columns you need makes a real performance difference.
The partition key is part of the schema. The unique constraints, exclusion constraints, and partial indexes from sections 5 and 6 all interact with the partition key. To create a unique constraint on a partitioned table, the documentation requires that the constraint's columns include all of the partition key columns. If partitioning is a possibility later, review it during key design. Discovering it afterwards means tearing up the schema.
The schema itself is a design decision too. Giving each tenant its own schema in a multi-tenant system looks like good isolation, but with thousands of tenants the catalog grows, migrations repeat thousands of times, and search_path switching collides with connection pooling. In most cases a tenant_id column plus row-level security scales better.
Quiz: Check Your Understanding
Question 1: You made the amount column double precision. What is wrong with that?
Answer: Floating point stores approximations, so it must not be used for money. Use numeric.
Explanation: The documentation warns of floating point that "inexact means that some values cannot be converted exactly to the internal format and are stored as approximations" and that "comparing two floating-point values for equality might not always work as expected," and states that "if you require exact storage and calculations (such as for monetary amounts), use the numeric type instead." Totals off by one unit, or rounding results that do not match accounting, come from here. The cost is the performance the documentation also states: "calculations on numeric values are very slow compared to the integer types, or to the floating-point types." That is why storing money as an integer in the smallest unit is also widely used. In that case you must specify the rounding rule in code for division and ratio calculations.
Question 2: You made created_at a timestamp column, and now overseas users' times look wrong.
Answer: timestamp without time zone neither carries nor converts zone information. Use timestamptz.
Explanation: Per the documentation, the SQL standard requires that writing just timestamp be equivalent to timestamp without time zone, and PostgreSQL honors that. So writing timestamp without thinking gives you the zone-less type. A value stored in it has no way of saying which zone's wall clock it is, so interpretations diverge when the server or client zone differs. timestamp with time zone, as documented, stores values internally in UTC and converts to the current TimeZone on output. Both types occupy 8 bytes, so there is no space argument for choosing timestamp. If it is already wrong, a type change causes a table rewrite, so handle it by adding a new column, backfilling, and switching over.
Question 3: Overlapping periods occasionally appear in a per-product price history, even though the application checks for them.
Answer: An application check is not atomic against concurrent requests. Let an exclusion constraint handle it in the database.
Explanation: "SELECT to check for an overlap, then INSERT if there is none" lets both requests through when they arrive at the same time. Raising the isolation level to Serializable would block it, but then you need retry handling. The more direct fix is an exclusion constraint.
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE product_prices
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (product_id WITH =, valid WITH &&);
Using an integer column for equality inside it requires the btree_gist extension. And on a partitioned table the documentation adds a further restriction: an exclusion constraint must include all the partition key columns and must compare those columns for equality.
Question 4: Expecting the settings to change often, you put every attribute into a single jsonb column. What problems follow?
Answer: You cannot attach constraints, query performance and plans get worse, and changing things actually becomes harder.
Explanation: Three things break at once. First, you cannot attach NOT NULL, foreign keys, or uniqueness, so bad values slip in quietly. Second, jsonb fields lack precise statistics, so the planner's row estimates drift and join method selection goes wrong as a result. Third, paradoxically, change becomes harder. With a column, the type system and the migration tool track the change for you; with a field inside jsonb you have to grep the whole codebase to find out where it is used. The criterion is this: promote any field you query, sort on, or constrain to a column, and keep only the remaining raw payload in jsonb. "A field might get added later" is not a reason, because per the documentation, adding a column with a non-volatile default finishes without a table rewrite.
Question 5: A post's comment_count column drifted from the actual comment count. What was missing?
Answer: A recomputation query for restoring consistency, plus a mechanism pinning the update path to one place.
Explanation: Denormalization is a trade that moves the responsibility for consistency from the database to the application. There are many paths to drift: a bulk delete that bypassed the trigger, a migration that disabled triggers temporarily, one of several code paths that forgot to update. So three things must accompany a denormalization: why you broke it, what guarantees consistency, and the recomputation query that fixes drift.
UPDATE posts p
SET comment_count = c.cnt
FROM (SELECT post_id, count(*) AS cnt FROM comments GROUP BY post_id) c
WHERE p.id = c.post_id AND p.comment_count IS DISTINCT FROM c.cnt;
Run this on a schedule and record the number of mismatched rows as a metric, and you can trace after the fact when consistency started to break.
Closing Thoughts
The most expensive mistake in data modeling is not picking the wrong normal form. It is making an irreversible decision without grounds. A type change is a table rewrite, a key design change ripples through every referencing table, and a partition key even changes the shape a unique constraint is allowed to take.
So set the order this way. Decide types and keys first, and record the reasoning. Then move every domain rule the database can enforce into a constraint. Add indexes once the real query patterns are visible. Denormalize only when there is a measured bottleneck, and when you do, commit the recomputation query along with it.
To experiment with schemas and queries directly, use the Postgres Playground and the SQL Playground; to fill your schema with test data, use the Mock Data Generator.
References
- PostgreSQL 18, Character Types: https://www.postgresql.org/docs/18/datatype-character.html (accessed 2026-08-15)
- PostgreSQL 18, Numeric Types: https://www.postgresql.org/docs/18/datatype-numeric.html (accessed 2026-08-15)
- PostgreSQL 18, Date/Time Types: https://www.postgresql.org/docs/18/datatype-datetime.html (accessed 2026-08-15)
- PostgreSQL 18, UUID Functions: https://www.postgresql.org/docs/18/functions-uuid.html (accessed 2026-08-15)
- PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/18/sql-altertable.html (accessed 2026-08-15)
- PostgreSQL 18, Explicit Locking: https://www.postgresql.org/docs/18/explicit-locking.html (accessed 2026-08-15)
- PostgreSQL 18, Table Partitioning: https://www.postgresql.org/docs/18/ddl-partitioning.html (accessed 2026-08-15)
- PostgreSQL 18, Index Types: https://www.postgresql.org/docs/18/indexes-types.html (accessed 2026-08-15)
Further Reading
- Previous in series: The Complete Guide to Bulk Data Processing — COPY and chunked batches
- Next in series: The Complete Guide to Database Performance Tuning — the order in which to measure
- The Complete Guide to Database Fundamentals — normalization and core concepts
- The Complete Guide to PostgreSQL Indexes — designing the indexes for your schema
- Postgres Playground — experiment with schemas and constraints
- SQL Playground — experiment with query syntax
- Mock Data Generator — fill the schema you designed