Post

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

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

캐시는 보통 성능 최적화 도구로 소개된다.
느린 외부 API 호출을 줄이고, 비싼 연산 결과를 재사용하고, 트래픽이 몰릴 때 응답 속도를 지키게 해준다.

여기까지만 보면 캐시는 언제나 좋은 선택처럼 보인다.
하지만 실제 운영 환경에서 로컬 캐시는 생각보다 자주 우리를 배신한다.

문제는 성능이 아니다.
문제는 일관성, 검증 가능성, 관측 가능성이다.

개발 환경에서는 잘 동작하던 캐시가 운영에서는 갑자기 이상해지는 이유도 여기에 있다.
단일 인스턴스에서는 단순한 메모리 최적화였던 것이, 멀티 인스턴스 환경으로 가는 순간 데이터 전달 경로 하나를 더 추가하는 구조가 되기 때문이다.

이번 글에서는 Spring 기반 서비스에서 로컬 캐시를 운영에 올릴 때 반복해서 마주친 몇 가지 함정을 정리해보려 한다.

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

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

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

하지만 캐시가 들어오면 중간에 하나의 질문이 생긴다.

지금 보고 있는 값이 원본인가, 캐시인가?

이 질문은 단순해 보이지만 운영에서는 꽤 무거운 의미를 갖는다.

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

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

@CacheEvict를 넣었는데도 값이 안 바뀌는 이유

Spring에서 캐시를 처음 붙일 때 가장 많이 하는 생각은 아마 이럴 것이다.

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

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

하지만 운영 환경이 멀티 인스턴스라면 얘기가 달라진다.

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

  • 인스턴스 A: 관리자 변경 요청을 처리하고 @CacheEvict 실행
  • 인스턴스 B: 일반 사용자 요청을 계속 처리
  • 인스턴스 C: 또 다른 사용자 요청을 처리

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

이 현상은 캐시가 고장 난 것이 아니라, 로컬 캐시가 원래 그런 구조이기 때문에 발생한다.

로컬 캐시는 각 인스턴스가 자기 메모리에 별도 사본을 가지고 있다.
그래서 evict는 캐시 정책이 아니라 사실상 “이 JVM 안에서만 유효한 정리 요청”에 가깝다.

이 지점에서 중요한 건 구현 방법이 아니라 기대치다.

  • 정말로 모든 인스턴스에서 즉시 같은 값이 보여야 하는가?
  • 아니면 일정 시간 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 차이를 감당할 수 있을 때
  • 응답 데이터가 작고 단순할 때
  • 운영자가 원본 검증 경로를 쉽게 확보할 수 있을 때
  • 캐시 miss 비용은 크지만, stale 비용은 상대적으로 작을 때
  • “즉시 전체 반영”이 비즈니스 요구사항이 아닐 때

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

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

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

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

캐시를 설계할 때 많은 팀이 가장 먼저 묻는 질문은 이것이다.

TTL을 얼마나 둘까?

하지만 실제로 더 먼저 물어야 할 질문은 이런 것들이다.

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

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

캐시는 시스템을 빠르게 만들 수 있다.
하지만 잘못 붙이면 운영자가 시스템을 확신하지 못하게 만든다.

내 경험에서는 후자가 훨씬 더 비싸다.

마무리

로컬 캐시는 아주 쉽게 붙일 수 있다.
그래서 더 위험하다.

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

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

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

로컬 캐시는 빠르다.
하지만 운영에서 정말 필요한 것은 종종 속도가 아니라 확신이다.

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