Database

뷰를 물질화 테이블로 — 읽기 비용을 쓰기 시점으로 옮기기

읽을 때마다 5개 CTE를 풀스캔하고 있었다

  • 대상 — 대시보드 조회에 쓰이는 엔티티 하나
  • 매핑 — @Subselect로 매핑된 뷰
  • 구성 — CTE 5개를 엮은 집계 뷰
  • MySQL의 동작 — 이 뷰를 실체화해두지 않음. 조회할 때마다 전체 재계산
  • 즉 화면을 열 때마다 풀스캔

  • 판단 기준: 데이터 변경은 드물고 읽기는 빈번 → 계산 비용을 읽기 시점에서 쓰기 시점으로 이동
  • 결정: 물리 테이블(이하 _mat)로 물질화

선례가 있었다

앞서 다른 뷰 하나를 같은 방식으로 물질화한 선례가 있었다:

  • 대상 — UNION ALL 뷰
  • sentinel 컬럼 도입 — OR IS NULLIN(?, 0)으로 바꿔 인덱스 사용
  • STORED GENERATED COLUMN 도입 — ORDER BY filesort 제거
  • 동기화 — 도메인 이벤트 + 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에 담았다면 “동기화가 맞는지”와 “읽기가 잘 도는지”를 동시에 검증해야 했을 텐데, 나눠두니 각 단계에서 확인할 게 하나씩이었다.