프로그램을 숨긴 뒤 상세에서는 사라지고 검색에는 남는다고 하자. 원본 변경 후 캐시를 삭제해도 다음 순서라면 오래된 값이 다시 들어갈 수 있다.
Reader: cache miss → 원본 version 41 조회
Writer: 원본 version 42 커밋 → 캐시 삭제
Reader: 늦게 끝난 조회 결과 version 41을 캐시에 저장
삭제가 실패한 것이 아니다. 삭제 전에 시작한 읽기가 뒤늦게 값을 채웠다. 반면 브라우저가 요청 자체를 보내지 않았다면 서버 캐시를 지워도 화면은 달라지지 않는다. 오래된 결과를 없애려면 어느 읽기 경로가 어떤 버전을 반환했는지부터 구분한다.
화면이 값을 얻는 경로부터 분리한다
원본 DB, Redis 응답 캐시, 검색 인덱스, 프론트 쿼리 캐시는 같은 이름의 데이터라도 다른 주체가 갱신한다. 상세는 원본, 검색은 인덱스, 최근 목록은 브라우저 캐시를 읽는 구조라면 화면별 결과가 달라질 수 있다.
진단에서는 요청 ID, 테넌트, 프로그램 ID, 원본·투영본 버전, 캐시 키·hit, 이벤트 생성·적용 시각을 연결한다. 이 항목은 설계할 관측 정보다. 특정 시스템에 모두 저장돼 있다고 가정하지 않고 현재 확보 가능한 값부터 비교한다.
TTL은 값의 보관 시간을 제한한다. 변경 직후의 최신성을 보장하거나 늦은 재삽입까지 막지는 않는다. 만료 때 이전 원본을 다시 채우면 불일치가 이어질 수도 있다. 허용 지연을 정한 뒤 무효화 실패와 오래된 값의 채움도 다룬다.
커밋 뒤 무효화와 전달 보장은 별개다
원본 변경이 롤백됐는데 캐시만 삭제되는 순서는 커밋 뒤 처리로 줄일 수 있다.
@Transactional
fun hideProgram(tenantId: Long, programId: Long) {
val program = repository.getByTenantAndId(tenantId, programId)
program.hide()
domainEvents.publish(ProgramVisibilityChanged(tenantId, programId))
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun evictProgramCache(event: ProgramVisibilityChanged) {
cache.evict(programKey(event.tenantId, event.programId))
searchCacheGeneration.bump(event.tenantId)
}
원본 커밋 후 상세 키를 지우고 검색 캐시 세대를 올리는 예시다. bump는 애플리케이션이 구현할 기능이며 Spring Cache가 접두어에 맞는 모든 키를 자동 삭제한다는 뜻이 아니다. Spring 트랜잭션 이벤트 문서는 리스너를 커밋 단계에 연결하는 동작을 설명한다.
메서드는 Spring 빈 내부에 있고 hideProgram은 트랜잭션 프록시를 거쳐 호출한다고 가정한다. 클래스 기반 프록시를 위해 @Service 빈에 kotlin-spring을 적용하거나 클래스와 메서드를 open으로 만든다. Spring의 Kotlin 지원 안내를 참고한다. domainEvents.publish는 Spring 애플리케이션 이벤트로 연결할 기능이며 이벤트의 tenantId·programId는 Kotlin 프로퍼티다.
프로세스가 커밋 직후 종료되면 이 리스너가 외부 시스템에 작업을 전달하지 못할 수 있다. 반드시 뒤따라야 할 갱신이라면 원본과 이벤트 기록을 같은 DB 트랜잭션에 넣는 outbox를 검토한다.
WITH changed AS (
UPDATE program
SET display_status = 'HIDDEN', version = version + 1
WHERE tenant_id = :tenantId AND id = :programId
AND version = :expectedVersion
RETURNING id, tenant_id, version
)
INSERT INTO outbox_event (
event_id, aggregate_type, aggregate_id, tenant_id,
aggregate_version, event_type, payload
)
SELECT :eventId, 'PROGRAM', id, tenant_id, version,
'PROGRAM_VISIBILITY_CHANGED', :payload
FROM changed
RETURNING aggregate_version;
변경이 0행이면 이벤트도 0행이다. 실제 변경이 만든 버전을 이벤트와 연결하고 호출자는 충돌·미존재를 구분한다. payload도 해당 변경 내용과 맞게 만들어야 한다. 이벤트를 읽어 외부로 전달하고 재시도하는 작업은 별도로 필요하다. Debezium Outbox Event Router는 이런 이벤트 전달 구조를 지원하는 한 선택지다.
늦은 채움을 거절할 기준을 남긴다
그림의 거절은 삭제만으로 생기지 않는다. 값과 별도로 최신 버전 표식이나 삭제 표식을 유지하고, 비교와 저장을 원자적으로 수행하는 설계가 필요하다. 표식도 값과 함께 지우면 늦은 version 41을 판단할 근거가 사라진다.
버전 표식의 생명주기는 오래 걸릴 수 있는 읽기와 재시도 범위를 고려한다. 다른 선택은 버전별 키와 현재 버전 포인터를 분리하거나, 변경 직후 중요한 조회를 원본으로 보내는 것이다. 약간의 불일치가 허용되면 짧은 TTL과 관측을 택할 수도 있다. 쓰기 빈도, 실패 복구와 허용 지연이 선택 기준이다.
검색 투영본에는 중복과 순서의 규칙이 필요하다
검색 인덱스는 여러 원본의 이름·가격·노출 상태를 모은 파생 모델일 수 있다. 삭제·숨김·복원뿐 아니라 소속이나 가격 정책 변경도 갱신 사건에 포함한다.
소비자는 이벤트 ID로 중복을 구분하고 원본 버전으로 적용 순서를 검사한다. 전체 snapshot 이벤트는 오래된 버전을 건너뛸 수 있다. 최신 원본을 읽는다면 그 실제 버전을 함께 저장한다. 반대로 금액 증가 같은 증분 이벤트는 중간 버전을 생략하면 결과가 달라지므로 연속성이나 재처리를 설계한다.
주기적 대조는 실시간 전달의 누락을 찾는다.
SELECT p.tenant_id, p.id, p.version, s.source_version
FROM program p
JOIN program_search_projection s
ON s.tenant_id = p.tenant_id AND s.program_id = p.id
WHERE p.version <> s.source_version
OR p.display_status <> s.display_status;
이 조회는 양쪽에 있는 행의 불일치를 찾는다. 한쪽에만 있는 누락·고아 행은 별도 대조가 필요하다. 이벤트 소비가 동작한다는 사실과 전체 투영본이 맞다는 사실을 나눠 확인한다.
응답을 바꾸는 차원을 키에 포함한다
같은 프로그램이라도 소속별 가격이 다르면 program:123 하나로 응답을 공유할 수 없다.
program-detail:v3:{tenantId}:{programId}:{audience}
program-search:v5:{tenantId}:{affiliationId}:{queryHash}:{page}
키의 범위는 인가 정책과 응답을 결정하는 차원을 반영한다. 구조 버전이나 세대를 사용할 때도 포인터 갱신과 오래된 키의 만료를 함께 정한다. 브라우저 캐시 키와 서버 키가 같은 변경을 어떻게 따라갈지도 확인한다.
이 설계의 확인 기준은 캐시 삭제 호출의 성공이 아니다. 변경 직후 상세·검색·사용자별 응답을 읽고, 이벤트 중복·역순·커밋 직후 중단에서도 의도한 버전으로 수렴하는지 검사한다. 다음에는 원본 커밋부터 각 화면 반영까지의 지연과 오래된 응답이 생긴 경로를 연결해 허용한 일관성 조건이 지켜지는지 판단한다.

