Post

이커머스에서 상품 조회 캐시 전략 선택하기 — 키 설계부터 읽기/쓰기/무효화 전략까지

TL;DR

  • 데이터 특성(변경 빈도, 무효화 복잡도)에 따라 TTL과 무효화 전략을 다르게 가져갔다.
  • 인덱스 방향 최적화 + 캐시 적용으로 p99 1.08s → 74ms(93% 개선), RPS 113 → 153(35% 향상).

들어가면서

이커머스 프로젝트를 하면서 상품 목록 조회, 상품 상세 조회 등 읽기 성능 개선이 필요해졌다. 막상 캐시를 적용하려니 고민이 생겼다.

“어떤 데이터를 캐시할까? 캐시 키 이름은? 데이터가 바뀌면 캐시는 어떻게?”

이번 글에서는 SLO 기준을 정하고, 테스트 데이터를 만든 뒤, 캐시를 적용하면서 고민했던 과정을 정리해봤다. 특히 캐시 전략을 선택할 때 어떤 기준으로 판단했는지에 집중했다.


캐시 전략 개요

읽기 전략, 쓰기 전략, 무효화 전략 세 가지를 조합해서 결정해야 한다.

읽기 전략

전략동작 방식고려할 점
Cache-Aside앱이 캐시 확인 → miss면 DB 조회 → 캐시 저장구현이 단순, 읽기 많은 워크로드에 적합
Read-Through캐시가 DB 조회 대행캐시 인프라 의존도 높음
Refresh-AheadTTL 만료 전 백그라운드 갱신핫 데이터에 유리, 구현 복잡도 증가

쓰기 전략

전략동작 방식고려할 점
Write-ThroughDB + 캐시 동시 저장일관성 보장, 쓰기 지연 증가
Write-Behind캐시만 저장 → 나중에 DB 반영쓰기 성능 최고, 데이터 유실 위험
Write-AroundDB만 저장, 캐시 무시캐시 오염 방지, 다음 읽기 시 miss

무효화 전략

전략동작 방식고려할 점
TTL Only시간 지나면 자동 만료구현 단순, 지연 허용 시 적합
Evict변경 시 캐시 삭제 → 다음 요청이 DB 조회구현 단순, 동시성 안전
Update변경 시 캐시 덮어쓰기miss 없이 즉시 반영, 동시성 문제 주의

이번에 브랜드 조회, 상품 상세/목록 조회에 캐시를 적용하면서 이 세 가지 전략을 조합해 선택했다.


테스트 환경

성능 측정을 위한 테스트 데이터를 준비했다.

데이터 규모

항목설계 근거
상품50만 건대용량 조회 시 인덱스 유무 차이를 명확히 측정
브랜드10개 (각 5만 건)균등 분배로 동일 조건에서 비교 가능
좋아요약 565만 건핫키 분포로 정렬 인덱스 효과 측정
유저1만 명-
주문5만 건유저당 평균 5개

좋아요 분포: 극단적 쏠림

구간상품 수좋아요 수비율
상위 1%5천 개각 1천 개 → 500만 개88%
상위 1~10%4.5만 개각 10개 → 45만 개8%
상위 10~30%10만 개각 2개 → 20만 개4%
하위 70%35만 개0개0%

실제 서비스에서 인기 상품에 좋아요가 몰리는 현상을 재현하고 싶었다. 상위 1%가 전체 좋아요의 88%를 차지하는 극단적 핫키 분포다.

SLO 기준

지표목표
p99 응답 시간300ms 이내
RPS100 이상
에러율1% 미만

이 중 p99와 RPS를 핵심 지표로 봤다.

  • p99: 평균이 빨라도 일부 요청이 느리면 사용자 경험이 나빠진다.
  • RPS: 처리량이 낮으면 트래픽 증가 시 병목이 된다.

상품/브랜드별 캐시 전략 선택과 이유

브랜드 단건

브랜드 정보는 거의 변하지 않다고 판단하여 TTL을 1일로 길게 잡고, 변경 시에만 캐시를 삭제하는 방식을 선택했다.

  • 읽기 (Cache-Aside): 캐시 miss 시 DB 조회 후 캐시에 저장한다.
  • 쓰기 (Write-Around): DB만 저장한다. 비동기적으로 DB에 저장하거나, DB와 캐시에 동시에 저장할 정도로 일관성이 중요하지 않다고 판단했다.
  • 무효화 (Evict + TTL): 변경 시 캐시를 삭제하고, TTL은 1일로 길게 잡았다. 변경이 적으니 히트율을 최대화할 수 있다.
  • : brand:v1:{id} — 버전을 포함해서 스키마 변경 시 대응할 수 있게 했다.
  • 캐시 레이어: Repository에서 캐싱한다. 브랜드는 여러 상품에서 참조되므로 재사용성이 높다.

상품 단건

상품은 브랜드보다 민감하다. 재고와 가격이 포함되어 있어서 변경이 많은 데이터다.

  • 읽기 (Cache-Aside): 캐시 miss 시 DB 조회 후 캐시에 저장한다. 재고라는 중요한 데이터를 갖고 있어서 캐시 인프라 의존도를 낮추고, miss 시 DB에서 정확한 값을 가져오는 방식을 택했다.
  • 쓰기 (Write-Around): DB만 저장한다. 브랜드와 마찬가지로 비동기 저장이나 동시 저장이 필요할 만큼 일관성이 중요하지 않다고 판단했다.
  • 무효화 (Evict + TTL): 변경 시 캐시를 삭제하고, 변경이 많을 것으로 판단해 브랜드보다 훨씬 짧은 TTL 5분을 사용했다.
  • : product:v1:{id} — 버전을 포함해서 스키마 변경 시 대응할 수 있게 했다.
  • 캐시 레이어: Repository에서 캐싱한다. 상품 상세, 목록 등 여러 곳에서 참조되므로 재사용성이 높다.

상품 목록

목록 캐시가 가장 까다로웠다. 조건 조합이 폭발하기 때문이다.

  • 읽기 (Cache-Aside): 캐시 miss 시 DB 조회 후 캐시에 저장한다.
  • 무효화 (TTL Only): Evict 대신 TTL Only를 선택했다. 상품 하나가 변경되면 상품 상세, 브랜드 목록, 전체 목록 등 여러 캐시를 삭제해야 하는데, 재고 변경이 빈번하면 히트율이 급락한다. 그래서 명시적 무효화 없이 1분 TTL로 자연 갱신되게 했다.
  • : product:list:v1:{sortType}:{brandId}
    • product:list: 네임스페이스. 역할별로 분리해서 충돌 방지.
    • v1: 버전. 스키마 변경 시 과거 캐시와 강제 분리.
    • {sortType}: 정렬 타입 (latest, likes_desc).
    • {brandId}: 브랜드 필터가 있으면 해당 값, 없으면 null.
    • 조건 조합이 많아서 전부 캐시하면 무효화가 복잡해지므로, 비회원 + 첫 페이지 + 최신순/인기순만 캐시한다.
  • 캐시 레이어: Facade에서 캐싱한다. 목록은 여러 조건이 조합된 결과이므로 Repository보다 상위 레이어에서 처리한다.

적용된 캐시 전략 요약

대상읽기쓰기무효화TTL캐시 레이어
브랜드Cache-AsideWrite-AroundEvict1일brand:v1:{id}Repository
상품 단건Cache-AsideWrite-AroundEvict5분product:v1:{id}Repository
상품 목록Cache-Aside-TTL Only1분product:list:v1:{sortType}:{brandId}Facade

고려했지만 적용하지 않은 것

TTL Only를 쓰면 캐시 스탬피드 문제가 생길 수 있다. 만료 시점에 요청이 몰리면서 DB 중복 조회와 캐시 중복 쓰기가 발생한다. 선계산 (Early Recompute)이나 TTL 지터(TTL Jitter) 같은 해결책이 있지만, 지금은 불필요하다고 판단해 적용하지 않았다.


인덱스 최적화 시행착오

캐시 적용 전에 인덱스 최적화를 먼저 시도했다.

인덱스를 적용했더니 오히려 p99가 느려졌다.

단계p99avgRPS
미적용1.32s463ms103
인덱스 적용 (ASC)1.41s362ms111

avg는 22% 개선됐지만, p99는 7% 악화됐다.

정확한 원인은 단정짓기 어렵지만, 처리량 증가로 인한 리소스 경합으로 추정했다. 쿼리가 빨라지면서 RPS가 8% 올랐고, 동시 요청이 늘어나 DB 커넥션 경합이 증가했을 가능성이 있다.

인덱스 방향 변경

EXPLAIN을 확인해보니 Backward index scan이 발생하고 있었다. 쿼리는 ORDER BY like_count DESC인데 인덱스는 ASC로 생성되어 있었다.

1
2
3
4
5
// 기존 (ASC)
@Index(name = "idx_product_like_count", columnList = "like_count")

// 변경 (DESC)
@Index(name = "idx_product_like_count", columnList = "like_count DESC")

Backward index scan의 오버헤드가 얼마 쿼리 정렬 방향과 인덱스 방향을 일치시켜봤다.

인덱스 방향 변경 결과

지표ASCDESC개선율
p991.08s586ms-46%
avg342ms173ms-49%
RPS113131+16%

p99가 46% 개선됐다.

APIASCDESC변화
브랜드필터57%99%+42%p
가격순75%99%+24%p
좋아요목록83%99%+16%p
주문목록83%99%+16%p
최신순39%60%+21%p
인기순38%59%+21%p

브랜드필터, 가격순, 좋아요목록, 주문목록은 99% 성공률을 달성했다. 하지만 최신순과 인기순은 60% 수준에 머물렀다.

인덱스만으로는 한계가 있었다

인덱스 방향 최적화로 상당한 개선이 있었지만, 전체 목록 정렬(최신순/인기순)은 여전히 SLO를 달성하지 못했다.

인덱스로 개별 쿼리 속도는 빨라졌지만, RPS가 늘면서 대기하는 요청도 늘어났다. 결국 DB 조회 자체를 줄이는 캐시 적용이 필요했다.


성능 개선 결과

전체 비교

단계p99avgRPS
인덱스 (ASC)1.08s342ms113
인덱스 (DESC)586ms173ms131
인덱스 (DESC) + 캐시74ms10ms153

단계별 개선율

비교p99avgRPS
ASC → DESC-46%-49%+16%
DESC → DESC + 캐시-87%-94%+17%
ASC → DESC + 캐시-93%-97%+35%

API별 성공률 (p99 < 300ms)

API인덱스만 (DESC)캐시 적용 후
최신순60%99%
인기순59%100%
브랜드필터99%100%
가격순99%100%
좋아요목록99%100%
주문목록99%100%

인덱스 방향 최적화만으로 일부 API는 99%를 달성했지만, 최신순/인기순은 60% 수준이었다. 캐시를 추가하자 전체 API가 99% 이상을 달성했다.


끝으로

캐시 전략에는 정답이 없어서, 나만의 기준을 찾는 게 어려웠다. 이번에 데이터 특성에 따라 읽기, 쓰기, 무효화 전략의 트레이드오프를 비교하는 연습을 해봤다.

이번에 캐시 전략을 선택하면서 세 가지 기준을 세웠다.

첫 번째, 읽기/쓰기 비율을 봤다. 자주 읽히고 덜 업데이트되는 데이터가 캐싱하기 좋다. 브랜드, 상품, 목록 모두 읽기가 압도적으로 많아서 캐싱 대상으로 적합하다고 판단했다.

두 번째, 무효화 복잡도를 봤다. 단건 조회는 데이터가 바뀌면 해당 키 하나만 삭제하면 된다. 반면 목록은 상품 하나가 바뀌면 전체 목록, 정렬별 목록 등 여러 캐시를 삭제해야 한다. 단건은 무효화가 단순하니 Evict로 즉시 삭제했다. 목록은 무효화가 복잡하고, 최신성보다 빠른 조회가 더 중요하다고 판단해서 명시적 무효화 없이 짧은 TTL로 자연 갱신되게 했다.

세 번째, 트래픽 집중도를 봤다. 목록의 경우 모든 정렬/필터 조합을 캐시하면 메모리 낭비고 히트율도 낮다. 가장 많이 호출되는 조합(최신순, 인기순)만 선별해서 캐시했다.

대상읽기쓰기무효화 복잡도읽기 전략쓰기 전략무효화 전략비고
브랜드단순Cache-AsideWrite-AroundEvict + TTL 1일변경 거의 없음
상품 단건단순Cache-AsideWrite-AroundEvict + TTL 5분재고/가격 변경 있음
상품 목록복잡Cache-Aside-TTL Only 1분최신순/인기순만 캐시

실무에서도 병원 통계 AI 분석 결과, 혈압 분석 결과 등 캐싱할 데이터가 늘어나고 있다. 이번 고민을 바탕으로 기존 캐시 전략도 점검하고, 새로운 대상에도 적절한 전략을 세워봐야겠다.


부록: 캐시 구현 구조

아키텍처

cache-dip-architecture

Application 계층은 CacheTemplate 인터페이스에만 의존한다. 프로덕션에서는 RedisCacheTemplate, 테스트에서는 동작 없는 NoOpCacheTemplate을 주입한다.

1
2
3
4
5
6
public interface CacheTemplate {
    <T> Optional<T> get(CacheKey<T> cacheKey);
    <T> void put(CacheKey<T> cacheKey, T value);
    void evict(CacheKey<?> cacheKey);
    <T> T getOrLoad(CacheKey<T> cacheKey, Supplier<T> loader);
}

getOrLoad 패턴

캐시 조회의 핵심은 getOrLoad() 메서드다. 캐시에 있으면 바로 반환하고, 없으면 Supplier를 실행해서 DB에서 조회한 뒤 캐시에 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
public <T> T getOrLoad(CacheKey<T> cacheKey, Supplier<T> loader) {
    Optional<T> cached = get(cacheKey);
    if (cached.isPresent()) {
        return cached.get();
    }

    T value = loader.get();
    if (value != null) {
        put(cacheKey, value);
    }
    return value;
}

Supplier<T>를 사용해서 캐시 미스 시에만 DB 조회가 실행된다.

CachePolicy로 TTL/키 중앙 관리

TTL과 키 생성 규칙이 코드 곳곳에 흩어지면 관리가 어렵다. CachePolicy enum으로 중앙 관리했다.

1
2
3
4
5
6
7
8
9
10
public enum CachePolicy {
    PRODUCT("product", Duration.ofMinutes(5)),
    BRAND("brand", Duration.ofHours(6)),
    PRODUCT_LIST("product:list", Duration.ofMinutes(1));

    public String buildKey(Long id) {
        return prefix + ":" + VERSION + ":" + id;
        // → "product:v1:123"
    }
}

캐시 구현 포인트 정리

포인트적용 내용
DIPCacheTemplate 인터페이스로 Redis 의존성 역전
Supplier캐시 미스 시에만 DB 조회 (지연 실행)
CachePolicyTTL/키 전략을 Enum으로 중앙 관리
테스트 격리NoOpCacheTemplate으로 캐시 없이 테스트
This post is licensed under CC BY 4.0 by the author.