Devin.KR

반정규화와 성능 트레이드오프 판단

개발자KR 조회 14

이 장에서 배우는 것

앞 장에서는 이상 현상을 없애기 위해 스키마를 쪼개고 3차 정규형까지 나누는 연습을 했다. 그런데 정규화를 마친 스키마를 그대로 운영에 올리면 화면 하나를 채우는 데 여러 테이블을 조인해야 하고, 트래픽이 몰리는 구간에서 이 비용이 누적된다. 이 장에서는 언제 반정규화(denormalization)를 검토해야 하는지, 어떤 기법을 쓰는지, 그리고 반정규화가 만들어내는 데이터 불일치를 어떻게 막는지를 온라인 서점 스키마로 살펴본다.

  • 반정규화를 검토하는 절차(처리 빈도, 대량 범위 처리, 통계)를 순서대로 적용할 수 있다
  • 중복 컬럼, 파생 컬럼, 이력 테이블 세 가지 반정규화 기법의 차이를 구분할 수 있다
  • 반정규화한 컬럼의 무결성을 트리거나 배치로 유지하는 방법을 설명할 수 있다
  • 반정규화 여부를 묻는 판단 문제에서 정규화 유지와 반정규화 적용을 근거 있게 고를 수 있다

문제 상황

온라인 서점의 주문 목록 화면은 하루 수십만 번 조회된다. 화면에는 주문번호, 주문한 회원 이름, 주문 총액이 함께 나와야 한다. 정규화된 스키마에서는 이 화면 하나를 채우려면 주문, 회원, 주문상세 세 테이블을 조인하고 주문상세는 주문번호로 묶어 합계를 내야 한다. 반면 회원 이름이나 등급이 바뀌는 일은 하루 몇 건에 그친다. 즉 조회는 압도적으로 많고 갱신은 드물다. 이런 비대칭 앞에서 "회원 이름과 주문 총액을 주문 테이블에 미리 넣어 둘까" 하는 질문이 반정규화 판단의 출발점이다.

반정규화를 섣불리 적용하면 회원 이름을 두 곳에 저장해 두고 한쪽만 갱신하는 사고가 난다. 반대로 무조건 정규화를 고집하면 조회 성능이 떨어진다. 이 장에서는 두 극단 사이에서 근거를 갖고 판단하는 방법을 다룬다.

반정규화는 조회 시 조인 단계를 줄이는 대신 갱신 시 동기화 책임을 늘린다

반정규화 판단 절차

반정규화는 정규화를 무효로 되돌리는 것이 아니라, 특정 조회 패턴을 위해 의도적으로 중복을 허용하는 것이다. 아무 컬럼이나 중복해서 저장하면 안 되고, 다음 세 가지를 순서대로 확인한 뒤 결정한다.

반정규화를 검토할 때 확인할 세 가지 기준
검토 항목확인 내용판단 기준
처리 빈도같은 조건의 조회가 얼마나 자주 반복되는가초당 수십 건 이상 반복되면 후보로 본다
대량 범위 처리한 번의 조회가 훑는 행 수가 얼마나 많은가조인·집계 대상이 수만 행을 넘으면 후보로 본다
통계(조회:갱신 비율)같은 데이터를 읽는 빈도와 갱신하는 빈도의 비율조회가 갱신보다 압도적으로 많을 때만 적용한다(예: 백 대 일 이상)

세 조건을 모두 만족할 때만 반정규화를 적용한다. 하나라도 애매하면 정규화를 유지하고 인덱스나 캐시 같은 다른 수단을 먼저 검토한다. 반정규화는 되돌리기 어렵고 스키마를 읽는 모든 사람에게 "이 컬럼은 왜 중복인가"라는 질문을 남기기 때문이다.

반정규화 판단 절차는 처리 빈도, 대량 범위 처리, 통계 확인을 거친 뒤에야 적용을 결정한다

반정규화 기법과 무결성 유지

중복 컬럼

다른 테이블에 있는 컬럼 값을 그대로 복사해 두는 기법이다. 주문 테이블에 회원 이름을 넣어 두면 주문 목록 화면에서 회원 테이블과 조인하지 않아도 된다. 대신 회원 이름이 바뀌면 주문 테이블의 복사본도 함께 바꿔야 한다.

파생 컬럼

다른 테이블의 값을 계산한 결과를 저장해 두는 기법이다. 주문 테이블에 총주문금액을 미리 계산해 두면 주문상세를 매번 그룹화해서 합산하지 않아도 된다. 대신 주문상세가 추가·수정·삭제될 때마다 합계를 다시 계산해야 한다.

이력 테이블

이력 테이블은 엄밀히는 새 테이블을 추가하는 것이라 정규화 원칙을 어기지는 않는다. 하지만 "현재 값만 남기면 충분한" 정규화 스키마에 과거 시점의 스냅숏을 덧붙인다는 점에서 반정규화 판단 문제와 함께 자주 등장한다. 회원 등급이 바뀔 때마다 원본 회원 테이블은 최신 값만 남기고, 변경 내역은 별도의 회원등급이력 테이블에 계속 쌓는 식이다.

원본 테이블은 현재 값만 덮어쓰지만 이력 테이블은 변경마다 새 행을 추가로 쌓는다

무결성 유지 방법

반정규화한 컬럼은 원본 데이터가 바뀔 때 함께 바뀌어야 한다. 방법은 트리거로 즉시 동기화하는 방법, 배치로 주기적으로 재계산하는 방법, 애플리케이션 트랜잭션 안에서 원본과 반정규화 컬럼을 함께 갱신하는 방법 세 가지다. 갱신 빈도가 낮은 컬럼은 트리거로 충분하고, 대량 배치성 변경이 잦은 컬럼은 야간 배치 재계산을 섞어 쓴다. MySQL 8과 Oracle은 트리거 문법에 차이가 있어 마이그레이션할 때 주의가 필요하다.

MySQL 8과 Oracle의 트리거 문법 차이
항목MySQL 8Oracle
트리거 이벤트 결합INSERT·UPDATE·DELETE마다 별도 트리거를 만든다한 트리거에 INSERT OR UPDATE OR DELETE를 함께 지정할 수 있다
변경 전후 값 참조OLD.컬럼, NEW.컬럼으로 참조한다:OLD.컬럼, :NEW.컬럼으로 참조한다
자동 증가 키AUTO_INCREMENT 컬럼으로 처리한다IDENTITY 컬럼이나 시퀀스로 처리한다

트리거가 같은 테이블을 다시 갱신하려는 시도는 MySQL 8에서 오류로 막힌다. 자세한 제약은 MySQL 저장 프로그램 제약 문서를 참고한다.

완성 코드

-- 스키마 초기화
DROP TABLE IF EXISTS 회원등급이력;
DROP TABLE IF EXISTS 주문상세;
DROP TABLE IF EXISTS 주문;
DROP TABLE IF EXISTS 도서;
DROP TABLE IF EXISTS 회원;

CREATE TABLE 회원 (
    member_id     INT PRIMARY KEY,
    member_name   VARCHAR(50) NOT NULL,
    grade         VARCHAR(10) NOT NULL,
    region        VARCHAR(50)
);

CREATE TABLE 도서 (
    book_id    INT PRIMARY KEY,
    title      VARCHAR(100) NOT NULL,
    price      INT NOT NULL,
    category   VARCHAR(30)
);

-- 반정규화 컬럼: member_name(중복 컬럼), total_amount(파생 컬럼)
CREATE TABLE 주문 (
    order_id       INT PRIMARY KEY,
    member_id      INT NOT NULL,
    member_name    VARCHAR(50) NOT NULL,
    order_date     DATE NOT NULL,
    total_amount   INT NOT NULL DEFAULT 0,
    FOREIGN KEY (member_id) REFERENCES 회원(member_id)
);

CREATE TABLE 주문상세 (
    order_id     INT NOT NULL,
    book_id      INT NOT NULL,
    qty          INT NOT NULL,
    sale_price   INT NOT NULL,
    PRIMARY KEY (order_id, book_id),
    FOREIGN KEY (order_id) REFERENCES 주문(order_id),
    FOREIGN KEY (book_id) REFERENCES 도서(book_id)
);

-- 이력 테이블: 대리 키를 PK로 두어 변경마다 새 행이 쌓이게 한다
CREATE TABLE 회원등급이력 (
    history_id   INT AUTO_INCREMENT PRIMARY KEY,
    member_id    INT NOT NULL,
    old_grade    VARCHAR(10),
    new_grade    VARCHAR(10) NOT NULL,
    changed_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (member_id) REFERENCES 회원(member_id)
);

DELIMITER $$

-- 회원 등급이 바뀔 때만 이력을 남긴다
CREATE TRIGGER trg_member_grade_history
AFTER UPDATE ON 회원
FOR EACH ROW
BEGIN
    IF OLD.grade <> NEW.grade THEN
        INSERT INTO 회원등급이력(member_id, old_grade, new_grade)
        VALUES (NEW.member_id, OLD.grade, NEW.grade);
    END IF;
END$$

-- 회원 이름이 바뀌면 주문에 중복 저장한 이름도 함께 바꾼다
CREATE TRIGGER trg_member_name_sync
AFTER UPDATE ON 회원
FOR EACH ROW
BEGIN
    IF OLD.member_name <> NEW.member_name THEN
        UPDATE 주문
           SET member_name = NEW.member_name
         WHERE member_id = NEW.member_id;
    END IF;
END$$

-- 주문상세가 늘거나 줄 때마다 주문의 파생 컬럼을 재계산한다
CREATE TRIGGER trg_order_total_insert
AFTER INSERT ON 주문상세
FOR EACH ROW
BEGIN
    UPDATE 주문
       SET total_amount = (
             SELECT COALESCE(SUM(qty * sale_price), 0)
               FROM 주문상세
              WHERE order_id = NEW.order_id
           )
     WHERE order_id = NEW.order_id;
END$$

CREATE TRIGGER trg_order_total_update
AFTER UPDATE ON 주문상세
FOR EACH ROW
BEGIN
    UPDATE 주문
       SET total_amount = (
             SELECT COALESCE(SUM(qty * sale_price), 0)
               FROM 주문상세
              WHERE order_id = NEW.order_id
           )
     WHERE order_id = NEW.order_id;
END$$

CREATE TRIGGER trg_order_total_delete
AFTER DELETE ON 주문상세
FOR EACH ROW
BEGIN
    UPDATE 주문
       SET total_amount = (
             SELECT COALESCE(SUM(qty * sale_price), 0)
               FROM 주문상세
              WHERE order_id = OLD.order_id
           )
     WHERE order_id = OLD.order_id;
END$$

DELIMITER ;

-- 샘플 데이터
INSERT INTO 회원 VALUES
(1, '김도윤', 'GOLD', '서울'),
(2, '이서연', 'SILVER', '부산');

INSERT INTO 도서 VALUES
(101, 'SQL 기본과 활용', 28000, 'IT'),
(102, '데이터 모델링 실전', 32000, 'IT');

INSERT INTO 주문 (order_id, member_id, member_name, order_date) VALUES
(1001, 1, '김도윤', '2026-09-01'),
(1002, 2, '이서연', '2026-09-02');

INSERT INTO 주문상세 VALUES
(1001, 101, 1, 28000),
(1001, 102, 1, 32000),
(1002, 101, 2, 28000);

UPDATE 회원 SET grade = 'VIP' WHERE member_id = 1;
UPDATE 회원 SET member_name = '김도윤(개명)' WHERE member_id = 1;

줄별 해설

  • 회원, 도서 테이블은 정규화된 원본 그대로다. 반정규화는 여기가 아니라 조회가 몰리는 주문 테이블에만 적용한다.
  • 주문 테이블의 member_name은 중복 컬럼, total_amount는 파생 컬럼이다. 두 컬럼 모두 원본(회원, 주문상세)에서 값을 가져오므로 트리거로 동기화 대상을 명시해야 한다.
  • 회원등급이력의 PK는 member_id가 아니라 history_id다. 등급이 여러 번 바뀌어도 매번 새 행이 추가되도록 대리 키를 썼다.
  • DELIMITER $$는 mysql 클라이언트가 트리거 본문의 세미콜론을 구분자로 오인하지 않도록 구분자를 임시로 바꾸는 지시어다. 트리거 정의가 끝나면 DELIMITER ;로 되돌린다.
  • trg_member_grade_history는 OLD.grade <> NEW.grade 조건으로 감싸, 등급 외 다른 컬럼만 바뀌었을 때 불필요한 이력 행이 쌓이지 않게 한다.
  • trg_member_name_sync도 같은 방식으로 이름이 실제로 바뀐 경우에만 주문 테이블을 갱신해 불필요한 쓰기를 줄인다.
  • 주문상세에 대한 트리거를 INSERT·UPDATE·DELETE 세 개로 나눈 이유는 MySQL 8이 하나의 트리거에 여러 이벤트를 묶는 문법을 지원하지 않기 때문이다.
  • 샘플 데이터는 회원 → 도서 → 주문 → 주문상세 순서로 넣는다. 외래 키 제약 때문에 참조 대상이 먼저 있어야 한다.
  • 마지막 두 UPDATE 문은 각각 trg_member_grade_history(등급 변경)와 trg_member_name_sync(이름 변경)를 발동시켜 이력 적재와 주문 테이블 동기화를 동시에 확인한다.

실행 결과

mysql> SELECT order_id, member_id, member_name, order_date, total_amount
    -> FROM 주문 ORDER BY order_id;
+----------+-----------+---------------+------------+--------------+
| order_id | member_id | member_name   | order_date | total_amount |
+----------+-----------+---------------+------------+--------------+
|     1001 |         1 | 김도윤(개명) | 2026-09-01 |        60000 |
|     1002 |         2 | 이서연        | 2026-09-02 |        56000 |
+----------+-----------+---------------+------------+--------------+
2 rows in set (0.00 sec)

mysql> SELECT history_id, member_id, old_grade, new_grade
    -> FROM 회원등급이력 ORDER BY history_id;
+------------+-----------+-----------+-----------+
| history_id | member_id | old_grade | new_grade |
+------------+-----------+-----------+-----------+
|          1 |         1 | GOLD      | VIP       |
+------------+-----------+-----------+-----------+
1 row in set (0.00 sec)

실무에서 자주 틀리는 것

파생 컬럼을 애플리케이션에만 맡긴다

트리거 없이 애플리케이션 코드에서 "주문상세 저장 후 total_amount도 갱신"을 두 단계로 나눠 처리하면, 두 번째 단계가 예외로 누락돼도 아무도 모른다.

-- 잘못된 예: 애플리케이션이 두 단계를 따로 실행
INSERT INTO 주문상세 VALUES (1003, 101, 1, 28000);
-- total_amount 갱신 코드가 배포 중 누락되면 여기서 끝나버린다
-- 고친 예: DB 트리거가 갱신을 책임진다(완성 코드의 trg_order_total_insert)
INSERT INTO 주문상세 VALUES (1003, 101, 1, 28000);
-- 트리거가 자동으로 주문.total_amount를 재계산한다

트리거 안에서 같은 테이블을 다시 갱신한다

주문상세 트리거 본문에서 주문상세 자신을 UPDATE하려 하면 MySQL 8이 오류를 낸다. 반정규화 컬럼은 반드시 다른 테이블(여기서는 주문)에 두고, 트리거도 그 테이블만 갱신해야 한다.

-- 잘못된 예: 트리거가 자기 자신의 테이블을 다시 갱신하려 한다
CREATE TRIGGER trg_bad
AFTER INSERT ON 주문상세
FOR EACH ROW
BEGIN
    UPDATE 주문상세 SET qty = qty WHERE order_id = NEW.order_id;
    -- Can't update table '주문상세' in stored function/trigger 오류 발생
END;
-- 고친 예: 파생 컬럼이 있는 다른 테이블(주문)만 갱신한다
CREATE TRIGGER trg_order_total_insert
AFTER INSERT ON 주문상세
FOR EACH ROW
BEGIN
    UPDATE 주문 SET total_amount = total_amount + NEW.qty * NEW.sale_price
     WHERE order_id = NEW.order_id;
END;

통계 확인 없이 자주 안 쓰는 컬럼까지 중복 저장한다

주문상세에 도서 카테고리까지 중복 저장해도, 카테고리별 집계가 한 달에 한 번뿐이라면 조인 비용보다 동기화 비용이 더 크다.

-- 잘못된 예: 조회 빈도를 확인하지 않고 무조건 중복 저장
CREATE TABLE 주문상세 (
    order_id     INT NOT NULL,
    book_id      INT NOT NULL,
    category     VARCHAR(30),  -- 도서 테이블과 중복, 갱신 이상만 유발
    qty          INT NOT NULL,
    sale_price   INT NOT NULL,
    PRIMARY KEY (order_id, book_id)
);
-- 고친 예: 필요할 때만 조인한다(조회가 드물면 중복 저장하지 않는다)
SELECT d.order_id, b.category, SUM(d.qty * d.sale_price) AS amount
  FROM 주문상세 d
  JOIN 도서 b ON b.book_id = d.book_id
 GROUP BY d.order_id, b.category;

이력 테이블의 PK를 원본과 똑같이 잡는다

이력 테이블 PK를 원본 테이블의 PK와 같게 두면 두 번째 변경부터 INSERT가 실패하거나, UPDATE로 덮어써서 이전 이력이 사라진다.

-- 잘못된 예: member_id를 PK로 잡아 두 번째 등급 변경이 막힌다
CREATE TABLE 회원등급이력_오류 (
    member_id  INT PRIMARY KEY,
    old_grade  VARCHAR(10),
    new_grade  VARCHAR(10) NOT NULL,
    changed_at DATETIME NOT NULL
);
-- 고친 예: 대리 키를 PK로 써서 변경마다 새 행을 쌓는다
CREATE TABLE 회원등급이력 (
    history_id INT AUTO_INCREMENT PRIMARY KEY,
    member_id  INT NOT NULL,
    old_grade  VARCHAR(10),
    new_grade  VARCHAR(10) NOT NULL,
    changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

한눈에 보기

반정규화 기법별 목적과 위험
기법목적무결성 유지대표 위험
중복 컬럼조인 없이 참조 데이터를 바로 노출원본 갱신 시 트리거·배치로 동시 갱신동기화를 놓치면 두 값이 어긋난다
파생 컬럼집계 결과를 미리 계산해 저장원본 행 증감마다 재계산대량 갱신 시 재계산 비용이 커진다
이력 테이블과거 시점 값을 append 방식으로 보존원본 갱신 시 이력 INSERT만 추가(수정 금지)PK를 원본과 같게 잡으면 이력이 쌓이지 못한다

연습 문제

  1. 아래 두 조회 패턴 중 반정규화를 검토할 대상을 고르고, 이유를 앞의 판단 절차 세 가지 기준으로 설명하시오. (A) 배송 담당자가 하루 한 번 미배송 주문을 모아 출력하는 화면 (B) 방문자가 도서 상세 페이지를 열 때마다 평균 평점과 리뷰 수를 함께 보여주는 화면
  2. 완성 코드의 trg_order_total_delete 트리거를 지우면 어떤 증상이 나타나는지 설명하고, 이미 어긋난 total_amount 값을 다시 맞추는 UPDATE 문을 작성하시오.
  3. 아래 이력 테이블 정의에서 잘못된 부분을 지적하시오.
    CREATE TABLE 주문상태이력 (
        order_id    INT PRIMARY KEY,
        status      VARCHAR(20) NOT NULL,
        changed_at  DATETIME NOT NULL
    );
  4. 회원 테이블에 파생 컬럼 total_order_count(누적 주문 건수)를 추가하고, 주문이 INSERT될 때마다 자동으로 1씩 증가하도록 트리거를 작성하시오.

정답과 해설

  1. (B)가 반정규화 후보다. 처리 빈도: 도서 상세 페이지는 방문자마다 반복 조회되어 빈도가 매우 높다. 대량 범위 처리: 평균 평점 계산은 리뷰 테이블을 도서별로 묶어 집계해야 해서 범위가 넓다. 통계: 리뷰는 하루 몇 건만 추가되는 반면 상세 페이지 조회는 수만 건이라 조회:갱신 비율이 크게 기운다. (A)는 하루 한 번뿐이라 조인 비용을 감수해도 된다.
  2. trg_order_total_delete가 없으면 주문상세에서 행을 지워도 주문.total_amount가 줄어들지 않아 실제 합계보다 큰 값이 남는다. 다음 SQL로 다시 맞춘다.
    UPDATE 주문 o
       SET total_amount = (
             SELECT COALESCE(SUM(d.qty * d.sale_price), 0)
               FROM 주문상세 d
              WHERE d.order_id = o.order_id
           );
  3. order_id를 PK로 잡아서 같은 주문의 상태가 두 번째로 바뀌면 INSERT가 PK 중복으로 실패하거나, UPDATE로 덮어쓰면 이전 상태 이력이 사라진다. history_id 같은 대리 키를 PK로 두고 order_id는 일반 컬럼(및 필요하면 FK)으로 둬야 상태가 바뀔 때마다 새 행을 쌓을 수 있다.
  4. 다음과 같이 작성한다.
    ALTER TABLE 회원 ADD COLUMN total_order_count INT NOT NULL DEFAULT 0;
    
    DELIMITER $$
    CREATE TRIGGER trg_member_order_count
    AFTER INSERT ON 주문
    FOR EACH ROW
    BEGIN
        UPDATE 회원
           SET total_order_count = total_order_count + 1
         WHERE member_id = NEW.member_id;
    END$$
    DELIMITER ;

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.