이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 정규화는 왜 필요한가?
- 반정규화는 언제 성능 최적화가 아니라 장애 원인이 되는가?
- 백엔드 개발자는 중복 데이터를 어떤 기준으로 허용해야 하는가?
개요
정규화는 데이터를 잘게 쪼개기 위한 이론이 아니라 한 사실을 한곳에서 관리하기 위한 설계 원칙이다. 반정규화는 조회 성능과 단순화를 위해 의도적으로 중복을 도입하는 선택이다.
좋은 설계는 정규화를 기본으로 두고, 확인된 조회 병목이나 API 요구사항에 맞춰 반정규화를 제한적으로 사용한다.
원리
회원과 주문이 있다고 하자.
CREATE TABLE members (
id bigint PRIMARY KEY,
email varchar(255) NOT NULL,
name varchar(100) NOT NULL
);
CREATE TABLE orders (
id bigint PRIMARY KEY,
member_id bigint NOT NULL REFERENCES members(id),
total_price bigint NOT NULL
);주문마다 회원 이메일을 복사해 넣지 않으면 이메일 변경 시 한곳만 바꾸면 된다. 이것이 정규화의 장점이다.
갱신 이상
중복 데이터는 갱신 이상을 만든다.
CREATE TABLE orders (
id bigint PRIMARY KEY,
member_id bigint NOT NULL,
member_email varchar(255) NOT NULL,
total_price bigint NOT NULL
);회원 이메일이 바뀌었는데 과거 주문의 member_email은 어떻게 할까? 주문 당시 이메일을 보존해야 한다면 snapshot으로 의미가 있다. 현재 이메일을 보여주려는 목적이라면 불일치 문제가 된다.
중복이 나쁜 것이 아니라 중복의 의미가 불명확한 것이 나쁘다.
따라서 중복 컬럼에는 이름으로 의도를 드러내는 편이 좋다. member_email보다 member_email_snapshot은 “현재 회원 이메일이 아니라 주문 당시 값”이라는 의미를 더 잘 드러낸다.
반정규화가 적합한 경우
반정규화는 다음 경우에 검토한다.
- 목록 API에서 매번 무거운 조인이 발생한다.
- 주문 당시 상품명처럼 과거 snapshot이 필요하다.
- 집계 결과를 빠르게 보여줘야 한다.
- 원본에서 재계산 가능하고 불일치 복구 방법이 있다.
- 읽기 성능이 쓰기 복잡도 증가보다 중요하다.
예를 들어 주문 항목에는 주문 당시 상품명을 저장할 수 있다.
CREATE TABLE order_items (
id bigint PRIMARY KEY,
order_id bigint NOT NULL,
product_id bigint NOT NULL,
product_name_snapshot varchar(255) NOT NULL,
price_snapshot bigint NOT NULL
);이것은 단순 중복이 아니라 주문 당시 계약 기록이다.
반대로 목록 성능을 위해 현재 값을 복사한다면 동기화와 검증이 필요하다.
-- 원본과 projection 불일치 탐지 예시
SELECT p.member_id, p.member_name, m.name AS current_name
FROM member_search_projection p
JOIN members m ON m.id = p.member_id
WHERE p.member_name <> m.name;이 쿼리를 운영에서 자주 돌려야 할 정도라면 projection 갱신 방식, 이벤트 누락, 재생성 절차를 다시 봐야 한다.
반정규화의 비용
반정규화는 다음 책임을 만든다.
- 원본 변경 시 복사본 갱신
- 불일치 탐지와 복구
- 쓰기 로직 복잡도 증가
- 저장 공간 증가
- 마이그레이션 난이도 증가
이 책임을 감당할 수 없으면 반정규화는 성능 최적화가 아니라 데이터 오염의 출발점이 된다.
반정규화 도입은 보통 다음 순서가 안전하다.
- 원본 테이블과 기존 API를 유지한다.
- projection/snapshot 컬럼이나 테이블을 추가한다.
- backfill로 기존 데이터를 채운다.
- 새 쓰기 경로에서 동기화한다.
- 불일치 검증 쿼리와 재생성 절차를 둔다.
- 읽기 경로를 점진적으로 전환한다.
projection에는 소유권도 필요하다.
projection: member_search_projection
source of truth: members
owner write path: MemberProfileService.updateName
rebuild job: member-search-projection-rebuild
freshness target: 1분 이내
verification: 원본과 projection 불일치 count 알림이 정도가 없으면 장애 때 “이 값은 어디서 고쳐야 하지?”가 된다. 반정규화 데이터는 빠른 조회용 복사본인지, 법적/계약적 snapshot인지, 재계산 가능한 projection인지 이름과 운영 문서로 구분한다.
실전 팁
- 정규화는 기본값, 반정규화는 근거 있는 예외로 둔다.
- 중복 컬럼에는 snapshot인지 cache인지 projection인지 이름과 문서로 드러낸다.
- 반정규화 데이터는 재생성 가능한지 확인한다.
- 조회 병목이 확인되기 전에는 복잡한 중복 구조를 서두르지 않는다.
- 집계 테이블은 원본과 검증 쿼리를 함께 둔다.
- 반정규화 데이터에는 소유 write path와 재생성 방법을 명시한다.
- projection은 장애 후 다시 만들 수 있어야 운영자가 안심하고 정정할 수 있다.
- snapshot은 현재 값과 달라져도 되는 데이터인지 제품/도메인 언어로 설명한다.
- projection마다 source of truth, 소유 write path, freshness 목표, rebuild 방법을 문서화한다.
위험 신호!
- 화면에 보이는 그대로 테이블을 만든다.
- 같은 의미의 값이 여러 테이블에 있고 소유자가 없다.
- 반정규화 데이터 갱신 실패를 감지할 방법이 없다.
- 원본과 projection의 차이를 팀원이 설명하지 못한다.
- 성능 문제 근거 없이 중복 컬럼을 먼저 추가한다.
- projection 불일치가 발생했는데 원본에서 다시 만들 수 없다.
- snapshot이어야 할 값과 현재 값을 보여줘야 하는 값을 같은 컬럼에 섞는다.
- projection을 여러 서비스가 각자 갱신해 최종 소유자가 없다.
확인 질문
- 정규화가 필요한 이유는 무엇인가?
- 한 사실을 한곳에서 관리해 중복과 갱신 이상을 줄이고 데이터 의미를 명확히 하기 위해서다.
- 반정규화가 정당화되려면 무엇이 필요한가?
- 조회 병목이나 snapshot 요구 같은 명확한 이유, 동기화 방식, 불일치 복구 방법이 필요하다.
- 주문 당시 상품명을 주문 항목에 저장하는 것은 왜 의미 있는 중복일 수 있는가?
- 현재 상품명이 바뀌어도 주문 당시 계약 내용을 보존해야 하기 때문이다.
- 반정규화 projection을 운영 가능하게 만들기 위해 필요한 것은 무엇인가?
- 원본 데이터, 갱신 경로, 불일치 탐지 쿼리, backfill/재생성 방법, 읽기 전환 계획이 필요하다.
- projection에 소유권 문서가 필요한 이유는 무엇인가?
- 장애나 불일치가 생겼을 때 어떤 원본에서 누가 어떤 job으로 재생성하고 어느 정도 지연을 허용하는지 판단하기 위해서다.