실전 사례 · 멀티모듈 Spring Boot 서비스
cache-adapter
이 서비스는 포트-어댑터(헥사고날) 구조를 따른다. 비즈니스 로직은 application / domain 에 두고,
외부 기술(MySQL, Redis, Kafka, Feign 클라이언트)은 adapter/* 모듈이 감싼다.
캐시도 예외가 아니다 — 캐시 설정 전체가 adapter/cache-adapter 한 모듈에 격리되어 있고,
엔드포인트 모듈(mapper-api, profile-api)은 @EnableCacheAdapter로 이를 가져다 쓴다.
이 구조가 이번 PR에서 결정적이다. "Redis를 뺀다"는 말이 곧 "cache-adapter 하나를 고치면 전파된다"는 뜻이기 때문이다.
Spring은 @Cacheable, @CacheEvict 같은 어노테이션으로 캐시를 추상화한다.
실제 저장소(Redis, Caffeine, Ehcache…)는 CacheManager라는 인터페이스 뒤에 숨겨진다.
하나의 앱에 CacheManager가 여러 개일 수도 있는데, 그땐 @Primary가 붙은 것이 "기본값"이 된다.
제거 전 cache-adapter의 CacheConfig는 CacheManager를 두 개 만들고 있었다:
유지 Caffeine · 제거 대상 Redis
그리고 캐시를 쓰는 코드는 어노테이션 하나로 둘 중 하나를 골라 썼다:
@Cacheable(value = ["authorizeJwt"], key = "#saId", sync = true)
// ↑ cacheManager 를 안 적었다 → @Primary 인 caffeineCacheManager 로 간다 (로컬)
@Cacheable(value = ["userWithSubscriptionGroupV2"], key = "#userId", cacheManager = "redisCacheManager")
// ↑ cacheManager 를 명시했다 → redisCacheManager 로 간다 (분산)
@Cacheable이 어디로 가는지는 "cacheManager를 명시했는가?" 하나로 갈린다.
명시한 것만 Redis에 묶여 있었고, 명시하지 않은 것은 이미 Caffeine(로컬)으로 돌고 있었다.
운영 중인 관리형 Redis 인프라에 곧 호환성 깨기(breaking change) 변경이 예정되어 있었다. 팀은 그 변경에 맞춰 가는 대신, 더 근본적으로 "Redis에 접속하는 서비스를 전부 제거"하기로 했다. 이 서비스도 마찬가지다.
다행히 이 서비스에서 Redis는 cache-adapter 하나를 통해서만 쓰이고 있었고 — 그것도 TTL이 2초~1분으로 매우 짧은 캐시 몇 개뿐이었다. 그래서 "분산 캐시를 포기하고 로컬 캐시만 남긴 채 Redis를 완전히 떼어낸다"는 결정이 가능했다.
"Redis는 빼되, 로컬 캐시(Caffeine)는 그대로 산다."
깔끔하게 떼어낼 수 있었던 이유는, 애초에 두 CacheManager가 분리되어 있었기 때문이다.
사용자가 userId=42로 "가입된 구독 그룹"을 물어본다고 치자. 이 조회를 서비스 두 개가 각자 처리한다고 상상하자:
A. 토큰 조회 Caffeine (유지)
cacheManager 미명시 → @Primary
B. 구독그룹 조회 Redis (제거)
cacheManager="redisCacheManager"
이 PR이 하는 일은, B 그림의 Redis 박스를 통째로 지우는 것이다.
그런데 Redis 박스를 지우면 B의 @Cacheable은 가리킬 대상을 잃는다.
그래서 B는 어노테이션째 제거한다(캐싱을 포기하고 매번 DB/외부 API로 간다).
반면 A는 Redis와 무관했으므로 — A는 손대지 않는다.
그리고 "Redis 박스"의 실체가 곧 redisCacheManager() Bean이다. 이 Bean을 만드는 CacheConfig 코드와
spring-boot-starter-data-redis 의존성, Redis 접속 정보(spring.data.redis.*)까지 한 묶음으로 처리하면 끝난다.
19개 파일은 사실 4개의 개념적 그룹으로 나뉜다. 알파벳순이 아니라, 이해하는 순서대로 읽자.
이 그룹이 PR의 핵심이다. Redis라는 저장소 엔진을 담당하던 코드와 의존성을 제거한다.
| 파일 | 변경 |
|---|---|
| adapter/cache-adapter/build.gradle.kts | spring-boot-starter-data-redis 의존성 1줄 제거. spring-boot-starter-cache·caffeine은 유지. |
| ....../CacheConfig.kt | redisCacheManager() Bean + private 헬퍼 2개(buildCacheManager, customObjectMapper) 제거 → 파일 48줄 감소. Redis import 9종 전부 삭제. |
| ....../CacheProperties.kt | redisCaches: Map<String, Duration> 필드 제거. localCaches만 남김. |
| application-cache-adapter.yml | spring.data.redis.* 섹션과 app.cache.redisCaches 항목 6종 제거. |
결과적으로 CacheConfig는 Caffeine만 남는 깔끔한 한 개 Bean이 된다:
@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheProperties::class)
class CacheConfig(private val cacheProperties: CacheProperties) {
@Primary // 이제 유일한 CacheManager. @Primary 표시는 그대로 두었다.
@Bean
fun caffeineCacheManager(): CaffeineCacheManager? {
val cm = CaffeineCacheManager()
for ((key, value) in cacheProperties.localCaches.entries) {
cm.registerCustomCache(key, Caffeine.from(value).build())
}
return cm
}
}
@Primary를 남겼을까? 더는 경쟁 CacheManager가 없으니 사실 의미는 없다.
하지만 "기본 캐시 매니저 = Caffeine"이라는 의도를 명시적으로 남겨, 향후 다른 매니저가 추가돼도 기본이 바뀌지 않게 한다.
@Cacheable / @CacheEvict를 어노테이션째 뺀다
redisCacheManager Bean이 사라졌으니, 그 이름을 명시적으로 가리키던 어노테이션들은 컴파일조차 안 된다.
(Spring이 시작될 때 "cacheManager 'redisCacheManager'를 찾을 수 없다"며 폭주한다.)
그래서 비즈니스 로직(메서드 본문)은 그대로 두고 어노테이션만 제거한다.
| 파일 | 제거된 어노테이션 | 원래 TTL |
|---|---|---|
| ...v2/FeignNmimsV2Client.kt | @CacheConfig(class) + @Cacheable 1 + @CacheEvict 3 (nmimsProfiles) | 2s |
| ....../GetUserWithSubscriptionGroupService.kt | @Cacheable 3 (BySaid / ByUserId / ByProfileId) | 5s |
| ....../GetUserWithSubscriptionGroupV2Service.kt | @Cacheable 1 (userWithSubscriptionGroupV2) | 1m |
| ....../SubscriptionGroupV2Service.kt | @CacheEvict 2 (join / delete) | — |
주의: 캐싱을 "로컬로 바꾼" 게 아니라 "포기한" 것이다. TTL이 2초~1분이면 사실상 burst 트래픽 완충용이었고, 매번 조회해도 영향이 제한적이라는 판단이다. (이게 "Caffeine으로 이관"이 아니라 "제거"를 택한 이유.)
management.health.redis 치운다
각 모듈 application.yml에 있던 아래 설정을 뺀다. 의존성 자체가 없어졌으므로 이 설정도 의미 없는 데드 코드가 되기 때문이다.
management:
health:
db:
enabled: false
redis: # ← 이 두 줄 제거 (db는 유지)
enabled: false
대상: mapper-api, profile-api, stream-inner-member, stream-change-member-user,
stream-retry-das-connect, stream-member-to-ukids, stream-ukids-to-member, stream-outer-member.
TestApiApplication.kt / TestProfileApiApplication.kt (src/test) 에서
Testcontainers로 띄우던 redisContainer() Bean을 제거한다. 더는 Redis에 접속하는 코드가 없으니
컨테이너를 띄울 이유가 없다. PostgreSQL/Kafka 컨테이너는 그대로 둔다.
cacheManager를 명시하지 않은 @Cacheable은 전부 Caffeine 로컬 캐시로 계속 동작한다.
즉 아래는 Redis 제거와 무관하게 살아있다:
ApimTokenAdapter · NmimsTokenAdapter · GetGuestServiceProductVasMappingPersistenceAdapter · SubscriptionGroupPersistenceAdapter · SubscriptionPlanPersistenceAdapter이게 "Redis만 쏙 빼고 캐싱은 건드리지 않는다"는 직관이 코드에서 성립하는 이유다.
퀴즈를 통과하기 전까지 PR 리뷰/머지를 권하지 않는다(Erick Litt의 speed regulator). 정답을 클릭하면 바로 피드백이 나온다.
@Cacheable에 cacheManager를 명시하지 않으면, Spring은 어느 CacheManager를 사용할까?redisCacheManager를 쓰던 @Cacheable을 Caffeine으로 바꾸지 않고 아예 제거한 이유는?application.yml에서 management.health.redis.enabled: false를 제거한 이유는?참고: 원본 아이디어 — Geoffrey Litt, explain-diff gist / Understanding is the new bottleneck