Skip to content

API 호출 제한과 과금, 그래서 캐시를 도입했다

외부 API를 붙이다 보면 캐시는 선택이 아니라 생존 전략이 된다. 공공데이터포털은 일일 호출 제한이 있고, OpenAI API는 호출할 때마다 과금이 쌓인다. Local Up을 개발하면서 이 두 가지를 동시에 쓰다 보니, 캐시를 어떻게 구성할지가 곧 서비스 안정성과 비용 구조를 좌우하는 문제로 다가왔다.

스프링 캐시 사용

Spring Boot에서는 @EnableCaching 한 줄이면 캐시가 활성화된다. 아무 설정도 하지 않으면 자동으로 ConcurrentMapCacheManager가 기본으로 동작한다. 이름 그대로 JVM 힙 위에 ConcurrentMap을 얹어 놓은 수준이라, 간단히 시작하기에는 충분해 보였다.

ConcurrentMapCacheManager의 주석처럼 문제는 세부 옵션을 설정할 수 없다는 점이다. 한 번 저장된 캐시는 계속 쌓이고, 수동으로 @CacheEvict를 호출하지 않는 한 메모리는 끝없이 늘어난다. 로컬에서 개발하면서도 이런 현상을 직접 확인할 수 있었다. 트래픽 패턴이 일정하지 않은 운영 환경에서는 이 방식이 위험하다. 순간적으로 요청이 몰리면 캐시는 급격히 커지고, 메모리 관리가 예측 불가능해지며, 심지어 GC(Garbage Collection) 부하나 OOM(Out Of Memory)으로 이어질 수 있다.

또한 단일 JVM에만 의존하기 때문에 다중 서버 환경에서는 서버마다 캐시가 따로 유지된다. Docker Compose로 2개 인스턴스를 띄워서 테스트해보니, 어떤 서버는 최신 데이터를 주고, 다른 서버는 구버전을 반환하는 불일치 문제가 발생했다. 개발 단계에서는 크게 문제가 되지 않지만, 운영 환경에서는 치명적이다.

결국 Redis

스프링은 CaffeineCacheManagerJCacheCacheManager라는 대안을 제공한다. Caffeine은 JVM 내부에서 동작하지만 TTL, TTI, 용량 제한 등 세밀한 만료 정책을 지원한다. JCache는 표준 API 기반으로, 구현체에 따라 분산 캐시까지 가능하다. 직접 적용해보지는 않았지만, ConcurrentMapCacheManager의 구조적 한계를 보완할 수 있는 길이 있다는 점에서 분명한 의미가 있었다.

그러나 운영 환경에서 안정성과 고가용성을 고려하면 결국 Redis로 귀결되었다. JVM 힙 기반 캐시만으로는 메모리 관리와 분산 환경 문제를 동시에 해결할 수 없었기 때문이다. Redis 또한 TTL, 영속화, 장애 복구 기능을 지원하고, 이미 인증/인가 처리에서 사용 중이기도 했다.

Spring Boot에서 Redis를 캐시 매니저로 붙이는 일은 간단했다.

kt
@Configuration
@EnableCaching
class CacheConfiguration {

    @Bean
    fun cacheManager(redisConnectionFactory: RedisConnectionFactory): RedisCacheManager {
        return RedisCacheManager.builder(redisConnectionFactory)
            .cacheDefaults(
                RedisCacheConfiguration.defaultCacheConfig()
                    .disableCachingNullValues()
            )
            .build()
    }
}
kt
@Service
class DomainService {
    @Cacheable(cacheNames = ["domain"], sync = true)
    fun findDomain(domainId: String): Domain {}
}

여기서 sync = true 옵션은 의외로 큰 차이를 만든다. 캐시가 비어 있는 순간 동시에 수십 개의 요청이 들어와도, 실제 외부 API 호출은 한 번만 일어난다. 로컬 테스트에서 동시 요청을 날려보니 이 설정의 효과를 확실히 확인할 수 있었다. 이를 통해 캐시 스탬피드(Cache Stampede) 문제를 단일 인스턴스 환경에서는 피할 수 있었다.

Cache Stampede(캐시 스탬피드)

캐시가 비어 있는 순간 다수의 요청이 동시에 들어와, 캐시 미스가 겹치면서 원본 API나 DB에 과부하를 일으키는 현상

다만 여러 인스턴스가 함께 동작하는 상황에서는 여전히 분산 락이 필요하다. Redisson 같은 라이브러리로 SETNX 락을 잡는 방식이 대표적이다. 이쯤 되면 단순히 @Cacheable 하나로는 충분하지 않고, 운영 환경 전반을 고려한 캐시 전략이 필요하다는 사실을 실감하게 된다.

장애 상황에 대한 고민

캐시 자체도 실패할 수 있다. Redis 장애, 네트워크 지연, 일시적 타임아웃 등 캐시 계층에서 문제가 나면, 캐시가 오히려 병목이 된다. 찾아본 방법 중에는 두 가지가 현실적이었다.

  1. 캐시 조회 실패 시 원본 로직을 실행하는 fallback
  2. 제한된 횟수로 재시도

아직 실제 장애를 겪은 적은 없지만, 단순히 캐시를 붙였다고 끝이 아니라 언제든 캐시 없이도 서비스가 돌아가야한다고 생각한다.

여기에 더해, 워밍업도 고려할 만했다. 배포 직후나 캐시 클리어 후에 핵심적인 키들을 미리 적재해 초기 미스 폭주를 줄이는 방식이다. 모든 키를 워밍업할 필요는 없고, 사용 빈도가 높은 데이터만 선별하는 편이 현실적이라고 본다.

캐시는 만능이 아니다

이번 경험 및 고민을 통해 배운 건 캐시가 결코 만능이 아니라는 점이다. 캐시를 붙였다고 해서 모든 문제가 해결되는 것은 아니었다. 실제로는 적중률이 얼마나 되는지, 히트/미스 비율이 어떤지를 꾸준히 확인해야 한다.

예를 들어, 공공데이터포털의 단기예보조회 API는 2시간마다 갱신된다. 그런데 TTL을 1분으로 잡으면 불필요한 캐시 미스가 폭증하고, API 호출량은 줄지 않는다. 반대로 TTL을 과도하게 길게 잡으면 최신성이 보장되지 않아 사용자 경험이 떨어진다. 결국 TTL의 기준은 외부 데이터의 갱신 주기와 사용자가 요구하는 최신성이라는 두 가지 축 사이에서 정해야 한다.

아직 운영에 나가지는 않았지만, 무엇을 모니터링해야 하는지 미리 찾아보니 몇 가지로 정리됐다.

  1. 적중률과 히트/미스 비율, 일정 수준 이하로 떨어지면 저장·조회 자체가 오버헤드가 될 수 있다.
  2. eviction의 형태와 빈도, TTL 만료로 나가는지, 메모리 정책 때문에 밀려나는지에 따라 해석이 달라진다.
  3. 응답 시간 분포, 평균값보다 피크 타임에서의 p95, p99가 실제 사용자 경험을 좌우한다.

p95, p99

전체 요청 중 95%, 99%가 그 이하에서 끝나는 응답 시간을 의미하는 지연 지표

수집 도구로는 Spring Boot Actuator와 Micrometer 조합이 많이 언급되었고, Redis 자체 모니터링과 함께 보는 것이 일반적인 접근법인 것 같다. 실제로 어떤 임계값에서 알람을 걸어야 하는지는 서비스별로 다를 테지만, 최소한 이런 지표들을 추적할 준비는 해둬야겠다고 생각했다.

캐시는 줄타기다.

비용, 성능, 일관성 사이에서 어디에 무게를 둘지를 매번 고민해야 한다. 그 고민이 빠지면 캐시는 금세 독이 된다. Redis를 붙이고 나서야 운영 환경에서 캐시를 쓸 만하다는 확신이 생겼지만, 동시에 캐시는 여기서 끝나는 것이 아니라, 꾸준히 모니터링하고 튜닝해야한다는 것을 깨달았다.

Released under the MIT License.