Post

로컬 캐시는 왜 운영에서 당신을 배신하는가

Spring 로컬 캐시를 운영에 올릴 때 자주 놓치는 무효화, 멀티 인스턴스 일관성, 검증 우회, Ehcache offheap 직렬화 함정을 정리한 글입니다.

관리자 화면에서 값을 수정했는데 사용자 요청에는 예전 데이터가 계속 내려온다.
@CacheEvict도 넣었고 TTL도 설정했는데, 어떤 인스턴스는 새 값을 보고 어떤 인스턴스는 여전히 오래된 값을 본다.

이런 순간이 오면 보통 캐시 설정부터 의심하게 된다.
그런데 운영에서 문제를 만드는 건 대개 설정 한 줄이 아니라, 로컬 캐시를 시스템이 무엇으로 믿고 있는지에 대한 기대치다.

캐시는 성능 도구다. 이 말은 맞다.
하지만 로컬 캐시를 운영에 올리는 순간 캐시는 성능 문제만이 아니라 일관성, 검증 가능성, 관측 가능성 문제로 바뀐다.

내가 반복해서 부딪힌 함정도 대체로 이 셋에서 나왔다.

  • @CacheEvict와 TTL만으로는 멀티 인스턴스 일관성을 해결할 수 없다
  • 운영에서는 완벽한 무효화보다 “이 요청 한 번만 원본을 보게 하는 장치”가 더 자주 필요하다
  • Ehcache offheap는 메모리 최적화 옵션이 아니라 직렬화 정책에 가깝다

이번 글에서는 Spring 기반 서비스에서 로컬 캐시를 운영에 올릴 때 왜 자꾸 확신을 잃게 되는지, 그리고 어떤 기준으로 다시 판단해야 하는지 정리해보려 한다.

캐시는 데이터를 저장하는 게 아니라, 데이터를 믿는 방식을 바꾼다

캐시를 붙이기 전에는 데이터 흐름이 단순하다.

  1. 요청이 들어온다
  2. 애플리케이션이 원본 데이터 소스에 접근한다
  3. 결과를 가공해 반환한다

그런데 캐시가 들어오면 질문 하나가 추가된다.

지금 보고 있는 값은 원본인가, 아니면 어떤 시점의 복사본인가?

개발 환경에서는 이 질문이 별문제처럼 보이지 않을 수 있다.
단일 인스턴스에서는 캐시가 단순한 메모리 최적화에 가깝기 때문이다.

하지만 운영에서 인스턴스가 늘어나는 순간 얘기가 달라진다.

  • 값이 언제 갱신됐는가
  • 지금 보는 값이 모든 인스턴스에서 같은가
  • 관리자가 방금 수정한 값이 왜 아직 반영되지 않는가
  • 장애 상황에서 현재 응답이 원본인지 캐시인지 어떻게 구분할 것인가

로컬 캐시는 응답 속도를 빠르게 만들지만, 동시에 시스템 해석 비용을 높인다.
특히 이 복잡성이 인스턴스 수만큼 복제된다는 점이 문제다.

@CacheEvict는 단일 인스턴스에서는 깔끔하지만, 멀티 인스턴스에서는 로컬 정리 요청에 가깝다

Spring에서 캐시를 처음 붙일 때 보통 이렇게 시작한다.

  • 조회에는 @Cacheable
  • 변경에는 @CacheEvict

단일 인스턴스에서는 이 패턴이 꽤 잘 맞는다.
같은 JVM 안에서 읽고, 같은 JVM 안에서 지우기 때문이다.

하지만 멀티 인스턴스 환경에서는 evict가 더 이상 시스템 전체의 무효화를 의미하지 않는다.

예를 들어 인스턴스가 세 대라고 해보자.

  • 인스턴스 A가 관리자 변경 요청을 처리하고 @CacheEvict를 실행한다
  • 인스턴스 B와 C는 계속 사용자 요청을 처리한다

이때 A의 로컬 캐시는 비워져도 B와 C의 캐시는 그대로 남아 있을 수 있다.
관리자는 “수정했다”고 생각하는데, 사용자는 여전히 이전 값을 받는다.

이건 캐시가 고장 난 게 아니다.
로컬 캐시가 원래 인스턴스별 사본을 들고 있기 때문에 생기는 정상적인 결과다.

그래서 운영에서는 구현보다 기대치를 먼저 정해야 한다.

  • 정말로 모든 인스턴스에서 즉시 같은 값이 보여야 하는가
  • 아니면 일정 시간 stale을 감수할 수 있는가

이 질문에 먼저 답하지 않으면, 나중에는 캐시 정책이 아니라 운영자가 시스템을 오해하게 된다.

운영에서 더 자주 필요했던 건 무효화보다 검증 우회였다

운영에서 실제로 더 자주 필요했던 것은 “캐시를 완전히 잘 지우는 방법”보다 지금 이 요청 한 번만 원본을 보게 하는 방법이었다.

이런 순간이 자주 생긴다.

  • 어드민에서 값을 수정한 뒤 바로 반영 여부를 확인하고 싶을 때
  • 배치가 데이터를 밀어넣은 직후 결과를 검증하고 싶을 때
  • 외부 ETL이나 시드 작업 이후 stale 응답인지 아닌지 구분해야 할 때

이때 TTL을 짧게 줄이는 방식은 보통 좋지 않다.

  • 평소 부하가 커진다
  • 캐시가 사실상 무의미해진다
  • 검증 문제를 시스템 전체 성능 문제로 바꿔버린다

중앙 캐시 전환이 정답일 수는 있다.
다만 그건 별도 마일스톤이 필요한 일이 많다.

그래서 꽤 실용적이었던 패턴이 호출 단위 우회 스위치였다.

1
2
3
4
5
6
@Cacheable(
    value = ["my_cache"],
    key = "#locale.toLanguageTag()",
    condition = "!#noCache"
)
fun getData(locale: Locale, noCache: Boolean = false): MyResponse { ... }

condition = "!#noCache"를 사용하면 noCache=true일 때 그 호출 한 번만 캐시 조회와 저장을 모두 건너뛸 수 있다.

이 방식이 좋았던 이유는 분명하다.

  • 기존 캐시 엔트리를 깨지 않는다
  • 운영자가 원본 데이터를 즉시 검증할 수 있다
  • 비운영 환경에서는 기본값을 우회로 두고 검증 편의를 높일 수 있다

운영에서 필요한 것은 항상 더 정교한 캐시 정책이 아니다.
종종 필요한 건 지금만 캐시를 믿지 않을 수 있는 장치다.

로컬 캐시가 위험한 순간은 실패가 실패처럼 보이지 않을 때다

캐시 문제가 까다로운 이유는 실패해도 종종 정상처럼 보이기 때문이다.

예를 들어 외부 API 결과를 prefetch해두는 구조를 생각해보자.

  • 외부 호출은 성공했다
  • 캐시에 쓰는 과정에서 문제가 생겼다
  • 요청은 다시 원본 경로를 타면서 어쨌든 응답은 나온다

사용자는 서비스를 쓸 수 있다.
모니터링에도 큰 에러가 안 잡힐 수 있다.
그런데 운영자는 “왜 계속 느리지?”, “왜 어떤 인스턴스만 이상하지?” 같은 현상만 보게 된다.

이런 종류의 문제는 특히 로컬 캐시에서 더 다루기 어렵다.
데이터 경로가 하나 더 생기는데, 그 경로가 눈에 잘 안 보이기 때문이다.

그래서 TTL과 key 설계만 볼 게 아니라 최소한 아래 질문을 함께 봐야 한다.

  • 캐시 write 실패를 로그로 충분히 남기는가
  • 현재 응답이 캐시 히트인지 알 수 있는가
  • 운영자가 원본을 직접 검증할 수 있는 우회 수단이 있는가
  • 멀티 인스턴스에서 일관성 기대치가 문서화되어 있는가

캐시를 붙이는 순간부터 시스템은 단순한 원본 조회 구조가 아니라, “원본 + 로컬 사본들”로 동작한다.
그 사실이 관측되지 않으면 운영자는 점점 확신을 잃는다.

Ehcache offheap는 메모리 최적화가 아니라 직렬화 정책이다

Ehcache를 쓸 때 자주 놓치는 함정이 하나 더 있다.
offheap를 켜는 순간 우리는 단순히 메모리를 아끼는 것이 아니라 직렬화 방식을 선택하게 된다.

예를 들어 이런 설정이 있다.

1
2
3
4
5
<cache alias="x">
  <resources>
    <offheap unit="MB">5</offheap>
  </resources>
</cache>

이 경우 캐시 객체는 네이티브 메모리에 저장되기 위해 직렬화되어야 한다.
즉 Kotlin data class나 Java DTO가 Serializable을 구현하지 않으면 첫 write 시점에 예외가 날 수 있다.

더 문제는 이 예외가 항상 서비스 전체 실패로 드러나지 않는다는 점이다.

  • 요청은 성공처럼 보일 수 있다
  • 캐시만 조용히 실패할 수 있다
  • WARN 로그만 남고 지나갈 수 있다

운영에서는 이런 종류의 실패가 더 위험하다.
바로 장애처럼 보이지 않기 때문이다.

그래서 offheap를 켤 때는 적어도 아래 질문을 먼저 던지는 편이 좋다.

  1. 이 캐시에 들어가는 타입은 전부 직렬화 가능한가
  2. nested object까지 포함해 일관되게 관리되는가
  3. 이 정도 크기의 데이터에 정말 offheap가 필요한가

작은 응답 캐시라면 heap entries 기반 캐시가 더 단순하고 안전한 경우가 많다.
반대로 멀티 인스턴스 일관성이 중요한 상황이라면, 로컬 offheap 최적화보다 중앙 캐시 전환이 더 자연스러운 선택일 수 있다.

로컬 캐시를 써도 되는 경우와 다시 의심해야 하는 경우

로컬 캐시가 항상 나쁘다는 뜻은 아니다.
여전히 좋은 도구다. 다만 시스템 기대치와 맞아야 한다.

내가 비교적 편하게 쓰는 조건은 이렇다.

  • 단일 인스턴스이거나 인스턴스 간 stale 차이를 감당할 수 있을 때
  • 응답 데이터가 작고 단순할 때
  • 운영자가 원본 검증 경로를 쉽게 확보할 수 있을 때
  • cache miss 비용은 크지만 stale 비용은 상대적으로 작을 때
  • “즉시 전체 반영”이 비즈니스 요구사항이 아닐 때

반대로 아래 조건이면 로컬 캐시를 다시 의심하는 편이 좋다.

  • 관리자 변경 후 즉시 반영이 중요할 때
  • 멀티 인스턴스 간 일관성이 중요할 때
  • 캐시 write 실패를 바로 감지하기 어려울 때
  • 외부 API, 배치, 시드 결과를 운영 중 자주 검증해야 할 때
  • 캐시 정책보다 운영 절차가 더 복잡해지기 시작할 때

이 경우는 대개 캐시 자체의 문제가 아니라, 로컬 캐시라는 선택이 시스템의 기대치와 맞지 않는 상태다.

캐시 설계에서 TTL보다 먼저 물어야 할 질문

캐시를 설계할 때 가장 먼저 나오는 질문은 보통 이것이다.

TTL을 얼마나 둘까?

그런데 운영 기준으로는 이 질문보다 먼저 정해야 할 것이 있다.

  • stale 데이터를 누가 얼마나 감당할 수 있는가
  • 데이터가 바뀌었을 때 누가 그것을 검증해야 하는가
  • 검증을 위해 캐시를 우회할 수 있는가
  • 캐시 일관성을 인스턴스 단위로 볼 것인가, 시스템 단위로 볼 것인가
  • 캐시 장애를 장애로 인식할 수 있는가

즉 캐시 설계는 성능 설계이기 전에 운영 설계다.

마무리

로컬 캐시는 붙이기 쉽다.
그래서 더 위험하다.

코드 몇 줄, 설정 몇 줄만으로 응답 속도는 눈에 띄게 좋아질 수 있다.
하지만 그 순간부터 시스템은 단순한 원본 조회 구조가 아니라, “원본 + 각 인스턴스의 기억”으로 동작하기 시작한다.

이 구조를 감당할 준비가 되어 있지 않다면, 캐시는 최적화가 아니라 오해의 출발점이 된다.

운영에서 중요한 것은 캐시를 얼마나 많이 쓰느냐가 아니다.
언제 캐시를 믿고, 언제 캐시를 의심해야 하는지를 시스템 차원에서 먼저 정해두는 것이다.

로컬 캐시는 빠르다.
하지만 운영에서 더 비싼 것은 느린 응답이 아니라, 시스템을 확신하지 못하는 상태다.

This post is licensed under CC BY 4.0 by the author.