Database

정규화된 조인을 버리고 역정규화하기 — 백필 마이그레이션 포함

문제

  • 장비 A의 소속 조직 파악 — 두 단계 조인 필요
A ──(customer_sq)──> B ──(매핑 테이블)──> 조직
  • A는 조직을 직접 갖지 않음
  • A → 고객 B에 부착
  • B → 조직 매핑 테이블을 통해 조직에 연결
  • 정규화 관점에서는 정상 — 조직 정보가 한 곳에만 있고 중복 없음

그런데 요구사항이 바뀌었다. A가 B와 독립적으로 자기 조직을 가질 수 있어야 한다.

  • 아직 고객에 배정되지 않은 장비도 특정 조직이 관리
  • 고객과 다른 조직에 속하는 장비도 존재

  • 현재 모델로는 표현 불가
  • 이유: A의 조직이 B의 조직의 함수
  • customer_sq가 NULL인 A는 조직을 아예 가질 수 없음

결정

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

역정규화는 보통 성능 때문에 한다. 이번엔 아니었다.

  • 실제 동기: 모델이 표현할 수 없는 상태 발생
  • 조회 속도 향상: 부수 효과
  • 이 구분이 중요한 이유
  • 성능 문제 → 캐시·뷰 같은 다른 선택지 있음
  • 표현력 문제 → 스키마를 바꿔야만 해결

마이그레이션

  • 네 단계, 순서가 전부 의미를 가짐
-- 1. 컬럼 추가 (DEFAULT 0 = 미할당)
ALTER TABLE tb_device
    ADD COLUMN IF NOT EXISTS org_id BIGINT NOT NULL DEFAULT 0;

-- 2. FK 제약 추가
ALTER TABLE tb_device
    ADD CONSTRAINT fk_device_org_id
        FOREIGN KEY (org_id) REFERENCES tb_org(org_id);

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

-- 4. 기존 데이터 백필
UPDATE tb_device dv
SET org_id = COALESCE(
    (SELECT map.org_id
     FROM tb_customer_org_map map
     WHERE map.customer_sq = dv.customer_sq
       AND map.is_active = TRUE
     LIMIT 1),
    0
)
WHERE dv.is_deleted = FALSE
  AND dv.removal_date IS NULL
  AND dv.customer_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가 참조하는 조직 테이블에 실재해야 하는 마법의 행
  • 가독성 조건: “미할당”이라는 의미를 아는 사람만 코드 해독 가능
  • 대가: 스키마의 자기설명력 저하

커밋을 쪼갠 순서

  • 스키마 변경 후 애플리케이션 교체 작업은 잘게 분할
  • 같은 날 남은 커밋들
1. 컬럼 추가 + 백필 마이그레이션
2. 컬럼 추가에 따른 side effects 일괄 수정
3. 조회 쿼리를 매핑 조인에서 직접 참조로 교체
4. 조직 이동(쓰기) 로직을 새 컬럼 기준으로 변경

순서가 이렇게 된 이유:

스키마 우선.

  • 컬럼이 없으면 쿼리 변경 불가
  • 백필까지 끝난 상태면 컬럼만 추가된 시점에서도 시스템 정상 동작 — 아무도 그 컬럼을 안 읽으므로
  • 롤백 지점으로 안전

읽기를 쓰기보다 먼저 변경.

  • 3번 → 조회 쿼리를 조인에서 직접 참조로 교체
  • 백필 덕에 결과 동일
  • 이 시점에 잘못돼도 화면 값이 틀리는 정도

쓰기는 마지막에 변경.

  • 4번 → 조직 이동 로직을 새 컬럼 기준으로 변경
  • 이게 들어가는 순간부터 두 경로의 값이 갈라질 수 있음
  • 따라서 제일 나중

fix all side effects 커밋 1건.

  • 커밋 메시지 그대로 솔직한 이름
  • 컬럼 추가 → 그 테이블을 읽는 모든 곳이 영향
  • 훑어야 할 범위: DAO, 서비스, 모달 UI
  • 한 번에 다 찾아지지 않았다는 뜻
  • 역정규화의 실제 비용 지점

남는 교훈

역정규화의 비용은 컬럼 추가가 아니라 파급 범위다.

  • 마이그레이션 SQL: 31줄
  • 그 뒤의 쿼리·DAO·서비스·UI 교체가 훨씬 컸음
  • “side effects”라는 이름의 별도 커밋 발생

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

중복은 이제 관리 대상이다.

  • 예전: 조직 정보가 한 곳에만 있어 틀릴 수가 없었음
  • 지금: A의 org_id와 B의 조직 매핑이 서로 다를 수 있음
  • 정상인 경우와 버그인 경우의 구분 필요
  • 정리: 표현력을 얻은 대가로 정합성 책임이 애플리케이션으로 이전