지난번 진행한 읽기 성능 개선 작업에서는 캐시 구조를 다음과 같이 설계했었다.
- 브랜드 목록: 상품 많은 순 상위 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 |