캐시 구조 개선

2025. 8. 22. 16:01·Study/Architecture

지난번 진행한 읽기 성능 개선 작업에서는 캐시 구조를 다음과 같이 설계했었다.

  • 브랜드 목록: 상품 많은 순 상위 5% (50개 브랜드)
  • 상품 목록: 각 상위 브랜드 별 최신순 상품 상위 100개

TTL은 브랜드 목록에 1일, 상품 목록에 1시간을 설정했다.

 

하지만 브랜드나 상품이 수정/삭제될 때 별도의 캐시 갱신 로직이 존재하지 않아, 데이터 갱신 시 캐시를 어떻게 관리할 것인가 하는 문제가 남아있었다. 이 문제를 방치한다면 데이터 정합성이 맞지 않게 되니까 수정이 필요했다.

 

기존 캐시 구조

조회 로직은 다음과 같은 흐름으로 구성되어 있었다.

검색 조건이 캐시 조건에 부합하는지
	→ 브랜드 목록 캐시가 존재하는지
		→ 상품 목록 캐시가 존재하는지
			→ 없다면 DB 조회

구현 당시 각 캐시는 단순히 List 형태로 저장했다.

  • 브랜드 목록 → List<BrandInfo>
  • 상품 목록 → List<ProductInfo>

그런데 RedisTemplate<String, Object> 로 List를 직접 저장하다 보니 역직렬화 오류가 발생했다.

내부 제네릭 타입 정보를 잃어버려 단순 List<Object> 로만 읽히는 것이 문제였던 것이다.

 

이를 해결하기 위해 별도의 wrapper 클래스를 도입했다.

  • 브랜드 목록 → List<BrandInfo> → BrandInfoList
  • 상품목록 → List<ProductInfo> → ProductInfoList

캐시 저장 시 List를 감싸는 전용 DTO를 두어 역직렬화 문제를 회피한 것이다.

 

하지만 이 방식에는 두가지 문제가 있었다.

1. 객체마다 래퍼 클래스를 만들어야해 관리 비용 증가

→ 캐싱 대상이 늘어나면 래퍼 클래스도 계속 생성해주어야함

2. 조회 시 O(N) 탐색 비용 발생

→ 브랜드 캐시에서 stream(). filter()로 원하는 브랜드를 찾는 과정이 비효율적임

 

 

HSET 도입

이때 Redis의 HSET 구조를 알게 되었다.

HSET 은 Map처럼 동작하기 때문에 brandId를 key로 BrandInfo를 저장할 수 있어 List 기반 캐시의 문제를 해결할 수 있었다.

적용 방식은 다음과 같다.

// 단건 조회
public BrandInfo getCachedBrand(Long brandId) {
    BrandInfo cachedInfo = (BrandInfo) redisTemplate.opsForHash().get(BRANDS_CACHE_KEY, brandId.toString());
    if (cachedInfo != null) return cachedInfo;

    refreshBrandsCache();

    return (BrandInfo) redisTemplate.opsForHash().get(BRANDS_CACHE_KEY, brandId.toString());
}

// 브랜드 목록 HSET 저장
public void refreshBrandsCache() {
    List<BrandInfo> topBrands = brandService.getTopList().stream()
            .map(BrandInfo::from)
            .toList();

    Map<String, BrandInfo> brandInfoMap = topBrands.stream()
            .collect(Collectors.toMap(brandInfo -> brandInfo.getId().toString(), Function.identity()));

    redisTemplate.opsForHash().putAll(BRANDS_CACHE_KEY, brandInfoMap);
    redisTemplate.expire(BRANDS_CACHE_KEY, BRANDS_CACHE_TTL);
}

이렇게 바꾸니 래퍼 클래스 없이도 직렬화/역직렬화가 안정적으로 동작했고, 브랜드 조회 시 O(1)에 가까운 속도로 접근할 수 있었다.

 

 

마무리

초기에는 단순 List 기반 캐시 구조로 구현했지만, 역직렬화와 탐색 비용 문제가 발생해 HSET 구조로 개선하게 되었다.

개선 후 다음과 같은 효과를 얻을 수 있었다.

  • 별도 래퍼 클래스 없이도 직렬화/역직렬화 안정화
  • 특정 브랜드 단건 속도 개선 ( O(N) → O(1) )
  • 캐시 관리 지점 단순화

 

하지만 아직 아쉬운 점도 있었다.

브랜드 별 상품 목록 캐시는 여전히 List<ProductInfo> 형태라 HSET처럼 개별 단위로 제어하기 어렵다고 판단해 개선하지 못했기 때문이다. 그래서 아직 상품 단건 수정/삭제 시에는 해당 브랜드의 전체 상품 목록 캐시를 갱신해야 하는 제약이 있다.

다만 상품 목록 캐시의 TTL을 브랜드보다 짧게 1시간으로 설정했었고, 추후 TTL을 좀 더 짧게 조정할 계획이라 당장은 별도로 개선을 진행하지 않고 마무리했다.

 

이번 개선을 통해 데이터 구조와 캐시 구조가 비즈니스 특성과 얼마나 밀접하게 맞물려야 하는지를 다시 느낄 수 있었다. 앞으로는 상품 캐시 역시 더 효율적으로 관리할 방법을 고민해보려고 한다.

반응형

'Study > Architecture' 카테고리의 다른 글

주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?  (2) 2025.08.29
장애 대응 시스템 구축하기  (0) 2025.08.22
읽기 성능 개선 보고서  (4) 2025.08.15
멱등성을 고려한 좋아요 기능 설계  (5) 2025.08.01
Validation, 어디에 어떻게 두어야 할까?  (3) 2025.07.18
'Study/Architecture' 카테고리의 다른 글
  • 주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?
  • 장애 대응 시스템 구축하기
  • 읽기 성능 개선 보고서
  • 멱등성을 고려한 좋아요 기능 설계
haylee
haylee
개발하면서 보고 듣고 느낀 것들을 정리하는중
  • haylee
    haylee
    haylee
    • 홈
    • GitHub
    • LinkedId
    • 분류 전체보기 (19)
      • Project (6)
        • 회고 (6)
      • Study (13)
        • Database (3)
        • Test (1)
        • Architecture (9)
  • hELLO· Designed By정상우.v4.10.1
haylee
캐시 구조 개선
상단으로

티스토리툴바