[8-2] Spring Boot Cache 설계: Caffeine + AFTER_COMMIT 이벤트 기반 — L1(Local)

minpractice_jhj·2025년 10월 30일

Side Projects

목록 보기
16/16
post-thumbnail

1부. 개요편 — 배경과 목표

1. 설계 배경

PinUp의 게시글/이미지 조회 요청은 읽기 비율이 90% 이상으로,

로컬 JVM 캐시만으로도 상당한 부하 절감이 가능했다.

단, 단일 정책(caffeine.spec)으로는 다음과 같은 문제점이 있었다.

문제설명
세분화 불가post:detail과 post:images의 TTL·Size 정책을 분리할 수 없음
테스트/운영 불투명성Cache 이름 오타나 미등록 캐시 접근 시 무시되어 버림
확장성 부족향후 L2(Redis) 확장 시 per-cache invalidation 불가능

이에 따라 Spring Cache의 기본 구성을 확장하여

“타입 안전한 정책 바인딩 + per-cache 주입 + 이벤트 기반 무효화”를 설계했다.

목표

  • L1(Local Cache)만으로도 높은 캐시 적중률 확보
  • 추후 L2(Redis)로 자연스럽게 확장 가능한 구조 설계

1-2. 참고 사례

기업/자료인사이트반영 포인트

| LG U+ Tech Blog
https://techblog.uplus.co.kr/%EB%A1%9C%EC%BB%AC-%EC%BA%90%EC%8B%9C-%EC%84%A0%ED%83%9D%ED%95%98%EA%B8%B0-e394202d5c87?utm_source=chatgpt.com | 글로벌(공유) 캐시 vs 로컬 캐시 비교, 분산 환경에서 캐시 무효화와 메시지 전파 고려를 강조. 전파 필요가 낮은 도메인은 로컬 캐시만으로도 충분. | 현재 L1(Caffeine) 단독 운영의 타당성 근거 확보. 다중 인스턴스 확장 시를 대비해 무효화 전파 표준(채널/포맷/멱등성) 설계 포함. |
| 카카오페이 Tech Blog
https://tech.kakaopay.com/post/local-caching-in-distributed-systems/?utm_source=chatgpt.com | 로컬 캐시 + Redis Pub/Sub로 최신화(무효화 전파), Eventual Consistency 수용. 도메인별로 로컬/Redis 역할 구분. | 현재의 이벤트 기반 정밀 무효화(AFTER_COMMIT), per-cache 정책(yml)과 철학 일치. 차기 릴리스에서 Pub/Sub 전파만 추가하면 구조 완성. |
| 00h0 티스토리 – 로컬 캐시/분산 정합성
https://00h0.tistory.com/112?utm_source=chatgpt.com | 분산 정합성 수단을 두 축(① TTL, ② invalidation message propagation)으로 제시. 커밋 이후 이벤트·Pub/Sub 전파 권장. | 이번 릴리스의 per-cache TTL로 bounded staleness 확보 + 이벤트로 postId 정밀 무효화는 권장과 동일 축. 차기에는 Pub/Sub 전파로 다중 인스턴스 일관성 강화. |

1-3. 설계 개요

캐시 설계 순서

단계목적산출물
①캐시 카탈로그 정의Cache Catalog(대상/TTL/무효화 기준)
②정책 저장소 분리spring.cache.app 블록(yml 기반)
③CacheManager 구성공통 동작 + per-cache 정책 반영
④무효화 이벤트 설계Post 수정/삭제 시 정밀 타격
⑤sync=true 대상 선정Hot Key 폭주 방지

2부. 설계편 — 정책·코드 구조

2-1) 설계 원칙(Design Checklist)

항목설계 의미(왜 필요한가)현재 릴리스(L1) 적용차기 릴리스(L2) 적용
yml per-cache 정책(spring.cache.app)TTL/size를 정책 레이어로 분리하여 운영 변경을 코드와 분리적용계속 사용
코드의 이름 상수(CacheNames)오타 방지, 로깅/알람 지표명 일관성 확보적용계속 사용
무효화 이벤트(PostCacheEvent + Listener)트랜잭션 커밋 이후 롤백-안전 정밀 무효화적용Pub/Sub 전파로 확장
sync=true 적용 기준동일 key 동시 미스(single-flight)로 스탬피드 방지핫키 한정 적용L2 Hit 충분 시 재평가/해제 가능
L1/L2 계층 구조Local→Redis 체인으로 확장 가능하게 설계설계만(유보)도입
Pub/Sub 무효화 표준다중 인스턴스 간 무효화 전파 표준화설계만(유보)도입(채널/포맷/멱등성)
관측성 SLO(4개)목표치를 수치화하여 운영 품질 관리L1용 SLO 적용L2 항목 추가 확장

관측성 SLO(초기안)

  • SLO-1: URI p95(상세 조회) 목표치 설정 및 추적
  • SLO-2: Cache Hit Rate(캐시별) 목표치 설정 및 추적
  • SLO-3: Evictions/Removals 이벤트율(업데이트 직후 스파이크 정상 여부)
  • SLO-4: Build/Load 비용 p95(미스 후 로드 시간)
  • L2 도입 시 추가: 전파 지연(무효화 Pub/Sub → 모든 인스턴스 적용 ≤ 1s), L2 RTT ≤ 20ms, L2 Hit ≥ 0.95

2-2) 정책 관리 구조 및 설계 비교

캐시 정책은 단일 전역 정책(spec)으로는 한계가 있으므로,

PinUp에서는 아래 구조로 설계했다.

spring:
  cache:
    type: caffeine
    strict: true
    app:
      defaults: { maximumSize: 40000, ttlSec: 300 }
      caches:
        "post:detail": { maximumSize: 20000, ttlSec: 300 }
        "post:images": { maximumSize: 20000, ttlSec: 1800 }

이 구조를 통해 각 캐시에 다른 TTL·Size 정책을 적용할 수 있으며,

운영 중에도 타입 안전하게 정책을 수정·확장할 수 있다.


왜 caffeine.spec 대신 spring.cache.app 구조인가

구분caffeine.specspring.cache.app
정의 위치spring.cache.caffeine.spec 문자열 기반spring.cache.app.* 계층형 구조
적용 범위전역 (모든 캐시 동일 정책)캐시별(per-cache) 세부 정책 가능
유형 안정성문자열 파싱 → 런타임 오류 가능타입 안전한 record 구조
운영 제어미등록 캐시 접근 시 무시strict=true 시 예외 발생
확장성TTL/Size 외 옵션 한정L2 확장, 통계, invalidation 연동 가능

요약하면,

caffeine.spec은 “설정” 중심,

spring.cache.app은 “설계” 중심이다.

전자는 단순히 Builder에 파라미터를 전달하는 문자열 기반 설정이며,

후자는 캐시 정책을 체계적으로 관리하기 위한 정책 저장소 계층이다.

PinUp에서는 운영 중 TTL 변경, 캐시 추가·삭제, 통계 설정 등을

명시적이고 안전하게 관리하기 위해 spring.cache.app 구조를 채택했다.


병행 불가능 및 실무 기준 정리

두 구조(caffeine.spec / app.*)는 병행이 불가능하다.

Spring Boot 자동 구성과 수동 CacheManager 빌더가 동시에 실행되어

Caffeine.newBuilder() 설정이 중복 적용되며,

maximumSize()나 expireAfterWrite() 중복 지정 시

IllegalStateException이 발생할 수 있다.

⚠️ Caffeine Builder는 불변(immutable) 객체이므로,

동일 속성 재설정은 런타임 예외로 이어진다.

상황권장 방식이유
단일 캐시, 전역 정책만 필요spring.cache.caffeine.spec간단하고 Spring Boot 자동 구성 그대로 사용 가능
여러 캐시, TTL·Size 분리 필요spring.cache.app.*캐시별 정책 세분화, 타입 안전 주입 가능
두 방법 병행사용 금지Builder 중복 호출로 충돌 발생 위험

2-3) 바인딩 활성화 (중요 한 줄)

AppCacheProps가 실제로 바인딩되도록 아래 둘 중 하나는 반드시 선언해야 한다.

이 줄이 없으면 props.caches()가 null → 부팅 시 NPE 발생.

// 방법 A
@Configuration
@EnableConfigurationProperties(AppCacheProps.class)
public class CacheConfig {}

// 방법 B
@SpringBootApplication
@ConfigurationPropertiesScan  // @ConfigurationProperties 자동 스캔
public class PinupApplication {}
// AppCacheProps — 정책 바인딩 DTO
@ConfigurationProperties(prefix = "spring.cache.app")
public record AppCacheProps(Defaults defaults, Map<String, Spec> caches) {
  public record Defaults(Long maximumSize, Integer ttlSec) {}
  public record Spec(Long maximumSize, Integer ttlSec) {}
}

역할

  • application.yml의 캐시 정책을 타입 안전하게 바인딩
  • “캐시 정책용 DTO”처럼 작동
  • 부팅 시 오타/누락 방지, 이후 CacheConfig에서 바로 props.caches() 접근 가능

2-4) CacheConfig — 정책 주입형 CacheManager

@Configuration
@EnableConfigurationProperties(AppCacheProps.class)
public class CacheConfig {
  @Bean
  public CacheManager cacheManager(AppCacheProps props) {
    return new CaffeineCacheManager() {
      { setAllowNullValues(false); setCacheNames(props.caches().keySet()); }
      @Override protected CaffeineCache createCaffeineCache(String name) {
        var b = Caffeine.newBuilder().recordStats();
        var d = props.defaults(); var s = props.caches().get(name);
        Long max = (s!=null&&s.maximumSize()!=null)? s.maximumSize() : (d!=null? d.maximumSize():null);
        Integer ttl= (s!=null&&s.ttlSec()!=null)? s.ttlSec() : (d!=null? d.ttlSec():null);
        if (max!=null) b = b.maximumSize(max);
        if (ttl!=null) b = b.expireAfterWrite(Duration.ofSeconds(ttl));
        return new CaffeineCache(name, b.build(), false);
      }
    };
  }
}

주의

  • maximumSize() / expireAfterWrite()는 한 번만 설정 가능
  • “한 트리”로 합쳐 쓰는 경우 특히 중복 호출 금지
  • 최종값 계산 → 1회 세팅 패턴 사용

2-5) 캐시 어노테이션 의미와 핵심 속성, SpEL 키

어노테이션 한 줄 정의

  • @Cacheable: 메서드 호출 전 캐시 조회, 미스면 실행 후 결과 저장
  • @CachePut: 메서드를 항상 실행하고 반환 결과로 캐시 갱신
  • @CacheEvict: 캐시 항목/전체 제거(운영 시 전체 제거는 신중)
  • @Caching: 한 메서드에 여러 캐시 어노테이션 조합

공통/핵심 속성(짧게)

  • value / cacheNames: 캐시 이름(키 스페이스)
  • key: SpEL로 키 지정(생략 시 파라미터 기반 SimpleKey 자동 생성)
  • condition: 호출 전 “캐싱 시도 여부”(입력 기준, #result 사용 불가)
  • unless: 호출 후 “저장 제외 조건”(반환값 기준, #result 사용 가능)
  • sync (Cacheable 전용): 동일 키 동시 미스 single-flight
  • beforeInvocation (Evict 전용): 실행 전 제거(예외 나도 제거)
  • allEntries (Evict 전용): 해당 캐시 전체 제거

SpEL 키 가이드(요약)

  • 정의: SpEL 키 = 캐시 키를 만드는 식이고, 입력(파라미터)로 충분히 구분되는, 불변·안정적인 키여야 함.
  • 컨텍스트: 파라미터 #p0/#id, 메타 #root.methodName/#root.args, 정적 호출 T(...). #result는 키에서 사용 불가(호출 전 평가) — unless 등 호출 후 평가에서만 사용.
  • 생략 시 기본: key가 없으면 파라미터로 SimpleKey 생성(0개=EMPTY, 1개=그 값, 2개+=합성).

2-6) 이벤트 기반 무효화의 장점 및 리스너

항목효과
응집도/확장성트랜잭션 로직과 캐시 무효화 분리
확장 용이성Redis Pub/Sub 등 L2 확장 시 그대로 재사용
정밀 타격postId 단위로 캐시 제거
@Component
@RequiredArgsConstructor
public class PostCacheInvalidationListener {

  private final CacheManager cacheManager;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void on(PostCacheEvent e) {
    switch (e.kind()) {
      case UPDATED -> {
        if (e.detailChanged()) cacheManager.getCache(CacheNames.POST_DETAIL).evict(e.postId());
        if (e.imagesChanged()) cacheManager.getCache(CacheNames.POST_IMAGES).evict(e.postId());
      }
      case DISABLED -> cacheManager.getCache(CacheNames.POST_DETAIL).evict(e.postId());
      case DELETED -> {
        cacheManager.getCache(CacheNames.POST_DETAIL).evict(e.postId());
        cacheManager.getCache(CacheNames.POST_IMAGES).evict(e.postId());
      }
    }
  }
}

3부. 적용편 — PinUp 실제 적용 및 검증

3-1) 이번 릴리스 적용 범위(L1: 로컬 캐시)

  • 정책 레이어: spring.cache.app(per-cache TTL/size) 적용, strict + setCacheNames로 비선언 캐시 사용 금지
  • 이름 상수: CacheNames.POST_DETAIL, CacheNames.POST_IMAGES 적용
  • 무효화: @TransactionalEventListener(AFTER_COMMIT) 기반 PostCacheEvent 정밀 타격(evict by postId)
  • sync=true: 핫키에 한정 적용(단일 키 동시 미스가 많을 때만)
  • 관측: recordStats() + Micrometer로 Hit/Miss/Put/Evict/Size, URI p95, 로드 시간 p95 대시보드 구성
  • 유보(차기): L1→L2 체인, Pub/Sub 전파 표준, L2용 SLO(전파 지연/RTT/Hit)

3-2) 도메인별 캐시 전략 (서비스 코드 예시 포함)

전략 표

적용 대상 구분캐시 이름Key 구조주요 호출자캐시 전략
게시글 상세post:detail#postIdPostService.getPostByIdsync=true, TTL 5분
게시글 이미지post:images#postIdPostImageService.findImagesByPostIdsync=true, TTL 30분
  • 핫경로: /api/post/{postId} 상세 조회
  • 비핫경로: /api/post/list/{storeId} 목록(캐시 미사용)
  • 상세·이미지 캐시는 둘 다 DB hit 1회 → 이후 수십 회 재사용 패턴

트랜잭션/이벤트 흐름

  1. Post 수정/삭제 요청
  2. @Transactional 내부에서 DB 조작(Post, PostImage)
  3. Commit 후 PostCacheEvent(updated or deleted) 발행
  4. PostCacheInvalidationListener.evict("post:detail", postId)
  5. PostCacheInvalidationListener.evict("post:images", postId)
  6. 다음 조회 시 캐시 miss → DB 재적재 → put → 최신화
  • AFTER_COMMIT 보장으로 롤백 시 캐시 무효화 없음
  • detail/images만 정밀 타격(evict)하여 불필요한 캐시 파괴 방지

서비스 코드 예시

sync=true 적용 기준(운영 가이드)

상황적용
로드 p95 ≥ 50–80ms 또는 동일 key 동시 미스 ≥ 3/초적용
로드가 매우 가볍고 키 분산이 넓음미적용

sync=true는 동일 키에 single-flight를 걸어 스탬피드 방지. 핫키 한정 적용이 정석.

단건 상세 캐시 (Hot key + 정합 민감)

@Cacheable(
  value = CacheNames.POST_DETAIL,
  key = "#p0",
  condition = "!#p1",  // isDeleted == false일 때만 캐시
  sync = true
)
public PostResponse getPostById(Long id, boolean isDeleted) {
  return postRepository.findByIdAndIsDeleted(id, isDeleted)
      .map(PostResponse::from)
      .orElseThrow(PostNotFoundException::new);
}

이미지 목록 캐시 (읽기 비용 큼 + 변경이 드문)

@Cacheable(
  value = CacheNames.POST_IMAGES,
  key = "#p0",
  sync = true
)
@Transactional(readOnly = true)
public List<PostImageResponse> findImagesByPostId(Long postId) {
  var postImages = postImageRepository.findByPostId(postId);
  return postImages.stream().map(PostImageResponse::from).collect(Collectors.toList());
}

위 예시는 정밀 무효화 이벤트(→ 3-2 본문, 2-6절)와 결합해 정합성 + 성능을 동시에 달성한다.


3-3) 동작 다이어그램

부팅 시 흐름

@SpringBootApplication
   |
   | @EnableConfigurationProperties(AppCacheProps.class)
   v
ConfigurationPropertiesBinder  →  AppCacheProps 생성
   |
   v
CacheConfig.cacheManager(props) → setCacheNames(...) → createCaffeineCache(...)

런타임 요청 흐름

[Client]→[Controller]→[Service @Cacheable]
                         |
                         | CacheInterceptor(AOP)
                         v
                   [Cache get(name,key)] ── hit? → return
                         |
                         └─ miss → [load] → [put] → return

쓰기/갱신

[Service] → (DB) → publish(PostCacheEvent) → Listener → cache.evict(name,key)

3-4) 운영 검증 결과

항목측정 결과관찰
Cache Hit Rate (post:detail)96.4%게시글 조회 대부분 캐시 적중
Cache Hit Rate (post:images)92.7%이미지 조회는 TTL 길어 안정적
Eviction Spike수정 이벤트 직후만 단발 상승부분 무효화 정상 작동
URI p95↓ 68ms → 21ms상세 페이지 평균 응답 3.2배 향상

StepWatch 로그 발췌

[PostService.updatePost]
  2.4.3 delete selected images          : 13 ms
  2.4.4 query remaining & maybe update  : 6 ms
  2.4.5 publish cache event             : 1 ms
→ PostCacheInvalidationListener.evict(detail, images)
  • 캐시 무효화 → 다음 조회 시 miss → put → 이후 hit으로 회복되는 순환이 로그로 확인됨

3-5) 트러블슈팅 요약

문제원인해결
수정 후 썸네일 갱신 지연썸네일 판단이 캐시 경로 호출DB 직조회로 변경
removals 메트릭 0@EventListener 사용으로 커밋 후 보장 없음@TransactionalEventListener(AFTER_COMMIT)로 교체
miss만 계속 발생@Cacheable 키 불일치#postId로 통일

운영 대시보드 예시(Prometheus + Grafana)

  • cache_hit_rate{cache="post:detail"}
  • cache_evictions_total{cache="post:detail"}
  • http_server_requests_seconds_bucket{uri="/api/post/{postId}"} (p95, p99)

대시보드의 “Cache Suite” 행에 URI p95와 Hit Rate를 나란히 배치해 즉시성 변화를 시각적으로 검증.

요약

  • PinUp의 L1 캐시 적용 후 DB read 부하 약 80% 감소, 상세 응답 3배 단축
  • 썸네일/이미지 일관성도 이벤트 기반 무효화로 안정 확보
  • 향후 Redis Pub/Sub 기반 L2 확장 시에도 그대로 재사용 가능

3-6)Spring Cache(Caffeine) 검증 시나리오 요약

1. 조회 기반 캐시 등록 + 캐시 히트 확인

(1) 첫 조회 — 캐시 미스(DB 조회 발생)

2025-10-30T00:00:05.301+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-1]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
2025-10-30T00:00:05.302+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-1]  k.c.p.posts.service.PostService          : 게시글 단건 요청: postId=10026, isDeleted=false
2025-10-30T00:00:05.309+09:00  INFO 43764 --- [pinup] [nio-8080-exec-1]  p6spy                                    : select p1_0.id,p1_0.title,p1_0.content from posts p1_0 where p1_0.id=10026 and p1_0.is_deleted=false

→ 의미: 캐시에 데이터가 없어서 DB 접근이 발생함.

→ 결과: @Cacheable 메서드 종료 시점에 post:detail 캐시에 저장됨.


(2) 두 번째 동일 조회 — 캐시 히트(DB 접근 없음)

2025-10-30T00:00:12.653+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-2]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
(이 시점에는 p6spy select 로그가 출력되지 않음)

→ 의미: DB 쿼리가 발생하지 않음 → 캐시에서 즉시 반환됨.

→ 결과: Caffeine 내부 hit 증가 (hitCount++).


2. 수정 → 이벤트 기반 무효화 → 재조회 시 캐시 재적재

(1) 게시글 수정 완료 → AFTER_COMMIT 이벤트 발생

2025-10-30T00:00:14.636+09:00  INFO 43764 --- [pinup] [nio-8080-exec-9]  k.c.p.custom.logging.StructuredLogger    : {"className":"PostApiController","methodName":"updatePost","targetId":"10026","details":{"kind":"UPDATED"},"message":"PostCacheEvent 수신"}
2025-10-30T00:00:14.637+09:00  INFO 43764 --- [pinup] [nio-8080-exec-9]  k.c.p.custom.logging.StructuredLogger    : {"className":"PostCacheInvalidationListener","methodName":"on","targetId":"10026","details":{"cache":"post:detail"},"message":"post:detail 캐시 무효화"}
2025-10-30T00:00:14.637+09:00  INFO 43764 --- [pinup] [nio-8080-exec-9]  k.c.p.custom.logging.StructuredLogger    : {"className":"PostCacheInvalidationListener","methodName":"on","targetId":"10026","details":{"cache":"post:images"},"message":"post:images 캐시 무효화"}

→ 의미: 트랜잭션 커밋 후 @TransactionalEventListener가 두 캐시를 모두 비움.

→ 결과: post:detail, post:images 캐시 evict 성공.


(2) 수정 직후 상세 재조회 — 캐시 미스(DB 재조회 발생)

2025-10-30T00:00:15.203+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-10]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
2025-10-30T00:00:15.203+09:00  INFO 43764 --- [pinup] [nio-8080-exec-10]  p6spy                                    : select p1_0.id,p1_0.title,p1_0.content from posts p1_0 where p1_0.id=10026 and p1_0.is_deleted=false

→ 의미: 캐시 무효화 직후이므로 DB 재조회 발생.

→ 결과: 조회 결과가 다시 post:detail 캐시에 저장됨 (재적재 완료).


(3) 그 다음 재조회 — 캐시 히트(DB 접근 없음)

2025-10-30T00:00:22.412+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-11]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
(이 시점에는 p6spy select 로그가 출력되지 않음)

→ 의미: DB 접근 없이 캐시에서 직접 반환됨.

→ 결과: 수정 이후에도 캐시 갱신 및 HIT 정상 작동 확인.


정리 요약

단계구분DB 쿼리(p6spy)캐시 상태
①첫 조회✅ 발생MISS → put
②두 번째 조회❌ 없음HIT
③수정 후 재조회✅ 발생MISS_AFTER_INVALIDATION
④최종 재조회❌ 없음HIT (정상 작동)

4. 마무리

  • 설정은 yml, 동작은 CacheManager, 무효화는 이벤트 기반
  • L1(Local Cache)만으로도 충분히 실효성 있고, 나중에 Redis Pub/Sub만 붙이면 L2로 자연 확장된다
  • recordStats() + Micrometer 기반 SLO 모니터링으로 hit/miss를 수치로 관리하며 캐시 효율을 안정적으로 추적하라

부록 A. ApplicationEventPublisher와 이벤트 발행 구조

A-1. ApplicationEventPublisher란

스프링 컨텍스트 내에서 이벤트를 발행하고, 등록된 리스너로 디스패치하는 컴포넌트.

서비스와 캐시 무효화 로직을 분리해 롤백-안전, 확장성을 확보한다.

@Service
@RequiredArgsConstructor
public class PostService {
    private final ApplicationEventPublisher events;

    @Transactional
    public void disable(Long postId) {
        postRepository.disable(postId);
        events.publishEvent(PostCacheEvent.disabled(postId)); // 커밋 후 리스너 실행
    }
}

A-2. @TransactionalEventListener(phase = AFTER_COMMIT)

publishEvent()는 즉시 발행되지만, @TransactionalEventListener는 트랜잭션 상태에 따라 실행 타이밍이 결정된다.

AFTER_COMMIT은 트랜잭션 커밋이 완료된 후 실행 → 롤백 시 캐시 무효화가 발생하지 않음.

A-3. 직접 evict 호출 대신 이벤트를 쓰는 이유

방식장점단점적합 상황
서비스 내부 직접 evict단순롤백 시 불일치, 강결합단순 서비스
이벤트 + AFTER_COMMIT롤백-안전, 확장성구조 약간 복잡권장 구조
AOP 후킹침투성 낮음트랜잭션 경계 제어 어려움로깅 등
TxSynchronization 직접 등록세밀한 제어가독성 저하특수 상황

A-4. PostCacheEvent 설계 포인트

public record PostCacheEvent(Long postId, Kind kind, boolean detailChanged, boolean imagesChanged) {
    public enum Kind { UPDATED, DISABLED, DELETED }
    public static PostCacheEvent updated(Long id, boolean detail, boolean images) { ... }
    public static PostCacheEvent disabled(Long id) { ... }
    public static PostCacheEvent deleted(Long id) { ... }
}
  • 명시적 팩토리 메서드로 의도 표현
  • Kind에 따라 detail/images 정밀 무효화 분기
  • 이벤트 확장 시 구조가 흔들리지 않음

A-5. 테스트 및 운영 팁

  • @TransactionalEventListener는 테스트에서 커밋이 없으면 실행되지 않음 → 실제 커밋이 발생하는 통합 테스트나 @Commit 사용 필요
  • 캐시 무효화 외의 부수 작업(알림, 감사 등)을 리스너로 확장 가능
  • 멀티 인스턴스 시에는 Redis Pub/Sub로 이벤트 전파 확장
  • cache_removals_total과 http_server_requests_seconds(p95)를 한 행에 배치해 효과 모니터링

A-6. 핵심 정리

왜 ApplicationEventPublisher인가?

  • 서비스는 “무엇이 바뀌었다”만 알리고, 커밋 이후 무효화는 리스너가 담당
  • 트랜잭션 롤백-안전, 관심사 분리, 확장성, Redis Pub/Sub 확장 용이
profile
운동처럼 개발도 작은 실천이 성장의 힘이 된다고 믿는 개발자 minpractice_jhj 기록

0개의 댓글