반정규화 전략 가이드: 언제, 왜, 어떻게 반정규화해야 하는가

데이터베이스 반정규화의 모든 것. 성능을 위한 전략적 반정규화 방법과 주의사항을 실전 예제로 배웁니다.

럿지 AI 팀
4분 읽기

반정규화 전략 가이드



반정규화란?



**정규화의 역:** 의도적으로 중복 허용

**목적:** 조회 성능 향상

**원칙:** "필요한 곳에만, 신중하게"

언제 반정규화할까?



1. JOIN이 너무 많을 때



``sql
-- 5개 테이블 JOIN (느림!)
SELECT
o.order_id,
m.member_name,
p.product_name,
c.category_name,
d.delivery_status
FROM orders o
JOIN members m ON o.member_id = m.member_id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN categories c ON p.category_id = c.category_id
JOIN deliveries d ON o.order_id = d.order_id;
-- 실행 시간: 3초
`

2. 집계 쿼리가 느릴 때



`sql
-- 매번 계산 (느림!)
SELECT
member_id,
COUNT(*) as order_count,
SUM(total_amount) as total_spent
FROM orders
GROUP BY member_id;
-- 100만 건 처리: 5초
`

3. 읽기가 쓰기보다 압도적으로 많을 때



**비율:**
- 읽기: 95%
- 쓰기: 5%

→ 반정규화 고려

반정규화 패턴



패턴 1: 중복 컬럼 추가



`sql
-- Before (정규화)
CREATE TABLE orders (
order_id INT,
member_id INT
);

-- 회원 이름 조회 시 항상 JOIN
SELECT o.*, m.member_name
FROM orders o
JOIN members m ON o.member_id = m.member_id;

-- After (반정규화)
CREATE TABLE orders (
order_id INT,
member_id INT,
member_name VARCHAR(50) -- 중복!
);

-- JOIN 없이 조회
SELECT order_id, member_name FROM orders;
-- 10배 빠름!
`

**주의:** member_name 변경 시 orders도 업데이트 필요

`sql
-- 트리거로 자동 동기화
CREATE TRIGGER sync_member_name
AFTER UPDATE ON members
FOR EACH ROW
UPDATE orders SET member_name = NEW.member_name
WHERE member_id = NEW.member_id;
`

패턴 2: 집계 테이블



`sql
-- Before: 매번 계산
SELECT
member_id,
COUNT(*) as order_count,
SUM(total_amount) as total_spent
FROM orders
GROUP BY member_id;

-- After: 집계 테이블 생성
CREATE TABLE member_stats (
member_id INT PRIMARY KEY,
order_count INT DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
last_order_date DATE,
updated_at DATETIME
);

-- 즉시 조회
SELECT * FROM member_stats WHERE member_id = 1;
-- 0.001초!
`

**갱신 전략:**

`sql
-- 주문 발생 시 트리거
CREATE TRIGGER update_member_stats
AFTER INSERT ON orders
FOR EACH ROW
UPDATE member_stats SET
order_count = order_count + 1,
total_spent = total_spent + NEW.total_amount,
last_order_date = NEW.order_date
WHERE member_id = NEW.member_id;
`

패턴 3: 캐시 테이블



`sql
-- 대시보드용 통계 (매시간 갱신)
CREATE TABLE dashboard_stats (
stat_date DATE PRIMARY KEY,
total_orders INT,
total_revenue DECIMAL(15,2),
new_members INT,
active_members INT,
updated_at DATETIME
);

-- 매시간 실행
INSERT INTO dashboard_stats
SELECT
CURDATE(),
COUNT(DISTINCT order_id),
SUM(total_amount),
(SELECT COUNT(*) FROM members WHERE DATE(created_at) = CURDATE()),
(SELECT COUNT(*) FROM members WHERE last_login_date >= CURDATE()),
NOW()
FROM orders
WHERE DATE(order_date) = CURDATE()
ON DUPLICATE KEY UPDATE
total_orders = VALUES(total_orders),
total_revenue = VALUES(total_revenue);
`

패턴 4: 역정규화 (1:1 테이블 합치기)



`sql
-- Before
CREATE TABLE members (
member_id INT PRIMARY KEY,
email VARCHAR(100)
);

CREATE TABLE member_profiles (
member_id INT PRIMARY KEY,
name VARCHAR(50),
phone VARCHAR(20),
FOREIGN KEY (member_id) REFERENCES members(member_id)
);

-- After (합침)
CREATE TABLE members (
member_id INT PRIMARY KEY,
email VARCHAR(100),
name VARCHAR(50),
phone VARCHAR(20)
);
-- JOIN 제거!
`

실전 예제



Case: 상품 상세 페이지



**요구사항:**
- 조회수 1,000/초
- 리뷰 평균 평점 표시
- 재고 수량 표시

**Before (정규화):**

`sql
SELECT
p.*,
AVG(r.rating) as avg_rating,
COUNT(r.review_id) as review_count,
s.stock_quantity
FROM products p
LEFT JOIN reviews r ON p.product_id = r.product_id
LEFT JOIN stocks s ON p.product_id = s.product_id
WHERE p.product_id = 123
GROUP BY p.product_id;
-- 응답 시간: 0.5초
`

**After (반정규화):**

`sql
CREATE TABLE products (
product_id INT PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10,2),
avg_rating DECIMAL(3,2), -- 반정규화!
review_count INT, -- 반정규화!
stock_quantity INT, -- 반정규화!
rating_updated_at DATETIME,
stock_updated_at DATETIME
);

SELECT * FROM products WHERE product_id = 123;
-- 응답 시간: 0.001초 (500배 향상!)
`

**갱신:**

`sql
-- 리뷰 등록 시
CREATE TRIGGER update_product_rating
AFTER INSERT ON reviews
FOR EACH ROW
UPDATE products SET
avg_rating = (
SELECT AVG(rating) FROM reviews WHERE product_id = NEW.product_id
),
review_count = review_count + 1
WHERE product_id = NEW.product_id;

-- 재고 변동 시
-- 비동기 큐로 처리 (트리거보다 성능 좋음)
`

주의사항



1. 데이터 일관성



**문제:**
중복 데이터 간 불일치 가능

**해결:**
- 트리거 사용
- 배치 작업으로 정기 동기화
- 애플리케이션 레벨에서 관리

2. 쓰기 성능 저하



**trade-off:**
- 읽기: 빠름
- 쓰기: 느림 (여러 곳 업데이트)

**판단:**
읽기:쓰기 비율이 9:1 이상일 때 유리

3. 저장 공간



중복 데이터로 디스크 사용 증가

**계산:**
- 비용: 저장 공간 +20%
- 이득: 쿼리 속도 10배

→ 가치 있는 투자

반정규화 결정 프로세스



`
1. 성능 문제 확인

2. EXPLAIN으로 분석

3. 인덱스로 해결 가능? → Yes: 인덱스 추가
↓ No
4. 읽기:쓰기 비율 확인

5. 9:1 이상? → Yes: 반정규화 고려
↓ No
6. 정규화 유지
``

더 배우기



김영한의 실전 데이터베이스
- 정규화 vs 반정규화
- 실전 최적화 전략
- 성능 튜닝

---

**태그**: #반정규화 #성능최적화 #집계테이블 #캐시테이블 #DB설계

L

럿지 AI 팀

AI 기술과 비즈니스 혁신을 선도하는 럿지 AI의 콘텐츠 팀입니다.