Part 0. 개선 할 기능

룩 상세 조회 기능은 룩 이미지와 해당 룩에 착용된 상품 리스트를 모두 조회하는 기능이다. 이 기능은 내부적으로 5개의 테이블에서 데이터를 조회하고 있고, 생성된 룩은 생성자만 수정할 수 있어 데이터 변경 빈도가 낮다는 특성을 가지고 있다.
Part 1. 문제 상황
💻 테스트 환경
- 단일 WAS(SpringBoot) : AWS EC2 t4g.xlarge(4 vCPU, 16GiB Memory)
- 단일 DB(MySQL) : AWS EC2 t4g.medium(2 vCPU, 4GiB Memory)
- 단일 Redis : AWS EC2 t4g.medium(2 vCPU, 4GiB Memory)
- 부하테스트 툴 : Locust
- APM 툴 : Pinpoint
- Metric 수집 및 시각화 툴 : Prometheus + Grafana
🧪 테스트 방식
- vUser : 500
- Ramp up : 100
- Run Time : 5m
- Think Time : 0.2s
🕹️ 부하테스트 결과


TPS : 534
p99 latency : 1450ms
Average response time : 668ms
부하테스트 결과 평균 응답 시간이 668ms로 개선이 필요해보인다
Part 2. Redis 캐싱 전략
룩 상세 조회의 경우 업데이트가 빈번하지 않아 캐시를 적용하기 좋다!
룩이 자주 변경되는 경우 데이터 불일치를 방지하기 위해 캐시를 더 빠르게 갱신해야 하는데 이는 캐시 적용의 효율성을 떨어뜨린다. 따라서 조회 기능이라 하더라도 데이터 변경 빈도가 높다면 캐시가 적합하지 않을 수 있으며 기능의 특성과 요구사항을 고려하여 캐시 적용 여부를 결정해야 한다. 다만 현재 기능의 경우 룩 생성자만 변경 가능하여 업데이트 빈도가 낮아 캐시 적용에 적합하다고 판단했다.
읽기 전략은 Cache-Aside
캐시 읽기 전략으로는 다음 두 가지를 고려했다
1. Read Through
2. Cache-Aside
Read Through의 경우 Application 서버는 캐시만 바라보고 캐시 미스가 발생하면 캐시가 직접 DB를 통해 조회하는 방식이다. 이는 캐시에 장애가 생겼을 때 DB 조회 경로가 차단되어 곧바로 서비스 장애로 이어질 수 있다. 반면 Cache-Aside은 캐시 미스가 발생하더라도 Application 서버가 직접 DB에서 조회해 캐시에 저장하는 구조이다. 이 경우 Redis에 장애가 발생하더라도 애플리케이션이 DB에서 조회한 데이터를 응답할 수 있어 Redis의 장애가 서비스의 장애로 전파되지 않는다. 따라서 가용성과 안정성을 고려하여 읽기 전략은 Cache-Aside 방식으로 결정했다
쓰기 전략은 Write-Through
쓰기 전략으로는 다음 3가지를 고려했다
1. Write-Behind
2. Write-Around
3. Write-Through
Write-Behind의 경우 캐시에만 먼저 쓰고 DB에는 백그라운드에서 반영한다. 이 방식도 Read Through와 비슷하게 Redis에 문제가 생기는 경우 데이터 손실 위험이 있다. 그리고 Write-Around의 경우 DB에만 쓰고 캐시는 업데이트 하지 않는 방식으로 캐시 미스가 나면 그때 DB에서 로드하는 방식이다. 마지막으로 Write-Through는 DB와 캐시에 동시에 데이터를 쓰는 방식으로 데이터 일관성은 보장되지만 쓰기 성능이 느리다.
먼저 데이터 손실 위험이 있는 Write-Behind는 제외했다. 현재 룩 상세 조회의 경우 룩을 생성한 사용자만 데이터 업데이트가 가능하다. 따라서 해당 사용자가 데이터를 변경한 경우 캐시까지 함께 변경해야 기존에 남아있던 캐시로 인해 데이터 불일치 현상이 발생하지 않는다. 따라서 결과적으로는 성능이 낮더라도 데이터 정합성을 위해 Write-Through 방식을 선택했고 결과적으로는 Cache-Aside + Write-Through 조합이 되었다.
Part 3. Cache Stampede
캐시가 만료된 상황에서 동시에 많은 트래픽이 몰린다면?
캐시 쇄도(Cache Stampede) 현상이 발생 가능하다. 캐시가 만료되는 순간 동시에 대량의 요청이 DB로 몰리면서 DB 과부하를 일으키고 전체 서비스 장애로 이어질 수 있다. 해결 방안으로는 분산 락(Distributed Lock)을 활용하여 캐시 갱신 시점에 하나의 요청만 DB에 접근하도록 제어하는 방법이 있다. Redis의 SETNX 명령을 사용해 락을 획득한 스레드만 DB 조회를 수행하고, 나머지는 잠시 대기 후 갱신된 캐시를 사용하도록 구현할 수 있다.
또한 Jitter 방식을 활용하여 캐시 만료 시간에 랜덤한 편차를 추가해 동시 만료를 방지하는 방법도 있다. Jitter는 캐시 TTL에 무작위 시간을 추가하여 모든 캐시가 동일한 시점에 만료되는 것을 방지한다. 예를 들어 30분 TTL에 ±3분의 jitter를 적용하면 각 캐시가 27~33분 사이에 랜덤하게 만료되어 캐시 쇄도 현상을 자연스럽게 분산시킬 수 있다.
분산락으로 예방하자
현재는 모든 룩들이 일괄적으로 캐싱되는게 아니라 개별적으로 조회가 발생함에 따라 캐싱되기 때문에 Jitter 방식의 효과가 제한적이라고 생각했다. 하지만 인기 룩의 경우 TTL이 만료되는 시점에 집중적인 트래픽이 발생하여 DB 과부하를 일으킬 가능성이 여전히 존재한다. 따라서 이러한 문제를 해결하기 위해 분산락 방식을 통해 DB 부하를 줄이는 방식을 선택했다.
RedisCacheWriter의 lockingRedisCacheWriter 메서드를 통해 쉽게 분산락을 적용할 수 있었다. 내부 구현을 간략히 설명하면 캐시 미스가 발생했을 때 Redis의 SET 명령어에 NX(키가 존재하지 않을 때만 설정)와 EX(만료시간 설정) 옵션을 사용하여 락을 획득한다. 락을 성공적으로 획득한 스레드만 DB에 접근하여 데이터를 조회하고 캐시에 저장하며, 락 획득에 실패한 다른 스레드들은 짧은 시간 대기 후 캐시에서 갱신된 데이터를 조회하게 된다. 이를 통해 동시에 여러 요청이 DB에 접근하는 것을 방지하고 시스템 안정성을 확보할 수 있었다.
Part 4. Cache Penetration
캐시 최신화를 위해 DB 조회 시 빈 값이라면?
Cache Penetration 문제로 존재하지 않는 데이터에 대한 반복적인 요청이 캐시를 우회하여 지속적으로 DB에 부하를 가하는 상황이다. ‘값이 없음’을 캐싱함으로써 DB의 트래픽을 줄이려면 블룸 필터를 사용하는 방법이 있다. 블룸 필터를 사용하면 확률적으로 캐시 관통을 방지하지만 블룸 필터의 정합성이 깨진다면, 블룸 필터를 복구하기 위해 모든 캐시를 읽어야 해서 운영이 어렵다
또 다른 방법으로는 널 오브젝트 패턴을 사용해서 ‘값이 없음’을 캐싱하는 방법이 있다. 객체 타입은 부재를 뜻하는 객체를 선언하여 사용하면 되지만, 원시 타입은 이 객체를 대체할 특정 값을 지정해야 한다. 예를 들어 양수만 존재하는 정수 타입의 데이터를 캐시할 때는 음수인 정수의 최솟값으로 '값이 없음'을 나타내기로 할 수 있다.
캐시 시스템 장애가 발생한다면?
캐시 시스템 장애 시에는 Circuit Breaker 패턴을 적용하여 캐시 장애를 감지하고 자동으로 DB 직접 조회로 전환해야 한다. 장애 감지 후 일정 시간 동안 캐시 호출을 차단하고, 주기적으로 캐시 상태를 확인하여 복구되면 다시 캐시를 사용하도록 한다. 또한 Multi-layer Caching 전략으로 Redis 장애 시 로컬 캐시(Caffeine)로 fallback하거나 캐시 클러스터링을 통해 고가용성을 확보하는 것도 가능하다.
그리고 Graceful Degradation 원칙에 따라 캐시 없이도 서비스가 동작할 수 있도록 DB 성능 최적화와 Connection Pool 튜닝을 병행해야 한다. 장애 상황에서는 비즈니스 적으로 비교적 덜 중요한 기능은 서비스를 중단하고 핵심 기능만 DB를 통해 운영이 될 수 있도록 하자.
Part 5. 개선 결과
테스트 환경과 방식은 개선전과 동일하다
🧪 테스트 방식
- vUser : 500
- Ramp up : 100
- Run Time : 5m
- Think Time : 0.2s

TPS : 1474
p99 latency : 172ms
Average response time : 89ms
📈 개선 전 대비 성능 비교 (Before vs After)
| Metric | Before (개선 전) | After (개선 후) | 개선 효과 |
| TPS (Transactions/sec) | 534 | 1474 | 176.0% ↑ |
| p99 latency | 1450ms | 172ms | 88.1% ↓ |
| Average response time | 668ms | 89ms | 86.7% ↓ |
- TPS: 초당 처리 가능한 트랜잭션 수가 534 → 1474로 약 176.0% 증가
- p99 latency: 전체 요청 중 99%가 1450ms → 172ms 이내에 처리되도록 약 88.1% 단축
- Average response time: 668ms → 89ms로 약 86.7% 단축
출처
https://toss.tech/article/cache-traffic-tip
'프로젝트' 카테고리의 다른 글
| [바로] AI 가상 피팅 기능 사용량 제한하기 (Ft. Token Bucket) (0) | 2025.09.25 |
|---|---|
| [바로] 일괄 주문 기능 개선 Vol.2 (Ft. Kafka, Transactional Outbox) (0) | 2025.09.15 |
| [바로] 스와이프 기능 개선 (Ft. MongoDB, Tomcat Thread 튜닝) (0) | 2025.09.11 |
| [바로] 인기상품조회 기능 개선 (Ft. DB Connection Pool) (0) | 2025.09.05 |
| [바로] Redis의 Lua Script는 Atomic 하지 않다..? (0) | 2025.09.04 |