콘텐츠로 이동

DB Modeling

분류: Layer 8 - 데이터베이스 심화

데이터베이스 모델링은 현실의 업무 사실을 테이블, 컬럼, 키, 관계, 제약 조건으로 바꾸는 설계 과정이다. 좋은 모델은 “데이터를 저장할 수 있다”에서 끝나지 않고, 같은 사실이 한 곳에서 관리되고, 관계가 명확하며, 조회와 변경이 예측 가능한 비용 안에서 일어나게 만든다.

이 문서에서 말하는 모델은 ORM(Object-Relational Mapper, 객체 코드와 관계형 DB를 연결하는 도구)의 Entity class 파일 하나가 아니라 데이터가 지켜야 할 구조 전체다. 엔티티(Entity)는 업무에서 식별하고 싶은 대상, 테이블(Table)은 그 엔티티를 행(Row)과 열(Column)로 저장하는 구조, 관계(Relationship)는 엔티티 사이의 연결 규칙이다.

클라이언트 상태를 byIdids로 펼쳐본 경험은 정규화 감각을 이해하는 데 도움이 된다. 다만 DB 모델링의 목표는 화면 상태 정리가 아니라, 영구 저장된 업무 사실이 장애와 동시 요청 이후에도 설명 가능한 상태로 남는 것이다.


데이터베이스는 애플리케이션의 기억이다. 모델링을 잘못하면 버그는 코드 한 줄보다 오래 남는다. 한 번 잘못 저장된 주문 금액, 잘못 연결된 정산 대상, 중복된 고객 주소는 API 서버를 재시작해도 사라지지 않는다.

모델링 문제는 보통 다음 네 가지 모양으로 나타난다.

  • 데이터 중복: 같은 사실이 여러 행이나 여러 테이블에 저장되어 일부만 수정된다.
  • 이상 현상(Anomaly): 삽입, 수정, 삭제 중에 의도하지 않은 불일치나 정보 손실이 생긴다.
  • 관계 모호성: 한 주문이 고객을 하나만 가질 수 있는지, 여러 배송지를 가질 수 있는지 같은 cardinality가 코드마다 다르게 해석된다.
  • 비용 증폭: 조회를 할 때 너무 많은 JOIN이 필요하거나, 쓰기를 할 때 너무 많은 중복 복사본을 함께 갱신해야 한다.

예를 들어 orders 테이블에 고객 이름, 고객 주소, 주문 상품, 상품 가격, 배송 상태를 모두 한 행에 넣을 수는 있다. 처음에는 빠르게 보인다. 그러나 고객 주소가 바뀌면 과거 주문의 주소도 바꿔야 하는지, 주문 당시 주소는 보존해야 하는지, 배송지 주소와 고객 기본 주소는 같은 의미인지가 곧 문제 된다. 모델링은 이런 질문의 답을 코드가 아니라 스키마에 먼저 새기는 일이다.


2.5 선행 한계 → DB 모델링 등장 이유

섹션 제목: “2.5 선행 한계 → DB 모델링 등장 이유”

초기 애플리케이션은 데이터를 한 파일이나 한 테이블에 몰아넣어도 작동한다. 문제는 데이터가 늘고 팀이 커지면서 같은 사실을 여러 곳에서 쓰기 시작할 때 생긴다. 고객 주소가 customers, orders, shipping_requests, invoices에 각각 복사되어 있으면 어느 값이 진실인지 알 수 없다.

정규화(Normalization)는 이 중복 문제를 줄이기 위해 등장했다. 핵심 철학은 하나의 사실은 한 곳에 둔다(Single Source of Truth) 는 것이다. 고객의 현재 주소는 고객 테이블이 소유하고, 주문 당시 배송지는 주문 배송 정보가 소유한다. 둘은 모두 주소 문자열이지만 의미와 생명주기가 다르기 때문에 같은 컬럼처럼 다루면 안 된다.

비정규화(Denormalization)는 그 반대처럼 보이지만 목적이 다르다. 정규화된 원본은 유지하되, 읽기 비용이나 집계 비용이 너무 커질 때 의도적으로 복사본을 만든다. 중요한 차이는 “중복을 몰라서 만든 것”이 아니라 “원본, 복사본, 갱신 지연, 실패 처리”를 알고 만든다는 점이다.


처음부터 1NF, 2NF 같은 용어를 외우기보다, 아래 질문을 반복해서 던지면 모델의 품질이 보인다.

  1. 무엇이 엔티티인가? 고객, 주문, 상품, 결제, 배송처럼 식별자가 있고 독립적으로 추적해야 하는 대상을 찾는다.

  2. 각 사실의 주인은 어디인가? 고객 이름은 고객 테이블이 소유할 수 있지만, 주문 당시 상품명은 주문 상세가 소유할 수 있다. 같은 문자열이어도 의미가 다르면 소유자가 다르다.

  3. 관계는 몇 대 몇인가? 고객 한 명은 주문 여러 개를 가진다. 주문 하나는 상품 여러 개를 가진다. 상품 하나도 여러 주문에 들어갈 수 있다. 이 N:M 관계는 보통 중간 테이블이 필요하다.

  4. 어떤 불변식을 DB가 막아야 하는가? 주문은 고객 없이 존재할 수 없는지, 주문 금액은 음수가 될 수 없는지, 같은 이메일은 두 고객에게 허용되는지 정한다. 이런 규칙은 애플리케이션 검증만이 아니라 NOT NULL, UNIQUE, FOREIGN KEY, CHECK 제약으로 표현할수록 강해진다.

  5. 주요 조회 경로는 무엇인가? 관리자 화면에서 주문 목록을 고객명과 함께 50개씩 본다면 orders -> customers 관계가 매번 쓰인다. 상세 튜닝은 인덱스 문서의 영역이지만, 모델링 단계에서 fan-out, 즉 관계를 따라가며 결과 행 수가 크게 불어나는 구조를 만들면 이후 최적화 여지가 줄어든다.

  6. 변경과 마이그레이션은 어떻게 안전하게 일어나는가? 컬럼을 추가하고, 기본값을 채우고, 코드가 새 컬럼을 쓰게 하고, 예전 컬럼을 지우는 순서가 필요하다. 스키마는 배포 한 번으로 완성되는 것이 아니라 서비스 운영 중에도 계속 바뀐다.


엔티티는 업무에서 따로 이름 붙여 추적할 수 있는 대상이다. Customer, Order, Product, Payment가 대표적이다. 테이블은 엔티티를 저장하는 물리적 구조다. 한 엔티티가 항상 한 테이블과 1:1로 대응하지는 않는다. 주문이라는 엔티티도 orders, order_items, order_status_history처럼 여러 테이블로 나뉠 수 있다.

컬럼은 엔티티의 속성(Attribute)을 저장한다. 속성은 단순히 값의 타입만으로 결정하지 않는다. address라는 문자열도 고객 기본 주소인지, 주문 배송지인지, 세금계산서 주소인지에 따라 소유자와 변경 규칙이 달라진다.

기본키(Primary Key)는 한 행을 안정적으로 식별하는 값이다. 보통 id 같은 surrogate key, 즉 업무 의미가 없는 내부 대체키를 쓰지만, 이메일이나 주문번호처럼 업무적으로 유일한 값은 별도 UNIQUE 제약으로 보호한다. 기본키는 내부 식별자, 유니크 제약은 업무 규칙을 표현한다고 구분하면 좋다.

외래키(Foreign Key)는 한 테이블의 값이 다른 테이블의 기본키를 참조한다는 약속이다. orders.customer_idcustomers.id를 참조하면, 존재하지 않는 고객의 주문을 DB가 거부할 수 있다. 이 참조 무결성은 트랜잭션과 함께 작동해 “주문은 있는데 고객은 없는” 상태를 줄인다.

Cardinality는 관계의 수량 규칙이다. 모델링에서 가장 자주 놓치는 부분이다.

관계의미전형적인 구현설계 질문
1:1한 행이 다른 한 행과 대응한쪽 테이블에 UNIQUE FK정말 분리할 이유가 있는가?
1:N한 부모가 여러 자식을 가짐자식 테이블에 부모 FK자식은 부모 없이 존재 가능한가?
N:M양쪽 모두 여러 개 연결 가능중간 테이블연결 자체에도 속성이 있는가?
선택 관계연결이 없을 수도 있음nullable FK 또는 별도 상태 테이블null이 업무적으로 어떤 의미인가?
시간에 따른 관계연결이 기간을 가짐history table, valid_from/to현재값과 과거 이력을 구분해야 하는가?

N:M 관계를 한 컬럼에 쉼표 문자열로 넣으면 처음에는 간단하다. 그러나 특정 상품을 포함한 주문을 찾거나, 주문별 상품 수량을 바꾸거나, 상품별 매출을 집계할 때 곧 막힌다. 중간 테이블은 복잡도를 늘리는 것이 아니라 관계를 검색, 검증, 변경 가능한 형태로 만드는 장치다.

주문 모델을 한 테이블로 시작해보자.

order_idcustomer_namecustomer_emailproduct_namestotal_pricestatus
1001홍길동hong@example.com사과, 바나나12000PAID
1002홍길동hong@example.com우유3000PAID

이 구조는 저장은 가능하지만 질문을 견디지 못한다.

  • 고객 이메일이 바뀌면 주문 여러 행을 모두 바꿔야 한다.
  • product_names 안에 쉼표로 들어간 상품은 FK로 보호할 수 없다.
  • 상품별 수량이 없다.
  • 주문 상태 변경 이력이 사라진다.
  • 고객이 삭제될 때 주문을 지워야 하는지 보존해야 하는지 규칙이 없다.

같은 도메인을 관계로 나누면 의미가 선명해진다.

customers
id
name
email
orders
id
customer_id
status
ordered_at
order_items
id
order_id
product_id
quantity
unit_price_snapshot
product_name_snapshot
products
id
name
current_price

여기서 customers.email은 현재 고객 연락처의 원본이다. order_items.product_name_snapshot은 상품명 중복처럼 보이지만, 주문 당시 표시명이라는 다른 사실이다. 현재 상품명이 바뀌어도 과거 주문 내역의 상품명은 바뀌지 않아야 하므로 이 컬럼은 의도적 비정규화다.

모델링은 ERD 그림에서 끝나지 않는다. DB가 직접 막아야 하는 규칙을 제약 조건으로 표현해야 한다.

CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
current_name TEXT NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status TEXT NOT NULL CHECK (status IN ('PENDING', 'PAID', 'CANCELED')),
total_amount NUMERIC(12, 2) NOT NULL CHECK (total_amount >= 0),
ordered_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_snapshot NUMERIC(12, 2) NOT NULL CHECK (unit_price_snapshot >= 0),
product_name_snapshot TEXT NOT NULL
);

CHECK (quantity > 0)은 “수량은 양수여야 한다”는 업무 규칙을 DB가 직접 지키게 한다. 애플리케이션에서 이미 검증하더라도 DB 제약이 있으면 다른 배치, 관리자 도구, 마이그레이션 코드가 잘못된 값을 넣는 것을 막을 수 있다.

트랜잭션은 이 제약이 여러 SQL 사이에서 함께 지켜지도록 하는 경계다. 이 문서에서는 트랜잭션 내부 원리보다 “어떤 모델이 부분 처리 상태를 만들기 쉬운가”에 집중한다. 예를 들어 주문 행만 만들고 주문 상세 행 생성에 실패하면 주문 총액과 상세 합계가 어긋난다. 이 위험은 모델링과 트랜잭션 경계가 함께 해결해야 한다.


RDBMS(Relational Database Management System)는 관계형 데이터베이스 관리 시스템이다. 데이터를 테이블로 저장하고, 테이블 사이의 관계를 키와 제약 조건으로 표현한다. PostgreSQL, MySQL, Oracle이 대표적이다.

NoSQL은 “SQL을 쓰지 않는다”보다 넓은 말이다. 관계형 모델 하나로 모든 워크로드를 처리하기 어렵기 때문에, 키-값, 문서, 컬럼 패밀리, 그래프처럼 다른 접근 방식을 택한 데이터 저장소를 묶어 부르는 이름이다.

RDBMS는 관계와 제약이 강한 업무 사실에 유리하다. NoSQL은 접근 패턴이 단순하고, 스키마 변화가 잦거나, 수평 확장과 낮은 지연이 중요한 경우에 유리할 수 있다. 둘 중 하나가 항상 더 좋은 것이 아니라 데이터의 의미, 변경 방식, 실패 허용 범위가 다르다.

RDBMS와 NoSQL의 기본 차이

RDBMS

테이블, 관계, 제약 조건, SQL을 중심으로 일관성과 복잡한 질의를 다룬다.

주문, 결제, 정산, 권한처럼 강한 정합성이 필요한 데이터에 적합하다.

NoSQL

접근 패턴에 맞춘 저장 구조를 우선하고, 확장성과 유연성을 얻는 대신 일부 검증을 애플리케이션이 책임진다.

캐시, 세션, 이벤트 로그, 피드, 문서형 콘텐츠처럼 패턴이 분명한 데이터에 적합하다.
유형핵심 모델좋은 사용처조심할 점
Key-Value Store키 하나로 값 하나를 찾음세션, 캐시, rate limit counter관계 조회와 복잡한 필터링이 약하다.
Document StoreJSON과 비슷한 문서 단위 저장카탈로그, CMS, 변동 큰 속성문서 밖 관계가 많아지면 중복이 늘어난다.
Column-Family Store파티션 키와 정렬 키 중심 저장이벤트 로그, 시계열, 대량 쓰기잘못된 파티션 키는 핫스팟을 만든다.
Graph DB노드와 엣지로 관계 탐색친구 관계, 추천, 사기 탐지단순 CRUD에는 과한 선택일 수 있다.

NoSQL을 고를 때 가장 먼저 물어야 할 질문은 “JOIN이 없어서 빠른가?”가 아니다. 데이터를 어떤 키로 읽고, 어떤 단위로 함께 바꾸며, 잠깐 불일치해도 괜찮은가? 다. 이 질문에 답하지 못하면 NoSQL은 유연함이 아니라 검증 책임을 잃은 저장소가 된다.

관계가 복잡하고 불변식을 DB가 강하게 지켜야 한다
-> RDBMS 우선
키 하나로 읽고 쓰는 데이터이며 사라져도 원본에서 재생성할 수 있다
-> Key-Value Store 고려
문서 단위로 함께 읽고 함께 수정되며 문서 밖 관계가 적다
-> Document Store 고려
쓰기량이 크고 접근 패턴이 파티션 키로 고정된다
-> Column-Family Store 고려
관계의 깊이 탐색이 핵심 기능이다
-> Graph DB 고려

수치 감각도 필요하다. 초당 50건의 주문 결제를 처리하는 시스템은 RDBMS로 충분할 가능성이 높다. 반면 초당 수만 건의 클릭 이벤트를 모두 주문 DB의 정규화 테이블에 넣고 실시간 집계를 돌리면, 정합성보다 쓰기 처리량과 파티션 설계가 먼저 문제가 된다.

문서형 DB나 파티션형 DB를 고를 때도 트랜잭션 범위와 hot partition 위험을 같이 본다. 파티션 키는 데이터를 여러 물리 구간으로 나누는 기준이고, hot partition은 같은 키나 같은 구간으로 읽기/쓰기가 몰려 그 파티션만 병목이 되는 상태다. 예를 들어 모든 클릭 이벤트의 파티션 키를 today 하나로 잡으면 노드를 여러 대 두어도 오늘 파티션만 뜨거워진다. 수평 확장은 “키가 고르게 퍼진다”는 전제가 있을 때 효과가 난다.

반례 1: 스키마가 자주 바뀌면 항상 NoSQL이 낫다.

스키마가 바뀐다는 말이 “필드 몇 개가 자주 추가된다”는 뜻이라면 RDBMS의 JSON 컬럼, nullable 컬럼, 별도 속성 테이블로도 충분할 수 있다. 반대로 “읽는 방식 자체가 계속 바뀐다”면 NoSQL도 인덱스와 데이터 재구성이 필요하다.

반례 2: JOIN이 있으니 RDBMS는 느리다.

JOIN은 관계를 표현하는 비용이다. 적절한 키와 인덱스가 있고 cardinality가 예측 가능하면 JOIN은 충분히 빠르다. 오히려 관계를 문서 안에 중복 저장하면 쓰기 때 여러 문서를 함께 고쳐야 해서 더 비싸질 수 있다.

반례 3: NoSQL은 정합성이 없다.

NoSQL도 조건부 쓰기, 버전 컬럼, 트랜잭션 옵션을 제공할 수 있다. 다만 그 기능을 자주 쓰기 시작하면 처음에 얻으려던 단순성과 확장성의 장점이 줄어든다. “가능하다”와 “그 모델에 자연스럽다”를 구분해야 한다.


정규화는 데이터 중복과 이상 현상을 줄이기 위해 테이블을 나누고 종속 관계를 정리하는 규칙이다. 핵심은 한 사실을 한 곳에 둔다 는 것이다.

여기서 사실은 값 자체가 아니라 의미다. 고객의 현재 이름과 주문 당시 수령인 이름은 둘 다 문자열이지만 같은 사실이 아니다. 현재 이름은 고객 정보의 사실이고, 주문 당시 수령인 이름은 주문 처리 시점의 스냅샷이다.

정규화는 읽기를 무조건 빠르게 만들지 않는다. 오히려 테이블이 나뉘면서 JOIN이 늘 수 있다. 정규화의 첫 목표는 쓰기와 변경의 정합성이다. 읽기 성능은 그 다음에 인덱스, 쿼리, 필요한 경우 비정규화로 다룬다.

이상 현상(Anomaly)은 테이블 구조 때문에 삽입, 수정, 삭제가 의도하지 않은 결과를 만드는 현상이다.

이상 현상무엇이 깨지는가작은 예시
삽입 이상어떤 정보를 넣으려면 불필요한 정보가 필요상품을 등록하려는데 주문이 있어야만 행을 만들 수 있음
수정 이상같은 사실을 여러 곳에서 고쳐야 함고객 주소를 주문 100건에 모두 업데이트해야 함
삭제 이상하나를 지웠더니 다른 사실도 사라짐마지막 주문을 삭제했더니 고객 연락처까지 사라짐

수정 이상을 숫자로 보면 위험이 더 선명하다. 고객 주소가 주문 행 10,000개에 중복되어 있고, 주소 변경 배치가 9,998행만 바꾸면 성공률은 99.98%처럼 보인다. 그러나 데이터 관점에서는 같은 고객에게 두 주소가 공존한다. 정합성 문제는 평균 성공률이 아니라 “하나라도 남았는가”로 판단해야 한다.

1NF(First Normal Form, 제1정규형)는 한 셀에 반복 그룹이나 여러 값을 넣지 않는 규칙이다. 하나의 컬럼에는 하나의 의미 있는 값만 들어가야 한다.

위반 예시:

order_idcustomerproducts
1홍길동사과, 바나나, 포도
2김철수우유

products에 쉼표 문자열을 넣으면 “사과를 포함한 주문”을 찾기 위해 LIKE '%사과%' 같은 검색을 해야 한다. 사과잼사과를 구분하기도 어렵고, 상품 ID로 FK를 걸 수도 없다.

1NF 적용 후:

order_idcustomerproduct
1홍길동사과
1홍길동바나나
1홍길동포도
2김철수우유

이제 각 행은 주문과 상품 하나의 관계를 나타낸다.

-- 1NF 위반: 다중값 문자열 검색
SELECT *
FROM orders_bad
WHERE products LIKE '%사과%';
-- 1NF 준수: 상품 행을 정확히 검색
SELECT *
FROM order_items
WHERE product = '사과';

1NF의 경계도 있다. 전화번호 여러 개를 하나의 JSON 배열에 넣는 것이 항상 틀린 것은 아니다. 그 전화번호를 개별 검색, 검증, 연결 대상으로 다루지 않고 문서 전체와 함께만 읽고 쓴다면 허용 가능한 모델일 수 있다. 하지만 개별 값에 제약, 검색, 관계가 필요해지는 순간 별도 행이 더 안전하다.

2NF(Second Normal Form, 제2정규형)는 1NF를 만족하면서 복합 기본키의 일부에만 종속되는 컬럼을 분리하는 규칙이다. 부분 종속은 order_id + product_id가 키인데 customer_addressorder_id에만 의존하는 상황이다.

위반 예시:

order_idproductquantitycustomer_namecustomer_address
1사과3홍길동서울시 강남구
1바나나1홍길동서울시 강남구
2우유2김철수부산시 해운대구

기본키가 order_id + product라면 quantity는 전체 키에 의존한다. 그러나 customer_namecustomer_address는 상품과 무관하다. 주문 1번에 상품이 10개 있으면 고객 주소도 10번 반복된다.

2NF 적용 후:

orders 테이블:

order_idcustomer_id
1101
2102

order_items 테이블:

order_idproductquantity
1사과3
1바나나1
2우유2

customers 테이블:

customer_idcustomer_namecustomer_address
101홍길동서울시 강남구
102김철수부산시 해운대구
UPDATE customers
SET customer_address = '서울시 마포구'
WHERE customer_id = 101;

이제 고객 주소 변경은 한 행의 변경이다. 주문 상세 행 수가 많아져도 주소 변경 비용은 주문 상품 수에 비례하지 않는다.

3NF(Third Normal Form, 제3정규형)는 비키 컬럼이 다른 비키 컬럼을 결정하는 구조를 분리하는 규칙이다. 이행 종속은 employee_id -> department_id -> department_location처럼 키가 아닌 컬럼을 거쳐 다른 컬럼이 결정되는 관계다.

위반 예시:

employee_idnamedepartment_iddepartment_namedepartment_location
1홍길동D01개발팀서울
2김철수D01개발팀서울
3이영희D02마케팅팀부산

department_namedepartment_location은 직원이 아니라 부서에 속한 사실이다. 개발팀 위치가 바뀌면 개발팀 직원 모든 행을 바꿔야 한다.

3NF 적용 후:

employees 테이블:

employee_idnamedepartment_id
1홍길동D01
2김철수D01
3이영희D02

departments 테이블:

department_iddepartment_namedepartment_location
D01개발팀서울
D02마케팅팀부산
UPDATE departments
SET department_location = '부산'
WHERE department_id = 'D01';

3NF는 실무 설계의 기본 출발점으로 충분한 경우가 많다. 모든 테이블을 수학적으로 가장 엄격한 형태까지 나누는 것보다, 어느 사실이 어느 테이블의 소유인지 설명할 수 있는지가 더 중요하다.

BCNF(Boyce-Codd Normal Form)는 3NF보다 엄격하다. 모든 결정자(Determinant)가 후보키(Candidate Key)여야 한다. 결정자는 다른 값을 결정하는 속성이고, 후보키는 행을 유일하게 식별할 수 있는 최소 속성 집합이다.

학생, 과목, 교수 배정 예시를 보자.

학생과목교수
홍길동데이터베이스김교수
김철수데이터베이스김교수
이영희알고리즘박교수

규칙이 “교수는 한 과목만 담당한다”라면 교수 -> 과목이 성립한다. 그런데 교수만으로 학생까지 식별할 수는 없으므로 후보키가 아니다. 이 구조는 교수가 담당 과목을 바꿀 때 여러 행을 수정해야 한다.

BCNF 적용 후:

professor_subject 테이블:

교수과목
김교수데이터베이스
박교수알고리즘

student_professor 테이블:

학생교수
홍길동김교수
김철수김교수
이영희박교수

BCNF는 모든 프로젝트에서 매번 끝까지 적용해야 하는 목표라기보다, “어떤 컬럼이 다른 컬럼을 결정하는데 그 컬럼이 키가 아닌가?”를 확인하게 해주는 렌즈다.

정규화는 중복을 줄이지만 모든 중복을 없애지는 않는다. 다음 중복은 오히려 올바를 수 있다.

  • 주문 당시 상품명과 가격을 주문 상세에 저장한다.
  • 결제 승인 응답의 원문 일부를 감사 목적으로 저장한다.
  • 배송 주소를 고객 기본 주소와 별도로 주문에 저장한다.
  • 매출 대시보드용 일별 합계를 별도 테이블에 저장한다.

공통점은 복사본의 의미와 갱신 정책이 명확하다는 것이다. “현재 상품명”과 “주문 당시 상품명”은 다른 사실이다. “실시간 주문 합계”와 “어제 자정 기준 매출 집계”도 다른 사실이다. 반대로 두 값이 같은 의미인데 둘 다 원본처럼 쓰인다면 정규화가 필요하다.


비정규화는 읽기 성능, 집계 비용, 사용자 경험, 검색 편의를 위해 의도적으로 중복 데이터를 저장하는 설계다. 정규화가 “원본을 명확히 한다”면 비정규화는 “자주 필요한 읽기 모양을 미리 준비한다”에 가깝다.

비정규화는 정규화의 실패가 아니다. 실패는 원본이 무엇인지 모르는 중복이다. 성공적인 비정규화는 다음 네 가지를 함께 문서화한다.

  1. 원본 테이블은 어디인가?
  2. 복사본은 어떤 조회를 빠르게 하는가?
  3. 복사본은 언제 갱신되는가?
  4. 복사본이 늦거나 틀렸을 때 사용자는 무엇을 보게 되는가?

정규화된 주문 모델에서 관리자 대시보드는 매번 다음 값을 원할 수 있다.

  • 날짜별 주문 수
  • 날짜별 총 매출
  • 지역별 평균 주문 금액
  • 상품별 판매 수량

요청마다 수백만 행의 ordersorder_items를 스캔해서 SUM, COUNT, GROUP BY를 수행하면 OLTP(Online Transaction Processing) 테이블이 분석 쿼리에 끌려다닌다. OLTP는 주문 생성, 결제 상태 변경처럼 짧고 빈번한 트랜잭션을 처리하는 영역이다. 대시보드 집계는 OLAP(Online Analytical Processing)에 가까운 읽기다.

예를 들어 하루 주문 200만 건, 관리자 대시보드 조회 2만 회, 각 조회가 당일 주문 전체를 집계한다고 해보자. 단순 계산으로 하루 400억 행을 반복 스캔하는 셈이다. 반면 일별 집계 테이블에 지역별 20행만 저장해두면 대시보드는 수십 행을 읽는다. 이 차이가 비정규화의 동기다.

상품 테이블은 현재 상품명을 소유한다. 하지만 주문 내역은 주문 당시 사용자가 본 상품명과 가격을 보여줘야 한다. 이 경우 order_items에 상품명과 단가 스냅샷을 저장하는 것은 좋은 비정규화다.

CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
product_name_snapshot TEXT NOT NULL,
unit_price_snapshot NUMERIC(12, 2) NOT NULL
);

이 컬럼은 상품명이 바뀔 때 함께 업데이트하지 않는다. 과거 주문 내역이 현재 상품명을 따라 바뀌면 오히려 감사 가능성이 깨진다.

판단 기준은 간단하다.

값이 바뀌면 과거 기록도 함께 바뀌어야 하는가?
-> 원본 참조를 유지한다.
값이 바뀌어도 과거 기록은 당시 상태를 보여줘야 하는가?
-> 스냅샷 컬럼을 둔다.

집계 테이블은 반복 계산이 비싼 값을 미리 계산해 저장한다.

CREATE TABLE daily_product_sales (
sale_date DATE NOT NULL,
product_id BIGINT NOT NULL,
product_name_snapshot TEXT NOT NULL,
total_quantity INTEGER NOT NULL,
total_revenue NUMERIC(14, 2) NOT NULL,
calculated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (sale_date, product_id)
);

이 테이블의 원본은 ordersorder_items다. daily_product_sales는 원본이 아니라 파생물이다. 따라서 calculated_at 같은 갱신 시각이 중요하다. 사용자가 보는 매출 수치가 5분 전 기준인지, 자정 기준인지 모르면 불일치가 버그인지 정상 지연인지 판단할 수 없다.

작은 반례도 있다. 결제 정산 금액처럼 법적 책임이 있는 데이터는 집계 테이블만 믿고 확정하면 안 된다. 정산 확정 시점에는 원본 거래 내역과 대조하거나, 확정된 정산 스냅샷을 별도 원본으로 승격하는 절차가 필요하다.

Materialized View(구체화 뷰)는 쿼리 결과를 물리적으로 저장해두는 DB 객체다. 일반 View가 매번 원본 쿼리를 실행한다면, Materialized View는 결과를 저장하고 필요할 때 갱신한다.

CREATE MATERIALIZED VIEW order_daily_summary AS
SELECT
o.ordered_at::date AS sale_date,
c.region,
COUNT(o.id) AS order_count,
SUM(oi.quantity * oi.unit_price_snapshot) AS total_revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'PAID'
GROUP BY o.ordered_at::date, c.region;

Materialized View는 선언적으로 만들 수 있어 집계 테이블보다 단순할 수 있다. 대신 갱신 주기와 갱신 중 읽기 정책을 정해야 한다. “매일 자정 갱신”이라면 오전 11시의 대시보드는 오늘 데이터가 빠져 있을 수 있다. “5분마다 갱신”이라면 DB에 주기적 부하가 생긴다.

여기서 중요한 것은 stale window, 즉 복사본이 원본보다 늦을 수 있는 허용 시간이다. PostgreSQL의 REFRESH MATERIALIZED VIEW CONCURRENTLY처럼 갱신 중 읽기를 어느 정도 유지하는 방법도 있지만, 고유 인덱스 같은 전제와 추가 비용이 있다. 그래서 refresh 정책은 “얼마나 빨리 최신이어야 하는가”와 “갱신 중 낡은 값을 읽어도 되는가”를 함께 정하는 모델링 결정이다.

비정규화 수단을 고를 때는 다음처럼 본다.

수단장점비용적합한 상황
스냅샷 컬럼조회 단순, 감사 기록 보존쓰기 시 컬럼 복사 필요주문 당시 상품명, 가격, 배송지
집계 테이블제어권 높음, 증분 갱신 가능갱신 로직을 직접 유지해야 함대시보드, 정산 후보 집계
Materialized View선언적이고 읽기 빠름refresh 정책과 잠금 고려 필요복잡하지만 주기 갱신 가능한 집계
검색 인덱스검색과 필터링에 강함원본과 인덱스 동기화 필요상품 검색, 관리자 통합 검색
캐시매우 빠른 반복 조회만료, 무효화, stampede 위험인기 상품, 권한 없는 공개 데이터

CDC(Change Data Capture)나 Outbox는 원본 DB 변경을 다른 저장소나 이벤트 스트림으로 옮기는 고급 동기화 방식이다. 이 문서에서는 “복사본 갱신 수단 중 하나”로만 기억하면 충분하다. 유실, 중복, 순서, 재처리 문제는 별도 cdc-outbox 토픽에서 다룬다.

비정규화는 느린 쿼리를 처음 봤을 때 바로 꺼내는 카드가 아니다.

1. 먼저 모델의 의미가 맞는지 확인한다.
원본과 복사본이 섞인 것이 아닌가?
2. 다음으로 쿼리와 인덱스로 해결 가능한지 확인한다.
이 단계의 상세는 db-index-query-optimization 문서가 맡는다.
3. 그래도 반복 계산이나 JOIN fan-out이 구조적으로 크면 비정규화를 검토한다.
4. 비정규화한다면 갱신 지연, 실패 시 복구, 검증 쿼리를 함께 설계한다.

정량 기준은 시스템마다 다르지만, 학습용으로는 이런 감각을 둘 수 있다.

신호의미먼저 볼 선택지
같은 집계가 분당 수백 번 반복CPU를 계산에 계속 쓰고 있음Materialized View, 집계 테이블
JOIN 결과가 원본보다 수십 배 커짐cardinality 예측이 틀렸거나 fan-out 큼관계 재검토, 페이지네이션, 집계
쓰기마다 5개 이상 복사본 갱신비정규화가 쓰기 경로를 복잡하게 만듦복사본 축소, 비동기 갱신, 원본 재정의
사용자에게 stale 값이 보임갱신 지연이 UX 또는 업무 판단에 노출됨last_synced_at, 원본 fallback

비정규화가 깨지면 보통 운영 명령보다 먼저 데이터 증상으로 드러난다.

  • 대시보드 합계와 원본 거래 합계가 반복적으로 다르다.
  • 복사본에 last_synced_at이나 버전 정보가 없어 지연인지 오류인지 구분할 수 없다.
  • 상품명 변경 후 어떤 화면은 새 이름, 어떤 화면은 예전 이름을 보여준다. 이것이 스냅샷 정책인지 버그인지 문서화되어 있지 않다.
  • 복사본 갱신 실패가 사용자 쓰기 실패로 전파되어 주문 생성 같은 핵심 경로가 느려진다.
  • 장애 복구 후 복사본을 다시 만들 수 있는 원본 기준이 없다.

8. 모델링 결정이 성능에 미치는 영향

섹션 제목: “8. 모델링 결정이 성능에 미치는 영향”

관계의 수량을 잘못 잡으면 쿼리 비용이 빠르게 커진다. 고객 1명당 주문 10개, 주문 1개당 주문상품 5개면 고객 1,000명을 조회할 때 주문상품은 평균 50,000행이 된다. 여기에 배송 이벤트가 주문상품당 4개씩 붙으면 200,000행으로 늘어난다.

이 fan-out은 인덱스만으로 사라지지 않는다. 인덱스는 필요한 행을 찾는 비용을 줄여주지만, 최종적으로 반환하거나 집계해야 하는 행 수 자체가 크면 비용은 남는다. 모델링 단계에서 관계를 나눌 때 “목록 화면에서 이 관계를 항상 함께 펼칠 것인가?”를 물어야 한다.

반대로 관계를 너무 합치면 쓰기 비용이 커진다. 주문 목록 화면을 빠르게 만들려고 고객명, 배송 상태, 결제 수단, 마지막 문의 내용을 모두 orders에 복사하면 주문 상태 변경 때마다 복사본 갱신 범위가 늘어난다.

8-2. N+1은 모델링만의 문제가 아니라 조회 경계의 신호다

섹션 제목: “8-2. N+1은 모델링만의 문제가 아니라 조회 경계의 신호다”

N+1 문제는 주문 100개를 가져온 뒤 각 주문의 고객을 100번 따로 조회하는 식의 패턴이다. 이것은 ORM 사용법 문제처럼 보이지만, 모델링 관점에서는 “목록에서 어떤 관계를 항상 함께 읽는가”가 정해지지 않았다는 신호이기도 하다.

주문 목록 50개 조회
1번: orders 50개 조회
50번: 각 order.customer 조회
50번: 각 order.items 조회
-> 총 101번 조회

해결의 중심은 “무조건 eager loading”이 아니다. 목록 화면이 고객명만 필요하다면 고객 전체 엔티티가 아니라 customer_display_name 스냅샷을 둘 수도 있다. 상세 화면이면 JOIN fetch가 맞을 수 있다. 분석 화면이면 별도 projection table이 맞을 수 있다. Projection/read model은 특정 조회를 빠르게 만들기 위해 원본 데이터에서 파생해 둔 읽기 전용 모델이다. 조회 모양이 다르면 모델 또는 읽기 모델도 달라질 수 있다.

customer_display_name 같은 스냅샷은 의미를 분명히 해야 한다. 주문 당시 표시명을 보존해야 한다면 스냅샷이 맞고, 항상 현재 고객명을 보여줘야 한다면 projection 갱신 정책이나 JOIN이 필요하다.

상세한 실행 계획, B+Tree, 인덱스 선택, JOIN 전략은 db-index-query-optimization.mdx에서 다룬다. 여기서는 cardinality와 읽기 모델이 튜닝 이전의 구조적 비용을 만든다는 점만 기억한다.

비정규화는 읽기를 빠르게 하는 대신 쓰기를 증폭시킨다. 주문 하나가 생성될 때 원본 주문, 주문 상세, 일별 집계, 상품별 집계, 검색 인덱스, 캐시 무효화까지 동시에 건드리면 쓰기 경로가 길어진다. 이 중 일부는 트랜잭션 안에 넣을 수 있고, 일부는 트랜잭션 밖 비동기 작업으로 빼야 한다.

PostgreSQL의 MVCC와 VACUUM, bloat는 트랜잭션 문서가 주로 다룬다. 모델링 문서에서 중요한 경계는 이것이다. UPDATE가 잦은 큰 JSON 컬럼이나 상태 테이블은 dead tuple을 많이 만들 수 있고, 길게 열린 트랜잭션은 정리를 막을 수 있다. 스키마가 자주 덮어쓰는 데이터를 한 행에 몰아넣으면 저장소 내부 비용도 커진다.

실패 신호는 다음처럼 본다.

  • 상태 업데이트가 많은 테이블의 크기가 실제 행 수보다 빠르게 커진다.
  • 단순 목록 조회인데 오래된 복사본과 현재 원본을 동시에 조합하느라 쿼리가 복잡하다.
  • 한 업무 이벤트가 너무 많은 테이블을 갱신해 장애 시 재처리 범위가 커진다.

스키마 마이그레이션은 테이블 구조를 운영 중에 바꾸는 일이다. 컬럼 추가, 타입 변경, 제약 추가, 테이블 분리, 데이터 백필이 모두 포함된다. 모델링을 잘해도 변경 순서가 잘못되면 배포 중 장애가 난다.

마이그레이션은 코드 배포와 DB 배포의 시간차를 고려해야 한다. 애플리케이션 인스턴스 여러 개가 순차 배포되는 동안 일부는 예전 컬럼을 읽고, 일부는 새 컬럼을 쓸 수 있다. 따라서 스키마 변경은 보통 backward compatible해야 한다.

컬럼 이름을 user_email에서 email로 바꾸는 상황을 보자. 위험한 방식은 한 번에 rename하고 코드를 배포하는 것이다. 배포 중 예전 코드가 user_email을 읽으면 실패한다.

안전한 흐름은 expand and contract다.

1. Expand: 새 nullable 컬럼 email을 추가한다.
2. Backfill: 기존 user_email 값을 email에 채운다.
3. Dual write/read: 새 코드는 email을 쓰고, 필요하면 user_email도 함께 유지한다.
4. Verify: 두 컬럼 값이 같은지 검증한다.
5. Contract: 모든 코드가 email만 쓰는 것이 확인되면 user_email을 제거한다.

이 과정은 느려 보이지만 장애 반경을 줄인다. 특히 큰 테이블에 NOT NULL, DEFAULT, 타입 변경, 인덱스 생성, FK 추가를 한 번에 적용하면 DB 버전과 변경 방식에 따라 긴 lock이나 rewrite 위험이 생길 수 있다. lock 자체의 내부 동작은 트랜잭션 주제지만, 모델링 관점에서는 “운영 중 바꿀 수 있는 구조인가?”가 중요한 설계 조건이다.

TypeORM의 synchronize: true 같은 자동 동기화 기능은 개발 초기에 편하다. 그러나 운영 DB에서는 엔티티 정의와 실제 테이블을 맞추기 위해 컬럼을 삭제하거나 타입을 바꿀 수 있다. 모델링 관점에서 이것은 검토된 마이그레이션이 아니라 런타임 추론이다.

실패 신호는 명확하다.

배포 직후 특정 컬럼이 없다는 오류가 난다.
최근 엔티티 필드명을 바꿨다.
운영 설정에 synchronize: true가 남아 있다.
마이그레이션 파일 없이 스키마가 바뀌었다.

운영에서는 마이그레이션 파일을 만들고, 리뷰하고, staging 데이터로 검증한 뒤 적용하는 흐름이 필요하다. Prisma, TypeORM, Sequelize 같은 도구 차이는 부차적이다. 핵심은 “스키마 변경이 코드 리뷰 가능한 산출물로 남는가”다.

변경위험한 이유안전한 접근
컬럼 삭제예전 코드가 읽으면 즉시 실패사용 중지 확인 후 별도 배포에서 삭제
nullable -> not null기존 null 데이터 때문에 실패 가능backfill 후 제약 추가
타입 변경변환 실패와 lock 가능새 컬럼 추가, 변환, 전환
큰 테이블에 기본값 컬럼 추가테이블 rewrite 또는 긴 lock 가능nullable 추가 후 batch backfill
FK 제약 추가기존 orphan row 때문에 실패 가능orphan 정리 후 제약 추가
unique 제약 추가중복 데이터가 있으면 실패중복 탐지, 정리, 제약 추가

마이그레이션 안전성은 성능이나 운영만의 문제가 아니다. 모델링이 “데이터가 어떤 상태일 수 있는가”를 정한다면, 마이그레이션은 “그 상태 집합을 어떻게 바꿀 것인가”를 정한다.


같은 값이 여러 행에서 반복되는가?
-> 그 값이 별도 엔티티의 속성인지 확인한다.
값의 생명주기가 다르게 움직이는가?
-> 별도 테이블 또는 스냅샷 컬럼을 검토한다.
값에 독립적인 제약이나 권한이 필요한가?
-> 별도 엔티티일 가능성이 높다.
항상 부모와 함께만 생성, 삭제, 조회되는가?
-> 같은 테이블 또는 내포 구조도 가능하다.

FK는 참조 무결성을 강하게 만든다. 주문이 고객을 반드시 가져야 한다면 FK가 자연스럽다. 하지만 외부 시스템 ID, 로그성 이벤트, 삭제된 사용자에 대한 감사 기록처럼 참조 대상이 사라져도 기록은 남아야 하는 경우에는 FK 대신 스냅샷 ID와 상태를 저장할 수 있다.

FK를 피하는 이유가 “성능이 걱정된다”뿐이라면 먼저 실제 쓰기량과 제약 검증 비용을 봐야 한다. FK가 없으면 애플리케이션 모든 쓰기 경로가 참조 무결성을 대신 책임져야 한다.

질문정규화 쪽 신호비정규화 쪽 신호
값이 자주 바뀌는가?한 곳에서 바뀌어야 한다.바뀌어도 과거 스냅샷은 유지해야 한다.
읽기와 쓰기 중 무엇이 압도적인가?쓰기 정합성이 중요하다.같은 읽기가 대량 반복된다.
불일치가 비즈니스에 치명적인가?원본 하나와 강한 제약이 필요하다.지연 허용 범위를 명시할 수 있다.
복사본을 다시 만들 수 있는가?원본만 유지한다.원본에서 재생성 가능하면 복사본 가능.
팀이 갱신 로직을 운영할 준비가 되어 있는가?준비 전에는 정규화 유지가 안전하다.검증/재처리 경로가 있으면 가능하다.

RDBMS를 먼저 고려할 때:

  • 여러 테이블의 관계가 업무 규칙의 중심이다.
  • 결제, 정산, 재고처럼 강한 정합성이 필요하다.
  • SQL로 ad-hoc 분석과 운영 확인을 해야 한다.
  • 제약 조건으로 잘못된 상태를 막고 싶다.

NoSQL을 고려할 때:

  • 접근 패턴이 키 중심으로 명확하다.
  • 데이터 구조가 문서 단위로 자연스럽게 묶인다.
  • 쓰기량이 매우 크고 수평 분산이 핵심이다.
  • 잠깐의 지연이나 최종 일관성을 업무적으로 설명할 수 있다.

둘을 섞는 경우도 많다. 주문 원본은 RDBMS에 두고, 검색 화면은 Elasticsearch나 OpenSearch 같은 검색 인덱스를 쓸 수 있다. 이때 검색 인덱스는 원본이 아니라 projection이다. 사용자에게 최신성이 중요한 화면인지, 검색 편의가 더 중요한 화면인지에 따라 지연 허용 범위를 정해야 한다.

10-5. 케이스 A: 고객 주소와 배송 주소

섹션 제목: “10-5. 케이스 A: 고객 주소와 배송 주소”

주소는 모델링 연습에 좋다. 같은 문자열처럼 보여도 의미가 여러 개로 갈라지기 때문이다.

원본 후보변경 전파 규칙설계 판단
고객 기본 주소customers.current_address고객이 수정하면 현재 기본 주소가 바뀜고객 프로필의 현재값
주문 당시 배송 주소order_shipments.address고객 주소가 바뀌어도 과거 주문은 유지주문 처리 시점 스냅샷
세금계산서 청구 주소billing_profiles.address회계 기준에 맞춰 별도 관리결제/정산 도메인 사실
물류센터 반품 주소return_centers.address운영 정책에 따라 바뀜배송 운영 설정

나쁜 모델은 이 네 값을 모두 address라는 이름으로 주문 테이블에 섞는다. 처음에는 화면에 주소가 보이니 성공처럼 보인다. 그러나 고객이 이사했을 때 어떤 주소를 바꿔야 하는지 알 수 없고, 과거 주문의 배송지가 현재 고객 주소로 덮이면 감사 기록이 깨진다.

좋은 모델은 “주소 문자열”이 아니라 “주소가 설명하는 사실”을 기준으로 나눈다. 고객 기본 주소는 고객이 직접 수정할 수 있는 현재 상태다. 주문 배송 주소는 주문 생성 시점에 확정된 처리 사실이다. 두 주소가 우연히 같은 값이어도 같은 원본을 공유하면 안 된다.

작은 반례를 보자.

2026-04-01 주문 생성
customer.current_address = 서울시 강남구
order_shipments.address = 서울시 강남구
2026-04-10 고객 이사
customer.current_address = 서울시 마포구
과거 주문 상세 조회
주문 당시 배송지는 서울시 강남구로 남아야 한다.

이 반례에서 order_shipments.address를 고객 주소 FK로만 두고 스냅샷을 저장하지 않으면, 과거 주문 상세가 현재 주소를 보여줄 수 있다. 정규화만 기계적으로 적용한 결과가 오히려 업무 사실을 잃는 경우다.

따라서 주소 모델링의 기준은 다음과 같다.

현재 프로필처럼 항상 최신값을 보여줘야 한다
-> 원본 테이블을 참조한다.
계약, 주문, 배송처럼 당시 값을 보존해야 한다
-> 스냅샷 컬럼을 둔다.
정책이나 설정처럼 운영자가 별도 관리한다
-> 별도 설정 엔티티로 분리한다.

10-6. 케이스 B: 역할과 권한의 N:M 관계

섹션 제목: “10-6. 케이스 B: 역할과 권한의 N:M 관계”

권한 모델은 cardinality를 잘못 잡으면 빠르게 망가진다. 사용자 한 명은 여러 조직에 속할 수 있고, 조직 안에서 여러 역할을 가질 수 있다. 역할 하나는 여러 권한을 포함하고, 권한 하나도 여러 역할에 들어갈 수 있다.

나쁜 시작점은 다음처럼 사용자 행에 역할을 문자열로 넣는 것이다.

user_idorg_idroles
110admin,billing
210viewer
120viewer,operator

이 모델은 다음 질문에 약하다.

  • billing 역할을 가진 사용자를 정확히 찾을 수 있는가?
  • admin 역할 이름을 owner로 바꾸면 몇 문자열을 수정해야 하는가?
  • 역할에 invoice:read 권한을 추가하면 어느 사용자의 행이 바뀌는가?
  • 한 사용자가 같은 조직에서 같은 역할을 중복으로 갖는 것을 막을 수 있는가?

관계를 분리하면 제약이 선명해진다.

users(id, email)
organizations(id, name)
roles(id, org_id, name)
permissions(id, code)
user_roles(user_id, org_id, role_id)
role_permissions(role_id, permission_id)

여기서 user_rolesrole_permissions는 단순 연결 테이블처럼 보이지만 중요한 업무 사실을 담는다. “누가 어떤 조직에서 어떤 역할을 갖는가”, “역할은 어떤 권한을 포함하는가”라는 관계 자체가 엔티티가 된 것이다.

제약 조건도 모델의 일부다.

ALTER TABLE user_roles
ADD CONSTRAINT user_roles_unique UNIQUE (user_id, org_id, role_id);
ALTER TABLE role_permissions
ADD CONSTRAINT role_permissions_unique UNIQUE (role_id, permission_id);

이 제약은 중복 권한 부여를 막는다. 애플리케이션에서 중복 체크를 해도, 관리자 도구나 배치가 같은 관계를 두 번 넣을 수 있다. DB 제약은 모든 쓰기 경로에 공통으로 적용되는 마지막 방어선이다.

반대로 모든 권한 체크를 매 요청마다 다섯 테이블 JOIN으로 수행하면 비용이 커질 수 있다. 이때 user_effective_permissions 같은 projection을 만들 수 있다. 단, 원본은 user_rolesrole_permissions이고 projection은 빠른 권한 체크를 위한 복사본이라는 경계를 유지해야 한다.

권한 projection의 실패 신호는 다음과 같다.

  • 역할을 변경했는데 사용자 권한 캐시가 갱신되지 않는다.
  • 권한 캐시가 언제 계산된 것인지 알 수 없다.
  • 권한 거부가 발생했을 때 원본 역할 문제인지 projection 지연인지 구분할 수 없다.
  • projection을 수동 수정해 원본과 다른 권한 상태가 생긴다.

10-7. 케이스 C: 대시보드 projection의 일관성 예산

섹션 제목: “10-7. 케이스 C: 대시보드 projection의 일관성 예산”

Projection은 특정 읽기 모양을 위해 만든 파생 테이블이다. 대시보드에서 자주 등장한다. projection을 만들 때는 “얼마나 빨라지는가”만큼 “얼마나 늦어도 되는가”가 중요하다.

예를 들어 daily_sales_summary가 5분마다 갱신된다고 하자.

원본:
orders
order_items
payments
projection:
daily_sales_summary
갱신 주기:
5분마다
허용 지연:
관리자 대시보드는 최대 5분 지연 허용
정산 확정은 지연 허용 안 됨

이 설계는 관리자 대시보드에는 적절할 수 있다. 그러나 정산 확정 버튼이 daily_sales_summary만 읽고 송금을 실행한다면 위험하다. 같은 숫자라도 “관찰용”과 “정산 근거”는 책임이 다르다.

일관성 예산을 표로 적으면 팀이 같은 기준을 공유할 수 있다.

항목예시 값왜 필요한가
원본orders, paymentsprojection 재생성 기준
갱신 주기5분stale 값의 정상 범위
최대 허용 지연10분장애 알림 기준
검증 쿼리원본 합계와 projection 합계 비교silent drift 탐지
사용자 표시 방식”마지막 갱신: 10:05”사용자 오해 감소
사용 금지 영역정산 확정, 세금 신고projection 오용 방지

여기서 핵심은 projection이 틀릴 수 있다는 사실을 인정하는 것이다. 틀릴 수 있는 값을 쓰면 안 된다는 뜻이 아니다. 틀릴 수 있는 범위와 복구 방법을 모델의 일부로 다뤄야 한다.

10-8. 케이스 D: 이벤트 이력과 현재 상태

섹션 제목: “10-8. 케이스 D: 이벤트 이력과 현재 상태”

상태는 현재값과 이력으로 나눠 생각해야 한다. 주문 배송 상태를 예로 들면 orders.current_status만 있어도 현재 화면은 만들 수 있다. 그러나 고객 문의, 장애 조사, 배송 SLA 분석에는 상태 전환 이력이 필요하다.

orders
id
current_status
order_status_events
id
order_id
from_status
to_status
reason
changed_at
changed_by

이 모델에서 현재 상태는 빠른 조회를 위한 값이고, 이벤트 이력은 상태가 왜 그렇게 되었는지 설명하는 기록이다. 둘 중 무엇을 원본으로 볼지는 시스템마다 다르다. 이벤트를 원본으로 보고 현재 상태를 projection으로 만들 수도 있고, 현재 상태를 원본으로 두고 이벤트를 감사 로그로 둘 수도 있다.

판단 기준은 다음 질문으로 잡는다.

상태 변경 이유와 변경자를 나중에 설명해야 하는가?
-> 이력 테이블이 필요하다.
현재 상태만 정확하면 되는가?
-> 단일 상태 컬럼으로 시작할 수 있다.
상태 전환 자체가 업무 이벤트인가?
-> 이벤트 이력을 원본에 가깝게 다룬다.
상태별 체류 시간, 실패율, SLA를 계산해야 하는가?
-> 이력 없이 나중에 복원하기 어렵다.

실패 신호도 명확하다. 고객센터가 “왜 배송이 취소되었는지”를 물었는데 현재 상태 CANCELED만 있고 이전 상태와 변경 주체가 없다면 모델이 필요한 설명력을 잃은 것이다.

스키마 리뷰에서는 취향보다 질문이 중요하다. 아래 질문에 답하지 못하면 구현 전에 모델을 더 다듬는 편이 낫다.

  1. 이 테이블의 한 행은 현실의 무엇 하나를 뜻하는가?
  2. 이 컬럼은 현재값인가, 특정 시점의 스냅샷인가?
  3. 같은 사실을 다른 테이블도 저장하고 있다면 어느 쪽이 원본인가?
  4. 관계의 cardinality는 1:1, 1:N, N:M 중 무엇인가?
  5. 부모가 삭제되면 자식은 삭제, 보존, 비활성 중 무엇이 되어야 하는가?
  6. 이 값의 변경은 과거 기록에 전파되어야 하는가?
  7. DB 제약으로 막아야 할 불가능한 상태는 무엇인가?
  8. 이 모델의 가장 흔한 목록 조회는 어떤 관계를 펼치는가?
  9. 이 모델이 10배 커졌을 때 쓰기와 읽기 중 무엇이 먼저 비싸지는가?
  10. 스키마 변경 시 예전 코드와 새 코드가 동시에 동작할 수 있는가?

질문 1번과 2번은 특히 중요하다. 행의 의미가 흐리면 테이블은 금방 “여러 화면이 필요해서 모아둔 컬럼 묶음”이 된다. 현재값과 스냅샷을 구분하지 못하면 정규화와 비정규화 판단도 흔들린다.


모델링 실패는 보통 다음 증상으로 관측된다.

  • 같은 고객의 주소가 테이블마다 다르다.
  • 주문 총액과 주문 상세 합계가 맞지 않는다.
  • FK가 없어 삭제된 고객을 가리키는 주문이 남는다.
  • 상태 값이 코드에 없는 문자열로 저장된다.
  • 과거 주문 내역이 현재 상품명 변경을 따라 바뀐다.
  • 목록 화면 하나가 수십 개 관계를 항상 펼친다.
  • ORM lazy loading 때문에 요청당 쿼리 수가 데이터 개수에 비례한다.
  • 집계 화면이 OLTP 원본 테이블을 매번 전체 스캔한다.
  • 비정규화 복사본 갱신 때문에 쓰기 latency가 눈에 띄게 늘어난다.
  • 같은 API가 데이터가 늘수록 선형보다 빠르게 느려진다.
  • 운영 DB에서 수동 SQL로 컬럼을 바꿔야 하는 일이 잦다.
  • 마이그레이션 파일 없이 스키마가 바뀐다.
  • 배포 중 새 코드와 예전 코드가 서로 다른 컬럼을 기대한다.
  • 복사본 테이블을 원본에서 다시 만들 수 있는 절차가 없다.
  • 대시보드 수치가 틀렸을 때 원본, 복사본, 캐시 중 어디가 틀렸는지 알 수 없다.

주문 원본은 정규화된 RDBMS 모델이 기본이다. 고객, 주문, 주문 상세, 결제, 배송은 관계가 많고 정합성 요구가 높다. 주문 생성과 재고 차감은 트랜잭션 경계 안에서 함께 다뤄야 하지만, 이 문서의 초점은 “그 트랜잭션이 어떤 테이블과 관계를 보호해야 하는가”다.

정산은 더 보수적으로 본다. 정산 금액은 집계 테이블로 빠르게 보여줄 수 있지만, 확정 정산은 원본 거래와 대조 가능한 스냅샷이 필요하다. “관리자 화면에 빠르게 보이는 숫자”와 “돈을 지급하는 근거”는 같은 테이블일 필요가 없다.

앞의 10-8. 케이스 D에서 본 것처럼 배송 상태는 현재값과 이력을 나눠 생각한다. 이 절의 핵심은 예시 구조를 반복하는 것이 아니라, 화면 요구사항에 따라 무엇을 원본으로 볼지 결정하는 것이다. 고객센터 화면은 현재 상태가 빨라야 하고, 장애 조사와 SLA 분석은 상태 변경 이력이 필요하다. 이벤트 소싱까지 깊게 들어가지 않더라도, 현재값과 이력의 소유권은 분리해서 생각해야 한다.

관리자 대시보드는 비정규화가 자주 등장한다. 다만 “느리니까 복사”가 아니라 “조회 주기, 허용 지연, 검증 기준”이 있어야 한다.

예를 들어 매출 대시보드에 다음 문구가 가능해야 한다.

이 수치는 5분마다 갱신된다.
원본은 orders와 payments다.
마지막 갱신 시각은 calculated_at이다.
정산 확정에는 이 테이블을 직접 사용하지 않는다.

이 정도 기준이 없으면 대시보드 테이블이 어느 순간 원본처럼 쓰이고, 불일치가 생겼을 때 책임 경계가 흐려진다.


기준정규화비정규화
목적중복과 이상 현상 감소특정 읽기 비용 감소
원본성원본 테이블을 명확히 함원본에서 만든 복사본일 수 있음
쓰기 비용보통 낮고 단일 지점에 집중복사본 갱신 때문에 높아질 수 있음
읽기 비용JOIN과 집계가 필요할 수 있음특정 조회는 단순해짐
실패 신호중복 값 불일치, orphan rowstale 값, 갱신 실패, 재생성 불가
기준SQL JOIN문서 내포
관계 표현테이블을 키로 연결한 문서 안에 함께 저장
좋은 조건독립 생명주기, 복잡한 관계함께 읽고 함께 바뀌는 작은 집합
위험fan-out과 N+1중복과 문서 크기 증가
정합성FK와 트랜잭션으로 보호 가능애플리케이션 규칙 의존이 커질 수 있음
기준원본 테이블Projection 또는 읽기 모델
역할업무 사실의 기준특정 화면이나 검색을 위한 파생물
변경 방식트랜잭션과 제약으로 보호배치, Materialized View, 이벤트 등
실패 처리잘못되면 데이터 정정 필요원본에서 재생성 가능해야 함
예시orders, paymentsdaily_sales, search_index

DB 설계 시 체크

  • 엔티티와 속성 값을 구분했는가?
  • 각 사실의 원본 테이블을 설명할 수 있는가?
  • 1:1, 1:N, N:M 관계의 cardinality를 명시했는가?
  • FK, UNIQUE, NOT NULL, CHECK로 DB가 직접 막아야 할 규칙을 표현했는가?
  • 과거 스냅샷으로 남겨야 할 값과 현재 원본을 따라가야 할 값을 구분했는가?

정규화/비정규화 체크

  • 최소 3NF 관점으로 중복과 종속 관계를 검토했는가?
  • 비정규화 컬럼마다 원본, 갱신 시점, 지연 허용 범위를 정의했는가?
  • 집계 테이블이나 Materialized View에 마지막 갱신 시각을 남기는가?
  • 복사본이 틀렸을 때 원본에서 다시 만들 수 있는가?
  • 비정규화가 쓰기 경로와 트랜잭션 경계를 과도하게 늘리지 않는가?

스키마 마이그레이션 체크

  • 운영 DB에서 자동 동기화 대신 리뷰 가능한 마이그레이션 파일을 쓰는가?
  • 큰 테이블의 NOT NULL, DEFAULT, FK, UNIQUE 추가 전에 기존 데이터를 검증했는가?
  • 컬럼 rename/delete를 expand and contract 흐름으로 나누었는가?
  • 예전 코드와 새 코드가 동시에 떠 있는 배포 시간을 고려했는가?
  • 마이그레이션 실패 시 롤백보다 forward fix가 필요한 상황을 구분했는가?

키워드설명
Entity업무에서 독립적으로 식별하고 추적해야 하는 대상
Table엔티티나 관계를 행과 열로 저장하는 구조
Primary Key한 행을 안정적으로 식별하는 키
Foreign Key다른 테이블의 행을 참조해 관계와 무결성을 지키는 키
Cardinality관계가 1:1, 1:N, N:M 중 어떤 수량 규칙을 갖는지 나타내는 개념
Single Source of Truth하나의 업무 사실은 한 곳에서 소유한다는 원칙
Anomaly삽입, 수정, 삭제 과정에서 구조 때문에 생기는 이상 현상
1NF한 셀에 반복 그룹이나 여러 값을 넣지 않는 정규형
2NF복합키 일부에만 종속되는 컬럼을 분리하는 정규형
3NF비키 컬럼이 다른 비키 컬럼을 결정하는 이행 종속을 제거하는 정규형
BCNF모든 결정자가 후보키여야 한다는 더 엄격한 정규형
Denormalization읽기나 집계를 위해 의도적으로 중복을 저장하는 설계
Materialized View쿼리 결과를 물리적으로 저장하고 필요할 때 갱신하는 뷰
Projection특정 조회나 검색을 위해 원본에서 만든 파생 읽기 모델
Expand and Contract스키마 변경을 추가, 전환, 제거 단계로 나눠 안전하게 배포하는 패턴

아래 테이블에서 세 가지 이상 현상을 찾아본다.

order_idcustomer_emailcustomer_addressproduct_namequantity
1a@example.com서울사과2
2a@example.com서울바나나1
3b@example.com부산우유3

질문:

  1. a@example.com 고객이 이사하면 몇 행을 바꿔야 하는가?
  2. 고객은 가입했지만 아직 주문이 없다면 이 테이블에 넣을 수 있는가?
  3. b@example.com 고객의 마지막 주문을 삭제하면 고객 주소 정보는 남는가?

해설:

  • 수정 이상: 같은 고객 주소가 여러 주문에 반복된다.
  • 삽입 이상: 주문 없이 고객만 저장하기 어렵다.
  • 삭제 이상: 마지막 주문을 삭제하면 고객 정보도 사라질 수 있다.

위 테이블을 다음 구조로 나누어본다.

customers(id, email, current_address)
orders(id, customer_id, ordered_at)
order_items(id, order_id, product_name_snapshot, quantity)

product_name_snapshot은 정규화 위반처럼 보이지만 주문 당시 상품명을 보존하기 위한 의도적 스냅샷이다. 반면 customer_address는 현재 고객 주소라면 customers가 원본이어야 한다. 두 값의 차이는 “변경 시 과거 기록도 따라 바뀌어야 하는가?”로 판단한다.

집계 테이블을 만들었다면 검증 질문도 함께 만든다.

-- 원본 기준 당일 매출
SELECT SUM(oi.quantity * oi.unit_price_snapshot) AS source_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'PAID'
AND o.ordered_at::date = DATE '2026-04-01';
-- 집계 테이블 기준 당일 매출
SELECT SUM(total_revenue) AS summary_total
FROM daily_product_sales
WHERE sale_date = DATE '2026-04-01';

두 값이 다를 수 있는 정상 이유가 있는지 먼저 정한다. 예를 들어 집계 테이블이 5분마다 갱신된다면 최근 5분 주문은 차이가 날 수 있다. 그 차이가 허용 범위를 넘으면 갱신 실패나 누락을 의심한다.

users.nameusers.display_name으로 바꾸고 싶다고 하자. 바로 rename하지 말고 다음 계획을 문장으로 적어본다.

1. display_name nullable 컬럼 추가
2. name 값을 display_name에 backfill
3. 새 코드가 display_name을 읽고 쓰도록 배포
4. 일정 기간 name과 display_name 차이를 검증
5. name 읽기 경로 제거 확인 후 name 컬럼 삭제

이 연습의 목적은 명령어 암기가 아니다. 스키마 변경도 사용자 트래픽이 흐르는 동안 일어나는 상태 전이라는 점을 익히는 것이다.


  • content/topics/L8/layer-intro.mdx: L8에서 DB Modeling이 “구조” 토픽을 맡는다는 레이어 흐름을 확인했다.
  • content/topics/L8/transaction-basics.mdx: 트랜잭션, ACID, WAL, MVCC, lock, 격리 수준, deadlock은 이 문서에서 상세 설명하지 않고 모델링의 정합성 결과로만 연결했다.
  • content/topics/L8/db-index-query-optimization.mdx: B+Tree, EXPLAIN, JOIN 전략, N+1, bloat는 상세 튜닝 토픽으로 넘기고 이 문서에서는 모델링이 만드는 구조적 비용 신호만 다뤘다.
  • content/topics/L8/cdc-outbox.mdx: CDC와 Outbox는 비정규화 projection 갱신 수단의 예로만 언급하고, 유실·중복·순서·재처리 문제는 별도 토픽 범위로 남겼다.

DB 모델링은 데이터를 테이블에 넣는 일이 아니라, 어떤 사실을 어디가 소유하고, 어떤 관계와 제약으로 지키며, 필요한 복사본을 어떤 책임 경계 안에서 운영할지 정하는 일이다.