Skill

[캐시] 10만 건 돌려보니까 보이더라(인덱스, 캐시 실전 적용기)

소범범 2026. 3. 12. 22:22

 

 

인덱스, 캐시 그래서 언제 어떻게 사용하는 거야?

10만 건 상품 데이터로 EXPLAIN 찍고, 부하테스트 돌려보니까 보이더라

TL;DR
복합 인덱스 하나로 읽은 행이 5,000 → 20으로 줄고, 실행 시간이 76~231배 개선됐다.
Redis 캐시만 쓰면 네트워크 왕복이 병목. 로컬 캐시(Caffeine)를 앞에 두니 목록 조회 p95가 10.71ms → 7.08ms로 떨어졌다.
"캐시 쓰면 빨라진다"는 감이 아니라, EXPLAIN과 부하테스트로 숫자로 증명하는 게 중요했다.

1. 인덱스가 뭘까?

DB에서 데이터를 찾을 때, 인덱스가 없으면 테이블 전체를 처음부터 끝까지 훑어야 한다. 10만 건이면 10만 행을 다 읽는 거다. 이걸 Full Table Scan이라고 한다.

인덱스는 책의 목차 같은 거다. "brand_id가 1인 상품"을 찾을 때, 목차에서 바로 해당 페이지로 점프할 수 있다. 내부적으로는 B-Tree라는 자료구조로 데이터를 정렬 상태로 유지하며, O(log n) 탐색을 보장한다. 10만 건이어도 3~4번 비교면 원하는 데이터를 찾는다.

Full Table Scan (인덱스 없음)

1행 → 2행 → 3행 → ... → 99,999행 → 100,000행 (10만 행 전부 읽음)

 

B-Tree Index Scan (인덱스 있음)

Root → Branch → Leaf → 데이터 (3~4번 비교로 도착)

단일 인덱스 vs 복합 인덱스

단일 인덱스는 컬럼 하나에 거는 인덱스다.

sql
CREATE INDEX idx_products_brand_id ON products (brand_id);

WHERE brand_id = 1로 필터링하면 인덱스를 타서 빠르게 찾는다. 하지만 여기에 ORDER BY created_at DESC가 붙으면? 인덱스로 brand_id = 1인 행은 빠르게 찾지만, 정렬은 메모리에서 따로 해야 한다. 이게 filesort다.

복합 인덱스는 컬럼 여러 개를 조합한 인덱스다.

sql
CREATE INDEX idx_products_brand_created ON products (brand_id, created_at DESC);

brand_id로 필터링하고, 그 안에서 created_at 순으로 이미 정렬되어 있다. ORDER BY created_at DESC를 추가해도 별도 정렬이 필요 없다.

내 프로젝트에서는 상품 목록에 3가지 정렬이 있었다.

kotlin — ProductSortType.kt
enum class ProductSortType(
    val fieldName: String,
    val direction: Sort.Direction,
) {
    LATEST("createdAt", DESC),
    PRICE_ASC("price", ASC),
    LIKES_DESC("likeCount", DESC),
}

각 정렬에 맞는 복합 인덱스를 3개 만들었다.

kotlin — Product.kt
@Table(
    name = "products",
    indexes = [
        Index(name = "idx_products_brand_created", columnList = "brand_id, created_at DESC"),
        Index(name = "idx_products_brand_price", columnList = "brand_id, price ASC"),
        Index(name = "idx_products_brand_like_count", columnList = "brand_id, like_count DESC"),
    ],
)

2. 캐시는 뭘까?

캐시의 본질은 단순하다. 자주 읽는 데이터를 빠른 저장소에 미리 넣어두는 것. "빠른 Map<String, String>"이라고 생각하면 감이 잡힌다.

캐시의 3가지 구성요소

구성요소 핵심 질문 설명
삽입 언제 넣을까? Cache-Aside: 조회 시 없으면 DB에서 가져와 저장
TTL 얼마나 둘까? 짧으면 정합성 좋고 성능 낮음, 길면 정합성 떨어지고 성능 좋음
Evict 언제 강제로 뺄까? 데이터 변경 시 TTL 전에 강제 삭제

TTL은 보험, Evict는 능동적 대응. 둘 다 있어야 안전하다.


3. 인덱스는 언제 써야할까?

"인덱스를 걸면 빨라진다"는 알겠는데, 진짜 얼마나 차이가 나는 걸까? 10만 건을 넣고 EXPLAIN ANALYZE를 돌려봤다. 브랜드 20개, 브랜드당 약 5,000건.

EXPLAIN으로 증명하기

sql
SELECT * FROM products
WHERE brand_id = 1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 20;

단일 인덱스(brand_id)만 있을 때:

Index lookup using idx_products_brand_id (brand_id=1)
→ Filter → Sort → Limit
→ 실제 읽은 행: 5,000행 | 실행 시간: 7.58ms

5,000건을 전부 읽고, 메모리에서 정렬한 다음, 앞에서 20개를 잘라냈다. LIMIT 20인데 5,000건을 다 읽은 거다.

단일 인덱스 실행 흐름

1. brand_id = 1 인덱스로 5,000행 찾음
2. 5,000행을 메모리에 올려서 created_at DESC 정렬 (filesort)
3. 정렬된 결과에서 앞 20개를 잘라냄 (LIMIT)

복합 인덱스(brand_id, created_at DESC)를 추가하니:

Index lookup using idx_products_brand_created (brand_id=1)
→ Filter → Limit
→ 실제 읽은 행: 20행 | 실행 시간: 0.08ms

인덱스 안에 이미 정렬이 되어 있으니까, 앞에서 20개만 읽고 멈췄다. Early Termination. 이게 복합 인덱스의 진짜 이점이다.

복합 인덱스 실행 흐름

1. (brand_id, created_at DESC) 인덱스에서 brand_id = 1 위치로 점프
2. 이미 created_at DESC로 정렬되어 있으므로 앞에서 20개만 읽고 멈춤 (Early Termination)
3. filesort 없음, 5,000행 → 20행만 읽음

4개 쿼리 전부 비교

쿼리 인덱스 없음 (행 / 시간) 인덱스 있음 (행 / 시간) 개선
브랜드별 최신순 5,000행 / 7.58ms 20행 / 0.08ms 95배
브랜드별 가격순 5,000행 / 13.9ms 20행 / 0.06ms 231배
브랜드별 좋아요순 5,000행 / 5.34ms 20행 / 0.07ms 76배
전체 좋아요순 100,000행 / 40.1ms 100,000행 / 44.2ms 효과 없음

마지막 쿼리가 흥미롭다. brand_id 필터 없이 전체 좋아요순으로 정렬하면, 복합 인덱스의 선두 컬럼(brand_id)을 사용할 수 없어서 Full Table Scan이 발생한다. 인덱스도 만능이 아니다.

인덱스를 3개나 만들어도 괜찮을까?

"인덱스 3개면 쓰기 성능이 떨어지지 않나?" 멘토링에서도 이 질문이 나왔다.

상품 테이블은 읽기:쓰기 비율이 압도적으로 읽기 쪽이다. 상품 등록은 하루 수십~수백 건이지만, 목록 조회는 초당 수백~수천 건. B-Tree에 노드 하나 추가하는 건 O(log n)이라 10만 건이어도 5~6번 비교면 끝. 쓰기 비용 대비 읽기 이득이 압도적이다.

멘토님도 같은 맥락의 답변을 주셨다.

"정렬에 사용되는 컬럼은 반드시 인덱스를 걸어야 슬로우 쿼리를 방지할 수 있다.
filesort가 발생하면 데이터가 많아질수록 급격히 느려지기 때문."

deleted_at IS NULL은 인덱스에 안 넣은 이유

모든 읽기 쿼리에 deleted_at IS NULL 조건이 붙는다. 이걸 인덱스에 포함해야 할까?

멘토링에서 팀원분이 직접 EXPLAIN ANALYZE로 실험한 결과를 공유해주셨다. (brand_id, price, deleted_at) vs (brand_id, price) — 결과가 거의 동일했다.

이유는 간단하다. 삭제된 상품이 거의 없으니까. deleted_at IS NULL 조건은 99%의 행을 통과시킨다. 거의 못 거르는 조건은 인덱스에 넣어도 효과가 없다. 인덱스 크기만 커질 뿐.


4. 캐시는 언제 써야할까?

앞에서 복합 인덱스로 0.08ms까지 줄였다. 그런데 왜 캐시까지 필요할까?

멘토링에서 이 질문에 대한 답이 명확했다. 단계적으로 도입하는 것이 원칙이다.

1단계: 인덱스 최적화 (비용 거의 0, 효과 큼) ← 대부분 여기서 충분
2단계: 비정규화 (JOIN이 병목일 때)
3단계: 캐시 (인덱스 + 비정규화로도 QPS를 못 버틸 때)

10만 건 + 인덱스면 조회가 수 ms. 초당 수천 요청이 아니면 캐시 없이도 충분하다. 그런데 트래픽이 올라가면? DB 커넥션 풀이 고갈될 정도의 요청이 오면? 그때 캐시가 필요하다.

실무에서는 "QPS가 몇 이상이면 캐시"라는 공식보다 증상을 보고 판단한다.

  • DB 커넥션 풀 대기 시간이 늘어남
  • 슬로우 쿼리 로그에 동일한 조회 쿼리가 반복적으로 찍힘
  • 같은 데이터를 같은 조건으로 반복 조회하는 패턴이 모니터링에 보임

이번 과제에서는 학습 목적으로 캐시까지 구현했지만, 실무에서는 이런 증상이 보일 때 도입을 검토하는 게 맞다.

멘토님도 데이터 성격에 따라 전략을 나누라고 강조하셨다.

"이 값이 1분 동안 틀리면 돈이 날아가는가?"
- Yes (재고, 결제 금액) → 캐시 안 함. 항상 DB에서 실시간 조회
- No (좋아요 수, 상품 설명) → TTL이나 AFTER_COMMIT으로 허용

캐시는 "대부분 맞고, 아주 가끔 짧은 시간 틀릴 수 있음"을 허용하는 전략이다. 모든 데이터에 적용하면 안 된다.

읽기 성능이 느리다
인덱스가 적절한가?
NO
인덱스 최적화 (1단계)
비용 ≈ 0
YES, 그래도 느리다
잠깐 틀려도 되는 데이터인가?
NO (재고, 결제)
캐시 안 함
항상 DB 조회
YES (좋아요, 설명)
캐시 도입 (3단계)

5. 로컬 캐시는 뭐야? - 우리 회사에서는 Ehcache를 쓰는데, 요즘은 Caffeine이라며?

캐시를 쓰기로 했다면, 어떤 캐시를 쓸지 골라야 한다. 회사에서 로컬 캐시로 Ehcache를 쓰고 있어서 처음에는 Ehcache를 적용하려 했다. 그런데 요즘 프로젝트들을 보면 Caffeine을 쓰는 경우가 많다. 뭐가 다른 걸까?

비교 Ehcache Caffeine
출신 Java EE 시절부터 쓰던 전통적인 캐시 Google Guava Cache의 후속작
기능 범위 로컬 캐시 + 디스크 저장 + 클러스터링 로컬 인메모리 캐시에 집중
Eviction 알고리즘 LRU (Least Recently Used) W-TinyLFU (Window TinyLFU)
성능 좋음 더 좋음 (벤치마크 기준 읽기 성능 우위)
설정 XML 기반 설정이 전통 (Java Config도 가능) 빌더 패턴으로 코드에서 설정
Spring 지원 Spring Boot에서 공식 지원 Spring Boot에서 공식 지원

핵심 차이는 Eviction 알고리즘이다. Ehcache의 LRU는 "가장 오래전에 접근한 것을 제거"하는 단순한 전략이다. Caffeine의 W-TinyLFU는 "접근 빈도와 최근성을 모두 고려"해서 캐시 히트율이 더 높다. 자주 접근하지만 잠깐 안 쓴 데이터를 LRU는 쫓아내고, W-TinyLFU는 유지한다.

Ehcache는 디스크 저장, 클러스터링 같은 무거운 기능까지 지원하는 "풀 패키지"고, Caffeine은 인메모리 캐시 하나에 집중하는 "경량 라이브러리"다. 이번 프로젝트처럼 Redis가 이미 분산 캐시 역할을 하고 있으면, 로컬 캐시는 가볍고 빠른 게 낫다. 그래서 Caffeine을 선택했다.


6. Redis 캐시는 뭐야?

Caffeine은 서버 프로세스 안의 메모리에 저장하는 로컬 캐시다. 빠르지만 서버가 여러 대면 각각 다른 값을 가질 수 있다.

Redis는 별도 서버에서 동작하는 원격 캐시다. 네트워크를 통해 접근하지만, 서버가 여러 대여도 같은 캐시를 공유한다.

Cache-Aside 패턴 구현

kotlin — ProductService.kt
@Transactional(readOnly = true)
fun getProductInfo(productId: Long): ProductInfo {
    // 1. 캐시에서 먼저 조회
    val cached = productCacheService.getProductDetail(productId)
    if (cached != null) return cached

    // 2. 캐시 미스 → DB 조회
    val info = ProductInfo.from(getProduct(productId))

    // 3. 캐시에 저장
    productCacheService.setProductDetail(productId, info)
    return info
}

단순하다. 캐시에 있으면 캐시에서, 없으면 DB에서 읽고 캐시에 넣는다.

클라이언트 요청
Redis 캐시 조회
캐시에 데이터가 있는가?
YES (캐시 히트)
캐시 데이터 즉시 반환
(DB 접근 없음)
NO (캐시 미스)
DB 조회
캐시에 저장 (TTL 설정)
DB 결과 반환

TTL — 상세 10분, 목록 5분의 이유

kotlin — ProductCacheRepository.kt
companion object {
    private const val DETAIL_KEY_PREFIX = "product:detail:"
    private const val LIST_KEY_PREFIX = "product:list:"
    private val DETAIL_TTL = Duration.ofMinutes(10)
    private val LIST_TTL = Duration.ofMinutes(5)
}

TTL을 결정할 때 일반적으로 알려진 경험적 공식이 있다.

TTL = 평균 데이터 변경 주기 × 허용 가능한 stale 비율

예를 들어 상품 정보가 평균 1시간에 1번 변경되고, 10% stale을 허용하면 TTL = 60분 × 0.1 = 6분.

목록 TTL을 상세보다 짧게 잡은 이유는, 목록은 어떤 상품이든 변경(생성/수정/삭제/좋아요)되면 전체 목록 캐시에 영향을 준다. 변경 빈도가 높으니 TTL을 짧게 잡았다. 상세는 해당 상품만 변경될 때 무효화되니까 상대적으로 안정적이다.

솔직히 10분/5분은 명확한 근거 없이 경험적 초기값이다. TTL은 처음에 정확히 맞추는 것보다, 부하 테스트 후 캐시 히트율을 측정하고 조정하는 게 현실적이다.

PageImpl 직렬화 문제 — 선택지가 3가지 있었다

Redis에 상품 목록을 캐시하려면 Page<ProductInfo>를 저장해야 하는데, Spring의 PageImpl은 Jackson 역직렬화가 불가능하다. 기본 생성자가 없고, 내부에 Pageable, Sort 같은 인터페이스 타입이 있어서 Jackson이 객체를 복원할 수 없다.

선택지 방식 장점 단점
RestPageImpl PageImpl을 상속 + @JsonCreator Page 인터페이스 그대로 사용 불필요한 상속, Spring 의존
CachedPage DTO 순수 data class로 캐시 전용 DTO Spring 의존 없는 순수 구조 변환 메서드 필요
content 분리 저장 Page를 캐시하지 않고 필드별로 따로 저장 가장 단순 캐시 키/값 구조 복잡

구글에서 "Spring Redis Page 캐시"를 검색하면 가장 많이 나오는 게 1번, RestPageImpl이다. 간편하고 Page 인터페이스도 그대로 쓸 수 있다.

그런데 현재 개발중인 프로젝트는 DDD 기반 레이어드 아키텍처로 설계했다. 인프라 레이어(캐시)에 저장하는 객체가 Spring의 PageImpl을 상속하고 있다는 건, 캐시 저장 구조가 프레임워크에 의존하게 된다는 뜻이다. 역직렬화 문제의 근본 원인 자체가 "프레임워크 클래스를 그대로 저장하려 한 것"이었는데, 상속으로 해결하면 같은 구조적 문제를 안고 가는 거다.

그래서 2번, CachedPage DTO를 선택했다.

kotlin — CachedPage.kt
data class CachedPage<T>(
    val content: List<T>,
    val page: Int,
    val size: Int,
    val totalElements: Long,
) {
    fun toPage(): Page<T> = PageImpl(content, PageRequest.of(page, size), totalElements)

    companion object {
        fun <T> from(page: Page<T>): CachedPage<T> = CachedPage(
            content = page.content,
            page = page.number,
            size = page.size,
            totalElements = page.totalElements,
        )
    }
}

캐시에 저장하는 데이터는 프레임워크에 의존하지 않는 순수한 구조가 좋다고 생각했다. 나중에 캐시 저장소를 바꿔도 CachedPage는 그대로 쓸 수 있다.

Redis 장애가 서비스를 죽이면 안 된다

kotlin — ProductCacheRepository.kt
fun getProductDetail(productId: Long): ProductInfo? {
    return try {
        val json = redisTemplate.opsForValue()
            .get("$DETAIL_KEY_PREFIX$productId") ?: return null
        objectMapper.readValue<ProductInfo>(json)
    } catch (e: Exception) {
        log.warn("Redis 캐시 조회 실패 - product:detail:{}", productId, e)
        null  // 캐시 실패 → DB로 폴백
    }
}

try-catch로 감싸서 Redis 장애를 무시한다. "캐시는 있으면 좋고, 없으면 DB에서 읽으면 된다."

"같은 VPC 내에서 네트워크 장애 확률이 낮고, 조회형 캐시(Cache-Aside)는 저장 실패해도 큰 리스크가 없다. 다음 요청에서 DB에서 다시 읽어 채우면 되니까."

캐시 삭제의 함정 — Cache Stampede

캐시 무효화를 처음 구현할 때는 단순하게 생각했다. 데이터가 변경되면 캐시를 삭제하고, 다음 조회 시 DB에서 읽어서 다시 채우면 되지 않나?

kotlin — 처음 구현: 변경 시 캐시 삭제
fun updateProduct(productId: Long, criteria: UpdateProductCriteria): ProductInfo {
    val product = productRepository.findById(productId) ?: throw ...
    product.update(...)
    val savedProduct = productRepository.save(product)
    productCacheService.evictProductDetail(productId)  // 캐시 삭제
    productCacheService.evictAllProductLists()
    return ProductInfo.from(savedProduct)
}

평소에는 문제없다. 그런데 인기 상품의 캐시가 삭제되는 순간을 생각해보자. 동시에 100명이 그 상품을 조회하고 있다면?

시점 0: 캐시 삭제 (evict)
시점 1: 요청 A → 캐시 미스 → DB 조회
시점 1: 요청 B → 캐시 미스 → DB 조회
시점 1: 요청 C → 캐시 미스 → DB 조회
시점 1: 요청 100 → 캐시 미스 → DB 조회 ← 100개가 동시에 DB를 때린다

이게 Cache Stampede(캐시 스탬피드)다. 캐시가 비어 있는 짧은 순간에 밀려든 요청이 전부 DB로 직행한다. 트래픽이 많을수록, 캐시가 보호하던 DB에 한순간 부하가 집중된다.

해결은 간단하다. 캐시를 삭제하지 말고 교체하면 된다. 변경된 데이터로 캐시를 덮어쓰면, 캐시가 비는 순간 자체가 없다.

kotlin — 개선: 변경 시 캐시 교체
fun updateProduct(productId: Long, criteria: UpdateProductCriteria): ProductInfo {
    val product = productRepository.findById(productId) ?: throw ...
    product.update(...)
    val savedProduct = productRepository.save(product)
    productCacheService.setProductDetail(productId, ProductInfo.from(savedProduct))  // 캐시 교체
    productCacheService.evictAllProductLists()
    return ProductInfo.from(savedProduct)
}

evictProductDetailsetProductDetail. 한 줄 차이지만, 동시 요청 100개가 전부 DB를 때리는 상황을 원천 차단한다.

좋아요 증감처럼 네이티브 쿼리로 벌크 업데이트하는 경우는 엔티티가 반환되지 않으니, 업데이트 후 findById로 최신 데이터를 한 번 조회해서 캐시를 채운다.

kotlin — 벌크 업데이트 후 캐시 교체
fun incrementLikeCount(productId: Long) {
    productRepository.incrementLikeCount(productId)  // 벌크 업데이트 — 엔티티 미반환
    val product = productRepository.findById(productId)
    if (product != null) {
        productCacheService.setProductDetail(productId, ProductInfo.from(product))
    }
    productCacheService.evictAllProductLists()
}

DB 조회 1회가 추가되지만, 동시 요청 N개가 각각 DB를 치는 것보다 훨씬 낫다. 1회 vs N회의 트레이드오프다.

단, 삭제된 상품은 evict를 유지한다. 이미 없는 데이터를 캐시에 교체할 이유가 없으니까. 목록 캐시도 여전히 evict다. 목록은 캐시 키가 brandId × sort × page × size 조합이라, 변경 시 어떤 조합이 영향을 받는지 특정할 수 없기 때문이다.


7. 로컬 캐시 vs Redis 캐시 vs 로컬 + Redis 캐시 — K6로 비교

이론은 충분히 다뤘으니, 진짜 차이가 나는지 확인할 차례다.

부하테스트 도구 - K6

K6는 Grafana에서 만든 오픈소스 부하 테스트 도구다. JavaScript로 테스트 시나리오를 작성하면, 지정한 수의 가상 사용자(VU)가 동시에 HTTP 요청을 보내면서 응답 시간, 처리량, 에러율 같은 지표를 측정해준다.

처음에는 서비스 코드에서 직접 호출하는 방식으로 부하 테스트를 생각했다. ExecutorService로 스레드를 만들고, 서비스 메서드를 직접 호출해서 시간을 재는 식으로. (동시성 테스트를 그렇게 했으니까 비슷하게 하면 되겠다 싶었다.)

그런데 팀원분의 블로그에서 K6로 캐시 모드별 성능을 비교하는 포스팅을 올려주셔서 확인했다. 서비스 코드 직접 호출은 비즈니스 로직의 처리 시간만 측정하지만, K6는 HTTP 요청부터 응답까지의 전체 레이턴시를 측정한다. 네트워크, 직렬화, Spring 컨텍스트 처리까지 포함된 실제 사용자 경험에 가까운 수치가 나온다.

실제로 캐시의 효과는 "서비스 메서드가 몇 ms 걸리느냐"보다 "사용자가 API를 호출했을 때 몇 ms 만에 응답이 오느냐"가 중요하니까, K6가 더 적합한 도구라고 판단했다.

3가지 캐시 모드로 부하테스트

로컬 캐시, Redis 캐시, 로컬 + Redis 캐시를 모두 비교하기 위해, 환경 변수로 모드 전환이 가능하도록 전략 패턴을 구현했다.

kotlin — ProductCacheService.kt
@Component
class ProductCacheService(
    private val redisCacheRepository: ProductCacheRepository,
    private val localCacheRepository: ProductLocalCacheRepository,
    @Value("\${cache.mode:redis}") private val mode: String,
) {
    fun getProductDetail(productId: Long): ProductInfo? {
        return when (cacheMode) {
            CacheMode.REDIS -> redisCacheRepository.getProductDetail(productId)
            CacheMode.LOCAL -> localCacheRepository.getProductDetail(productId)
            CacheMode.LAYERED -> {
                // L1: 로컬 캐시에서 먼저 조회
                localCacheRepository.getProductDetail(productId)?.let { return it }
                // L2: 로컬 미스 → Redis에서 조회 → 로컬에 채움
                redisCacheRepository.getProductDetail(productId)?.also {
                    localCacheRepository.setProductDetail(productId, it)
                }
            }
        }
    }
}
yaml — application.yml
cache:
  mode: ${CACHE_MODE:redis}  # redis | local | layered

테스트 시나리오

100 VUs(동시 사용자), 30초간 테스트. 각 모드마다 워밍업 10초를 선행했다.

javascript — product-load-test.js
export default function () {
    if (Math.random() < 0.7) {
        // 70% 목록 조회 — 랜덤 브랜드, 랜덤 정렬
        const brandId = Math.ceil(Math.random() * 20);
        const sort = ['LATEST', 'PRICE_ASC', 'LIKES_DESC'][Math.floor(Math.random() * 3)];
        http.get(`${BASE_URL}/api/v1/products?brandId=${brandId}&sort=${sort}&page=${page}&size=20`);
    } else {
        // 30% 상세 조회 — 랜덤 상품 ID
        const productId = Math.ceil(Math.random() * 100000);
        http.get(`${BASE_URL}/api/v1/products/${productId}`);
    }
}

Redis 캐시만 사용했을 때

모든 캐시 조회가 Redis를 거친다. 매 요청마다 네트워크 왕복이 발생한다.

지표 결과
목록 조회 avg / p75 / p95 5.99ms / 6.67ms / 10.71ms
상세 조회 avg / p75 / p95 7.76ms / 8.67ms / 13.62ms
처리량 936 req/s
목록 캐시 히트율 100.00%
상세 캐시 히트율 4.68%

목록 캐시는 TTL 5분이라 워밍업 이후 100% 히트. 상세는 10만 개 상품 중 랜덤 조회라 같은 상품을 두 번 조회할 확률이 낮아 히트율이 매우 낮다.

로컬 캐시(Caffeine)만 사용했을 때

kotlin — ProductLocalCacheRepository.kt
private val detailCache: Cache<Long, ProductInfo> = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofSeconds(10))
    .build()

네트워크 없이 서버 메모리에서 바로 읽는다. 서버마다 다른 값을 가질 수 있기 때문에 TTL은 10초로 짧게 잡았다.

지표 결과
목록 조회 avg / p75 / p95 5.42ms / 6.16ms / 10.76ms
상세 조회 avg / p75 / p95 6.10ms / 6.91ms / 10.81ms
처리량 944 req/s
목록 캐시 히트율 92.42%
상세 캐시 히트율 1.69%

네트워크 없이 메모리 직접 접근이니까 상세 조회 avg는 가장 빠르다. 하지만 목록 캐시 히트율이 92%로 Redis(100%)보다 낮다. TTL이 10초라 10초마다 캐시가 만료되고, 그 순간 DB를 직접 조회해야 하기 때문이다.

로컬 + Redis(Layered) 사용했을 때

로컬 캐시 히트 → 끝. 로컬 미스 → Redis 조회 → 로컬에 채움.

클라이언트 요청
L1 로컬 캐시 (Caffeine) 조회
L1 히트?
YES
즉시 반환
(네트워크 0, 가장 빠름)
NO
L2 Redis 캐시 조회
L2 히트?
YES
반환 + L1에 채움
NO
DB 조회
L2 + L1에 저장
지표 결과
목록 조회 avg / p75 / p95 4.23ms / 4.82ms / 7.08ms
상세 조회 avg / p75 / p95 6.31ms / 7.16ms / 10.21ms
처리량 951 req/s
L1(로컬) 목록 히트율 92.58%
L2(Redis) 목록 히트율 64.01%

L1 로컬 캐시가 92%를 처리하고, 나머지 8%의 미스 중 64%를 Redis가 받아준다. 결과적으로 DB까지 가는 요청은 전체의 약 3%에 불과하다.

한눈에 비교 — 응답 시간

지표 Redis Local (Caffeine) Layered (Local+Redis)
목록 조회 avg 5.99ms 5.42ms 4.23ms
목록 조회 p75 6.67ms 6.16ms 4.82ms
목록 조회 p95 10.71ms 10.76ms 7.08ms
상세 조회 avg 7.76ms 6.10ms 6.31ms
상세 조회 p75 8.67ms 6.91ms 7.16ms
상세 조회 p95 13.62ms 10.81ms 10.21ms
처리량 (req/s) 936/s 944/s 951/s

한눈에 비교 — 캐시 히트율

캐시 Redis Local (Caffeine) Layered L1 (로컬) Layered L2 (Redis)
상세 캐시 4.68% 1.69% 1.68% 10.39%
목록 캐시 100.00% 92.42% 92.58% 64.01%

히트율이 응답 시간의 차이를 설명한다.

Local-only의 목록 캐시 히트율은 92%다. TTL이 10초라 10초마다 캐시가 만료되고, 그 순간 DB를 직접 조회해야 한다. 이 8%의 미스가 p95를 끌어올린다 — Local(10.76ms)이 Redis(10.71ms)와 거의 같은 이유다.

반면 Layered는 로컬 캐시가 만료되어도 Redis가 받아준다. Redis TTL은 5분이라 로컬 미스의 64%를 Redis에서 처리하고, DB까지 가는 요청은 전체의 약 3%에 불과하다. 그래서 목록 p95가 7.08ms로 가장 낮다.

상세 조회는 10만 개 상품 중 랜덤이라 어떤 모드든 히트율이 낮다. 이 경우 네트워크 없이 바로 DB를 치는 Local이 avg 기준 가장 빠르다. 하지만 p95 기준으로는 Layered가 근소하게 앞서는데, Redis에 캐싱된 소수의 상품이 꼬리 지연을 줄여주기 때문이다.

"로컬 캐시(서버 메모리)와 원격 캐시(Redis)를 함께 쓰는 2-tier 구조가 일반적이다. 로컬 캐시가 원격 캐시를 바라보는 구조로, 로컬 캐시 갱신 주기를 짧게 설정하여 최신성을 유지한다."

모드 장점 단점 적합한 상황
Redis 서버 간 공유, 데이터 일관성 네트워크 레이턴시 서버 여러 대, 일관성 중요
Local 가장 낮은 레이턴시 서버별 불일치, 메모리 사용 단일 서버, 짧은 TTL 허용
Layered 최고 성능 + Redis로 일관성 보완 구현 복잡, 이중 무효화 트래픽 높고 성능 중요

8. 결론

최적화는 단계적으로

단계 최적화 비용 효과 현재 프로젝트에서
1 복합 인덱스 거의 0 76~231배 개선 가장 효과적
2 Redis 캐시 중간 DB 접근 제거 네트워크 오버헤드 존재
3 로컬 캐시 중간 네트워크도 제거 서버 간 불일치
4 Layered 캐시 높음 최고 성능 목록 p95 기준 34% 추가 개선

목록 캐시 키 폭발 문제 — 첫 3페이지만 캐싱

앞서 Cache Stampede를 이야기하면서 이런 말을 했다. "목록 캐시는 여전히 evict다. 캐시 키가 brandId × sort × page × size 조합이라, 변경 시 어떤 조합이 영향을 받는지 특정할 수 없기 때문이다."

그런데 이 evict가 문제였다. 좋아요 하나 누를 때마다 evictAllProductLists()가 호출되면서, brandId × sort × page × size 전체 조합의 목록 캐시를 한꺼번에 날린다. 캐시 키가 많을수록 evict 비용이 커지고, evict 직후에는 모든 조합이 캐시 미스 → DB 조회로 빠진다.

멘토링에서 받은 조언이 정확히 이 지점을 짚었다.

"브랜드별 3페이지 정도만 캐시하고, 필터가 걸린 페이지는 캐시하지 않는다. 핫한 조합만 선택적으로 캐시하는 거다."

생각해보면 대부분의 사용자는 첫 페이지에서 원하는 상품을 찾는다. 3페이지 이상 넘기는 사용자는 극소수다. 그렇다면 전체 페이지를 캐싱할 이유가 없다.

해결은 단순했다. MAX_CACHED_PAGE = 3 상수 하나로 page 0~2만 캐싱하고, page 3 이상은 캐시를 건너뛰어 DB에서 직접 조회하도록 했다.

kotlin — ProductCacheService.kt
@Component
class ProductCacheService(
    private val redisCacheRepository: ProductCacheRepository,
    private val localCacheRepository: ProductLocalCacheRepository,
    @Value("\${cache.mode}") private val mode: String,
) {
    companion object {
        private const val MAX_CACHED_PAGE = 3  // page 0, 1, 2만 캐싱
    }

    fun getProductList(brandId: Long?, sort: String, page: Int, size: Int): CachedPage<ProductInfo>? {
        if (page >= MAX_CACHED_PAGE) return null  // 3페이지 이상은 캐시 skip
        return when (cacheMode) { ... }
    }

    fun setProductList(brandId: Long?, sort: String, page: Int, size: Int, data: CachedPage<ProductInfo>) {
        if (page >= MAX_CACHED_PAGE) return  // 3페이지 이상은 캐시에 저장하지 않음
        when (cacheMode) { ... }
    }
}

ProductService는 변경하지 않았다. 캐시 미스 시 DB fallback이 이미 구현되어 있으니까, 캐시 정책은 ProductCacheService에서만 관리하는 구조를 유지했다. 나중에 캐시 전략을 바꾸더라도 ProductCacheService만 수정하면 된다.

장점 단점
캐시 키 수 대폭 감소 page 3+ 요청은 항상 DB 직접 조회
evict 비용 감소 (삭제 대상 키가 적음) 뒷페이지 탐색이 많으면 DB 부하 집중 가능
캐시 히트율 안정화 (evict 후 빠른 warm-up) MAX_CACHED_PAGE = 3이라는 가정에 의존
Redis/Local 메모리 절약 트래픽 패턴이 바뀌면 재조정 필요

단순한 상수 하나로 캐시 키 폭발과 evict 비용 문제를 상당 부분 해소할 수 있었다. 다만 "3"이라는 숫자가 실제 서비스 트래픽에서 유효한지는 모니터링으로 검증해야 한다. 운영 환경에서는 접근 로그 기반으로 적정 페이지 수를 조정하는 것도 고려해볼 만하다.

추가로 고려해야하는 것들

Read Replica를 캐시보다 먼저 검토하라.
읽기 부하 분산은 DB 복제로도 해결 가능하고, 코드 변경이 거의 없다. "레플리카 DB와 Redis 캐시 조합으로 수백만 MAU도 충분히 처리 가능할수 있다."

돌아보며

처음에 Redis를 처음 써보는 거라 "캐시 = 어려운 기술"이라고 생각했다. 그런데 본질은 "빠른 Map<String, String>"이었다. 삽입, TTL, Evict — 이 세 가지 판단을 내리면 된다.

인덱스는 DB 내부에서 쿼리를 최적화하는 것이고, 캐시는 DB를 아예 안 치게 하는 것이다. 둘은 경쟁이 아니라 보완 관계다. 캐시 미스가 나면 인덱스가 빠르게 DB를 처리하고, 캐시 히트가 나면 DB 자체를 안 치니까.

"감"으로 "캐시 쓰면 빨라지겠지"가 아니라, EXPLAIN으로 인덱스 효과를 확인하고, K6로 캐시 효과를 측정하고, 숫자로 의사결정하는 것. 그게 이번 주에 배운 가장 큰 내용이다.


References