Spring Boot 환경에서 성능 최적화를 위해 @Cacheable 어노테이션을 중첩(상위 메서드 10분, 하위 메서드 60분)하여 사용하는 도중, 이론적으로는 최대 70분 이내에 데이터가 갱신되어야 함에도 불구하고 수일(약 3일) 동안 캐시가 만료되지 않고 유지되는 기이한 현상을 겪었습니다.

촘촘한 트래픽 환경 속에서 발생한 이 미스터리한 버그의 원인과 해결 과정을 공유합니다.


1. 문제 상황 (As-Is)

  • 기술 스택: Spring Boot + Caffeine Cache + SimpleCacheManager
  • 구조: 두 개의 빈(Bean)이 서로를 호출하는 중첩 구조
    • A 빈 (상위 메서드): @Cacheable(value = "cache:10m") ➡️ 10분 만료 설정
    • B 빈 (하위 메서드): @Cacheable(value = "cache:60m+evictByKafka") ➡️ 60분 만료 설정 (expireAfterWrite)
  • 현상: 이론상 두 캐시의 만료 주기가 얽히더라도 최대 70분 뒤에는 DB의 최신 데이터가 반영되어야 마땅하나, 실환경에서는 약 3일 동안 과거 데이터가 계속 반환되는 현상 발생.

2. 원인 추적 및 가설 검증

트래픽이 하루 종일 끊임없이 들어오는 환경이었기 때문에, 단순한 타이밍 어긋남(Shift)이나 트래픽 공백으로 인한 지연(Jump)으로는 ‘3일 체감 주기’를 설명할 수 없었습니다.

진짜 범인은 캐시 이름에 포함된 특수문자(+)Spring Cache의 독특한 예외 처리 메커니즘이 만든 합작품이었습니다.

🔍 핵심 원인: Spring Cache의 Fallback 메커니즘

Spring Cache는 개발자가 실수하더라도 애플리케이션이 크래시(Crash)나는 것을 방지하기 위해 매우 관대한 방어책을 사용합니다.

  1. 이름 매핑 실패: @Cacheable(value = "cache:60m+evictByKafka")에 사용된 + 기호는 SpEL(스프링 표현식) 파서나 내부 토큰 파싱 과정에서 연산자 등으로 오인될 여지가 큽니다. 이로 인해 SimpleCacheManager에 자바 코드로 정식 등록한 Caffeine 빈의 이름과 런타임 매핑이 어긋나게 됩니다.
  2. 무제한 임시 캐시 자동 생성: 스프링은 매핑되는 정식 Caffeine 캐시 빈을 찾지 못하면 에러를 던지지 않고, 그 자리에서 ConcurrentMapCache(Java HashMap 기반) 객체를 동적으로 새로 만들어 할당합니다.
  3. 만료 설정(TTL)의 증발: 이렇게 임시로 생성된 기본 캐시에는 우리가 Caffeine 빌더로 설정했던 expireAfterWrite(60, TimeUnit.MINUTES) 같은 만료 정책이 전혀 적용되지 않습니다. 즉, 만료 조건이 아예 없는 ‘영원한 좀비 캐시’가 되어버린 것입니다.

❓ 그런데 왜 ‘영원히’ 안 가고 ‘3일’ 뒤엔 갱신되었을까?

만약 리소스가 무한하고 외부 충격이 없다면 이 캐시는 영원히 유지되는 게 맞습니다. 하지만 실제 운영 환경에서는 다음 두 가지 요인 때문에 대략 3일 주기로 캐시가 깨지는 착시가 발생했습니다.

  • 서버의 정기 배포 및 롤링 재시작: 인메모리 캐시는 서버가 리부팅되면 완전히 초기화되므로 데이터가 갱신됩니다.
  • JVM Full GC 및 메모리 압박: 촘촘한 트래픽 속에서 3일쯤 지나 메모리 한계에 도달하면, JVM이 Old 영역을 청소(Garbage Collection)하는 과정에서 해당 임시 캐시 참조가 끊기거나 증발하여 순간적으로 DB 데이터를 다시 읽어오게 된 것입니다. 즉, 캐시가 정상 작동한 게 아니라 시스템 한계로 인해 강제 사망 후 부활했던 흔적이었습니다.

3. 해결 방법 (To-Be)

원인을 알면 해결은 허탈할 정도로 간단합니다. 스프링 내부 파서를 자극하고 오작동을 유발하는 수식 연산자 기호 +를 이름에서 완전히 제거하면 됩니다. (관례적으로 네임스페이스 구분자로 자주 쓰이는 콜론(:)이나 언더바(_)는 안전합니다.)

변경 전

// Cache Config
manager.setCaches(Arrays.asList(CaffeineCacheBuilder.build("cache:60m+evictByKafka", 60)));

// Service Logic
@Cacheable(value = "cache:60m+evictByKafka")

변경 후

// Cache Config
manager.setCaches(Arrays.asList(CaffeineCacheBuilder.build("cache:60m:evictByKafka", 60)));

// Service Logic
@Cacheable(value = "cache:60m:evictByKafka")

이름을 매칭 가능한 안전한 문자열로 변경해 주면, 스프링이 우리가 의도한 Caffeine 캐시 빈을 정확히 찾아내어 바인딩하므로 원래의 설계대로 최대 70분 이내에 칼같이 만료 및 갱신이 이루어집니다.

💡 요약 및 교훈 (Takeaway)

  • 캐시 이름에 특수문자(+, * 등) 사용 금지: 프레임워크 내부 파서가 연산자로 오인하여 매핑을 깨뜨릴 수 있습니다. 구분자가 필요하다면 이미 관례화된 콜론(:)이나 언더바(_)를 씁시다.

  • Spring Cache는 친절해서 위험하다: 존재하지 않거나 매핑이 깨진 캐시 이름을 적으면 에러 없이 만료 기간이 없는 임시 HashMap 캐시를 동적으로 생성해 버립니다. 눈에 보이는 에러가 없으니 더 무서운 버그였습니다.

  • 의심스러울 땐 트레이스 로그 활용: 캐시 동작이 의심스러울 땐 logging.level.org.springframework.cache=TRACE 설정을 켜서 프레임워크가 진짜 내가 만든 Caffeine 빈을 참조하고 있는지 꼭 확인해 봅시다.