Home > AI > DB-Query > 뷰를 물질화 테이블로 — 읽기 비용을 쓰기 시점으로 옮기기

뷰를 물질화 테이블로 — 읽기 비용을 쓰기 시점으로 옮기기
AI Claude Code 성능최적화 JPA 데이터베이스

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

대시보드 조회에 쓰이는 엔티티 하나가 @Subselect로 매핑된 뷰였다. CTE 5개를 엮은 집계 뷰인데, MySQL은 이 뷰를 실체화해두지 않으므로 조회할 때마다 전체를 다시 계산한다. 화면을 열 때마다 풀스캔이 도는 셈이다.

데이터가 자주 바뀌지 않는데 읽기는 빈번하다면, 계산 비용을 읽기 시점에서 쓰기 시점으로 옮기는 게 맞다. 물리 테이블(이하 _mat)로 물질화하기로 했다.

선례가 있었다

529ab30a에서 이미 다른 뷰 하나를 같은 방식으로 물질화한 적이 있다. UNION ALL 뷰를 _mat 테이블로 만들면서 sentinel 컬럼으로 OR IS NULLIN(?, 0)으로 바꿔 인덱스를 타게 하고, STORED GENERATED COLUMN으로 ORDER BY filesort를 제거했다. 동기화는 도메인 이벤트 + AFTER_COMMIT 리스너 패턴이었다.

이번 작업은 그 패턴을 그대로 미러링했다. 다만 롤아웃 방식을 나눴다는 게 달랐다.

2단계 롤아웃 — 섀도우 후 전환

PR을 두 개로 쪼갰다.

단계 커밋 내용 읽기 경로
1단계 (섀도우) b02f15bf _mat 테이블 생성 + 백필 + 동기화 뷰 그대로
2단계 (전환) 40f00801 엔티티 매핑 변경 _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가 놓치고 있던 경로가 하나 있었는데, 하필 I/O 편집·삭제라는 가장 빈번한 경로였다. 캐시 무효화가 이미 걸려 있는 곳을 정답으로 가정했다면 이 경로를 통째로 놓쳤을 것이다.

여기서 얻은 건 이렇다. 기존 무효화 지점은 힌트이지 정답이 아니다. 캐시가 이미 어긋나 있었을 수도 있고(실제로 그랬다), 애초에 캐시 대상 범위가 달랐을 수도 있다. 소스에서 출발해 write 경로를 훑는 게 맞다.

전환은 2줄

2단계 커밋(40f00801)의 실질 변경은 엔티티 애노테이션 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에 담았다면 “동기화가 맞는지”와 “읽기가 잘 도는지”를 동시에 검증해야 했을 텐데, 나눠두니 각 단계에서 확인할 게 하나씩이었다.