상품 목록/상세 조회 캐시 설계

zion·2025년 11월 28일

상품 목록과 상세 조회를 최적화하기 위해 캐시 구조를 설계하면서 겪은 고민과 시행착오를 정리해보았습니다. 왜 이렇게 설계했는지, 어떤 문제를 만났는지를 공유합니다.


1. 조회 목록 기능에서 고려해야 할 조건

  • 브랜드별 상품 목록 조회
  • 정렬 기준: 최신순, 가격순, 좋아요수
  • 페이징: 페이지 단위로 결과 제공

목록 조회는 자주 발생하지만, 일부 조건(최신순, 좋아요순)에 따라 데이터 변경 빈도가 달라지므로 캐시 전략을 달리할 필요가 있었습니다.


2. 목록/상세 캐시 설계

2-1. 비정규화 테이블 생성

처음에는 상품 목록 조회 시, 상품 상세(Product) + 재고(Stock) + 좋아요수(Like)를 조회 시점에 조합하는 방식으로 개발했습니다.
조회/수정이 빈번한 좋아요 수로 인한 상품 조회/수정시 성능 이슈가 걱정 되어서 였습니다. 대신 캐시 설계시 비정규화 테이블을 생성하기로 결정했습니다.

  • 문제: 매번 조회 시 조합해야 하므로 DB 부하가 발생
  • 해결: 목록 조회에 최적화된 비정규화 테이블 ProductListView 생성
    • 좋아요수 포함
    • 목록 조회 시 바로 사용할 수 있음
    • 상세 캐시는 별도로 관리(ProductStock)

요약: 목록 조회는 비정규화 테이블, 상세 조회는 Product + Stock 조합


2-2. 목록/상세 캐시 분리 및 ID 기반 조합

처음 구현에서는 목록 캐시와 상세 캐시를 각각 생성되어있고, 총 좋아요 수 가 각 캐시에 저징되어 있어, 좋아요 수정시 목록/상세 캐시가 모두 업데이트 되어야만 하는방식이었습니다. 때문에 상품과 좋아요 캐시를 우선 분리하고, 목록 캐시에서 상세 캐시를 조합하는 방식으로 진행했습니다.

// 초기 캐시 구조
목록 캐시: product:list:{brandId}:{sort}:{page}:{size}
상세 캐시: product:detail:{productId}
  • 문제: 좋아요수가 자주 변경될 경우, 모든 목록/상세 캐시를 삭제해야 함 → 성능 저하
  • 해결: 목록 캐시는 ID 리스트만 캐싱하고, 상세 캐시는 ID 기준으로 조합하는 방식 진행
// 목록 조회 시 상세 캐시 조합 예시
List<ProductWithLikeCount> list = productIds.stream()
    .map(id -> {
        ProductStock stock = cacheRepository.get(id);
        if (stock == null) stock = getProductStock(id);
        LikeInfo like = likeCacheRepository.getLikeInfo(userId, id);
        return new ProductWithLikeCount(
            id,
            stock.product().getName(),
            stock.product().getPrice().getAmount(),
            like.likeCount()
        );
    }).toList();


* 장점:

  1. 상세 캐시 미스가 있어도 전체 목록을 DB에서 재조회하지 않음
  2. 좋아요수 변경 시 페이지별 캐시를 일일이 삭제할 필요 없음
  3. 캐시 효율과 데이터 최신성을 모두 고려 가능

3. 상세 캐시 미스 처리

  • 목록 ID 리스트 캐시는 존재하지만, 일부 상세 캐시(ProductStock)가 없는 경우
  • 각 ID 마다 상세 조회가 일어남
  • 해결 방법: 해당 페이지의 상세 캐시가 없는 ID만 DB 조회 및 캐싱
List<Long> missIds = productIds.stream()
    .filter(id -> cacheRepository.get(id) == null)
    .toList();

if (!missIds.isEmpty()) {
    List<ProductStock> stocks = missIds.stream()
        .map(id -> getProductStock(id))
        .toList();
    stocks.forEach(stock -> cacheRepository.save(stock, DETAIL_TTL));
}

4. 페이지 단위 TTL 전략

각 목록별 특징을 고려해 페이지 마다 다른 TTL 전략을 고려했습니다.

  • 최신순(latest):
    신규 데이터 추가 시 1페이지는 즉시 변경됨 → 1분
    뒤로 갈수록 변화 영향이 매우 적음 → TTL 증가 가능

  • 좋아요순(likes_desc):
    좋아요는 실시간으로 변하지만 1페이지만 영향 큼
    뒤쪽 페이지는 순위 변동률이 낮음 → 2분 유지

  • 가격순, 기본
    * 데이터 변경 자체가 거의 없음 → TTL 길게(10분 이상)

private Duration getListTtl(String sort, int page) {
    if ("latest".equals(sort)) {
        if (page == 0) return Duration.ofMinutes(1);
        if (page <= 4) return Duration.ofMinutes(2);
        return Duration.ofMinutes(5);
    } else if ("likes_desc".equals(sort)) {
        if (page == 0) return Duration.ofMinutes(1);
        return Duration.ofMinutes(2);
    }
    return Duration.ofMinutes(10);
}

5. 좋아요수 캐시 전략

  • 좋아요수는 수정이 빈번한 데이터로 언제 DB에 저장하더라고 해당 데이터가 가장 정확한 데이터라고 보장 받을수 없습니다.
  • 캐시에서 먼저 증가/감소 후, DB는 추후 배치로 저장 예정
  • 배치 개발 전에는 순서를 맞추기 위해 캐시 → DB 저장 구조로 개발
  • 키 구성:
사용자별 좋아요 여부: product:liked:{userId}:{productId}
상품별 좋아요 수: product:likeCount:{productId}
  • 장점: 높은 읽기 트래픽에서 캐시 성능 보장, DB 부담 최소화

6. 현 구조의 문제점 및 추가 개발 사항

  • 배치를 통한 DB 저장, 동기화 로직 추가 필요.
  • 추가 캐시 TTL 전략 고려, 인기 상품 TTL 연장, 저빈도 브랜드 캐시 주기적 삭제 등 고려 가능
  • 캐시 미스시 id 리스트 전체 조화로 수정

7. 정리

  1. 비정규화 테이블로 목록 조회 최적화
  2. 목록/상세 캐시 분리 → ID 리스트 기반 조합
  3. 상세 캐시 미스는 해당 ID만 조회, 전체 재조회 방지
  4. 페이지 단위 TTL 전략 적용
  5. 좋아요수는 캐시 우선 → 배치 DB 저장

이번 설계를 통해, 읽기 최적화 + 캐시 효율 + 변경 데이터 처리 문제를 모두 고려해볼수 있었습니다.

profile
be_zion

0개의 댓글