실전 사례 · 멀티모듈 Spring Boot 서비스

Redis 의존성 제거 — 코드 이해 설명서

19개 파일 · +2 / −129 변경
헥사고날 아키텍처 기반 회원 매핑 서비스

목차
  1. Background — 이 시스템은 원래 어떻게 돌아가나
  2. Intuition — 핵심 직관 한 줄
  3. Code — 변경을 4그룹으로 읽기
  4. Quiz — 이해도 확인 (5문항)

1. Background — 이 시스템은 원래 어떻게 돌아가나

헥사고날 아키텍처와 cache-adapter

이 서비스는 포트-어댑터(헥사고날) 구조를 따른다. 비즈니스 로직은 application / domain 에 두고, 외부 기술(MySQL, Redis, Kafka, Feign 클라이언트)은 adapter/* 모듈이 감싼다. 캐시도 예외가 아니다 — 캐시 설정 전체가 adapter/cache-adapter 한 모듈에 격리되어 있고, 엔드포인트 모듈(mapper-api, profile-api)은 @EnableCacheAdapter로 이를 가져다 쓴다.

이 구조가 이번 PR에서 결정적이다. "Redis를 뺀다"는 말이 곧 "cache-adapter 하나를 고치면 전파된다"는 뜻이기 때문이다.

Spring Cache 추상화와 "두 개의 CacheManager"

Spring은 @Cacheable, @CacheEvict 같은 어노테이션으로 캐시를 추상화한다. 실제 저장소(Redis, Caffeine, Ehcache…)는 CacheManager라는 인터페이스 뒤에 숨겨진다. 하나의 앱에 CacheManager가 여러 개일 수도 있는데, 그땐 @Primary가 붙은 것이 "기본값"이 된다.

제거 전 cache-adapter의 CacheConfig는 CacheManager를 두 개 만들고 있었다:

caffeineCacheManager
@Primary · 로컬(JVM 메모리)
└─ 같은 앱 안에 공존 ─┘
redisCacheManager
분산 (관리형 Redis)

유지 Caffeine · 제거 대상 Redis

그리고 캐시를 쓰는 코드는 어노테이션 하나로 둘 중 하나를 골라 썼다:

@Cacheable(value = ["authorizeJwt"], key = "#saId", sync = true)
//  ↑ cacheManager 를 안 적었다 → @Primary 인 caffeineCacheManager 로 간다 (로컬)

@Cacheable(value = ["userWithSubscriptionGroupV2"], key = "#userId", cacheManager = "redisCacheManager")
//  ↑ cacheManager 를 명시했다 → redisCacheManager 로 간다 (분산)
이 PR 전체를 관통하는 관찰: @Cacheable이 어디로 가는지는 "cacheManager를 명시했는가?" 하나로 갈린다. 명시한 것만 Redis에 묶여 있었고, 명시하지 않은 것은 이미 Caffeine(로컬)으로 돌고 있었다.

왜 Redis를 아예 빼야 했나

운영 중인 관리형 Redis 인프라에 곧 호환성 깨기(breaking change) 변경이 예정되어 있었다. 팀은 그 변경에 맞춰 가는 대신, 더 근본적으로 "Redis에 접속하는 서비스를 전부 제거"하기로 했다. 이 서비스도 마찬가지다.

다행히 이 서비스에서 Redis는 cache-adapter 하나를 통해서만 쓰이고 있었고 — 그것도 TTL이 2초~1분으로 매우 짧은 캐시 몇 개뿐이었다. 그래서 "분산 캐시를 포기하고 로컬 캐시만 남긴 채 Redis를 완전히 떼어낸다"는 결정이 가능했다.

2. Intuition — 핵심 직관 한 줄

"Redis는 빼되, 로컬 캐시(Caffeine)는 그대로 산다."
깔끔하게 떼어낼 수 있었던 이유는, 애초에 두 CacheManager가 분리되어 있었기 때문이다.

장난감 예시 — 같은 요청, 두 갈래

사용자가 userId=42로 "가입된 구독 그룹"을 물어본다고 치자. 이 조회를 서비스 두 개가 각자 처리한다고 상상하자:

A. 토큰 조회 Caffeine (유지)

요청
→
Caffeine
→
응답

cacheManager 미명시 → @Primary

B. 구독그룹 조회 Redis (제거)

요청
→
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.*)까지 한 묶음으로 처리하면 끝난다.

3. Code — 변경을 4그룹으로 읽기

19개 파일은 사실 4개의 개념적 그룹으로 나뉜다. 알파벳순이 아니라, 이해하는 순서대로 읽자.

그룹 1 · cache-adapter에서 Redis "엔진"을 뽑아낸다

이 그룹이 PR의 핵심이다. Redis라는 저장소 엔진을 담당하던 코드와 의존성을 제거한다.

파일변경
adapter/cache-adapter/build.gradle.ktsspring-boot-starter-data-redis 의존성 1줄 제거. spring-boot-starter-cache·caffeine은 유지.
....../CacheConfig.ktredisCacheManager() Bean + private 헬퍼 2개(buildCacheManager, customObjectMapper) 제거 → 파일 48줄 감소. Redis import 9종 전부 삭제.
....../CacheProperties.ktredisCaches: Map<String, Duration> 필드 제거. localCaches만 남김.
application-cache-adapter.ymlspring.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"이라는 의도를 명시적으로 남겨, 향후 다른 매니저가 추가돼도 기본이 바뀌지 않게 한다.

그룹 2 · Redis로 가던 @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으로 이관"이 아니라 "제거"를 택한 이유.)

그룹 3 · 8개 모듈 yml에서 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.

그룹 4 · 테스트용 Redis 컨테이너를 뺀다

TestApiApplication.kt / TestProfileApiApplication.kt (src/test) 에서 Testcontainers로 띄우던 redisContainer() Bean을 제거한다. 더는 Redis에 접속하는 코드가 없으니 컨테이너를 띄울 이유가 없다. PostgreSQL/Kafka 컨테이너는 그대로 둔다.


유지한 것들 (이번 PR에서 손대지 않은 것)

cacheManager를 명시하지 않은 @Cacheable은 전부 Caffeine 로컬 캐시로 계속 동작한다. 즉 아래는 Redis 제거와 무관하게 살아있다:

이게 "Redis만 쏙 빼고 캐싱은 건드리지 않는다"는 직관이 코드에서 성립하는 이유다.

4. Quiz — 이해도 확인

퀴즈를 통과하기 전까지 PR 리뷰/머지를 권하지 않는다(Erick Litt의 speed regulator). 정답을 클릭하면 바로 피드백이 나온다.

Q1. @Cacheable에 cacheManager를 명시하지 않으면, Spring은 어느 CacheManager를 사용할까?

Q2. 이 PR이 redisCacheManager를 쓰던 @Cacheable을 Caffeine으로 바꾸지 않고 아예 제거한 이유는?

Q3. Caffeine 로컬 캐시가 이 PR에서 영향 없이 유지될 수 있었던 결정적 이유는?

Q4. 기존 Redis 캐시의 TTL이 2초~1분으로 짧았다는 점이, "제거" 결정에 어떤 역할을 했나?

Q5. 8개 모듈 application.yml에서 management.health.redis.enabled: false를 제거한 이유는?


참고: 원본 아이디어 — Geoffrey Litt, explain-diff gist / Understanding is the new bottleneck