
PinUp의 게시글/이미지 조회 요청은 읽기 비율이 90% 이상으로,
로컬 JVM 캐시만으로도 상당한 부하 절감이 가능했다.
단, 단일 정책(caffeine.spec)으로는 다음과 같은 문제점이 있었다.
| 문제 | 설명 |
|---|---|
| 세분화 불가 | post:detail과 post:images의 TTL·Size 정책을 분리할 수 없음 |
| 테스트/운영 불투명성 | Cache 이름 오타나 미등록 캐시 접근 시 무시되어 버림 |
| 확장성 부족 | 향후 L2(Redis) 확장 시 per-cache invalidation 불가능 |
이에 따라 Spring Cache의 기본 구성을 확장하여
“타입 안전한 정책 바인딩 + per-cache 주입 + 이벤트 기반 무효화”를 설계했다.
| 기업/자료 | 인사이트 | 반영 포인트 |
|---|
| 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 전파로 다중 인스턴스 일관성 강화. |
| 단계 | 목적 | 산출물 |
|---|---|---|
| ① | 캐시 카탈로그 정의 | Cache Catalog(대상/TTL/무효화 기준) |
| ② | 정책 저장소 분리 | spring.cache.app 블록(yml 기반) |
| ③ | CacheManager 구성 | 공통 동작 + per-cache 정책 반영 |
| ④ | 무효화 이벤트 설계 | Post 수정/삭제 시 정밀 타격 |
| ⑤ | sync=true 대상 선정 | Hot Key 폭주 방지 |
| 항목 | 설계 의미(왜 필요한가) | 현재 릴리스(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 항목 추가 확장 |
캐시 정책은 단일 전역 정책(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 |
|---|---|---|
| 정의 위치 | 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 중복 호출로 충돌 발생 위험 |
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의 캐시 정책을 타입 안전하게 바인딩CacheConfig에서 바로 props.caches() 접근 가능@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()는 한 번만 설정 가능SimpleKey 자동 생성)#result 사용 불가)#result 사용 가능)#p0/#id, 메타 #root.methodName/#root.args, 정적 호출 T(...). #result는 키에서 사용 불가(호출 전 평가) — unless 등 호출 후 평가에서만 사용.key가 없으면 파라미터로 SimpleKey 생성(0개=EMPTY, 1개=그 값, 2개+=합성).
| 항목 | 효과 |
|---|---|
| 응집도/확장성 | 트랜잭션 로직과 캐시 무효화 분리 |
| 확장 용이성 | 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());
}
}
}
}
spring.cache.app(per-cache TTL/size) 적용, strict + setCacheNames로 비선언 캐시 사용 금지CacheNames.POST_DETAIL, CacheNames.POST_IMAGES 적용@TransactionalEventListener(AFTER_COMMIT) 기반 PostCacheEvent 정밀 타격(evict by postId)recordStats() + Micrometer로 Hit/Miss/Put/Evict/Size, URI p95, 로드 시간 p95 대시보드 구성| 적용 대상 구분 | 캐시 이름 | Key 구조 | 주요 호출자 | 캐시 전략 |
|---|---|---|---|---|
| 게시글 상세 | post:detail | #postId | PostService.getPostById | sync=true, TTL 5분 |
| 게시글 이미지 | post:images | #postId | PostImageService.findImagesByPostId | sync=true, TTL 30분 |
/api/post/{postId} 상세 조회/api/post/list/{storeId} 목록(캐시 미사용)@Transactional 내부에서 DB 조작(Post, PostImage)PostCacheEvent(updated or deleted) 발행PostCacheInvalidationListener.evict("post:detail", postId)PostCacheInvalidationListener.evict("post:images", postId)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절)와 결합해 정합성 + 성능을 동시에 달성한다.
부팅 시 흐름
@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)
| 항목 | 측정 결과 | 관찰 |
|---|---|---|
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)
| 문제 | 원인 | 해결 |
|---|---|---|
| 수정 후 썸네일 갱신 지연 | 썸네일 판단이 캐시 경로 호출 | 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를 나란히 배치해 즉시성 변화를 시각적으로 검증.
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 캐시에 저장됨.
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++).
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 성공.
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 캐시에 저장됨 (재적재 완료).
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 (정상 작동) |
recordStats() + Micrometer 기반 SLO 모니터링으로 hit/miss를 수치로 관리하며 캐시 효율을 안정적으로 추적하라스프링 컨텍스트 내에서 이벤트를 발행하고, 등록된 리스너로 디스패치하는 컴포넌트.
서비스와 캐시 무효화 로직을 분리해 롤백-안전, 확장성을 확보한다.
@Service
@RequiredArgsConstructor
public class PostService {
private final ApplicationEventPublisher events;
@Transactional
public void disable(Long postId) {
postRepository.disable(postId);
events.publishEvent(PostCacheEvent.disabled(postId)); // 커밋 후 리스너 실행
}
}
@TransactionalEventListener(phase = AFTER_COMMIT)publishEvent()는 즉시 발행되지만, @TransactionalEventListener는 트랜잭션 상태에 따라 실행 타이밍이 결정된다.
AFTER_COMMIT은 트랜잭션 커밋이 완료된 후 실행 → 롤백 시 캐시 무효화가 발생하지 않음.
| 방식 | 장점 | 단점 | 적합 상황 |
|---|---|---|---|
| 서비스 내부 직접 evict | 단순 | 롤백 시 불일치, 강결합 | 단순 서비스 |
| 이벤트 + AFTER_COMMIT | 롤백-안전, 확장성 | 구조 약간 복잡 | 권장 구조 |
| AOP 후킹 | 침투성 낮음 | 트랜잭션 경계 제어 어려움 | 로깅 등 |
TxSynchronization 직접 등록 | 세밀한 제어 | 가독성 저하 | 특수 상황 |
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 정밀 무효화 분기@TransactionalEventListener는 테스트에서 커밋이 없으면 실행되지 않음 → 실제 커밋이 발생하는 통합 테스트나 @Commit 사용 필요cache_removals_total과 http_server_requests_seconds(p95)를 한 행에 배치해 효과 모니터링왜 ApplicationEventPublisher인가?