읽을 때마다 5개 CTE를 풀스캔하고 있었다
- 대상 — 대시보드 조회에 쓰이는 엔티티 하나
- 매핑 —
@Subselect로 매핑된 뷰 - 구성 — CTE 5개를 엮은 집계 뷰
- MySQL의 동작 — 이 뷰를 실체화해두지 않음. 조회할 때마다 전체 재계산
-
즉 화면을 열 때마다 풀스캔
- 판단 기준: 데이터 변경은 드물고 읽기는 빈번 → 계산 비용을 읽기 시점에서 쓰기 시점으로 이동
- 결정: 물리 테이블(이하
_mat)로 물질화
선례가 있었다
앞서 다른 뷰 하나를 같은 방식으로 물질화한 선례가 있었다:
- 대상 — UNION ALL 뷰
- sentinel 컬럼 도입 —
OR IS NULL을IN(?, 0)으로 바꿔 인덱스 사용 - STORED GENERATED COLUMN 도입 —
ORDER BYfilesort 제거 -
동기화 — 도메인 이벤트 +
AFTER_COMMIT리스너 패턴 - 이번 작업: 그 패턴을 그대로 미러링
- 차이점: 롤아웃 방식 분할
2단계 롤아웃 — 섀도우 후 전환
PR 2개로 분할:
| 단계 | 내용 | 읽기 경로 |
|---|---|---|
| 1단계 (섀도우) |
_mat 테이블 생성 + 백필 + 동기화 |
뷰 그대로 |
| 2단계 (전환) | 엔티티 매핑 변경 | _mat |
1단계의 성격:
- 하는 일 —
_mat테이블 생성, 동기화 로직 부착 - 읽기 — 여전히 뷰
- 배포 시 사용자 체감 변화 — 없음
- 얻는 것 —
_mat이 뷰를 정확히 따라오는지 실데이터로 관찰
무중단 롤아웃의 핵심 — “언제든 되돌릴 수 있는 상태” 유지:
- 1단계만 배포된 상태 — 되돌릴 게 없음(읽기 경로 무변경)
- 2단계 — 애노테이션 2줄이라 되돌리기 즉각
테이블 생성 방식 — CTAS:
- 목적 — 뷰의 출력 타입과 collation을 그대로 상속
- 손으로 타입을 다시 적을 때의 위험 — collation 하나만 어긋나도 JOIN에서 1267(Illegal mix of collations)
- 실제 제약 — 소스 테이블의 키 컬럼이
utf8mb4_unicode_ci - 멱등성 —
DROP IF EXISTS선행
동기화 — AFTER_COMMIT 이벤트
- 요구사항: 원본 변경 시
_mat갱신 필수 - 방식: 도메인 이벤트 발행 →
@TransactionalEventListener(AFTER_COMMIT)으로 수신
@TransactionalEventListener(phase = AFTER_COMMIT)
public void on(LciaConnectionChangedEvent event) { ... }
설계 포인트 세 가지:
-
id 단위 delete-then-reinsert — 전체 재계산이 아니라 바뀐 id의 행만 삭제 후 뷰에서
INSERT ... SELECT로 재적재. 즉 증분 갱신 - AFTER_COMMIT인 이유 — 원본 트랜잭션 커밋 후에 동기화해야 뷰가 확정된 데이터를 읽음. 커밋 전 실행 시 아직 반영되지 않은 상태를 물질화
-
실패는 로그만 남김 — 동기화 실패가 원본 트랜잭션에 무영향.
_mat은 파생 데이터이므로 이것 때문에 사용자의 저장이 실패하면 안 됨. 대신 관리자용 전체 재동기화 엔드포인트로 복구 경로 확보
발행 지점을 어떻게 찾았나
가장 실수하기 쉬운 부분:
- 찾아야 하는 것 — 뷰의 소스 데이터를 바꾸는 코드가 어디인지
- 하나라도 빠뜨릴 때 — 그 경로로 저장했을 때
_mat이 조용히 낡음 - 참고 후보 — 기존
@CacheEvict가 붙어 있는 3곳 - 실제 조사 방법 — 그것만 믿지 않고 뷰 소스 테이블에 write 하는 코드를 전수 조사
-
결과 — 4곳
- 기존
@CacheEvict가 놓치고 있던 경로 1건 — 하필 I/O 편집·삭제라는 가장 빈번한 경로 - 캐시 무효화가 이미 걸린 곳을 정답으로 가정했다면 이 경로 전체 누락
여기서 얻은 건 이렇다. 기존 무효화 지점은 힌트이지 정답이 아니다. 캐시가 이미 어긋나 있었을 수도 있고(실제로 그랬다), 애초에 캐시 대상 범위가 달랐을 수도 있다. 소스에서 출발해 write 경로를 훑는 게 맞다.
전환은 2줄
2단계 커밋의 실질 변경 — 엔티티 애노테이션 2줄:
// @Subselect("... 5-CTE 집계 뷰 ...")
@Table(name = "tb_..._mat")
- QueryDSL·JPQL 호출부 — 한 줄도 미수정
- 엔티티 매핑만 바꾼 효과 — 모든 조회가 자동으로
_mat참조 -
@Immutable— 유지. 쓰기는 native 동기화 전용
물질화 작업을 이렇게 작은 전환으로 마무리할 수 있었던 건, 애초에 뷰가 엔티티로 매핑돼 있어서 호출부가 매핑 대상을 몰랐기 때문이다. 추상화가 값을 하는 지점이다.
검증
- SQL 로그에
_mat조회만 남고 뷰 참조 0건 -
_mat↔ 뷰 행 집합 패리티 636 = 636, 양쪽 unique 0 - 전 엔드포인트 200
행 수만 같은 게 아니라 양쪽 집합 차집합이 0이라는 걸 확인했다. 행 수 비교만으로는 “같은 개수의 다른 행”을 놓친다.
배포 순서 주의
- stg / prod 설정 —
ddl-auto: validate - 따라서
_mat테이블이 먼저 존재해야 부팅 성공 - 코드를 먼저 배포하면 — 엔티티가 가리키는 테이블이 없어 컨텍스트 기동 실패
- 조치 — DDL을 각 환경에 먼저 적용한 뒤 배포하도록 커밋 메시지에 명시
남는 교훈
읽기 비용을 쓰기 시점으로 옮기는 건 오래된 기법이지만, 실무에서 리스크는 성능이 아니라 정합성에 있다. _mat은 원본의 파생물이라 조용히 어긋날 수 있고, 어긋나도 에러가 나지 않는다. 그래서 이 작업의 대부분은 “빠르게 만드는 것”이 아니라 “어긋나지 않게 만드는 것”에 들어갔다 — 발행 지점 전수 조사, 패리티 검증, 재동기화 엔드포인트, 섀도우 단계.
특히 섀도우 단계를 따로 뗀 게 컸다. 물질화와 전환을 한 PR에 담았다면 “동기화가 맞는지”와 “읽기가 잘 도는지”를 동시에 검증해야 했을 텐데, 나눠두니 각 단계에서 확인할 게 하나씩이었다.