정규화로 없앤 중복을 읽기 성능을 위해 의도적으로 되살리는 설계.
왜 되돌리나
정규화 ↑ 중복 없음 · 갱신 쉬움 · 무결성 강함
그러나 조인이 많아져 읽기가 느려진다
반정규화 ↑ 조인 없이 한 번에 읽는다
그러나 중복된 값을 누군가 동기화해야 한다
흔한 세 가지 형태
-- ① 집계 칼럼 캐싱 — 매번 count(*) 하지 않는다
ALTER TABLE post ADD COLUMN comment_count INT NOT NULL DEFAULT 0;
-- ② 중복 칼럼 — 조인을 없앤다
ALTER TABLE orders ADD COLUMN member_name VARCHAR(50); -- member 를 조인하지 않으려고
-- ③ 요약 테이블 — 통계를 미리 만들어 둔다
CREATE TABLE daily_sales AS
SELECT date(created_at) AS d, sum(amount) AS total FROM orders GROUP BY 1;
동기화 책임이 따라온다
-- 트리거로 강제하기 — DB 가 책임진다
CREATE OR REPLACE FUNCTION bump_comment_count() RETURNS TRIGGER AS $$
BEGIN
UPDATE post SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_comment_ins AFTER INSERT ON comment
FOR EACH ROW EXECUTE FUNCTION bump_comment_count();
트리거는 확실하지만 쓰기 경로에 부하와 락을 더한다. 애플리케이션에서 처리하면 가볍지만 경로가 여러 개면 빠뜨리기 쉽다. 어느 쪽이든 틀어졌을 때 바로잡는 배치를 함께 둔다.
-- 정합성 점검·보정 배치 (주기적으로 돌린다)
UPDATE post p SET comment_count = c.cnt
FROM (SELECT post_id, count(*) cnt FROM comment GROUP BY post_id) c
WHERE p.id = c.post_id AND p.comment_count <> c.cnt;
PostgreSQL의 구체화 뷰라는 선택지
-- 요약 테이블을 직접 관리하는 대신 DB 에 맡긴다
CREATE MATERIALIZED VIEW daily_sales AS
SELECT date(created_at) AS d, sum(amount) AS total FROM orders GROUP BY 1;
CREATE UNIQUE INDEX ON daily_sales (d); -- CONCURRENTLY 갱신에 필요
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales; -- 읽기를 막지 않고 갱신
CONCURRENTLY 없이 갱신하면 그동안 조회가 막힌다 — 운영에서 자주 데는 지점이다.
순서가 중요하다
- ① 3NF 로 설계한다
- ② 실제로 측정한다 (EXPLAIN ANALYZE · 슬로우 쿼리 로그)
- ③ 느린 곳만 신중히 반정규화한다
처음부터 성능을 핑계로 넓은 테이블을 만드는 건 조기 최적화다. 대개는 인덱스나 쿼리 수정으로 해결되고, 그 편이 되돌리기도 쉽다.
면접 함정
- ❌ "반정규화는 나쁜 설계" → 측정에 근거한 의도적 선택이면 정당하다. 문제는 측정 없이 하는 것이다.
- ❌ "반정규화하면 빨라진다" → 읽기만 빨라지고 쓰기는 느려진다. 읽기 비중이 높을 때만 이득이다.