무한스크롤의 경우, 어떻게 Redis 키를 설정하면 좋을지에 대한 고민
(생각의 흐름을 적고, 정리해보는거라 두서없을 가능성 200%입니다.)
문제사항
게시판 글목록 캐시하기 단, 100개씩 보여주는 무한 스크롤
내가 생각한 캐시 고려사항
- 데이터 정합성
- 메모리 효율성
- TTL 관리
여러 사항을 고려해 보기
키 당 데이터를 50개씩 저장하면?
가장 최신 글의 ID가 1100이라 해보자.
처음 게시판에 들어오면 다음과 같이 데이터 캐시
1100 - 1051,
1050 - 1001
유저 쭉쭉 내리다 1001에 닿으면
서버로 요청을 보내서
1000 - 951 캐시하고,
950 - 901 캐시함.
→ 이건 좀 비효율적이다.
왜 ?
한 번에 보여줘야 할 데이터가 100개인데, 해당 데이터를 보여주기 위해 계속 2번 나눠서 저장해야 한다.
즉, 한 페이지에 있는 데이터에 접근하는데 Redis에 있는 2개의 키에 왔다 갔다 접근해야한다.
→ 연속적인 데이터에 접근하기 위해 RAM의 두 키 사이를 왔다갔다 해야 하기에 불필요한 오버헤드가 발생한다.
→ Redis는 클라이언트-서버 모델이니까, 각 키에 대한 요청이 네트워크로 전달된다. 두 개의 키를 요청하기에 두 번의 네트워크 왕복이 생
긴다. (한 번에 데이터를 가져오는 거보다 응답 시간이 2배 걸림!)
1100개의 글이 있다 했을 때 100 크기로 캐시하면 키가 11개이지만, 50개로 하면 키가 22개.
→ 불필요하게 키가 많아진다.
→ Redis는 키당 40 - 50바이트 정도의 메타데이터를 저장한다.
그러니까 불필요하게 키가 많아지면 저장하는 메타데이터도 많아지겠지?
메모리를 더 많이 잡아먹을 거란 생각!
전체 흐름 정리
단계 | 작업 내용 |
요청 수신 | 클라이언트가 요청을 보냄 |
키 계산 | 필요한 Redis 키를 계산 (key1, key2) |
데이터 조회 | Redis에서 두 키(key1, key2)의 데이터를 각각 조회 |
데이터 병합 | 두 키의 데이터를 병합하여 요청 범위(1100~1001)를 충족 |
응답 생성 | 병합된 데이터를 JSON으로 변환하여 클라이언트에 반환 |
조회도 2번, 병합까지 해야하기에 효율이 나쁘다.
키 당 데이터를 100개씩 저장하면?
가장 최신 글이 1100이라 해보자.
이때 Redis엔 다음과 같이 캐시 될 것이다.
1100~1001 캐시
유저가 1001에 닿으면
1000 - 901 캐시함
50개씩 캐시하는 것과 비교해서 어떤 장단점이 있을지 생각해 보자.
장점
- 단일 키 조회를 통해 클라이언트가 요청한 모든 데이터를 반환할 수 있다.
- Redis에 한 번만 접근하면 된다.
- 메타데이터 오버헤드도 줄어든다.
- 한 페이지당 1개의 키로 관리하니까, 여러 키로 분할 관리하는 것보다 전체 메모리 사용량이 줄어들 것!
- 키 당 불필요한 데이터를 저장할 이유가 없다.
- 유저에게 보여주는 데이터 개수만큼만 저장하니까!
단점
- 캐시 갱신을 하면 50 사이즈보다 캐시 갱신 사이즈가 크다.
전체 흐름 정리
단계 작업 내용
단계 | 작업 내용 |
요청 수신 | 클라이언트가 요청을 보냄 |
데이터 조회 | Redis에서 키(key1)의 데이터를 조회 (한 페이지 당 하나의 키로 저장되니 계산할게 없다.) |
응답 생성 | 키에 할당된 데이터를 JSON으로 변환하여 클라이언트에 반환 |
조회도 한 번만 하면되고, 데이터를 병합할 필요도 없으니 더 효율적이다!
키 당 데이터를 150개씩 저장하면?
캐시엔 이런 식으로 저장이 될 것이다.
1100 - 951 ,
950 - 801
장점
- 키의 개수는 50개씩 저장했을 때 22개 , 100개씩 저장하면 11개 150개씩 저장하면 8개의 키가 필요하기에 메모리 부분에서 더 효율적이다 .
단점
- 유저는 100개씩 요청을 하는데, 서버에선 150개씩 캐싱을 하니까 불필요한 데이터 전송이 발생한다.
- 데이터 전송 사이즈도 커지니까 네트워크 대역폭도 낭비가 된다.
- Redis 공간이 낭비된다.
- 유저는 100개씩 읽는 데 150개씩 저장하니까 사용하지 않는 데이터가 추가로 Redis에 저장되어 있다!
- 캐시 갱신이 발생하면 데이터 갱신의 사이즈가 커진다.
- 갱신해야 하는 사이즈(100)보다 더 크게 갱신(150)
- 클라이언트가 추가로 데이터 필터링을 해야 한다.
- 100개만 보여주는데, 서버에서 150개를 주니까! 150개 중 100개만 필터링하겠지?!
- 메모리가 가득 찼을 때, 예상치 못한 데이터가 Evict 될 수도 있다!
상기한 부분들을 고려했을 때, 100개씩 저장하는 것이 가장 좋아 보인다!
새로운 데이터가 추가되거나 , 데이터가 삭제되었을 때, 캐시 갱신을 어떻게 해야 할까?
일단 떠오르는 방법은 2가지
- 갱신된 부분의 캐시만 업데이트
- 캐시 전체 새로 업데이트
1. 갱신된 부분만 업데이트
새로운 글이 추가됐다고 생각해 보자.
Redis에 다음처럼 저장되어 있다고 해보자.
key1 -> [1100 , 1099 , ... , 1001]
key2 -> [1000 , 999 , ... , 901]
새로운 글이 추가됐다.
부분 업데이트니까 key1만 업데이트된다.
key1 -> [1101 , 1099 , ... , 1002]
key2 -> [1000 , 999 , ... , 901]
여기서 문제는
1001번 글이 사라져, 유저가 1001번 글을 볼 수 없다.
중간 데이터가 삭제됐다고 생각해 보자.
# 초기 상태
key1 → [1100, 1099, ..., 1001]
key2 → [1000, 999, ..., 901]
1150 번째 글을 삭제했다고 해보자!
# 1150 번째 글 삭제 → key1의 1150번 제거 (부분 갱신)
key1 → [1100, 1099, ..., 1051, 1049, ..., 1001] (99개)
100개씩 데이터를 보여주니까 부분갱신을 해야겠지!?
key1 → [1100, 1099, ..., 1051, 1049, ..., 1001, 1000] (ID 1000 추가!)
key2 → [1000, 999, ..., 901]
1000번 글이 중복으로 존재한다 🥲
일단 부분 갱신을 할 때 다음과 같은 문제가 떠오른다..
1. 특정 데이터가 유저에게 노출되지 않는다.
2. 특정 데이터가 중복으로 보일 수 있다.
2. 캐시 전체 업데이트
새로운 글이 추가됐다고 생각해 보자.
캐시 키에 version을 달아서 관리해 보자.
# 초기 상태
key1:v1 -> [1100 , 1099 , ... , 1001]
key2:v1 -> [1000 , 999 , ... , 901]
새로운 글이 추가되면?
# 초기 상태
key1:v2 -> [1101 , 1099 , ... , 1002]
key2:v2 -> [1001 , 999 , ... , 902]
누락되는 데이터가 없다.
중간 데이터가 삭제됐다고 생각해 보자.
# 초기 상태
key1:v1 → [1100, 1099, ..., 1001]
key2:v1 → [1000, 999, ..., 901]
1150번 글이 삭제됐다고 해보자.
# v2로 업데이트한다!
→ key1:v2 → [1100, 1099, ..., 1051, 1049, ..., 1001, 1000]
→ key2:v2 → [999, 999, ..., 900]
캐시 전체를 업데이트하니까 중복으로 데이터가 보이는 문제도 없다!
version으로 관리하고 전체 캐시를 업데이트하는 방식이 더 좋아 보인다.
version으로 관리할 때 문제점
version으로 관리하고, 모든 캐시를 업데이트하는 것이 좋아 보이는 데 여기도 문제가 있다.
일단 이전 버전의 캐시가 메모리에 남아있다.
또 버전이 바뀌면 해당 게시판에 대한 모든 캐시가 업데이트된다.
이 경우 순간적으로 캐시가 비는데, 이때 순식간에 DB 요청이 급증한다.
해당 문제에 대해 고민하다 'stale-while-revalidate' 에 대해 알게 되었다.
간단히 소개하면 최신 캐시 버전(v2라 하자.)이 없는 경우, 구버전(v1) 캐시가 여전히 남아있다면, 구버전 데이터를 응답으로 제공하며 비동기적으로 최신 버전(v2)으로 캐시를 백그라운드에서 업데이트하는 방식이다.
대략 아래와 같은 흐름이 될 것이다.
public List<Post> getPosts(String boardType, int block) {
int currentVersion = getCurrentVersion(boardType); // 최신 버전 확인 예: v2
String newKey = generateKey(boardType, block, currentVersion);
// 1. 최신 버전 캐시 조회
List<Post> posts = redis.get(newKey);
if (posts != null) {
return posts;
}
// 최신 버전의 캐시가 없는 경우, 구버전을 확인하자!
// 2. 구버전(v1) 캐시 확인. 만약 구버전 캐시가 있다면 이를 응답
String oldKey = generateKey(boardType, currentVersion - 1);
posts = redis.get(oldKey);
if (posts != null) {
// 백그라운드에서 최신 캐시 빌드
CompletableFuture.runAsync(() -> {
List<Post> newData = fetchFromDB(boardType, block);
setCacheWithTTL(newKey, newData);
});
return posts;
}
// 3. 두 캐시 모두 없으면 DB 조회 및 캐시 저장
posts = fetchFromDB(boardType, block);
setCacheWithTTL(currentKey, posts);
return posts;
}
private String generateKey(String boardType, int version) {
// 키 생성 메서드
}
private void setCacheWithTTL(String key, List<Post> data) {
redis.setex(key, ttl, data);
}
이 방식으로 일단 캐시 미스로 인해 발생하는 DB 접근을 최소화할 수 있을 것이다.
그리고 좀 더 효율을 높이기 위한 방법은 무엇이 있을까?
핫키의 경우 꾸준하게 캐시를 갱신해 주면 좋지 않을까?
@Scheduled(fixedRate = 60000) // 예: 1분마다 실행
public void prewarmCache() {
int currentVersion = getCurrentVersion("boardType");
List<int> hotKeys = getHotKeys(); // 빈번하게 요청되는 키 목록
for (int key : hotKeys) {
if (!redis.exists(key)) {
// redis에 업데이트 로직
}
}
}
이런 방식으로 자주 사용되는 키들을 주기적으로 빌드해 DB 접근을 최소화할 수 있다.
그래서 전체 흐름을 다시 생각 해보자.
캐시 업데이트는 다음과 같은 로직이 될 것이다.
public void updateCacheForBoard(String boardType) {
// 1. 현재 버전 조회 및 버전 증가
int currentVersion = getCurrentVersion(boardType); // currentVersion = v1
int newVersion = incrementVersion(boardType); // newVersion = v2
Integer lastCursor = null;
int block = 0;
while (true) {
// 2. DB에서 최신 데이터 100개 단위로 조회
List<Post> posts = fetchPostsFromDB(boardType, block * 100, 100); // 블록 + 100개씩 가져온다고 생각
if (posts == null || posts.isEmpty()) {
break; // DB에서 모든 데이터를 다 가져와 갱신하면 끝
}
// 3. 신버전 캐시 키 생성 및 저장
lastCursor = posts.get(posts.size() - 1).getId();
String newKey = generateKey(boardType, newVersion, lastCursor);
saveToRedis(newKey, posts, TTL);
block++; // 다음 블록으로 이동
}
// 이후 클라이언트는 항상 getPosts 메서드를 통해 getCurrentVersion(boardType)을 통해 v2 캐시로만 접근하게 된다.
}
Redis에 남아있는 구버전 캐시들을 바로 삭제하는 것이 좋을까?
-> 캐시미스로 인한 DB 부하 발생.
백그라운드에서 비동기로, 순차적으로 구버전 캐시를 삭제하는 것이 좋아 보인다.
추가로 구버전 캐시가 있어야 swr 방식을 사용할 수 있다.
Eviction Policy는 무엇으로 설정하는 것이 좋을까?
만약 메모리가 가득 차버리면?
키를 삭제해서 공간을 확보해야 한다.
기본적으론 volatile-lru 가 default이다.
(분산 환경에선 noeviction policy가 기본이다.)
volatile-lru는 expire가 true인 값 중 최근에 가장 덜 사용된 키를 삭제하는 것이다.
그런데 무한 스크롤에선?
당연히 최신 글이 가장 빈도가 높을 것이다.
(게시판에 들어오면 일단 최신 글부터 보게 되니까)
그런데 lru를 사용하면 최근에 사용이 덜 된 키를 삭제한다.
우연히라도 잘 접근이 안 되는 키가 최근에 사용됐다면?
접근이 많이 발생하는 키가 삭제될 수도 있다.
그래서 내 생각엔 volatile-lfu 가 더 적절해 보인다.
일단 더 빈도로 삭제하기에, 가장 자주 접근되는 키는 삭제되지 않는다.
또 앞서 생각했던 핫키 캐시 최신화 등을 사용한다면? 자주 사용되는 키들은 expire가 되지 않을 가능성이 높다!
TTL은 어떻게 설정하는게 좋을까?
앞에도 그랬지만 여긴 완전히 뇌피셜..
만약 유저가 특정 페이지에 5분간 머문다고 가정해 보면, 유저는 최신 글부터 나중 글까지 쭉쭉 스크롤을 할 것이다.
그런데 이미 지나간 게시글 목록을 다시 보려고 돌아오는 경우가 많을까?
나는 딱히 없었던 것 같다.
그러니까 5분간 머문다면 TTL은 그 2배 정도를 설정해 주면, 만료가 되어 캐시미스가 발생하는 일은 적지 않을까?
나라면 모니터링을 한 뒤,
유저가 한 페이지에 체류하는 시간 * 2
로 TTL을 설정할 것 같다.
그래서 내가 생각한 key ?
board:{boardType}:v{version}:lastCursor:{lastCursor}
boardType을 통해 어떤 게시판인지 확인하고,
version을 통해 캐시 갱신을 관리하고,
lastCursor를 통해 유저가 어디까지 요청 했는지 알 수 있다.