역정규화 - 중복 컬럼 추가
문제 상황
주문 내역 조회는 우리 쇼핑몰에서 가장 빈번하게 일어나는 핵심 기능 중 하나다. 사용자는 "마이페이지"에서 자신의 주문 내역을 볼 때, 각 주문에 어떤 상품들이 포함되어 있는지 상품명과 함께 확인하고 싶어 한다. 현재의 정규화된 모델에서 이 기능을 구현하려면, order_item 테이블과 product 테이블을 항상 "JOIN"해야만 상품명(product_name)을 가져올 수 있다.
SELECT
oi.order_id,
p.product_name, -- 상품명을 위해 반드시 product 테이블을 JOIN 해야 한다.
oi.order_price,
oi.order_quantity
FROM order_item oi
JOIN product p ON oi.product_id = p.product_id
WHERE oi.order_id = 100;
- 주문 데이터가 수백만, 수천만 건으로 늘어난다면, 이 빈번한 JOIN 연산은 데이터베이스에 상당한 부하를 주게 되고, 주문 내역 페이지의 로딩 속도를 저하시키는 주범이 될 것이다.
"왜 우리는 매번 상품명을 알기 위해 product 테이블을 찾아가야만 할까? 주문 내역을 볼 때 거의 항상 상품명이 필요한데, 이 JOIN 비용을 없앨 수 있는 방법은 없을까?"
이 질문에 대한 해답이 바로 역정규화다. 가장 대표적인 역정규화 기법인 "중복 컬럼 추가"를 적용해 보자. 즉, order_item 테이블에 " "product_name" 컬럼을 추가하여 상품명을 직접 저장하는 것이다.
1단계: 컬럼 추가
ALTER TABLE order_item
ADD COLUMN product_name VARCHAR(100) NOT NULL COMMENT '주문 당시 상품명';
- 먼저, order_item 테이블에 상품명을 저장할 "product_name" 컬럼을 추가한다.
- "COMMENT"를 추가하여 이 컬럼이 어떤 역할을 하는지 명확히 남겨두는 것은 좋은 습관이다.
2단계: 데이터 저장 방식 변경
이제부터 새로운 주문이 들어와 order_item 데이터를 생성할 때, 애플리케이션 로직에서 product 테이블을 한번 조회하여 상품명을 가져온 뒤, order_item 테이블의 "product_name" 컬럼에 함께 저장해 주어야 한다.
역정규화 적용 후
-- 이제 product 테이블과의 JOIN이 필요 없다!
SELECT
oi.order_id,
oi.product_name, -- order_item 테이블에서 바로 상품명을 조회한다.
oi.order_price,
oi.order_quantity
FROM order_item oi
WHERE oi.order_id = 100;
- "JOIN"이 사라진 매우 단순하고 빠른 쿼리가 완성되었다.
- 조회 성능 향상을 기대할 수 있다.
실무 경험 기반 설명
이렇게 하면 데이터 중복이 생기고, 만약 product 테이블에서 상품명이 바뀌면 "order_item"에 있는 상품명은 옛날 이름 그대로 남아서 데이터가 달라지는 문제가 생길 수 있다.
이것이 바로 역정규화가 초래하는 데이터 불일치(Inconsistency)의 위험이며, 우리가 감수해야 할 트레이드오프다. 데이터 불일치 문제를 해결하려면, 상품명이 바뀔 때 마다 order_item에 저장된 모든 상품명도 함께 바꾸어야 한다.
사실 이것은 역정규화가 아니다.
사실 이것은 엄밀히 말해서 역정규화라기 보다는 앞서 놓친 비즈니스 요구사항을 물리적 구현 단계에서 발견한 것에 가깝다. "주문" 데이터의 특성을 깊이 생각해 보자. 만약 고객이 "A 노트북"을 "150만원"에 주문했는데, 며칠 뒤 쇼핑몰에서 상품명을 "A 게이밍 노트북"으로 바꾸고 가격을 "160만원"으로 올렸다고 가정하자. 이때 고객의 과거 주문 내역까지 "A 게이밍 노트북"을 "150만원"에 산 것으로 바뀌어야 할까? 절대 아니다.
고객의 주문 내역은 주문이 일어난 그 시점의 정보를 그대로 보존해야 하는 역사적 데이터(Historical Data) 또는 스냅샷(Snapshot)이다.즉, 주문 당시의 상품명(product_name)과 주문 당시의 가격(order_price)은 나중에 원본 상품 정보가 바뀌더라도 절대 함께 변경되어서는 안 된다. 이런 비즈니스 규칙 덕분에, order_item에 "product_name"을 복사해두는 것은 데이터 불일치 문제가 아니라, 오히려 비즈니스 요구사항을 더 정확하게 만족시키는 훌륭한 설계가 된다. 우리는 이미 "order_price"를 통해 이 원칙을 적용하고 있었다. "product_name"도 마찬가지다. 결론적으로, 이 경우에는 성능 향상과 비즈니스 요구사항 만족이라는 두 마리 토끼를 모두 잡을 수 있었다.
역정규화 - 파생 컬럼 추가(계산된 값 저장)
문제 상황
쇼핑몰 관리자는 주문 관리 페이지에서 각 주문의 "총 결제 금액"을 한눈에 파악하고 싶어 한다. 이 총 금액을 기준으로 매출 통계를 내거나, 특정 금액 이상 주문한 고객을 필터링하는 기능이 필요하다. 고객의 경우도 마찬가지다. 자신의 주문 관리 페이지에서 각 주문의 "총 결제 금액"을 한눈에 파악하고 싶어 한다.
현재 모델에서 주문 ID가 "100번"인 주문의 총 결제 금액을 계산하려면, order_item 테이블에서 해당 주문에 속한 모든 상품들의 "order_price"와 "order_quantity"를 곱한 값을 다시 모두 더하는 복잡한 집계 쿼리를 실행해야 한다.
SELECT
o.order_id,
o.ordered_at,
SUM(oi.order_price * oi.order_quantity) AS total_amount -- 매번 복잡한 계산이 필요하다.
FROM orders o
JOIN order_item oi ON o.order_id = oi.order_id
WHERE o.order_id = 100
GROUP BY o.order_id, o.ordered_at;
- 주문 목록을 보여줄 때마다 모든 주문에 대해 이 계산을 반복한다면, 데이터베이스는 엄청난 계산 부하를 견뎌야 한다.
"왜 우리는 총 주문 금액을 알기 위해 매번 이렇게 복잡한 계산을 반복해야 할까? 어차피 한번 계산된 총 금액은 잘 변하지 않는데, 이 계산 결과를 미리 저장해두면 안 될까?"
이것이 바로 "파생 컬럼 추가" 또는 "계산된 값 저장"이라는 역정규화 기법이다. orders 테이블에 "total_amount"라는 컬럼을 추가하여, 주문이 생성되는 시점에 총 주문 금액을 미리 계산해서 저장해두는 것이다.
1단계: 컬럼 추가
ALTER TABLE orders
ADD COLUMN total_amount INT NOT NULL COMMENT '총 주문 금액';
- orders 테이블에 총 주문 금액을 저장할 "total_amount" 컬럼을 추가한다.
2단계: 데이터 저장 방식 변경
- "order_item"에 각 상품 정보를 저장한다.
- 저장된 "order_item"들을 바탕으로 총 주문 금액을 계산한다.
- 계산된 총 금액을 orders 테이블의 "total_amount" 컬럼에 저장한다.
3단계: 조회 쿼리 개선
-- 계산 없이 orders 테이블에서 바로 총 금액을 조회한다.
SELECT
order_id,
ordered_at,
total_amount
FROM orders
WHERE order_id = 100
- 이제 총 주문 금액을 조회하는 쿼리는 "JOIN"과 "SUM"이 모두 사라진, 매우 빠르고 단순한 쿼리로 바뀐다.
실무 경험 기반 설명
이 기법 역시 트레이드오프가 명확하다. 우리는 읽기(SELECT) 성능을 극적으로 향상시키는 대신, 쓰기(INSERT/UPDATE) 로직의 복잡성과 부하를 증가시켰다.
또한 데이터 정합성을 유지하기 위한 책임이 애플리케이션 개발자에게 넘어왔다. 만약 주문 생성 후 "total_amount"를 업데이트하는 로직을 빠뜨리거나, 주문 상품이 일부 취소되었을 때 "total_amount"를 다시 계산하거나, 차감하는 로직을 제대로 구현하지 않으면, 실제 주문 내역과 총 금액이 맞지 않는 심각한 데이터 불일치 문제가 발생한다.
따라서 이 기법은 조회는 매우 빈번하지만, 데이터 변경은 상대적으로 드물고, 쓰기 시점의 약간의 부하를 감수할 수 있는 경우에 매우 효과적이다. 쇼핑몰의 주문 데이터가 바로 이런 특성에 해당한다
역정규화 - 테이블 통합
문제 상황
어떤 개발자가 이런 제안을 했다고 가정해 보자. "가만 보니 orders와 delivery는 거의 항상 "1:1" 관계입니다. 주문 정보를 볼 때 배송 상태나 주소도 같이 보는 경우가 많으니, 두 테이블을 하나로 합쳐서 JOIN을 아예 없애버리면 성능에 더 좋지 않을까요?"
매우 그럴듯하게 들리는 주장이다. delivery 테이블의 컬럼들(ship_addr, delivery_status, tracking_no)을 orders 테이블로 옮기면, JOIN이 하나 줄어드니 조회 성능이 빨라질 것이다. 그리고 관리해야 하는 테이블도 하나 줄어들어서 개발도 더 편리할 것 같다.
하지만 이 방법은 매우 신중하게 고민해야 한다. "왜 우리는 이처럼 명확해 보이는 성능상의 이점에도 불구하고, 테이블 통합을 신중하게 다시 생각해봐야 할까?"
데이터베이스 전문가라면 이 제안이 좋은 방법이 아니라고 단호하게 말할 것이다. 그 이유는 "JOIN"을 한번 줄여서 얻는 사소한 이점보다, 테이블을 분리함으로써 얻는 구조적인 유연성과 데이터 관리의 명확성이 훨씬 더 중요하기 때문이다. 역정규화는 항상 트레이드오프를 고려해야 하며, 이 경우는 얻는 것 보다 잃는 것이 훨씬 크다.
데이터베이스 전문가의 반대 이유
1) 데이터의 생명주기(Lifecycle)가 다르다
- 주문(orders) 데이터는 고객이 "주문하기" 버튼을 누르는 즉시 생성된다.
- 이때 주문 상품, 금액 등은 모두 확정된다.
- 배송(delivery) 데이터는 주문 이후에 생성되고, 상태가 계속 변한다.
- 운송장 번호(tracking_no)는 상품이 실제 발송 준비가 되어야만 입력될 수 있다.
- 만약 두 테이블을 합치면, 주문이 막 생성된 시점에는 "tracking_no" 같은 배송 관련 컬럼들이 모두 NULL 값으로 채워져 있게 된다.
- 불필요한 "NULL" 값은 저장 공간을 낭비하고, 데이터의 의미를 불분명하게 만든다.
2) 데이터 변경 빈도가 다르다
- orders나 order_item의 핵심 데이터(주문자, 주문 상품, 가격)는 한번 생성되면 거의 변경되지 않는다.
- 반면 delivery의 "delivery_status" 컬럼은 "준비중", "배송중", "배송완료"처럼 상태가 매우 빈번하게 "UPDATE"된다.
- 만약 하나의 거대한 테이블로 합쳐져 있다면, 단지 배송 상태 하나를 바꾸기 위해 주문 정보까지 포함된 훨씬 큰 데이터를 건드려야 한다.
- 이는 불필요한 I/O를 유발하고, 데이터베이스 락(Lock) 경합의 가능성을 높여 오히려 전체적인 시스템 성능을 저하시킬 수 있다.
3) 테이블의 책임과 응집도가 깨진다 (SRP 위반)
- orders 테이블의 책임은 "누가, 언제, 무엇을 주문했는가"에 대한 정보를 관리하는 것이다.
- delivery 테이블의 책임은 "그 주문이 어떻게, 어디로, 어떤 상태로 배송되고 있는가"를 관리하는 것이다.
- 두 테이블을 합치는 것은, 성격이 다른 두 책임(주문, 배송)을 하나의 테이블에 억지로 구겨 넣는 행위다.
- 이는 소프트웨어 설계의 기본 원칙인 단일 책임 원칙(Single Responsibility Principle)을 위배하며, 테이블의 의미를 모호하게 만들고 향후 유지보수를 어렵게 만든다.
4) 확장성을 심각하게 저해한다
- 지금은 주문 하나당 배송 하나(1:1)지만, 서비스가 성장하면 어떻게 될까?
- "묶음 배송"이나 "분할 배송"(하나의 주문에 포함된 여러 상품을 여러 곳으로 나누어 보내는 경우) 기능이 추가된다면, orders와 delivery의 관계는 "1:N"으로 바뀌어야 한다.
- 테이블이 분리되어 있다면, 이런 요구사항 변경에 유연하게 대처할 수 있다.
- 하지만 이미 하나의 테이블로 통합되어 있다면, 데이터베이스 구조 전체를 뜯어고쳐야 하는 대공사가 필요하게 된다.
5) 선택적 관계(Optional Relationship)에 대한 처리
- 만약 우리 쇼핑몰에서 E-Book이나 온라인 강의 같은 디지털 상품을 판매하게 된다면 어떻게 될까?
- 디지털 상품은 배송 자체가 필요 없다.
- 통합된 테이블에서는 이런 주문이 들어올 때마다 모든 배송 관련 컬럼이 "NULL"로 남게 된다.
- 이는 데이터 무결성을 해치고, "delivery_status"가 "NULL"인 데이터가 "배송 전"인지 "배송 불가 상품"인지 구분하기 어렵게 만든다.
6) 성능 이점이 크지 않음
- orders와 delivery의 "JOIN"은 "order_id"라는 "PK/FK/UNIQUE 키"를 기반으로 한 "1:1" 조인이다.
- 이런 종류의 JOIN은 인덱스가 잘 설정되어 있다면 데이터베이스가 매우 효율적으로 처리하여 성능 부하가 크지 않다.
- 즉, 테이블을 합쳐서 얻는 성능상 이점은 매우 미미한데, 위에서 언급한 구조적 문제점들은 매우 크다.
결과적으로 delivery"는 그대로 분리한다 이러한 트레이드오프를 신중하게 고려한 결과, delivery 테이블을 orders 테이블로 통합하는 것은 장기적으로 더 큰 문제를 야기할 수 있는, 좋지 않은 선택이라는 결론에 도달했다. "JOIN"을 한 번 줄이는 단기적인 성능 이익보다, 데이터 모델의 명확성, 유연성, 확장성을 지키는 장기적인 이익이 훨씬 더 크다. 따라서 우리의 최종 물리적 모델에서도 delivery 테이블은 orders와 분리된 상태를 그대로 유지한다.
실무 이야기
이번에 다룬 orders와 delivery 테이블의 통합 여부 문제는 비단 데이터베이스 설계에만 국한된 이야기가 아니다. 이것은 객체 지향 프로그래밍에서 클래스를 어떻게 나눌 것인가와 같은, 소프트웨어 설계 전반을 관통하는 매우 근본적인 고민이다. 무언가를 분리할지, 아니면 합칠지를 결정해야 할 때, 오늘 우리가 배운 두 가지 기준인 데이터의 생명 주기와 변경 빈도를 떠올리면 도움이 될 것이다.
- 생명주기가 다른 데이터는 분리하라
- 주문(orders)은 결제 시점에 "생성"되고 거의 변하지 않는다.
- 반면 배송(delivery)은 주문 이후에 "생성"되고, 상태가 계속 "변경"되다가 배송 완료 시점에 사실상 생명이 다한다.
- 이렇게 태어나고 죽는 시점, 그리고 살아가는 모습이 다른 데이터들을 하나의 테이블에 묶어두면 앞서 본 것처럼 수많은 NULL 값과 불필요한 복잡성을 낳게 된다.
- 각자의 생명주기에 맞는 집(테이블)을 만들어 주는 것이 가장 자연스러운 설계다.
- 변경 빈도가 다른 데이터는 분리하라
- 주문의 핵심 정보는 한번 정해지면 바뀔 일이 거의 없는 정적인 데이터다.
- 반면 배송 상태는 계속해서 "UPDATE"가 일어나는 동적인 데이터다.
- 만약 이 둘을 합치면, 우리는 고작 배송 상태 하나를 바꾸기 위해 거의 바뀌지 않는 주문 정보까지 매번 함께 조회하고 수정해야 하는 비효율을 감수해야 한다.
- 이는 데이터베이스의 "UPDATE" 성능 저하와 락(Lock) 경합의 원인이 되기도 한다.
- 자주 바뀌는 데이터는 따로 분리해서, 다른 데이터에 미치는 영향을 최소화하는 것이 현명하다.
결국 좋은 설계는 "함께 변경될 가능성이 높은 것들은 모으고, 그렇지 않은 것들은 분리하는 것"에서 출발한다. 이 원칙 하나만 잘 기억하고 적용해도, 훨씬 더 유연하고 확장성 있는 데이터베이스 모델을 만들 수 있을 것이다.
'데이터 베이스 > 설계 1편, 현대적 데이터 모델링 완전 정복' 카테고리의 다른 글
| Ch11. 물리적 모델링 실습 - 쇼핑몰 DDL과 DB 만들기, ERD 자동 완성 (0) | 2026.07.07 |
|---|---|
| Ch11. 물리적 모델링 실습 - 테이블 정의서 (0) | 2026.07.06 |
| Ch11. 물리적 모델링 실습 - 인덱스 설계 (0) | 2026.07.05 |
| Ch11. 물리적 모델링 실습 - 설계 (0) | 2026.07.05 |
| Ch10. 물리적 모델링 - 테이블 정의서 (0) | 2026.07.01 |