Cache
데이터 임시 저장소
Cache
“캐시(cache, 문화어: 캐쉬, 고속완충기, 고속완충기억기)는 컴퓨터 과학에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
캐시는 캐시의 접근 시간에 비해 미가공 데이터 또는 1차 데이터(raw data or primary data)에 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다.
캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간없이 더 빠른 속도로 데이터에 접근할 수 있다.”
위는 나무위키에서 “cache” 에 대한 정의입니다. 요약하자면, “임시저장소” 와 “계산이나 접근 시간없이 더 빠른 속도로 데이터에 접근할 수 있다.”에 초점을 맞춰서 설명합니다.
아래 그림에서 볼수 있듯이 아래로 갈수록 지연속도와 size는 증가하지만 bandwidh(속도라고 봐도 무방)가 낮아지는 것을 볼 수 있습니다. 좀 더 자세히 설명하자면 cpu에 가까워 질수록 작고, 비싸며, 빨라집니다. 반대로, cpu와 멀어지면 크고, 싸며, 느려집니다.
사용 목적
캐시의 주된 목적은 데이터를 보다 빠르게 가져오는 데 있습니다. 우리가 일반적으로 사용하는 데이터베이스는 대용량 스토리지(Mass Storage) 에 저장되어 있으며, 필요할 때마다 이를 직접 조회하면 속도가 느려질 수밖에 없습니다.
하지만 자주 사용하는 데이터라면, 보다 빠른 접근 속도를 위해 더 상위 계층의 저장 공간을 활용하는 것이 효과적입니다. 예를 들어, RAM 메모리, 1차 캐시(L1 Cache), 2차 캐시(L2 Cache) 등의 계층적 구조를 활용하면, 데이터베이스를 직접 조회하는 것보다 훨씬 더 빠르게 데이터를 가져올 수 있습니다.
데이터베이스를 거치치 않고 데이터를 조회할 수 있다는 말은 데이터베이스 부하를 줄일 수 있다는 말이고, 데이터베이스 부하가 줄어든다면 API 호출시 큰 병목구간인(DB I/O)가 줄어든다는 말과 동일합니다. 그렇다는 것은 API 응답 속도 향상을 의미합니다.
이처럼 캐시는 데이터 접근 속도를 최적화하고, 시스템 성능을 극대화하는 핵심적인 기술입니다.
사용처
아래와 같이 응답속도가 빨라야하는 혹은 조회가 많은 곳에 cache를 사용한다면 보다 효율적입니다.
- 웹 브라우저 캐시: 자주 방문하는 웹사이트의 이미지, CSS, JavaScript 파일을 저장
- 데이터베이스 쿼리 캐싱: 자주 조회하는 데이터를 캐싱해 빠르게 반환
- API 응답 캐싱: REST API의 응답을 저장해 동일한 요청 시 빠른 응답 제공
- CDN(Content Delivery Network): 정적 파일(이미지, 영상, JS, CSS 등)을 엣지 서버에 캐싱
Cache hit & Cache miss
그러나 모든 요청이 캐시에서 데이터를 가져오는 것은 아닙니다. 캐시를 사용한다고 해서 항상 성능이 향상되는 것이 아니라, 얼마나 효율적으로 캐시가 활용되느냐가 중요합니다.
요청이 들어왔을 때 캐시에 데이터가 존재하는 경우(Cache Hit) 와 캐시에 데이터가 없는 경우(Cache Miss) 를 비교해보겠습니다.
- Cache Hit: 캐시에 저장된 데이터를 즉시 반환 -> 빠른 응답 속도
- Cache Miss: 캐시에 데이터가 없어 원본 데이터 저장소(DB)에서 조회 -> 응답 속도 저하
다음은 API 응답 캐싱을 적용한 시스템으로 cache hit와 cache miss를 설명합니다.
Cache Hit
- 사용자가 상품 목록 조회 API를 호출
- 캐시에 상품 목록이 저장되어 있음
- 캐시에서 즉시 데이터를 반환 -> 빠른 응답
Cache Miss
- 사용자가 상품 목록 조회 API를 호출
- 캐시에 데이터가 없음
- 원본 데이터베이스에서 조회 후, 데이터를 캐시에 저장
- 이후 동일한 요청이 오면 Cache Hit 발생
Cache Hit 비율이 높을수록 시스템 성능이 향상되며, 데이터베이스 부하가 줄어듭니다.
하지만 Cache Miss가 자주 발생하면 캐시의 효과가 줄어듭니다.
따라서 효율적인 캐시 운영을 위해서는 적절한 캐시 전략이 필요합니다.
캐시 전략 (Cache Strategies)
캐시를 효율적으로 운영하려면 cache hit 비율을 높여야합니다. cache hit 비율을 높이기 위해선 어떤 데이터를 캐시에 저장할지, 얼마나 오래 유지할지, 캐시가 가득 찼을 때 어떤 데이터를 제거할지, 캐시저장소를 어디에 둘지 등을 결정하는 것이 중요합니다.
또한, 캐시의 용량은 제한적이므로 제한적인 공간에서 cache hit 비율을 높히기 위한 방법을 설명합니다.
캐시 교체 정책(Cache Eviction Policy)
제한적인 캐시 공간에서 어떤 데이터를 제거할 것인지에 대한 정책이 필요합니다.
- Noeviction
- 데이터를 저장만하고 제거하지 않은채로 데이터를 계속 유지(redis default)
- LRU (Least Recently Used): 가장 오랫동안 사용되지 않은 데이터 삭제
- 가장 오래된 데이터를 삭제하는 방식
- 최근에 사용되지 않은 데이터를 캐시에서 제거하여 자주 사용되는 데이터를 유지
- 예제: 웹 브라우저의 탭 캐시
- LFU (Least Frequently Used): 사용 빈도가 낮은 데이터 삭제
- 사용 빈도가 낮은 데이터를 삭제하는 방식
- 자주 사용된 데이터는 유지하고, 사용이 적은 데이터는 제거
- 예제: 뉴스 사이트에서 특정 기사가 일정 기간 동안 조회되지 않으면 삭제
- FIFO (First-In First-Out): 먼저 들어온 데이터 삭제
- 캐시에 먼저 저장된 데이터를 우선 삭제
- 가장 단순한 방식이지만, 최근에 사용된 데이터도 삭제될 수 있음
- 예제: 큐(queue) 방식으로 동작하는 캐시 시스템
- TTL (Time-To-Live): 특정 시간이 지나면 자동 삭제)
- 데이터의 유효 기간을 설정하고, 일정 시간이 지나면 자동으로 삭제
- 캐시가 오래된 데이터를 유지하는 것을 방지
- 예제: 로그인 세션 캐싱
- Random
- 랜덤하게 데이터 제거
각 캐시 전략은 사용 목적에 따라 선택해야 합니다.
예를 들어, LRU는 메모리가 제한적인 환경에서 적합하며, TTL은 최신성을 유지해야 하는 데이터에 적합합니다.
웹 서버의 세션 캐시를 운영할 때 LRU 방식을 사용하면 오랫동안 접속하지 않은 사용자의 세션이 자동으로 삭제되므로, 서버 메모리를 효율적으로 활용할 수 있습니다.
캐시 저장 위치(Local Cache, Global Cache)
캐시는 어디에 저장하느냐에 따라 크게 두 가지로 나뉩니다.
Local Cache (로컬 캐시)
- 개별 애플리케이션 내부에 캐시를 저장
- 속도가 빠르지만, 여러 서버에서 캐시를 공유할 수 없음(데이터의 일관성 유지 X)
- 예: Spring의 @Cacheable, Caffeine, Guava Cache
Global Cache (글로벌 캐시)
- 별도의 캐시 서버(Redis, Memcached 등)에 저장
- 여러 서버가 캐시 데이터를 공유 가능하며, 분산 환경에서도 일관성을 유지할 수 있으나 네트워크 오버헤드 발생
- 예: Redis, Memcached, Ehcache
Cache Strategies
어떤 방식으로 캐시에 데이터를 저장(write)하고 조회(read)할지에 따라 전략이 달라집니다.
- Read 전략
- Look-Aside
- Read-Through
- Write 전략
- Write-Through
- Write-Back (Write-Behind)
- Write-Around
Look-Aside
- Lazy Loading 방식, Application Cache라고도 불림
- 애플리케이션이 캐시를 직접 조회하고, 캐시에 없으면 DB에서 데이터를 가져와 캐시에 저장한 후 반환
- 가장 일반적으로 사용됨 (Spring에서 @Cacheable 적용 시 기본 방식)
- 캐시가 갱신될 필요가 없을 때 유리
- 하지만 Cache Miss 발생 시 DB 부하 증가
- 활용 예시
- API 응답 캐싱 : 동일한 요청이 반복되는 경우 빠르게 응답하도록 캐싱
- 데이터베이스 조회 최적화 : 자주 조회되는 데이터는 캐시에 저장하여 DB 부하 감소
- 동작 방식
- 애플리케이션이 먼저 캐시에 데이터가 있는지 확인
- 캐시에 데이터가 있으면(Cache Hit) 바로 반환
- 없으면(Cache Miss) DB에서 조회 후 캐시에 저장
1
2
3
4
5
6
7
8
public String getUserInfo(String userId) {
String userInfo = cache.get(userId);
if (userInfo == null) { // Cache Miss
userInfo = database.queryUserInfo(userId); // DB 조회
cache.put(userId, userInfo); // 캐시에 저장
}
return userInfo;
}
Read-Through
- 캐시가 DB 조회까지 수행하는 방식
- 애플리케이션이 캐시와 DB를 따로 다룰 필요 없음 -> 구현이 간단
- 데이터가 자주 조회되는 경우, 캐시가 자동으로 업데이트됨
- 하지만 불필요한 데이터가 캐시에 저장될 가능성 있음
- 활용예시
- 검색 엔진 : 자주 검색되는 키워드는 캐시에 저장하여 빠르게 제공
- 상품 목록 API : 상품 리스트 데이터를 DB에서 가져와 캐시에 저장한 후 빠르게 제공
- 동작 방식
- 애플리케이션은 캐시에서 데이터를 조회
- Cache Miss 발생 시, 캐시가 자동으로 DB에서 데이터를 조회 후 저장
- 이후 동일한 요청이 오면 캐시에서 직접 반환
1
2
3
4
5
// Spring에서 @Cacheable과 유사
@Cacheable("userInfo")
public String getUserInfo(String userId) {
return database.queryUserInfo(userId); // 캐시 미스 발생 시 DB 조회
}
Write-Through
- 데이터가 캐시에 먼저 저장된 후, 즉시 DB에도 반영되는 방식
- 읽기(Read)가 많고, 쓰기(Write)가 적은 경우에 적합
- 데이터 일관성 보장 (DB와 항상 동일)
- 쓰기 성능 저하 가능성 존재 (캐시와 DB 모두 반영해야 함)
- 활용예시
- 사용자 프로필 업데이트 : 사용자가 닉네임을 변경하면 캐시에 즉시 반영되고 DB에도 저장됨
- 결제 시스템 : 데이터 일관성이 중요한 경우 (ex: 결제 내역 기록)
- 동작 방식
- 애플리케이션이 데이터를 캐시에 저장
- 캐시는 즉시 DB에도 데이터를 반영
1
2
3
4
public void updateUserInfo(String userId, String newInfo) {
cache.put(userId, newInfo); // 캐시에 저장
database.updateUserInfo(userId, newInfo); // DB에도 즉시 반영
}
Write-Back (Write-Behind)
- 캐시에 먼저 저장하고, 일정 시간 후에 DB에 반영하는 방식
- 쓰기 성능이 가장 빠름 (DB에 즉시 반영하지 않음)
- 쓰기(Write)가 빈번하고, 즉각적인 DB 반영이 필요하지 않은 경우 유용
- 하지만 장애 발생 시 데이터 유실 가능성 있음
- 활용예시
- 로그 저장 시스템 : 실시간으로 많은 로그가 쌓이지만, 일정 주기로 DB에 저장해도 무방한 경우
- 소셜 미디어 게시물 임시 저장 : 사용자가 게시글을 자주 수정하지만, 일정 주기로 DB에 반영하는 방식
- 쇼핑몰 장바구니 : 사용자가 자주 수정하지만, 바로 DB에 반영할 필요가 없는 경우
- 동작 방식
- 애플리케이션이 데이터를 캐시에 저장
- 일정 시간이 지나거나 특정 조건이 만족되면 DB에 반영
1
2
3
4
public void updateUserInfo(String userId, String newInfo) {
cache.put(userId, newInfo); // 캐시에 저장
asyncWriteToDB(userId, newInfo); // 비동기적으로 DB에 반영
}
Write-Around
- DB에 직접 저장하고, 캐시는 갱신하지 않는 방식
- 데이터를 DB에만 저장하고, 캐시는 읽기 요청 시에만 사용 (Look-Aside 방식과 유사)
- 캐시에 불필요한 데이터가 저장되는 것을 방지
- Cache miss가 발생하는 경우에만 캐시에 데이터를 저장하기 때문에 캐시와 DB 내의 데이터가 다를 수 있음
- 활용예시
- 변경이 적은 데이터 (공지사항, 상품 정보, 설정 값 등) : 쓰기보다 읽기가 많은 경우 캐시를 효율적으로 사용 가능
- 동작 방식
- 애플리케이션이 데이터를 DB에만 저장
- 이후 해당 데이터가 조회될 때 캐시에 저장 (Look-Aside 방식과 결합됨)
1
2
3
public void updateUserInfo(String userId, String newInfo) {
database.updateUserInfo(userId, newInfo); // DB에만 저장 (캐시는 업데이트 X)
}
정리
- 읽기 전략
- Look-Aside: 애플리케이션이 캐시를 직접 관리 (Spring @Cacheable 기본 방식)
- Read-Through: 캐시가 DB 조회까지 수행
- 쓰기 전략
- Write-Through: 캐시와 DB를 동시에 업데이트
- Write-Back: 캐시에 먼저 저장 후 나중에 DB 반영 (빠르지만 데이터 유실 위험)
- Write-Around: DB에만 저장하고, 이후 필요할 때 캐시에 적재
캐시 전략 | 장점 | 단점 | 사용 사례 |
---|---|---|---|
Write-Through | 데이터 일관성 유지 | 쓰기 성능 저하 | 사용자 프로필, 결제 데이터 |
Write-Back | 빠른 쓰기 성능 | 데이터 유실 가능 | 로그 저장, 소셜 미디어 피드 |
Write-Around | 캐시 오염 방지 | Cache Miss 증가 | 변경이 적은 데이터 (상품 정보) |
Read-Through | 캐시 관리 간편 | 불필요한 데이터 캐싱 가능 | 검색 엔진, API 응답 캐싱 |
Look-Aside | 유연한 캐싱 가능 | 캐시 갱신 필요 | 일반적인 캐시 (Spring @Cacheable ) |
캐시 사용시 문제
캐시 스탬피드(Cache Stampede) 현상
스탬피드란, “우르르 도망치게 하다, 큰 동물 무리가 특히 흥분하거나 두려워서 갑자기 같은 방향으로 달려가는 상황”라는 뜻을 가지고 있습니다.
뜻에서 볼 수 있듯이 캐시 스탬피드는 요청이 한번에 몰렸을때 캐시가 있음에도 DB에 부하가 몰리는 현상을 말합니다.
- 특정 캐시 데이터가 만료되었을 때, 다수의 요청이 동시에 DB로 몰려 부하가 발생하는 문제
- 캐시가 만료되면 동일한 데이터를 조회하려는 요청들이 한꺼번에 DB로 향하면서 성능 저하 또는 장애가 발생할 수 있음
예를 들어서, 인기 뉴스 목록을 캐시에 저장했는데, 만료 시점에 수천 명의 사용자가 동시에 조회 요청합니다. 기존에는 캐시에서 응답하였으나 현재 캐시에 데이터가 없어 Cache miss가 발생하고, 모든 요청이 DB 조회로 이뤄지게 됩니다.
아래의 방법으로 해결할 수 있습니다.
- 캐시 만료 시간 분산 (TTL Randomization)
- 동일한 시간에 모든 캐시가 만료되지 않도록 랜덤하게 TTL 설정
1
redisTemplate.opsForValue().set("popular_news", newsData, Duration.ofSeconds(300 + Random.nextInt(60)));
- 동일한 시간에 모든 캐시가 만료되지 않도록 랜덤하게 TTL 설정
- Mutex Lock 적용
- 하나의 요청만 DB에서 데이터를 가져오도록 락을 걸고, 나머지 요청은 대기
- 캐시 재생성 전에 갱신 (Cache Warming, preloading)
- 만료되기 전에 미리 새로운 데이터를 로드해 두어 요청이 몰리는 상황을 방지
- Read-Through 캐싱을 사용하여 자동으로 캐시를 갱신
캐시 일관성
캐시와 데이터베이스의 데이터가 일치하지 않는 문제가 발생할 수 있습니다. 데이터가 변경되었는데 캐시에는 반영되지 않아 오래된 데이터가 반환될 수 있는 캐시 일관성의 문제가 있습니다.
예를 들어서 사용자가 프로필을 변경했지만, 여전히 캐시에는 이전 프로필 정보가 남아 있어서, API로 사용자 프로필 조회시 캐시 데이터를 먼저 조회하므로, 변경 사항이 즉시 반영되지 않는 경우입니다.
아래의 방법으로 해결할 수 있습니다.
- Write-Through 전략
- 데이터를 변경할 때 캐시와 DB를 동시에 업데이트
1 2
redisTemplate.opsForValue().set("user:123", updatedUser); userRepository.save(updatedUser);
- 데이터를 변경할 때 캐시와 DB를 동시에 업데이트
- Cache Aside (Look-Aside) 패턴
- 데이터가 변경되면 캐시를 삭제하고, 다음 요청에서 DB에서 가져와 다시 저장
1 2
userRepository.save(updatedUser); redisTemplate.delete("user:123");
- 데이터가 변경되면 캐시를 삭제하고, 다음 요청에서 DB에서 가져와 다시 저장
- TTL(Time-To-Live) 설정
- 캐시가 오래 유지되지 않도록 주기적으로 만료되도록 설정
1
redisTemplate.opsForValue().set("user:123", userData, Duration.ofMinutes(10));
- 캐시가 오래 유지되지 않도록 주기적으로 만료되도록 설정
캐시 전략을 도입할 때 고려해야 할 점
- 캐시 데이터의 일관성을 유지해야 하는가?
- 글로벌 캐시 vs. 로컬 캐시
- Write-Through vs. Write-Back
- 캐시의 저장 공간이 제한적이라면 어떤 교체 정책을 사용할 것인가?
- LRU / LFU / FIFO
- 읽기(Read) 성능과 쓰기(Write) 성능 중 어느 것이 중요한가?
- Write-Back -> 빠른 쓰기, 하지만 장애 발생 시 데이터 유실 가능
- Write-Through -> 안정적인 데이터 보장, 하지만 쓰기 성능 저하
Redis란?
Redis는 메모리 기반의 NoSQL 데이터 저장소로, 빠른 읽기/쓰기 성능을 제공하는 캐시 시스템입니다. 키-값(Key-Value) 구조로 데이터를 저장하며, 데이터 지속성(Persistence) 기능도 지원합니다. 주로 캐시용으로 사용됩니다.
- Redis 주요 특징
- 빠른 속도 -> RAM을 사용하여 높은 처리량 제공
- 다양한 데이터 구조 지원 -> Strings, Lists, Sets, Sorted Sets, Hashes 등
- 데이터 만료(TTL) 지원 -> 캐시 자동 삭제 가능
- 클러스터링(Sharding) 지원 -> 대용량 데이터 처리 가능
[출처]
- https://hazelcast.com/foundations/caching/caching/
- https://velog.io/@claraqn/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%A0%88%EB%94%94%EC%8A%A4-5%EC%9E%A5-%EB%A0%88%EB%94%94%EC%8A%A4%EB%A5%BC-%EC%BA%90%EC%8B%9C%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
- https://yoongrammer.tistory.com/101