RDBMS
테이블, 관계, 제약 조건, SQL을 중심으로 일관성과 복잡한 질의를 다룬다.
주문, 결제, 정산, 권한처럼 강한 정합성이 필요한 데이터에 적합하다.분류: Layer 8 - 데이터베이스 심화
데이터베이스 모델링은 현실의 업무 사실을 테이블, 컬럼, 키, 관계, 제약 조건으로 바꾸는 설계 과정이다. 좋은 모델은 “데이터를 저장할 수 있다”에서 끝나지 않고, 같은 사실이 한 곳에서 관리되고, 관계가 명확하며, 조회와 변경이 예측 가능한 비용 안에서 일어나게 만든다.
이 문서에서 말하는 모델은 ORM(Object-Relational Mapper, 객체 코드와 관계형 DB를 연결하는 도구)의 Entity class 파일 하나가 아니라 데이터가 지켜야 할 구조 전체다. 엔티티(Entity)는 업무에서 식별하고 싶은 대상, 테이블(Table)은 그 엔티티를 행(Row)과 열(Column)로 저장하는 구조, 관계(Relationship)는 엔티티 사이의 연결 규칙이다.
클라이언트 상태를 byId와 ids로 펼쳐본 경험은 정규화 감각을 이해하는 데 도움이 된다. 다만 DB 모델링의 목표는 화면 상태 정리가 아니라, 영구 저장된 업무 사실이 장애와 동시 요청 이후에도 설명 가능한 상태로 남는 것이다.
데이터베이스는 애플리케이션의 기억이다. 모델링을 잘못하면 버그는 코드 한 줄보다 오래 남는다. 한 번 잘못 저장된 주문 금액, 잘못 연결된 정산 대상, 중복된 고객 주소는 API 서버를 재시작해도 사라지지 않는다.
모델링 문제는 보통 다음 네 가지 모양으로 나타난다.
예를 들어 orders 테이블에 고객 이름, 고객 주소, 주문 상품, 상품 가격, 배송 상태를 모두 한 행에 넣을 수는 있다. 처음에는 빠르게 보인다. 그러나 고객 주소가 바뀌면 과거 주문의 주소도 바꿔야 하는지, 주문 당시 주소는 보존해야 하는지, 배송지 주소와 고객 기본 주소는 같은 의미인지가 곧 문제 된다. 모델링은 이런 질문의 답을 코드가 아니라 스키마에 먼저 새기는 일이다.
초기 애플리케이션은 데이터를 한 파일이나 한 테이블에 몰아넣어도 작동한다. 문제는 데이터가 늘고 팀이 커지면서 같은 사실을 여러 곳에서 쓰기 시작할 때 생긴다. 고객 주소가 customers, orders, shipping_requests, invoices에 각각 복사되어 있으면 어느 값이 진실인지 알 수 없다.
정규화(Normalization)는 이 중복 문제를 줄이기 위해 등장했다. 핵심 철학은 하나의 사실은 한 곳에 둔다(Single Source of Truth) 는 것이다. 고객의 현재 주소는 고객 테이블이 소유하고, 주문 당시 배송지는 주문 배송 정보가 소유한다. 둘은 모두 주소 문자열이지만 의미와 생명주기가 다르기 때문에 같은 컬럼처럼 다루면 안 된다.
비정규화(Denormalization)는 그 반대처럼 보이지만 목적이 다르다. 정규화된 원본은 유지하되, 읽기 비용이나 집계 비용이 너무 커질 때 의도적으로 복사본을 만든다. 중요한 차이는 “중복을 몰라서 만든 것”이 아니라 “원본, 복사본, 갱신 지연, 실패 처리”를 알고 만든다는 점이다.
처음부터 1NF, 2NF 같은 용어를 외우기보다, 아래 질문을 반복해서 던지면 모델의 품질이 보인다.
무엇이 엔티티인가? 고객, 주문, 상품, 결제, 배송처럼 식별자가 있고 독립적으로 추적해야 하는 대상을 찾는다.
각 사실의 주인은 어디인가? 고객 이름은 고객 테이블이 소유할 수 있지만, 주문 당시 상품명은 주문 상세가 소유할 수 있다. 같은 문자열이어도 의미가 다르면 소유자가 다르다.
관계는 몇 대 몇인가?
고객 한 명은 주문 여러 개를 가진다. 주문 하나는 상품 여러 개를 가진다. 상품 하나도 여러 주문에 들어갈 수 있다. 이 N:M 관계는 보통 중간 테이블이 필요하다.
어떤 불변식을 DB가 막아야 하는가?
주문은 고객 없이 존재할 수 없는지, 주문 금액은 음수가 될 수 없는지, 같은 이메일은 두 고객에게 허용되는지 정한다. 이런 규칙은 애플리케이션 검증만이 아니라 NOT NULL, UNIQUE, FOREIGN KEY, CHECK 제약으로 표현할수록 강해진다.
주요 조회 경로는 무엇인가?
관리자 화면에서 주문 목록을 고객명과 함께 50개씩 본다면 orders -> customers 관계가 매번 쓰인다. 상세 튜닝은 인덱스 문서의 영역이지만, 모델링 단계에서 fan-out, 즉 관계를 따라가며 결과 행 수가 크게 불어나는 구조를 만들면 이후 최적화 여지가 줄어든다.
변경과 마이그레이션은 어떻게 안전하게 일어나는가? 컬럼을 추가하고, 기본값을 채우고, 코드가 새 컬럼을 쓰게 하고, 예전 컬럼을 지우는 순서가 필요하다. 스키마는 배포 한 번으로 완성되는 것이 아니라 서비스 운영 중에도 계속 바뀐다.
엔티티는 업무에서 따로 이름 붙여 추적할 수 있는 대상이다. Customer, Order, Product, Payment가 대표적이다. 테이블은 엔티티를 저장하는 물리적 구조다. 한 엔티티가 항상 한 테이블과 1:1로 대응하지는 않는다. 주문이라는 엔티티도 orders, order_items, order_status_history처럼 여러 테이블로 나뉠 수 있다.
컬럼은 엔티티의 속성(Attribute)을 저장한다. 속성은 단순히 값의 타입만으로 결정하지 않는다. address라는 문자열도 고객 기본 주소인지, 주문 배송지인지, 세금계산서 주소인지에 따라 소유자와 변경 규칙이 달라진다.
기본키(Primary Key)는 한 행을 안정적으로 식별하는 값이다. 보통 id 같은 surrogate key, 즉 업무 의미가 없는 내부 대체키를 쓰지만, 이메일이나 주문번호처럼 업무적으로 유일한 값은 별도 UNIQUE 제약으로 보호한다. 기본키는 내부 식별자, 유니크 제약은 업무 규칙을 표현한다고 구분하면 좋다.
외래키(Foreign Key)는 한 테이블의 값이 다른 테이블의 기본키를 참조한다는 약속이다. orders.customer_id가 customers.id를 참조하면, 존재하지 않는 고객의 주문을 DB가 거부할 수 있다. 이 참조 무결성은 트랜잭션과 함께 작동해 “주문은 있는데 고객은 없는” 상태를 줄인다.
Cardinality는 관계의 수량 규칙이다. 모델링에서 가장 자주 놓치는 부분이다.
| 관계 | 의미 | 전형적인 구현 | 설계 질문 |
|---|---|---|---|
1:1 | 한 행이 다른 한 행과 대응 | 한쪽 테이블에 UNIQUE FK | 정말 분리할 이유가 있는가? |
1:N | 한 부모가 여러 자식을 가짐 | 자식 테이블에 부모 FK | 자식은 부모 없이 존재 가능한가? |
N:M | 양쪽 모두 여러 개 연결 가능 | 중간 테이블 | 연결 자체에도 속성이 있는가? |
| 선택 관계 | 연결이 없을 수도 있음 | nullable FK 또는 별도 상태 테이블 | null이 업무적으로 어떤 의미인가? |
| 시간에 따른 관계 | 연결이 기간을 가짐 | history table, valid_from/to | 현재값과 과거 이력을 구분해야 하는가? |
N:M 관계를 한 컬럼에 쉼표 문자열로 넣으면 처음에는 간단하다. 그러나 특정 상품을 포함한 주문을 찾거나, 주문별 상품 수량을 바꾸거나, 상품별 매출을 집계할 때 곧 막힌다. 중간 테이블은 복잡도를 늘리는 것이 아니라 관계를 검색, 검증, 변경 가능한 형태로 만드는 장치다.
주문 모델을 한 테이블로 시작해보자.
| order_id | customer_name | customer_email | product_names | total_price | status |
|---|---|---|---|---|---|
| 1001 | 홍길동 | hong@example.com | 사과, 바나나 | 12000 | PAID |
| 1002 | 홍길동 | hong@example.com | 우유 | 3000 | PAID |
이 구조는 저장은 가능하지만 질문을 견디지 못한다.
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은 접근 패턴이 단순하고, 스키마 변화가 잦거나, 수평 확장과 낮은 지연이 중요한 경우에 유리할 수 있다. 둘 중 하나가 항상 더 좋은 것이 아니라 데이터의 의미, 변경 방식, 실패 허용 범위가 다르다.
테이블, 관계, 제약 조건, SQL을 중심으로 일관성과 복잡한 질의를 다룬다.
주문, 결제, 정산, 권한처럼 강한 정합성이 필요한 데이터에 적합하다.접근 패턴에 맞춘 저장 구조를 우선하고, 확장성과 유연성을 얻는 대신 일부 검증을 애플리케이션이 책임진다.
캐시, 세션, 이벤트 로그, 피드, 문서형 콘텐츠처럼 패턴이 분명한 데이터에 적합하다.| 유형 | 핵심 모델 | 좋은 사용처 | 조심할 점 |
|---|---|---|---|
| Key-Value Store | 키 하나로 값 하나를 찾음 | 세션, 캐시, rate limit counter | 관계 조회와 복잡한 필터링이 약하다. |
| Document Store | JSON과 비슷한 문서 단위 저장 | 카탈로그, 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_id | customer | products |
|---|---|---|
| 1 | 홍길동 | 사과, 바나나, 포도 |
| 2 | 김철수 | 우유 |
products에 쉼표 문자열을 넣으면 “사과를 포함한 주문”을 찾기 위해 LIKE '%사과%' 같은 검색을 해야 한다. 사과잼과 사과를 구분하기도 어렵고, 상품 ID로 FK를 걸 수도 없다.
1NF 적용 후:
| order_id | customer | product |
|---|---|---|
| 1 | 홍길동 | 사과 |
| 1 | 홍길동 | 바나나 |
| 1 | 홍길동 | 포도 |
| 2 | 김철수 | 우유 |
이제 각 행은 주문과 상품 하나의 관계를 나타낸다.
-- 1NF 위반: 다중값 문자열 검색SELECT *FROM orders_badWHERE products LIKE '%사과%';
-- 1NF 준수: 상품 행을 정확히 검색SELECT *FROM order_itemsWHERE product = '사과';1NF의 경계도 있다. 전화번호 여러 개를 하나의 JSON 배열에 넣는 것이 항상 틀린 것은 아니다. 그 전화번호를 개별 검색, 검증, 연결 대상으로 다루지 않고 문서 전체와 함께만 읽고 쓴다면 허용 가능한 모델일 수 있다. 하지만 개별 값에 제약, 검색, 관계가 필요해지는 순간 별도 행이 더 안전하다.
2NF(Second Normal Form, 제2정규형)는 1NF를 만족하면서 복합 기본키의 일부에만 종속되는 컬럼을 분리하는 규칙이다. 부분 종속은 order_id + product_id가 키인데 customer_address가 order_id에만 의존하는 상황이다.
위반 예시:
| order_id | product | quantity | customer_name | customer_address |
|---|---|---|---|---|
| 1 | 사과 | 3 | 홍길동 | 서울시 강남구 |
| 1 | 바나나 | 1 | 홍길동 | 서울시 강남구 |
| 2 | 우유 | 2 | 김철수 | 부산시 해운대구 |
기본키가 order_id + product라면 quantity는 전체 키에 의존한다. 그러나 customer_name과 customer_address는 상품과 무관하다. 주문 1번에 상품이 10개 있으면 고객 주소도 10번 반복된다.
2NF 적용 후:
orders 테이블:
| order_id | customer_id |
|---|---|
| 1 | 101 |
| 2 | 102 |
order_items 테이블:
| order_id | product | quantity |
|---|---|---|
| 1 | 사과 | 3 |
| 1 | 바나나 | 1 |
| 2 | 우유 | 2 |
customers 테이블:
| customer_id | customer_name | customer_address |
|---|---|---|
| 101 | 홍길동 | 서울시 강남구 |
| 102 | 김철수 | 부산시 해운대구 |
UPDATE customersSET customer_address = '서울시 마포구'WHERE customer_id = 101;이제 고객 주소 변경은 한 행의 변경이다. 주문 상세 행 수가 많아져도 주소 변경 비용은 주문 상품 수에 비례하지 않는다.
3NF(Third Normal Form, 제3정규형)는 비키 컬럼이 다른 비키 컬럼을 결정하는 구조를 분리하는 규칙이다. 이행 종속은 employee_id -> department_id -> department_location처럼 키가 아닌 컬럼을 거쳐 다른 컬럼이 결정되는 관계다.
위반 예시:
| employee_id | name | department_id | department_name | department_location |
|---|---|---|---|---|
| 1 | 홍길동 | D01 | 개발팀 | 서울 |
| 2 | 김철수 | D01 | 개발팀 | 서울 |
| 3 | 이영희 | D02 | 마케팅팀 | 부산 |
department_name과 department_location은 직원이 아니라 부서에 속한 사실이다. 개발팀 위치가 바뀌면 개발팀 직원 모든 행을 바꿔야 한다.
3NF 적용 후:
employees 테이블:
| employee_id | name | department_id |
|---|---|---|
| 1 | 홍길동 | D01 |
| 2 | 김철수 | D01 |
| 3 | 이영희 | D02 |
departments 테이블:
| department_id | department_name | department_location |
|---|---|---|
| D01 | 개발팀 | 서울 |
| D02 | 마케팅팀 | 부산 |
UPDATE departmentsSET department_location = '부산'WHERE department_id = 'D01';3NF는 실무 설계의 기본 출발점으로 충분한 경우가 많다. 모든 테이블을 수학적으로 가장 엄격한 형태까지 나누는 것보다, 어느 사실이 어느 테이블의 소유인지 설명할 수 있는지가 더 중요하다.
BCNF(Boyce-Codd Normal Form)는 3NF보다 엄격하다. 모든 결정자(Determinant)가 후보키(Candidate Key)여야 한다. 결정자는 다른 값을 결정하는 속성이고, 후보키는 행을 유일하게 식별할 수 있는 최소 속성 집합이다.
학생, 과목, 교수 배정 예시를 보자.
| 학생 | 과목 | 교수 |
|---|---|---|
| 홍길동 | 데이터베이스 | 김교수 |
| 김철수 | 데이터베이스 | 김교수 |
| 이영희 | 알고리즘 | 박교수 |
규칙이 “교수는 한 과목만 담당한다”라면 교수 -> 과목이 성립한다. 그런데 교수만으로 학생까지 식별할 수는 없으므로 후보키가 아니다. 이 구조는 교수가 담당 과목을 바꿀 때 여러 행을 수정해야 한다.
BCNF 적용 후:
professor_subject 테이블:
| 교수 | 과목 |
|---|---|
| 김교수 | 데이터베이스 |
| 박교수 | 알고리즘 |
student_professor 테이블:
| 학생 | 교수 |
|---|---|
| 홍길동 | 김교수 |
| 김철수 | 김교수 |
| 이영희 | 박교수 |
BCNF는 모든 프로젝트에서 매번 끝까지 적용해야 하는 목표라기보다, “어떤 컬럼이 다른 컬럼을 결정하는데 그 컬럼이 키가 아닌가?”를 확인하게 해주는 렌즈다.
정규화는 중복을 줄이지만 모든 중복을 없애지는 않는다. 다음 중복은 오히려 올바를 수 있다.
공통점은 복사본의 의미와 갱신 정책이 명확하다는 것이다. “현재 상품명”과 “주문 당시 상품명”은 다른 사실이다. “실시간 주문 합계”와 “어제 자정 기준 매출 집계”도 다른 사실이다. 반대로 두 값이 같은 의미인데 둘 다 원본처럼 쓰인다면 정규화가 필요하다.
비정규화는 읽기 성능, 집계 비용, 사용자 경험, 검색 편의를 위해 의도적으로 중복 데이터를 저장하는 설계다. 정규화가 “원본을 명확히 한다”면 비정규화는 “자주 필요한 읽기 모양을 미리 준비한다”에 가깝다.
비정규화는 정규화의 실패가 아니다. 실패는 원본이 무엇인지 모르는 중복이다. 성공적인 비정규화는 다음 네 가지를 함께 문서화한다.
정규화된 주문 모델에서 관리자 대시보드는 매번 다음 값을 원할 수 있다.
요청마다 수백만 행의 orders와 order_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));이 테이블의 원본은 orders와 order_items다. daily_product_sales는 원본이 아니라 파생물이다. 따라서 calculated_at 같은 갱신 시각이 중요하다. 사용자가 보는 매출 수치가 5분 전 기준인지, 자정 기준인지 모르면 불일치가 버그인지 정상 지연인지 판단할 수 없다.
작은 반례도 있다. 결제 정산 금액처럼 법적 책임이 있는 데이터는 집계 테이블만 믿고 확정하면 안 된다. 정산 확정 시점에는 원본 거래 내역과 대조하거나, 확정된 정산 스냅샷을 별도 원본으로 승격하는 절차가 필요하다.
Materialized View(구체화 뷰)는 쿼리 결과를 물리적으로 저장해두는 DB 객체다. 일반 View가 매번 원본 쿼리를 실행한다면, Materialized View는 결과를 저장하고 필요할 때 갱신한다.
CREATE MATERIALIZED VIEW order_daily_summary ASSELECT o.ordered_at::date AS sale_date, c.region, COUNT(o.id) AS order_count, SUM(oi.quantity * oi.unit_price_snapshot) AS total_revenueFROM orders oJOIN customers c ON c.id = o.customer_idJOIN order_items oi ON oi.order_id = o.idWHERE 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이나 버전 정보가 없어 지연인지 오류인지 구분할 수 없다.관계의 수량을 잘못 잡으면 쿼리 비용이 빠르게 커진다. 고객 1명당 주문 10개, 주문 1개당 주문상품 5개면 고객 1,000명을 조회할 때 주문상품은 평균 50,000행이 된다. 여기에 배송 이벤트가 주문상품당 4개씩 붙으면 200,000행으로 늘어난다.
이 fan-out은 인덱스만으로 사라지지 않는다. 인덱스는 필요한 행을 찾는 비용을 줄여주지만, 최종적으로 반환하거나 집계해야 하는 행 수 자체가 크면 비용은 남는다. 모델링 단계에서 관계를 나눌 때 “목록 화면에서 이 관계를 항상 함께 펼칠 것인가?”를 물어야 한다.
반대로 관계를 너무 합치면 쓰기 비용이 커진다. 주문 목록 화면을 빠르게 만들려고 고객명, 배송 상태, 결제 수단, 마지막 문의 내용을 모두 orders에 복사하면 주문 상태 변경 때마다 복사본 갱신 범위가 늘어난다.
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를 먼저 고려할 때:
NoSQL을 고려할 때:
둘을 섞는 경우도 많다. 주문 원본은 RDBMS에 두고, 검색 화면은 Elasticsearch나 OpenSearch 같은 검색 인덱스를 쓸 수 있다. 이때 검색 인덱스는 원본이 아니라 projection이다. 사용자에게 최신성이 중요한 화면인지, 검색 편의가 더 중요한 화면인지에 따라 지연 허용 범위를 정해야 한다.
주소는 모델링 연습에 좋다. 같은 문자열처럼 보여도 의미가 여러 개로 갈라지기 때문이다.
| 값 | 원본 후보 | 변경 전파 규칙 | 설계 판단 |
|---|---|---|---|
| 고객 기본 주소 | 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로만 두고 스냅샷을 저장하지 않으면, 과거 주문 상세가 현재 주소를 보여줄 수 있다. 정규화만 기계적으로 적용한 결과가 오히려 업무 사실을 잃는 경우다.
따라서 주소 모델링의 기준은 다음과 같다.
현재 프로필처럼 항상 최신값을 보여줘야 한다 -> 원본 테이블을 참조한다.
계약, 주문, 배송처럼 당시 값을 보존해야 한다 -> 스냅샷 컬럼을 둔다.
정책이나 설정처럼 운영자가 별도 관리한다 -> 별도 설정 엔티티로 분리한다.권한 모델은 cardinality를 잘못 잡으면 빠르게 망가진다. 사용자 한 명은 여러 조직에 속할 수 있고, 조직 안에서 여러 역할을 가질 수 있다. 역할 하나는 여러 권한을 포함하고, 권한 하나도 여러 역할에 들어갈 수 있다.
나쁜 시작점은 다음처럼 사용자 행에 역할을 문자열로 넣는 것이다.
| user_id | org_id | roles |
|---|---|---|
| 1 | 10 | admin,billing |
| 2 | 10 | viewer |
| 1 | 20 | viewer,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_roles와 role_permissions는 단순 연결 테이블처럼 보이지만 중요한 업무 사실을 담는다. “누가 어떤 조직에서 어떤 역할을 갖는가”, “역할은 어떤 권한을 포함하는가”라는 관계 자체가 엔티티가 된 것이다.
제약 조건도 모델의 일부다.
ALTER TABLE user_rolesADD CONSTRAINT user_roles_unique UNIQUE (user_id, org_id, role_id);
ALTER TABLE role_permissionsADD CONSTRAINT role_permissions_unique UNIQUE (role_id, permission_id);이 제약은 중복 권한 부여를 막는다. 애플리케이션에서 중복 체크를 해도, 관리자 도구나 배치가 같은 관계를 두 번 넣을 수 있다. DB 제약은 모든 쓰기 경로에 공통으로 적용되는 마지막 방어선이다.
반대로 모든 권한 체크를 매 요청마다 다섯 테이블 JOIN으로 수행하면 비용이 커질 수 있다. 이때 user_effective_permissions 같은 projection을 만들 수 있다. 단, 원본은 user_roles와 role_permissions이고 projection은 빠른 권한 체크를 위한 복사본이라는 경계를 유지해야 한다.
권한 projection의 실패 신호는 다음과 같다.
Projection은 특정 읽기 모양을 위해 만든 파생 테이블이다. 대시보드에서 자주 등장한다. projection을 만들 때는 “얼마나 빨라지는가”만큼 “얼마나 늦어도 되는가”가 중요하다.
예를 들어 daily_sales_summary가 5분마다 갱신된다고 하자.
원본: orders order_items payments
projection: daily_sales_summary
갱신 주기: 5분마다
허용 지연: 관리자 대시보드는 최대 5분 지연 허용 정산 확정은 지연 허용 안 됨이 설계는 관리자 대시보드에는 적절할 수 있다. 그러나 정산 확정 버튼이 daily_sales_summary만 읽고 송금을 실행한다면 위험하다. 같은 숫자라도 “관찰용”과 “정산 근거”는 책임이 다르다.
일관성 예산을 표로 적으면 팀이 같은 기준을 공유할 수 있다.
| 항목 | 예시 값 | 왜 필요한가 |
|---|---|---|
| 원본 | orders, payments | projection 재생성 기준 |
| 갱신 주기 | 5분 | stale 값의 정상 범위 |
| 최대 허용 지연 | 10분 | 장애 알림 기준 |
| 검증 쿼리 | 원본 합계와 projection 합계 비교 | silent drift 탐지 |
| 사용자 표시 방식 | ”마지막 갱신: 10:05” | 사용자 오해 감소 |
| 사용 금지 영역 | 정산 확정, 세금 신고 | projection 오용 방지 |
여기서 핵심은 projection이 틀릴 수 있다는 사실을 인정하는 것이다. 틀릴 수 있는 값을 쓰면 안 된다는 뜻이 아니다. 틀릴 수 있는 범위와 복구 방법을 모델의 일부로 다뤄야 한다.
상태는 현재값과 이력으로 나눠 생각해야 한다. 주문 배송 상태를 예로 들면 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번은 특히 중요하다. 행의 의미가 흐리면 테이블은 금방 “여러 화면이 필요해서 모아둔 컬럼 묶음”이 된다. 현재값과 스냅샷을 구분하지 못하면 정규화와 비정규화 판단도 흔들린다.
모델링 실패는 보통 다음 증상으로 관측된다.
주문 원본은 정규화된 RDBMS 모델이 기본이다. 고객, 주문, 주문 상세, 결제, 배송은 관계가 많고 정합성 요구가 높다. 주문 생성과 재고 차감은 트랜잭션 경계 안에서 함께 다뤄야 하지만, 이 문서의 초점은 “그 트랜잭션이 어떤 테이블과 관계를 보호해야 하는가”다.
정산은 더 보수적으로 본다. 정산 금액은 집계 테이블로 빠르게 보여줄 수 있지만, 확정 정산은 원본 거래와 대조 가능한 스냅샷이 필요하다. “관리자 화면에 빠르게 보이는 숫자”와 “돈을 지급하는 근거”는 같은 테이블일 필요가 없다.
앞의 10-8. 케이스 D에서 본 것처럼 배송 상태는 현재값과 이력을 나눠 생각한다. 이 절의 핵심은 예시 구조를 반복하는 것이 아니라, 화면 요구사항에 따라 무엇을 원본으로 볼지 결정하는 것이다. 고객센터 화면은 현재 상태가 빨라야 하고, 장애 조사와 SLA 분석은 상태 변경 이력이 필요하다. 이벤트 소싱까지 깊게 들어가지 않더라도, 현재값과 이력의 소유권은 분리해서 생각해야 한다.
관리자 대시보드는 비정규화가 자주 등장한다. 다만 “느리니까 복사”가 아니라 “조회 주기, 허용 지연, 검증 기준”이 있어야 한다.
예를 들어 매출 대시보드에 다음 문구가 가능해야 한다.
이 수치는 5분마다 갱신된다.원본은 orders와 payments다.마지막 갱신 시각은 calculated_at이다.정산 확정에는 이 테이블을 직접 사용하지 않는다.이 정도 기준이 없으면 대시보드 테이블이 어느 순간 원본처럼 쓰이고, 불일치가 생겼을 때 책임 경계가 흐려진다.
| 기준 | 정규화 | 비정규화 |
|---|---|---|
| 목적 | 중복과 이상 현상 감소 | 특정 읽기 비용 감소 |
| 원본성 | 원본 테이블을 명확히 함 | 원본에서 만든 복사본일 수 있음 |
| 쓰기 비용 | 보통 낮고 단일 지점에 집중 | 복사본 갱신 때문에 높아질 수 있음 |
| 읽기 비용 | JOIN과 집계가 필요할 수 있음 | 특정 조회는 단순해짐 |
| 실패 신호 | 중복 값 불일치, orphan row | stale 값, 갱신 실패, 재생성 불가 |
| 기준 | SQL JOIN | 문서 내포 |
|---|---|---|
| 관계 표현 | 테이블을 키로 연결 | 한 문서 안에 함께 저장 |
| 좋은 조건 | 독립 생명주기, 복잡한 관계 | 함께 읽고 함께 바뀌는 작은 집합 |
| 위험 | fan-out과 N+1 | 중복과 문서 크기 증가 |
| 정합성 | FK와 트랜잭션으로 보호 가능 | 애플리케이션 규칙 의존이 커질 수 있음 |
| 기준 | 원본 테이블 | Projection 또는 읽기 모델 |
|---|---|---|
| 역할 | 업무 사실의 기준 | 특정 화면이나 검색을 위한 파생물 |
| 변경 방식 | 트랜잭션과 제약으로 보호 | 배치, Materialized View, 이벤트 등 |
| 실패 처리 | 잘못되면 데이터 정정 필요 | 원본에서 재생성 가능해야 함 |
| 예시 | orders, payments | daily_sales, search_index |
| 키워드 | 설명 |
|---|---|
| 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_id | customer_email | customer_address | product_name | quantity |
|---|---|---|---|---|
| 1 | a@example.com | 서울 | 사과 | 2 |
| 2 | a@example.com | 서울 | 바나나 | 1 |
| 3 | b@example.com | 부산 | 우유 | 3 |
질문:
a@example.com 고객이 이사하면 몇 행을 바꿔야 하는가?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_totalFROM orders oJOIN order_items oi ON oi.order_id = o.idWHERE o.status = 'PAID' AND o.ordered_at::date = DATE '2026-04-01';
-- 집계 테이블 기준 당일 매출SELECT SUM(total_revenue) AS summary_totalFROM daily_product_salesWHERE sale_date = DATE '2026-04-01';두 값이 다를 수 있는 정상 이유가 있는지 먼저 정한다. 예를 들어 집계 테이블이 5분마다 갱신된다면 최근 5분 주문은 차이가 날 수 있다. 그 차이가 허용 범위를 넘으면 갱신 실패나 누락을 의심한다.
users.name을 users.display_name으로 바꾸고 싶다고 하자. 바로 rename하지 말고 다음 계획을 문장으로 적어본다.
1. display_name nullable 컬럼 추가2. name 값을 display_name에 backfill3. 새 코드가 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 모델링은 데이터를 테이블에 넣는 일이 아니라, 어떤 사실을 어디가 소유하고, 어떤 관계와 제약으로 지키며, 필요한 복사본을 어떤 책임 경계 안에서 운영할지 정하는 일이다.