🔎 Overview
백엔드 개발자에게 가장 필요한 능력이자, 가장 관심있게 봐야할 부분이 무엇일까? 필자는 '성능 최적화' 가 반드시 그 중 하나라고 생각한다.
지금까지 학습을 하고 프로젝트를 진행해가면서, 이러한 '성능 최적화' 를 위해 여러 시도를 했다. 대표적으로 '쿼리 튜닝' 및 '인덱싱' 이 있다.
이러한 작업들을 어느 정도 만족스럽게 마무리한 이후, 성능을 더욱 극대화하고 싶다는 생각이 들어 찾아보던 중, 캐싱을 적용하면 이러한 목적을 달성할 수 있다는 것을 알게 되었다.
당연히 캐시의 개념과 목적 정도는 알고 있었지만, 이를 더욱 자세하게 이해하고 코드로 녹여내는 것은 별개의 일이다. 당장 내 프로젝트에 캐싱을 적용하고 싶어도, 그러기 위해서는 캐시에 대한 개념과 확실한 이해가 필요하다.
그렇기에, 당분간 캐시의 개념부터 시작해, 스프링에서 캐시를 사용하기 위해 알아야할 지식들을 점차적으로 포스팅할 예정이다.
이번 포스팅은 캐시를 이해하기 위한, 캐시의 개념에 대한 내용이다.
1. 캐시란?
캐시
- 비싼 계산 및 조회 결과를 더 빠른 저장소에 잠시 저장해, 같은 요청이 다시 오면 원본 대신 그 값을 재활용하여 돌려주는 것
- 이를 통해 지연시간(latency)를 감소시키고, 처리량(throughput)을 증가
- 캐시는 원 저장소가 아닌 성능을 위한 복제본이므로 데이터 일관성 문제가 항상 존재
- 캐시에 남아있는 값이 최신 값이 아닐 수 있음
캐시 핵심 요소
1️⃣ Key 설계
- 캐시는 기본적으로
key -> value 맵
- 성능을 가르는 건 키 설계가 절반이라고 봐도 무방
- 키는 정규화 필수
- 키 길이는 너무 길면 네트워크/메모리 측면에서 비효율적
2️⃣ Value (저장 전략)
- 실제 캐싱 대상 데이터
- 캐시에 무엇을 저장할지에 대한 설계 영역
- 단순 DTO뿐 아니라 저장 단위 + 표현 방식 + 크기 전략까지 모두 포함
- 직렬화 전략 선택 필요 (분산 캐시에서는 필수)
- JSON -> 디버깅이 쉽지만 크기가 큼
- Kryo -> 빠르고 작지만 설정이 필요
- JDK Serialization -> 구현이 간단하지만 성능 및 보안 이슈 존재
- 트래픽이 많아질수록, 직렬화 비용이 병목이 될 가능성 존재
- 크기 관리 중요
- 크기가 큰 객체는 네트워크 및 메모리 부담 증가
- 따라서 모두 다 캐싱하는 방법은 안티패턴
- 캐싱 단위 선택
- 캐시 객체는 가급적 불변으로 설계
- 참조 객체 수정은 캐시 오염으로 번질 수 있음
3️⃣ TTL (Time To Live)
- 자동 만료 시간
- 무효화가 어려운 데이터도 시간 단위로 해결 가능
- TTL이 너무 길면 stale 리스크 증가
- TTL이 너무 짧으면 cache miss 확률이 증가하여 효과 감소
- Redis 기반 Spring Cache는 엔트리 TTL을 지원하며, 고정 Duration 및 엔트리별 TTL 함수도 지원
4️⃣ Eviction 정책
- 캐시가 무한대로 커지면 메모리 및 성능 문제 발생
- Caffeine 같은 로컬 캐시는 보통, maximumSize, expireAfterWrite, expireAfterAccess 같은 정책을 조합
캐시 용어
- Cache Hit / Miss
- 키로 조회했을 때 값이 존재하면 Hit, 없으면 Miss
- TTL(Time To Live)
- 값이 캐시에 살아있는 최대 시간
- 생성 시점 기준, 고정된 만료 시간
- TTI(Time To Idle)
- 마지막 접근 이후 유휴 시간이 일정 시간 지나면 만료
- 값을 조회하면 만료 시간 리셋
- Eviction
- 용량 제한 및 정책에 의해 일부 키를 제거
- 예시 알고리즘 -> LRU, LFU, FIFO, Random
- Warmup / Preload
- 캐시를 미리 채워서 Cold Start를 완화
- Negative Caching
- Jitter
- Stampede / Thundering herd
- 특정 키가 만료되는 순간, 방대한 요청이 동시에 Cache Miss 발생
- 이로 인한 DB 과부하 발생
2. 캐시 전략
1️⃣ Cache-Aside
애플리케이션이 캐시 조회
- Cache Hit -> 반환
- Cache Miss -> DB 조회
- DB 결과를 캐시에 저장
- 클라이언트에 반환
- 읽기:
- 캐시 조회 -> Miss 시 DB 조회 -> 캐시에 추가 후 반환
- 쓰기:
- DB에 저장 -> 캐시 Evict(무효화) 또는 Update(갱신)
- 가장 흔한 방식
@Cacheable 어노테이션은 기본적으로, 이 모델을 메서드 단위로 편하게 쓰는 느낌
- 장점
- 간단한 구현
- 필요한 데이터만 캐싱
- 메모리가 효율적
- 단점
- 첫 요청 시 Cold Miss
- 캐시 만료 시 Stampede 위험성 존재
2️⃣ Read-Through
App -> Cache -> DB
- 캐시가 DB 접근을 대신해서 수행
- Cache Miss 시, DB에서 데이터를 가져와 자동으로 저장
- 장점
- 단점
- 캐시 시스템 복잡
- 특정 DB 로직 제어의 어려움
3️⃣ Write-Through
1. 데이터 쓰기 요청
2. 캐시에 기록
3. DB에 기록
- 쓰기 시점에 캐시와 DB를 함께 갱신
- 장점
- 캐시와 DB 간의 일관성을 항상 유지
- 읽기 시 Cache Miss 확률 적음
- 단점
- 쓰기 비용 증가
- 잘 안 쓰이는 데이터도 캐시에 적재
4️⃣ Write-Behind (Write-Back)
1. 캐시에 기록
2. 즉시 응답
3. 배치/비동기로 DB에 반영
- 캐시에 먼저 쓰고, DB에는 비동기로 나중에 반영
- 로그, 통계 등에서 주로 사용
- 장점
- 쓰기 성능 향상
- 배치 처리로 DB 부하 감소 가능
- 단점
- 장애 시, 데이터 유실로 인한 정합성 이슈
- 따라서, 강한 일관성을 보장하기 어려움
5️⃣ Refresh-Ahead
- 만료 이전에 미리 갱신
- TTL 만료 이전, 백그라운드 갱신을 통해 히트율과 안정성을 높임
- 장점
- Cache Stampede 방지
- 일정한 응답 속도
- 단점
6️⃣ Stale-While-Revalidate
1. 캐시 만료
2. 기존 값 응답
3. 비동기 갱신
- 만료 시에도 오래된 값 먼저 응답
- 데이터의 정합성 보다는, 가용성과 지연을 우선시하는 선택
- Cloudflare 같은 대규모 CDN에서 많이 사용
- 장점
‼️ 문제 상황
TTL 만료
-> 사용자 요청 1만 건이 동시에 발생
-> DB 조회 폭주
-> 지연/장애
- 이를 해결할 방법으로, 보통 2가지 선택지 존재
- 최신성 보장
- TTL이 만료되었으므로 DB에서 조회
- 그동안 사용자 대기
- 트래픽이 많으면 장애 위험 존재
- Stale 허용
- 일단 예전 값 응답
- 백그라운드에서 새로 갱신
- 이로 인한 사용자 지연은 0에 수렴
📌 대규모 트래픽 시스템은 보통 Stale 허용 방식을 선택
- 사용자는 느린 최신 데이터보다, 빠르고 조금 지난 데이터를 더 선호한다는 철학
- 특히 트래픽 폭주 상황에서는, 잠깐의 Stale 데이터보다 잠깐의 장애 발생이 훨씬 크리티컬
- 상품 목록, 통계, 조회수 등에서 사용 가능
- 조금의 시간적 차이가 존재해도 치명적이지 않은 데이터
- 계좌 잔액, 재고 수량, 결제 상태 등에서는 사용하면 안됨
💡 일반적으로 정합성 유지를 위해 다음과 같이 설계
Soft TTL -> 60초 (Stale 허용)
Hard TTL -> 5분 (Deadline)
- Soft TTL 초과 -> Stale 응답 + 백그라운드 갱신
- Hard TTL 초과 -> 무조건 DB 조회
- 이러한 방식으로 너무 오래된 데이터는 방지
캐시 전략 요약
| 패턴 | 읽기 성능 | 쓰기 성능 | 일관성 | 복잡도 |
|---|
| Cache-Aside | 높음 | 보통 | 보통 | 낮음 |
| Read-Through | 높음 | 보통 | 보통 | 중간 |
| Write-Through | 높음 | 낮음 | 높음 | 중간 |
| Write-Behind | 높음 | 매우 높음 | 낮음 | 높음 |
| Refresh-Ahead | 매우 높음 | 보통 | 보통 | 높음 |