문제
- 장비 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의 조직 매핑이 서로 다를 수 있음 - 정상인 경우와 버그인 경우의 구분 필요
- 정리: 표현력을 얻은 대가로 정합성 책임이 애플리케이션으로 이전