API 응답 속도 개선하기 - cache

Sion·2026년 3월 13일

서론

앞선 글에서는 대량의 상품 데이터(테스트용 약 20만 건)를 조회할 때,
응답 시간을 개선하기 위한 첫 번째 방법으로 DB 인덱스 최적화를 진행했다.

상품 목록 조회 쿼리는 다음과 같은 조건을 가지고 있다.

브랜드 필터 (brand_id)
노출 여부 (visibility)
삭제 여부 (deleted_at)
좋아요 수 기준 정렬 (ORDER BY like_count DESC)
페이지네이션 (LIMIT)

이 중 좋아요 수(like_count)는 정렬에 사용되는 중요한 컬럼이다.
사용자는 보통 "인기 상품" 또는 "좋아요순 상품"을 통해 콘텐츠를 탐색하기 때문이다.

하지만 인덱스를 설계하는 과정에서 like_count는 최종 인덱스 후보에서 제외했다.
그 이유는 다음과 같다.

  • 좋아요 등록 / 취소는 사용자 행동에 따라 매우 자주 발생
  • like_count는 그때마다 UPDATE가 발생하는 컬럼
  • 인덱스가 존재하면 인덱스 유지 비용이 함께 증가

따라서, 조회 성능을 위한 인덱스를 추가하는 것이 오히려 쓰기 비용을 증가시킬 수 있다고 판단했다.

그렇다고 좋아요 순 상품 정렬 조회를 느린채로 둘 수는 없는 일이다.
사람들이 가장 보고 싶어하는 정보를 가장 느리게 제공할 수는 없으니까.
그럼 인덱스 없이 어떻게 빠르게 응답을 제공할 수 있을까?

이 문제를 해결하기 위해 캐시(Cache)를 도입해보기로 했다.

캐시 도입 전략과 선택 이유

좋아요 순 정렬이 중요한 사용자 경험임은 틀림없지만,
꼭 오차 없이 실시간으로 정렬되어야 하는가에 대해서는 의문이 들었다.

가격이나 재고처럼 즉각 반영이 필요한 데이터와 달리, 좋아요 수는 1~10분의 오차가 있어도 사용자 경험에 큰 영향이 없다고 판단했다. 완벽한 실시간성은 포기하되, 빠른 응답을 택하자.

좋아요 순 목록은 일정 시간 동안 캐시된 데이터를 사용하고,
캐시가 만료되거나 존재하지 않을 때만 DB를 조회하도록 설계했다.

왜 Redis인가 (Caffeine vs Redis)

Spring Cache 추상화를 사용하려면 실제 데이터를 저장할 캐시 저장소가 필요하다. 고민한 선택지는 두 가지였다.

  • Local Cache (Caffeine): JVM 메모리에 직접 저장, 네트워크 호출 없음
  • Remote Cache (Redis): 네트워크를 통해 접근하는 외부 캐시

Caffeine은 네트워크 호출이 없어 속도 자체는 더 빠르다. 하지만 멀티 인스턴스 환경에서는 서버마다 캐시가 별도로 존재하기 때문에 캐시 불일치 문제가 생긴다.

서버 A: 상품 수정 → 로컬 캐시 evict
서버 B: 로컬 캐시 살아있음 → TTL 만료 전까지 오래된 데이터 반환

이러한 문제를 해결하기 위해 실무에서는 로컬 캐시 TTL을 매우 짧게 설정하기도 하지만,
캐시 일관성을 보다 단순하게 유지할 수 있다는 점에서 Redis 선택이 더 합리적이라고 판단했다.

캐시 적용 방식

상품 목록 조회 시 brandId, 정렬 조건, 페이지 정보를 조합한 캐시 키를 사용했다.

 @Cacheable(
      cacheNames = "products",
      key = "#brandId + ':' + #sort + ':' + #pageable.pageNumber + ':' + #pageable.pageSize"
  )
  public PageResult<ProductInfo> getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) {
      ...
  }

예를 들어 brandId=null, sort=LATEST, page=0, size=10으로 조회하면 Redis에 다음 키로 저장된다.

products::null:LATEST:0:10

동작 방식은 단순하다.

  • 캐시에 데이터가 존재하면 → Redis에서 바로 응답
  • 캐시에 데이터가 없으면 → DB 조회 후 Redis에 저장
  • 최초 요청만 DB를 조회하고, 이후 동일한 조건의 요청은 대부분 캐시에서 처리된다.

인덱스 없이 캐시만으로 괜찮은가?

일부 구현에서는 캐시 미스 상황을 대비해 DB에도 인덱스를 추가하는 전략을 사용하기도 한다.

나는 그렇게 하지 않았다. 캐시 미스는 TTL 만료 후 해당 키에 대한 첫 번째 요청에서 발생한다. 즉, DB 조회 비용은 최초 1회에 집중되고 이후에는 캐시가 처리한다.

  • 트래픽이 적으면: 캐시 미스 자체가 드물어서 문제 없다
  • 트래픽이 많으면: 첫 번째 이후 전부 캐시 히트이므로 오히려 더 유리하다

좋아요 순 정렬처럼 동일한 요청이 반복되는 경우,
완벽한 실시간성보다 빠른 응답이 더 중요하므로 이 전략이 충분히 합리적이라고 판단했다.

개선된 응답시간 확인

Postman에서 동일한 요청을 반복 호출하여 캐시 적용 전후 응답 시간을 비교했다.

호출 순서상태상품 상세상품 목록
1st캐시 미스686ms770ms
2nd캐시 히트219ms13ms
3rd캐시 히트27ms10ms

실제로 응답시간이 대폭 감소하는걸 눈으로 확인하니 왠지모를 희열이 느껴졌다.
캐시의 힘... 잘 배웠습니다.

더 고민해볼 점 - Cache Stampede

인덱스 없이 캐싱에만 의존하는 방식에는 분명한 트레이드오프가 존재한다.

빠른 응답과 인덱스 유지 비용 절감을 얻는 대신,
좋아요 카운트가 실시간 반영되지 않아 TTL 만료 전까지는 이전 데이터가 유지될 수 있다.

한 가지 더 고려해야 할 문제가 있다.
TTL 만료 직후 수백 개의 요청이 동시에 들어온다면, 그 요청이 전부 DB로 쏠리는 Cache Stampede 현상이다.
현재는 테스트 규모이므로 허용하고 넘어갔지만, 트래픽이 커진다면 별도 대응(예: Mutex Lock, Probabilistic Early Expiration 등)이 필요하다.

마무리

고객이 더 원하는 정보는 뭘까?에 집중하고,
실시간 정확성을 포기하는 대신 빠른 응답을 선택했다.

고객의 관심사는
"많은 사람들에게 인기있는 상품이 무엇인 지?"
"대략 얼마나 많은 사람들이 관심을 갖고 있는지?"이지,
정확한 숫자가 궁금한 것이 아니라고 생각했기 때문이다.

그러나 완벽한 정합성이 필요한 가격/재고 같은 데이터라면?
이 부분도 많은 고민이 필요한 부분인 것 같다.

profile
한걸음씩 나아가는 개발자

0개의 댓글