본문 바로가기
TYLER SONGBlog
← Blog index
CS학습5 min read

반정규화: 읽기 성능을 위해 중복을 허용하는 설계 전략

반정규화(Denormalization)는 정규화된 스키마에 의도적으로 중복 데이터나 사전 계산된 값을 추가해 조인과 집계 비용을 줄이는 기법이다. 읽기 성능은 좋아지지만 쓰기 비용이 늘고 데이터 불일치 위험이 생긴다. 이 글은 원리, PostgreSQL 예제, 일관성 유지 방법, 실무 적용 기준을 다룬다.

By 송민성

1. 개념

반정규화(Denormalization)는 정규화(Normalization)를 마친 스키마에서 성능을 위해 의도적으로 중복을 도입하거나 구조를 합치는 작업이다. 정규화되지 않은 설계와는 다르다. 정규화를 먼저 수행하고, 측정으로 확인한 병목에 대해서만 선택적으로 되돌린다.

대표적인 반정규화 방식은 다음과 같다.

방식설명예
컬럼 중복다른 테이블의 컬럼을 복사해 둠주문 테이블에 user_name 복사
파생 컬럼계산 결과를 저장comment_count, total_price
테이블 병합1:1 관계 테이블을 하나로 합침users + user_profiles
테이블 분할자주 쓰는 컬럼과 아닌 컬럼을 분리수직 분할
요약 테이블집계 결과를 별도 테이블에 저장일별 매출 통계

2. 왜 사용하는가

정규화된 스키마는 한 사실을 한 곳에만 저장하므로 갱신 이상(Update Anomaly)이 없다. 대신 데이터를 조회할 때 여러 테이블을 조인(JOIN)해야 한다. 다음 상황에서는 이 비용이 문제가 된다.

  • 읽기 비율이 쓰기보다 압도적으로 높다.
  • 여러 테이블을 조인하는 쿼리가 매우 자주 실행된다.
  • 대량 행에 대한 COUNT, SUM 같은 집계를 실시간으로 계산해야 한다.
  • 인덱스 튜닝, 쿼리 개선, 캐시로도 목표 응답 시간을 맞추지 못한다.

반정규화는 읽기 시점의 계산을 쓰기 시점으로 옮기는 트레이드오프다.

3. 동작 원리

게시글 목록에 "작성자 이름"과 "댓글 수"를 보여주는 화면을 생각해 보자.

정규화된 구조에서의 조회

  1. posts를 읽는다.
  2. users와 조인해 작성자 이름을 가져온다.
  3. comments를 post_id로 그룹화해 개수를 센다.

반정규화된 구조에서의 조회

  1. posts만 읽는다. 작성자 이름과 댓글 수가 이미 같은 행에 있다.

대신 원본이 바뀔 때 사본도 함께 갱신해야 한다. 동기화 방법은 크게 세 가지다.

  • 애플리케이션 코드: 트랜잭션 안에서 원본과 사본을 함께 수정한다.
  • 데이터베이스 트리거(Trigger): 원본 변경 시 DB가 사본을 갱신한다.
  • 배치/비동기 갱신: 주기적으로 재계산하거나 이벤트로 반영한다. 이 경우 일시적 불일치를 허용해야 한다.

PostgreSQL의 구체화 뷰(Materialized View)는 쿼리 결과를 물리적으로 저장하고 REFRESH MATERIALIZED VIEW로 갱신하는 방식이다. 반정규화를 DB 기능으로 구현하는 한 형태다.

4. 코드 예제

PostgreSQL에서 바로 실행할 수 있는 예제다. 트리거로 댓글 수 파생 컬럼을 유지한다.

sql
-- 1) 정규화된 스키마 CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL ); CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id), title TEXT NOT NULL, -- 반정규화: 파생 컬럼 comment_count INT NOT NULL DEFAULT 0 ); CREATE TABLE comments ( id SERIAL PRIMARY KEY, post_id INT NOT NULL REFERENCES posts(id), body TEXT NOT NULL ); -- 2) 댓글 추가/삭제 시 comment_count 자동 갱신 CREATE OR REPLACE FUNCTION sync_comment_count() RETURNS trigger AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id; RETURN NEW; ELSIF TG_OP = 'DELETE' THEN UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id; RETURN OLD; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_comment_count AFTER INSERT OR DELETE ON comments FOR EACH ROW EXECUTE FUNCTION sync_comment_count(); -- 3) 동작 확인 INSERT INTO users (name) VALUES ('민수'); INSERT INTO posts (user_id, title) VALUES (1, '반정규화 입문'); INSERT INTO comments (post_id, body) VALUES (1, '좋은 글'), (1, '감사합니다'); -- 반정규화 조회: 집계 없이 바로 읽는다 SELECT id, title, comment_count FROM posts; -- 정합성 검증 쿼리: 불일치 행 탐지 SELECT p.id, p.comment_count, COUNT(c.id) AS actual FROM posts p LEFT JOIN comments c ON c.post_id = p.id GROUP BY p.id, p.comment_count HAVING p.comment_count <> COUNT(c.id);

요약 테이블을 구체화 뷰로 만드는 예는 다음과 같다.

sql
CREATE MATERIALIZED VIEW post_stats AS SELECT p.id AS post_id, p.title, COUNT(c.id) AS comment_count FROM posts p LEFT JOIN comments c ON c.post_id = p.id GROUP BY p.id, p.title; -- REFRESH ... CONCURRENTLY를 쓰려면 유니크 인덱스가 필요하다 CREATE UNIQUE INDEX ON post_stats (post_id); REFRESH MATERIALIZED VIEW CONCURRENTLY post_stats;

5. 시간 복잡도 또는 성능 특성

정확한 수치는 데이터 분포와 인덱스, 하드웨어에 따라 달라지므로 구조적 특성만 정리한다.

항목정규화반정규화
조회조인·집계 필요단일 테이블 읽기로 단축 가능
쓰기한 곳만 수정사본까지 함께 수정
저장 공간적음중복만큼 증가
일관성스키마가 보장동기화 로직이 보장
  • 댓글 수를 COUNT로 구하면 해당 게시글의 댓글 행 수에 비례하는 작업이 필요하다. 파생 컬럼은 이를 단일 행 읽기로 바꾼다.
  • 반면 댓글 하나를 쓸 때마다 posts 행 갱신이 추가된다. 인기 게시글에 쓰기가 몰리면 같은 행에 대한 행 잠금(Row Lock) 경합이 병목이 될 수 있다.
  • PostgreSQL은 MVCC 구조상 UPDATE가 새 행 버전을 만든다. 자주 갱신되는 카운터 컬럼은 테이블 부풀음(bloat)과 VACUUM 부담을 키울 수 있다.

6. 실무 사용 사례

  • 목록 화면용 카운터: 좋아요 수, 댓글 수, 조회 수를 게시글 행에 저장한다.
  • 스냅샷 보존: 주문 시점의 상품명과 가격을 주문 상세에 복사한다. 이 경우는 성능뿐 아니라 "그 시점의 값을 기록"하는 업무 요건이다. 상품 가격이 나중에 바뀌어도 과거 주문 금액은 변하면 안 된다.
  • 통계·리포트: 일별/월별 집계를 요약 테이블이나 구체화 뷰로 미리 계산한다.
  • 읽기 모델 분리: 쓰기용 정규화 DB와 읽기용 비정규화 모델을 나누는 CQRS 패턴에서 사용한다.
  • NoSQL 설계: 문서형 DB에서는 조인이 제한적이어서 관련 데이터를 한 문서에 포함하는 설계가 흔하다.

7. 주의할 점

  • 먼저 측정한다. EXPLAIN (ANALYZE, BUFFERS)로 실제 병목을 확인하고, 적절한 인덱스·쿼리 재작성·캐시로 해결되는지부터 본다.
  • 진실의 원천(Source of Truth)을 하나로 정한다. 어느 쪽이 원본이고 어느 쪽이 사본인지 명확해야 한다.
  • 동기화 누락 경로를 막는다. 애플리케이션 코드로 동기화하면 배치 스크립트나 수동 SQL이 갱신을 우회해 불일치가 생긴다. 트리거는 이를 줄이지만 로직이 DB에 숨어 유지보수가 어려워질 수 있다.
  • 동시성 문제를 고려한다. 위 예제의 comment_count + 1 방식은 UPDATE 하나가 원자적으로 처리되므로 읽고 쓰는 방식(SELECT 후 값을 계산해 UPDATE)보다 안전하다.
  • 정합성 검증 및 보정 수단을 마련한다. 예제의 검증 쿼리처럼 주기적으로 불일치를 찾고 재계산하는 작업을 둔다.
  • 쓰기 증폭을 계산한다. 사본이 많을수록 한 번의 변경이 여러 행·테이블 수정으로 번진다.

8. 핵심 정리

  • 반정규화는 정규화를 마친 뒤, 측정된 읽기 병목을 해결하려고 중복이나 파생 값을 의도적으로 추가하는 기법이다.
  • 읽기 시점의 조인·집계 비용을 쓰기 시점의 동기화 비용으로 옮기는 트레이드오프다.
  • 동기화는 애플리케이션 트랜잭션, 트리거, 배치·구체화 뷰 중 일관성 요구 수준에 맞춰 선택한다.
  • 주문 시점 가격 보존처럼 성능이 아니라 업무 요건 때문에 중복이 필요한 경우도 있다.
  • 적용 전에는 인덱스·쿼리 개선·캐시를 먼저 검토하고, 적용 후에는 정합성 검증 쿼리를 함께 운영한다.