개발자를위한레디스 5장 - 레디스를 캐시로 사용하기
레디스를 캐시로 사용하기
레디스를 캐시로 사용하기
레디스를 캐시로 사용하는 방법을 설명합니다.
캐시란?
캐시란 데이터의 원본보다 더 빠르고 효율적으로 액세스할 수 있는 임시 데이터 저장소를 의미합니다. 사용자가 동일한 정보를 반복적으로 액세스할 때 원본이 아니라 캐시에서 데이터를 가지고 옴으로써 리소스를 줄일 수 있습니다.
애플리케이션이 다음 조건을 만족시킨다면, 캐시를 도입했을 때 성능을 효과적으로 개선할 수 있습니다.
- 원본 데이터 저장소에서 원하는 데이터를 찾기 위해 검색하는 시간이 오래 걸리거나, 매번 계산을 통해 데이터를 가져와야 한다.
- 캐시에서 데이터를 가져오는 것이 원본 데이터 저장소 데이터를 요청하는 것보다 빨라야 한다.
- 캐시에 저장된 데이터는 잘 변하지 않는 데이터다.
- 캐시에 저장된 데이터는 자주 검색되는 데이터다.
캐시로서의 레디스
- 레디스는 단순하게 key-value 형태로 저장하므로, 데이터를 저장하고 반환하는 것이 간단
- 다양한 자료 구조를 제공하기 때문에 애플리케이션에서 사용하던 list, hash 등의 자료 구조를 변환하는 과정 없이 레디스에 바로 저장 가능
- 모든 데이터를 메모리에 저장하는 인메모리 데이터 저장소이기 때문에 데이터를 검색하고 반환하는 것이 상당히 빠름
- 자체적으로 고가용성(HA) 기능 존재
- 레디스의 클러스터를 사용하면 캐시의 스케일 아웃을 쉽게 처리 가능
캐싱 전략
레디스를 캐시로 사용할 때 레디스를 어떻게 배치할 것인지에 따라 서비스의 성능에 큰 영향을 끼칠 수 있음
읽기전략 - 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;
}
쓰기 전략과 캐시의 일관성
데이터가 변경될 때 원본 데이터베이스에만 업데이트되어 캐시에는 반영되지 않는다면 데이터 간 불일치 발생.(캐시 불일치)
예를 들어서. a라는 값은 데이터베이스에 28로 업데이트됐지만, 레디스에는 아직 3인 데이터가 저장되어 있음.
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에도 즉시 반영
}
cache invalidation
- 데이터베이스에 값을 업데이트할 때마다 캐시에서는 데이터를 삭제
- 저장소에서 특정 데이터를 삭제하는 것이 새로운 데이터를 저장하는 것보다 훨씬 리소스를 적게 사용
write behind(write back)
- 캐시에 먼저 저장하고, 일정 시간 후에 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에 반영
}
캐시에서의 데이터 흐름
레디스는 메모리에 모든 데이터를 저장하며, 기본적으로 메모리는 서버의 스토리지보다 훨씬 적은 양을 보관할 수밖에 없으므로 캐시는 가득 차지 않게 유지해야 하며 계속해서 새로운 데이터가 저장되고 기존 데이터는 삭제될 수 있도록 관리되어야 합니다. 그리거에 캐시로 레디스를 사용할 때에는 데이터를 저장함과 동시에 적절한 시간의 TTL 값을 지정하는 것이 바람직합니다.
만료시간
- TTL(Time To Live)은 데이터가 얼마나 오래 저장될 것인지를 나타내는 시간 설정
- 만료시간은 일반적으로 초단위로 표현
- 만료 시간이 설정되면 해당 키와 관련된 데이터는 지정된 시간이 지난 후에 레디스에서 자동으로 삭제
아래는 “a라는 키에 100을 저장한 뒤 EXPIRE 커맨드를 이용해 만료 시간을 60초로 설정하는 예제”입니다.
1
2
3
4
5
6
7
8
> SET a 100
"OK"
> EXPIRE a 60
(integer) 1
> TTL a
(integer) 58
데이터를 조작하거나 키의 이름을 바꿔도 설정된 만료 시간은 변경되지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
> INCR a
(integer) 101
> TTL a
(integer) 51
> RENAME a apple
"OK"
> TTL apple
(integer) 41
기존 키에 새로운 값을 저장해 덮어 쓸 때는 이전에 설정한 만료 시간은 사라집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
> SET b 100
"OK"
> EXPIRE b 60
(integer) 1
> TTL b
(integer) 57
> SET b banana
"OK"
> TTL b
(integer) -1
레디스에서 키가 만료됐다고 해도 바로 메모리에서 삭제되는 것은 아닙니다.
passive방식(클라이언트가 키에 접근하고자 할 때 키가 만료됐다면 메모리에서 수동적으로 삭제하는 방식)과
active방식(TTL값이 있는 키 중 20개를 랜덤하게 뽑아낸 뒤, 만료된 키를 모두 메모리에서 삭제하는 방식) 이 두가지로 삭제됩니다.
만료된 키를 곧바로 삭제하지 않기 때문에 키를 삭제하는 데에 들어가는 리소스를 줄일 수 있지만, 그만큼 메모리를 더 사용할 가능성이 존재합니다.
최악의 경우 전체 메모리의 1/4는 이미 만료된 키 값일 수 있습니다.
메모리 관리와 maxmemory-policy 설정
메모리의 용량을 초과하면 레디스는 내부 정책을 사용해 어떤 키를 삭제할지 결정합니다.
- maxmemory 설정: 데이터의 최대 저장 용량을 설정
- maxmemory-policy 설정값: 용량을 초과할 때의 처리 방식을 결정
Noeviction
기본값으로, 레디스에 데이터가 가득 차면 에러를 반환하며 데이터는 삭제하지 않는 정책
장애 상황으로 이어질 수 있고, 관리자가 데이터를 직접 지워야 하기 때문에 레디스를 캐시로 사용할 때 권장되지 않습니다.
LRU eviction
레디스에 데이터가 가득 찼을 때, 가장 최근에 사용되지 않은 데이터부터 삭제하는 정책 레디스는 LRU 알고리즘을 이용한 2가지 설정값 존재.
- volatile-lru: 만료 시간이 설정되어 있는 키에 한해서 LRU 방식으로 삭제
- allkeys-LRU: 모든 키에 대해 LRU 방식으로 삭제
LFU eviction
레디스에 데이터가 가득 찼을 때 가장 자주 사용되지 않은 데이터부터 삭제하는 정책
- volatile-lfu: 만료 시간이 설정되어 있는 키에 한해서 LFU 방식으로 삭제
- allkeys-lfu: 모든 키에 대해 LFU 방식으로 삭제
RANDOM eviction
레디스에 저장된 키 중 하나를 임의로 골라내 삭제.
알고리즘을 사용하지 않기 때문에 미세하지만 레디스의 부하 감소
랜덤하게 삭제되기 때문에 자주 사용하는 캐시가 삭제되면 다시 넣어주는 작업이 더 부하가 크기에 권장되지 않음
- volatile-random: 만료 시간이 설정되어 있는 키에 한해 랜덤하게 삭제
- allkeys-random: 모든 키에 대해 랜덤하게 삭제
volatile-ttl
만료 시간이 가장 작은 키 삭제. 근사 알고리즘을 사용하기 때문에 간단하게 키를 찾을 수 있음.
캐시 스탬피드(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 캐싱을 사용하여 자동으로 캐시를 갱신
책에서는 적절한 만료 시간 설정 하는 방법을 다루고 있습니다. 아래에서 2가지 방식에 대해 다시 설명합니다.
선계산
look aside 방식으로 캐시를 사용할 때 애플리케이션은 다음 코드와 비슷하게 동작할 것임.
“캐시에 데이터가 있는지 확인하고, 없으면 데이터베이스에서 데이터를 가져와 레디스에 저장.”
1
2
3
4
5
6
def fetch(key):
value = redis.get(key)
if(!value):
value = db.fetch(key)
redis.set(value)
return value
만약 키가 실제로 만료되기 전에 이 값을 미리 갱신해준다면 여러 애플리케이션에서 한번에 데이터베이스에 접근해 데이터를 읽어오는 과정을 줄일 수 있을 것입니다.
다음 코드에서는 레디스가 실제로 만료되기 전 랜덤한 확률로 데이터베이스에 접근해서 데이터를 읽어와 캐시의 값을 갱신합니다.
1
2
3
4
5
6
7
8
9
10
11
12
def fetch(key, expiry_gap):
ttl = redis.ttl(key)
if ttl - (random() * expiry_gap) > 0:
return redis.get(key)
else:
value = db.fetch(key)
redis.set(value, KEY_TTL)
return value
# Usage
fetch('hello', 2)
상황에 따라 캐시 스탬피드 현상을 줄일 수 있기 때문에 전체적인 성능을 향상시킬 수도 있습니다. 이때 expiry_gap 값은 적절히 설정해주는 것이 중요하다. (불필요한 작업이 늘어날 수 있음)
PER 알고리즘
캐시 값이 만료되기 전에 언제 데이터베이스에 접근해서 값을 읽어오면 되는지 최적으로 계산할 수 있습니다.
데이터를 가져오는 과정에서 GET 대신 아래 함수를 사용하는 것은 캐시 스탬피드 현상을 줄이고 성능을 최적화할 수 있습니다.
1
currentTime - ( timeToCompute * beta * log(rand()) ) > expiry
- currentTime: 현재 남은 만료 시간
- timeToCompute: 캐시된 값을 다시 계산하는 데 걸리는 시간
- beta: 기본적으로 1.0보다 큰 값으로 설정 가능
- rand(): 0과 1 사이의 랜덤 값을 반환하는 함수
- expiry: 키를 재설정할 때 새로 넣어줄 만료 시간
위의 수식을 보면 만료시간이 점점 다가올 때 더 자주 만료된 캐시 항목을 확인하게 되는 것을 의미합니다.
이 알고리즘은 만료 시간에 가까워 질수록 true를 반환할 확률이 증가하므로, 이는 불필욯나 재계산을 효과적으로 방지하는 가장 효율적인 방법일 수 있습니다.
따라서 데이터를 가져오는 과정에서 GET 대신 이 함수를 사용하는 것은 캐시 스탬피드 현상을 줄이고 성능 최적화하는데 도움이 됩니다.
세션 스토어로서의 레디스
세션이란?
서비스를 사용하는 클라이언트의 상태 정보를 의미합니다.
레디스는 key-value 형식으로 사용이 간단하며 string, set, hash 등의 자료 구조를 제공하기 때문에 사용자 데이터를 저장하기에 용이합니다.
세션 스토어가 필요한 이유
- 서비스 초창기 또는 프로토타입용 서비스에서는 굳이 세션 스토어가 필요x
- 웹 서버가 늘어나면 여러 개의 웹 서버에 트래픽을 분배할 수 있기 때문에 더 많은 유저 수용 가능.
- 이때 각 웹 서버별로 세션 스토어를 따로 관리한다면 유저는 유저의 세션 정보를 갖고 있는 웹 서버에 종속됨.
- 특정 웹 서버에 유저가 몰려 트래픽이 집중되는 상황이 발생해도 유저는 다른 서버를 사용할 수 없어, 트래픽을 분산시킬 수 없는 상황 발생
- 유저의 세션 정보를 모든 웹 서버에 복제해서 저장하는 방법을 생각해볼 수 있음
- 데이터가 복제되면서 불필요한 저장 공간을 차지하게 되고, 불필요한 네트워크 트래픽도 다수 발생
- 데이터베이스를 세션 스토어로 사용하는 방법 고려
- 유저가 많아질수록 서비스의 전반적인 응답 속도를 저하시키는 요인이 될 가능성 존재
- 레디스를 세션 스토어로 사용해 서버, 데이터베이스와 분리하고, 여러 서버에서 세션 스토어를 바라보도록 구성
- 트래픽을 효율적으로 분산시킬 수 있고, 데이터의 일관성을 지킬 수 있음. (모든 웹 서버가 동일한 세션 스토어 사용)
- 관계형 데이터베이스보다 훨씬 빠르고 접근성 용이
- 레디스의 hash 자료 구조는 세션 데이터를 저장하기에 알맞은 형태
캐시와 세션의 차이
- 캐시는 데이터베이스의 완벽한 서브셋으로 동작(look aside 전략)
- 캐시에 저장된 데이터는 여러 애플리케이션에서 함께 사용 가능
- 세션 스토어에 저장된 데이터는 여러 사용자 간 공유되지 않으며, 특정 사용자 ID에 한해 유효
- 세션이 활성화되어 있는 동안에는 애플리케이션은 유저의 데이터를 세션 스토어에만 저장 (데이터베이스 X)
- 유저가 로그아웃할 때 세션은 종료되며 이때 데이터의 종류에 따라 데이터베이스에 저장해 영구적으로 보관(Ex. 장바구니)할 것인지, 삭제(Ex. 최근 본 상품)할 것인지 결정
- 세션 스토어에 장애가 발생하면 내부 데이터가 손쇨될 수 있으므로 레디스를 세션 데이터로 활용할 때는 캐시로 사용할 때보다 더 신중한 운영이 필요