Home > AI > DB-Query > 정규화된 조인을 버리고 역정규화하기 — 백필 마이그레이션 포함

정규화된 조인을 버리고 역정규화하기 — 백필 마이그레이션 포함
AI Claude Code PostgreSQL 데이터모델링

문제

장비 A가 어느 조직에 속하는지 알아내려면 두 단계 조인이 필요했다.

A ──(point_sq)──> B ──(매핑 테이블)──> 조직

A는 조직을 직접 갖지 않는다. A는 고객 B에 붙어 있고, B가 조직 매핑 테이블을 통해 조직에 연결된다. 정규화 관점에서는 정상이다. 조직 정보가 한 곳에만 있고 중복이 없다.

그런데 요구사항이 바뀌었다. A가 B와 독립적으로 자기 조직을 가질 수 있어야 한다. 아직 고객에 배정되지 않은 장비도 특정 조직이 관리해야 하고, 고객과 다른 조직에 속하는 장비도 있어야 한다.

현재 모델로는 표현이 불가능하다. A의 조직은 B의 조직의 함수이기 때문이다. point_sq가 NULL인 A는 조직을 아예 가질 수 없다.

결정

A 테이블에 org_id를 직접 추가하기로 했다. 조인 두 단계를 컬럼 하나로 대체하는 역정규화다.

역정규화는 보통 성능 때문에 한다. 이번엔 아니었다. 모델이 표현할 수 없는 상태가 생겼기 때문이다. 조회가 빨라지는 건 부수 효과다. 이 구분이 중요한 게, 성능 때문이라면 캐시나 뷰 같은 다른 선택지가 있지만 표현력 문제는 스키마를 바꿔야만 풀린다.

마이그레이션 V209

커밋 f55047c의 마이그레이션은 네 단계로 되어 있고, 순서가 전부 의미를 갖는다.

-- 1. 컬럼 추가 (DEFAULT 0 = 미할당)
ALTER TABLE client_meter
    ADD COLUMN IF NOT EXISTS org_id BIGINT NOT NULL DEFAULT 0;

-- 2. FK 제약 추가
ALTER TABLE client_meter
    ADD CONSTRAINT fk_client_meter_org_id
        FOREIGN KEY (org_id) REFERENCES admin_organization(org_id);

-- 3. 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_client_meter_org_id
    ON client_meter(org_id)
    WHERE is_deleted = FALSE AND removal_date IS NULL;

-- 4. 기존 데이터 백필
UPDATE client_meter mt
SET org_id = COALESCE(
    (SELECT ccom.org_id
     FROM client_customer_organization_mapping ccom
     WHERE ccom.point_sq = mt.point_sq
       AND ccom.is_active = TRUE
     LIMIT 1),
    0
)
WHERE mt.is_deleted = FALSE
  AND mt.removal_date IS NULL
  AND mt.point_sq IS NOT NULL;

백필이 하는 일은 기존 조인 경로를 그대로 따라가서 그 결과를 컬럼에 굳히는 것이다. 마이그레이션 직후 새 컬럼은 옛 조인과 정확히 같은 답을 준다. 그래서 쿼리를 하나씩 갈아끼우는 동안 신구 경로가 같은 값을 내고, 중간 상태에서도 화면이 깨지지 않는다.

NULL 대신 0 센티널

여기가 논쟁적인 부분이다. “조직 미할당”을 NULL이 아니라 0으로 표현했다.

방식 장점 단점
NULL 허용 의미가 정확하다. 미할당은 진짜로 값이 없는 상태다 모든 조회에 IS NULL 분기가 필요하고, 조인 시 행이 사라진다
0 센티널 NOT NULL + FK를 동시에 만족. 조건절이 단순해진다 0이 실재하지 않는 조직을 가리키는 마법의 값이 된다

0을 택한 이유는 NOT NULL과 FK를 동시에 걸고 싶었기 때문이다. NULL을 허용하면 FK는 걸 수 있지만(NULL은 FK 검사를 통과한다) NOT NULL은 포기해야 하고, 그러면 “값이 없는 경우”를 애플리케이션 코드가 매번 처리해야 한다. JSP + 바닐라 JS로 짜인 화면 수십 개에서 그 분기를 빠짐없이 넣는 것보다 0 하나로 통일하는 쪽이 실수가 적다고 봤다.

정직하게 말하면 이건 트레이드오프지 정답이 아니다. 0은 FK가 참조하는 조직 테이블에 실제로 존재해야 하는 마법의 행이고, “미할당”이라는 의미를 아는 사람만 코드를 제대로 읽는다. 스키마가 스스로 설명하는 정도가 낮아졌다.

커밋을 쪼갠 순서

스키마를 바꾼 뒤 애플리케이션을 갈아끼우는 작업은 잘게 쪼갰다. 같은 날 남은 커밋들이다.

f55047c  feat: add org_id column to client_meter with backfill migration
d0d9a91  feat: add org_id to client_meter and fix all side effects
67d1527  feat: replace meter org queries from ccom JOIN to mt.org_id direct
5cbd194  feat: change METER move logic to use client_meter.org_id directly

순서가 이렇게 된 데는 이유가 있다.

스키마가 먼저다. 컬럼이 없으면 쿼리를 바꿀 수 없다. 그리고 백필까지 끝난 상태라면 컬럼만 추가된 시점에서도 시스템은 완전히 정상 동작한다 — 아무도 그 컬럼을 안 읽으니까. 롤백 지점으로 안전하다.

읽기를 쓰기보다 먼저 바꿨다. 67d1527이 조회 쿼리를 조인에서 직접 참조로 교체한다. 백필 덕에 결과가 동일하므로 이 시점에 잘못돼도 화면 값이 틀리지 그 이상은 아니다.

쓰기를 마지막에 바꿨다. 5cbd194가 조직 이동 로직을 새 컬럼 기준으로 바꾼다. 이게 들어가는 순간부터 두 경로의 값이 갈라질 수 있으므로 제일 나중이어야 한다.

fix all side effects가 하나 있다. 커밋 메시지가 솔직하다. 컬럼을 추가하면 그 테이블을 읽는 모든 곳이 영향받는다. DAO, 서비스, 모달 UI까지 훑어야 하는데 한 번에 다 찾아지지 않았다는 뜻이다. 역정규화의 실제 비용이 여기에 있다.

남는 교훈

역정규화의 비용은 컬럼 추가가 아니라 파급 범위다. 마이그레이션 SQL은 31줄이다. 그 뒤에 붙은 쿼리·DAO·서비스·UI 교체가 훨씬 컸고, “side effects”라는 이름의 커밋이 따로 필요했다.

백필을 기존 조인 경로로 작성하면 전환이 안전해진다. 새 컬럼과 옛 경로가 같은 답을 내는 구간을 만들어두면, 그 안에서는 아무 순서로 갈아끼워도 시스템이 일관된다. 이 구간이 없으면 스키마 변경과 코드 변경을 원자적으로 배포해야 하는데, 그건 훨씬 위험하다.

중복은 이제 관리 대상이다. 예전엔 조직 정보가 한 곳에만 있어서 틀릴 수가 없었다. 지금은 A의 org_id와 B의 조직 매핑이 서로 다를 수 있고, 그게 정상인 경우와 버그인 경우를 구분해야 한다. 표현력을 얻은 대가로 정합성 책임을 애플리케이션이 떠안은 것이다.