Spring Cache (5/5)

이전 편: [Spring Cache] LoadingCache와 캐시 스탬피드

이 문서가 시리즈의 마지막 편입니다.

시리즈 안내

1. 캐시란 무엇인가

2. Spring Cache 추상화

3. Caffeine 설정과 활용

4. LoadingCache와 캐시 스탬피드

5. Actuator로 캐시 모니터링 ← 현재 문서

왜 모니터링이 필요한가

캐시를 적용했다고 끝이 아니다. "정말 잘 동작하고 있는가?"를 확인해야 한다.

  • 적중률이 10%면? 캐시가 거의 안 되고 있다. TTL이 너무 짧거나, 키 설계가 잘못됐을 수 있다.
  • 제거 횟수가 지나치게 많으면? maximumSize가 너무 작아서 데이터가 들어오자마자 밀려나고 있다.
  • 특정 캐시만 미스가 많으면? 그 캐시의 설정을 개별 튜닝해야 한다.

이런 판단을 하려면 숫자로 된 통계가 있어야 한다. Caffeine의 recordStats와 Spring Actuator를 연동하면, 캐시의 적중/미스/제거 횟수를 API로 조회할 수 있다.

Actuator가 노출하는 캐시 관련 엔드포인트와 메트릭을 한눈에 정리하면 이렇다.

mindmap root(캐시 메트릭) 캐시 목록 /actuator/caches 등록된 캐시 이름 확인 조회 통계 cache.gets result:hit result:miss 적중률 계산 제거 통계 cache.evictions TTL 만료 maximumSize 초과 크기 cache.size 현재 항목 수

설정

recordStats 활성화

Caffeine이 통계를 수집하려면 recordStats를 켜야 한다. 꺼져 있으면 Actuator에서 캐시 메트릭이 안 나온다.

application.yml로 설정하는 경우:

spring:
  cache:
    type: caffeine
    cache-names:
      - users
      - channels
      - notifications
    caffeine:
      spec: >
        maximumSize=100,
        expireAfterWrite=600s,
        recordStats

Java Config로 설정하는 경우:

Caffeine.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(600, TimeUnit.SECONDS)
    .recordStats()   // 이 한 줄 추가
    .build();
recordStats의 성능 비용

통계 수집은 약간의 오버헤드를 추가한다. 극도의 성능이 필요한 환경에서는 운영 서버에서 끄고 모니터링 서버에서만 켜는 경우도 있다. 하지만 대부분의 애플리케이션에서 체감할 수 있는 차이는 없다.

Actuator 엔드포인트 노출

Actuator의 캐시 관련 엔드포인트를 노출해야 한다.

management:
  endpoints:
    web:
      exposure:
        include: health,caches,metrics
  • /actuator/caches : 등록된 캐시 목록
  • /actuator/metrics/cache.gets : 캐시 조회 통계 (히트/미스)
  • /actuator/metrics/cache.evictions : 캐시 제거 통계
  • /actuator/metrics/cache.size : 현재 캐시 항목 수

캐시 목록 조회

curl http://localhost:8080/actuator/caches
{
  "cacheManagers": {
    "cacheManager": {
      "caches": {
        "users": { "target": "com.github.benmanes.caffeine.cache.BoundedLocalCache" },
        "channels": { "target": "com.github.benmanes.caffeine.cache.BoundedLocalCache" },
        "notifications": { "target": "com.github.benmanes.caffeine.cache.BoundedLocalCache" }
      }
    }
  }
}

등록된 캐시 이름과 구현체를 확인할 수 있다. @Cacheable("users")로 사용한 캐시가 실제로 등록되어 있는지 확인하는 용도다.

적중률 분석

가장 중요한 메트릭이다. 캐시 히트와 미스 횟수를 조회한다.

curl http://localhost:8080/actuator/metrics/cache.gets?tag=cache:users
{
  "name": "cache.gets",
  "measurements": [
    { "statistic": "COUNT", "value": 150.0 }
  ],
  "availableTags": [
    { "tag": "result", "values": ["hit", "miss"] },
    { "tag": "cache", "values": ["users", "channels", "notifications"] }
  ]
}

히트와 미스를 각각 조회하려면 result 태그를 추가한다.

# 캐시 히트 횟수
curl "http://localhost:8080/actuator/metrics/cache.gets?tag=cache:users&tag=result:hit"

# 캐시 미스 횟수
curl "http://localhost:8080/actuator/metrics/cache.gets?tag=cache:users&tag=result:miss"

적중률은 이렇게 계산한다.

적중률 = hit / (hit + miss)
적중률의미조치
90% 이상우수유지
70~90%양호TTL 또는 maximumSize 조정 검토
50% 이하문제키 설계, TTL, 무효화 로직 전면 점검
앱 시작 직후는 적중률이 낮다

서버가 막 시작되면 캐시가 비어있어서 모든 요청이 미스다. 시간이 지나면서 캐시가 채워지면 적중률이 올라간다. 워밍업 후의 적중률로 판단해야 한다.

제거 통계

캐시에서 항목이 제거된 횟수를 확인한다.

curl http://localhost:8080/actuator/metrics/cache.evictions?tag=cache:users
{
  "name": "cache.evictions",
  "measurements": [
    { "statistic": "COUNT", "value": 25.0 }
  ]
}

제거가 빈번하면 maximumSize가 너무 작은 것이다. 데이터가 들어오자마자 밀려나고, 밀려난 데이터가 다시 요청되면 DB를 조회해야 한다. maximumSize를 늘리면 적중률이 올라간다.

캐시 크기

현재 캐시에 저장된 항목 수를 확인한다.

curl http://localhost:8080/actuator/metrics/cache.size?tag=cache:users
{
  "name": "cache.size",
  "measurements": [
    { "statistic": "VALUE", "value": 42.0 }
  ]
}

maximumSize=100으로 설정했는데 실제 크기가 항상 100 근처면, 캐시가 가득 차서 제거가 빈번하다는 뜻이다. 크기를 늘려야 한다.

모니터링 흐름 요약

캐시를 적용하고 모니터링하는 전체 흐름을 정리하면 이렇다.

flowchart TD subgraph 초기설정["초기 설정"] A(["캐시 적용"]) --> B["recordStats 활성화"] --> C["Actuator 노출"] end subgraph 확인["모니터링"] D["적중률/제거 통계 확인"] --> E{"지표 양호?"} E -->|Yes| F(["현재 설정 유지"]) end subgraph 튜닝["튜닝"] G["원인 파악"] --> H["TTL 조정"] G --> I["크기 조정"] G --> J["키/무효화 재설계"] end C --> D E -->|No| G H & I & J --> D style A fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style E fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style F fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style G fill:#FFEBEE,stroke:#f44336,stroke-width:2px style 초기설정 fill:#f0f8f0,stroke:#4CAF50 style 확인 fill:#f0f4f8,stroke:#2196F3 style 튜닝 fill:#fff8f0,stroke:#FF9800

초기 설정 후 Actuator 지표를 주기적으로 확인하고, 지표가 저조하면 원인을 파악해서 튜닝한다. 튜닝 후 다시 지표를 확인하는 반복 사이클이 핵심이다. 원인에 따라 조치가 달라진다.

  • TTL 조정 : 적중률이 낮고 캐시 미스가 많으면 TTL이 너무 짧은 것이다.
  • 크기 조정 : cache.evictions가 많으면 maximumSize를 늘려야 한다.
  • 키/무효화 재설계 : 히트 수가 극히 낮으면 키가 너무 세분화되었거나, @CacheEvict가 너무 잦아서 저장하자마자 삭제되고 있다.

자주 하는 실수

메트릭이 안 나온다면 — recordStats 확인

Actuator에서 캐시 메트릭이 0으로 나오거나 아예 안 보이는 가장 흔한 원인이다. caffeine.specrecordStats를 빠뜨렸거나, Java Config에서 .recordStats()를 호출하지 않은 경우다. 설정을 넣었는데도 안 되면, 캐시 매니저가 두 개 등록되어 Actuator가 다른 쪽을 바라보고 있는 건 아닌지 확인하자.

[!BUG] 엔드포인트가 404라면 — exposure 설정 확인

management.endpoints.web.exposure.includecachesmetrics가 포함되어야 한다. Spring Boot 기본값은 health만 노출하므로 직접 추가해야 한다. 빠뜨리면 /actuator/metrics/cache.gets 자체가 404를 반환한다.

[!WARNING] 적중률은 워밍업 이후에 판단

서버가 막 뜨면 캐시가 비어있어서 모든 요청이 미스다. 이때 측정한 적중률은 의미가 없다. 충분한 트래픽이 흐른 뒤 (최소 TTL × 2 이상 경과) 측정해야 실제 운영 적중률에 가깝다.

면접 Q&A

캐시를 적용했는데 DB 부하가 안 줄었다면?

1. recordStats + Actuator로 적중률을 확인한다. 적중률이 낮으면 캐시가 제 역할을 못하고 있다.

2. TTL이 너무 짧지 않은지 확인. TTL 1초면 사실상 매번 DB를 조회하는 것과 같다.

3. @CacheEvict가 너무 빈번하게 호출되는지 확인. 쓰기가 읽기보다 많으면 캐시를 적용하기 부적합한 데이터다.

4. 캐시 키가 너무 세밀해서 같은 키 히트가 거의 없는 건 아닌지 확인.

[!QUESTION] 캐시 스탬피드가 뭐고, 어떻게 해결하나요?

TTL이 만료되는 순간 다수의 요청이 동시에 캐시 미스를 만나서 DB에 동일 쿼리가 쏟아지는 현상이다. 해결 방법:

1. LoadingCache : 같은 키에 대해 하나의 스레드만 DB를 조회하고 나머지는 대기

2. refreshAfterWrite : TTL 후에도 기존 값을 반환하면서 백그라운드로 갱신

3. 확률적 조기 갱신 : TTL 만료 전에 일정 확률로 미리 갱신 (고급 기법)

함정 : "TTL을 아주 길게 잡으면 되지 않나요?" → 데이터 신선도가 떨어진다. 스탬피드를 방지하면서도 적절한 TTL을 유지해야 한다.

[!QUESTION] 로컬 캐시(Caffeine)의 한계는?

서버가 여러 대일 때, 서버 A에서 데이터를 수정해도 서버 B의 로컬 캐시에는 반영되지 않는다. 사용자가 로드밸런서를 통해 다른 서버에 요청하면 오래된 데이터를 볼 수 있다. 이 문제를 해결하려면 Redis 같은 분산 캐시를 사용하거나, 캐시 무효화 이벤트를 서버 간에 전파해야 한다.