TL;DR: 비정규화와 인덱스 최적화로 응답 속도를 줄였지만, 읽기 빈도가 높은 목록 조회 API는 여전히 매 요청마다 DB를 조회하고 있었다. 캐싱, Cache-Aside 패턴을 적용하여 DB 부하를 거의 0에 가깝게 줄였고, 응답 속도를 20ms 수준에서 안정화시켰다.
문제 관측
상품 목록 조회 API의 성능을 검증하기 위해, 상품 100만 건, 상품 좋아요 700만 건의 데이터를 준비하고 부하 테스트를 진행했다.
초기 구현은 단순한 GROUP BY 쿼리로 좋아요 수를 계산하는 방식이었는데, 데이터가 많아지자 응답 속도가 크게 느려졌다.
통합 실험결과 (캐시 적용 전)
상품 100만건, 50명의 유저가 30초동안 매초 상품 목록 조회 요청 시나리오

A → B: 인덱스 적용만으로 평균 응답 시간이 약 84배 단축되고, 실패율이 0%로 감소했다.
B → C: 좋아요 수를 별도 집계 테이블에 비정규화하여 조인하자, 평균 응답 시간이 약 3배 더 단축되었다.
이 단계까지로도 대부분의 요청이 100ms 이내에 처리되었지만, 여전히 모든 요청이 DB를 조회하고 있었기 때문에 읽기 요청 자체를 DB에서 덜어내는 방법이 필요했다.
캐시 전략 선택 과정
비정규화와 인덱스 적용으로 응답 속도를 크게 줄였지만, 목록 조회 API는 호출 빈도가 매우 높은 특성상 여전히 DB 부하를 발생시키고 있었다.
성능을 한 단계 더 끌어올리기 위해 읽기 요청 자체를 캐시로 처리하는 방안을 검토했다.
후보로 고려한 캐시 패턴
Cache Aside
- 조회 시 캐시에서 값을 가져오고 없으면 DB 조회 후 캐시에 저장한다.
- 데이터 변경 시점에는 캐시 무효화를 하지 않는다.
Read-Through
- 캐시 미스 시 캐시 계층이 DB를 읽어 채움(라이브러리/프로바이더 주도).
Cache-Aside를 선택한 이유
목록 조회 API는 다음과 같은 특성이 있었다.
- 읽기 비율이 압도적으로 높아 캐시 적중률이 높으면 DB 접근을 대부분 차단 가능하다.
- 데이터 변경 빈도가 낮아 실시간성보다는 조회 속도가 더 중요하다.
- 결과 일관성이 약간 늦어져도 무방해 좋아요 수가 수 초 정도 늦게 반영되어도 문제 없다.
이 조건에서는 Cache-Aside 패턴이 구현 단순성, 캐시 적중률, 부하 절감 효과 면에서 가장 적합했다.
특히 쓰기 시점에 캐시 무효화를 신경 쓰지 않아도 된다는 점이 유지보수 부담을 줄여줬다.
Cache-Aside 구현
설계 목표
- 조회 많은 목록 API에서 DB 호출 자체를 줄인다.
- 구현은 단순하게 유지하고, 실패 시 안전하게 폴백한다.
- 캐시 일관성은 “몇 초 지연 허용” 전제를 둔다.
구성 요소
이번 캐시 구조는 다섯 개의 핵심 구성으로 이루어져 있다.
CacheNamespaces
- 캐시 키의 네임스페이스를 상수로 정의
- 기능별·도메인별로 분리해 키 충돌 방지, 부분 무효화 용이
KeyBuilder
- 키 생성 규칙을 중앙집중화
- 파라미터 순서 고정, null/빈 값 처리, 스키마 버전 전환 기능 포함
- 예시: `product:list:v=1:brand=55:sort=likes_desc:page=0:size=20`
CachePolicy
- TTL, null 캐싱 여부, null TTL 등을 정책으로 관리
- 현재 Jitter 필드는 없으며, 추후 적용 예정
CacheTemplate
- 애플리케이션 계층이 의존하는 캐시 추상화
- findOrLoad 메서드로 Cache-Around 표준화
RedisCacheTemplate
- Redis 기반 구현
- 읽기/쓰기 템플릿 분리(master/replica 구조 대응)
- null 마커(`{“__null__”:true}`)로 캐시 관통 방지
Cache-Aside 동작 방식
..
override fun <T : Any> findOrLoad(
key: String,
type: TypeReference<T>,
policy: CachePolicy,
loader: () -> T?,
): T? {
get(key, type)?.let { return it }
val loaded = loader()
if (loaded == null) {
if (policy.cacheNullAbsent) {
redisMaster.opsForValue().set(key, NULL_MARKER_JSON, policy.nullTtl)
}
return null
}
// 현재는 Jitter 미적용. 추후 policy 확장 시 set(key, loaded, policy.ttlWithJitter())로 교체 예정
set(key, loaded, policy.ttl)
return loaded
}
companion object {
private const val NULL_MARKER_JSON = """{"__null__":true}"""
}
..
캐시 키 생성
- CacheNamespaces와 KeyBuilder를 조합해 호출부에서 일관된 키를 생성, 버전 필드를 활용해 스키마 변경 시 무효화 없이 점진 전환 가능
캐시 조회
- get 호출로 Redis에서 데이터를 읽음
- null 마커를 만나면 DB를 조회하지 않고 null 반환
캐시 미스 시 DB 조회
- loader() 실행
- 결과가 null이면 null 마커를 CachePolicy.nullTtl 동안 저장
- 값이 있으면 CachePolicy.ttl 동안 저장
데이터 반환
- 캐시 또는 DB 결과를 반환
- 캐시 저장 과정에서 직렬화 예외가 발생하면 캐시를 건너뛰고 DB 결과만 반환
(아래 null 캐싱과 Jitter는 캐시에서 발생할 수 있는 문제를 해결하기 위함인데, 잘 작성된 글을 첨부한다. 캐시 문제 해결 가이드 — DB 과부하 방지 실전 팁)
Null 캐싱
- 목적: 존재하지 않는 데이터 요청이 반복될 때 DB 부하를 막기 위함
- 저장 방식: `{“__null__”:true}` 문자열 저장
- TTL: 짧게(예: 30초) 설정해 데이터 생성 가능성 반영
향후 Jitter 적용 계획
- 목적: 캐시 만료 시점이 집중되어 발생하는 Cache Stampede 완화
- 방식: TTL에 ± 일정 비율(예: ±5%) 무작위 편차를 부여
- 구현: CachePolicy에 jitterRatio 필드와 ttlWithJitter 메서드 추가 후 set 호출 시 적용
적용 결과(캐시 적용 후)



캐싱을 적용한 이후 평균 응답 시간은 약 20ms, p95는 약 53ms로 안정화됐다.
테스트 구간에서 동일 키 조회 시 DB를 타는 요청 비율은 거의 0에 가까워졌다.
간단한 해석
캐시가 적중하는 경우 DB를 거치지 않기 때문에 평균 응답 속도와 테일 레이턴시 모두 크게 개선됐다.
상품 조회에서는 null 조회가 존재하지 않아 효용성이 없지만 추후 필요한 곳이 있다면 더 테스트를 해볼 예정이다.
회고
단계별로 인덱스 → 비정규화 → 캐시를 적용하면서 성능을 끌어올렸다.
읽기 비중이 높은 API에서 캐시는 즉각적인 효과를 주지만, TTL·무효화·만료 분산 (Jitter) 같은 운영 요소도 함께 고민해야 안정적으로 유지할 수 있다.
'프로젝트' 카테고리의 다른 글
| 하나로 묶인 트랜잭션을 쪼개다: 이벤트 기반 주문 시스템 리팩토링기 (2) | 2025.09.25 |
|---|---|
| 동시성 제어 전략 선택기: 상황에 맞는 락을 고르는 기준 (0) | 2025.09.24 |
| 좋아요 수 집계 테이블 도입 고민기: 설계 단계에서의 선택 (0) | 2025.09.24 |
| Value Class 도입기: String에서 타입 안전성까지 (0) | 2025.09.22 |