네부캠 그룹프로젝트 - 5. Redis 활용기

date
Dec 23, 2022
thumbnail
slug
naver-camp-project5
author
status
Published
tags
Project
summary
프로젝트의 여러 기능들을 Redis를 활용해 구현하면서 배운 것들
type
Post
updatedAt
Jan 23, 2023 03:43 PM
boostcampwm-2022/web21-devrank

1️⃣ 왜 Redis인가?

사실 너무나 유명한 인메모리 DB여서 장점이 널리 알려져있다.
  • 인메모리 DB로 디스크I/O에 비해 병목이 적다.
  • key-value형태로 간단하게 저장이 가능하다.
  • 기본적인 string외에도 List, set, sorted-set, hash같은 다양한 자료구조를 지원한다.
  • TTL 기능이 refresh token의 만료나 API 캐싱에 유용하다.
  • 단순히 서버측 메모리에 저장하는 것보다 scale-out 시 공유가 편하다.
  • 메모리DB라지만 RDB나 AOF를 통해 영속성을 지원한다.
  • 이전에 잠깐 써보았지만 pub-sub패턴도 지원한다.
  • replication으로 안정성을 챙길 수 있다. (하지만 지금 서버의 메모리가 매우 작기 때문에 고민되는 부분이다…)

Memcached도 유명하던데?

주로 언급되는 둘의 큰 차이점은 멀티스레드 vs 싱글스레드이다.
난 싱글스레드가 갖는 제일 큰 장점은 멀티스레드가 갖는 동기화 문제, 복잡성에서 자유롭다는 것이라 생각한다.
이와 같은 이유로 Javascript는 단일한 콜스택을 갖도록 설계되었고, python도 Cpython을 기준으로 GIL(Global Interpreter Lock)이 걸려서 I/O 작업을 제외하고는 스레드를 분리해도 싱글 스레드처럼 동작한다.
 
물론 싱글스레드도 자칫 거대한 CPU작업 혹은 긴 transaction을 올바르게 처리하지 못하면 bloking이 발생하게 되는 문제가 있지만, Redis나 node.js처럼 보통 이벤트 루프를 이용하여 적절하게 해결하는 것을 볼 수 있다.
사실 우리 서비스에서 Redis를 선택할 당시에는 스레드에 관한 고민은 없었다. 스레드에 대한 내용은 Redis를 선택하고 공부하면서 알게된 사실이다.
당시에 Redis를 선택 가장 큰 이유는 다양한 자료구조 때문이었다. 기본적인 string외에도 list, set, sorted-set, hash 등을 지원한다고 보았기 때문이었다.
특히 우리 서비스는 랭킹을 매겨야하는데, 이 때 Redis의 sorted-set이 아주 유용하게 사용된다.

2️⃣ 각 유저의 전체 등수, 티어 내 등수 캐싱

notion image
랭킹 서비스다보니 모든 유저의 등수 정보를 나타내줘야 한다.
심지어 각 유저가 모든 유저와 비교했을 때 갖는 등수 뿐 아니라 동일한 티어 내의 유저들 사이에서 갖는 등수도 요구사항에 존재한다.
초기에는 sorted-set을 제대로 학습하지 않았어서, 해당 자료구조를 사용하지 않고 비효울적인 방식으로 구현했었다.

초기 구현 방식 및 문제점

처음에는 단순히 Redis의 hash 자료구조를 사용해서 아래와 같은 로직으로 구현을 했었다.
  1. 유저 프로필을 조회하는 요청이 들어온다.
  1. Redis에 해당 유저의 등수 정보(전체 등수, 티어 내 등수)가 캐싱되어 있다면 반환한다.
  1. 캐싱되어있지 않다면 MongoDB에 저장된 모든 유저의 score를 바탕으로 등수를 계산하고 캐싱{전체 등수: x, 티어 내 등수: y} 후 반환한다.
프로필에 대한 매 요청마다 MongoDB에 저장된 모든 유저의 score를 바탕으로 계산하는 것을 반복하는게 굉장히 비효율적이라 생각해서 이렇게 계산 결과를 캐싱했었다.
하지만 이렇게 구현했을 때 발생하는 문제점들은 아래와 같았다.
notion image
  • 사진처럼 특정 유저의 등수 정보가 부정확해진다. 이유는 한 유저의 등수 변화가 캐싱된 다른 유저의 등수에 영향을 주지 않기 때문이다.
    • 예를 들어 현재 A가 1등, B가 2등으로 캐싱되어 있다.
    • 이 상태에서 B가 점수 업데이트를 요청을 하고 A보다 점수가 높아져서 1등으로 캐싱이된다.
    • A와 B 모두 각자의 프로필에 들어가면 1등으로 표시된다.
  • 사용자 프로필에서만 랭킹 정보 표기가 가능하고, 여러 유저들이 존재하는 랭킹페이지에서는 전체 등수 정보를 표시할 수 없다.
    • 이유는 위와 같이 특정 유저의 등수 정보가 부정확하기 때문이다.
결국 한명의 유저 점수에 개별 업데이트로 인해 등수가 변경되면, 나머지 모든 유저의 등수도 업데이트 되어야 일관성이 깨지지 않는다. 하지만 한명의 유저를 업데이트 할 때마다 나머지 모든 유저를 순회하면서 등수를 업데이트 할 수는 없는 노릇이다.
결국 위에서 발생한 문제를 해결하려면 한명의 유저가 등수가 변함에 따라 자동으로 정렬이 이루어져야한다.
이로 인해 조회/삽입/삭제가 모두 log로 떨어지면서 데이터가 변하면 정렬된 순서를 유지해야하는 자료구조가 필요하다.

그렇다면 Redis sorted-set은 어떻게 구현되는 걸까?

Redis의 sorted-set은 보통 hash-table과 skip-list로 구현된다고 한다.
skip-list가 무엇인지는 이 글에서 학습하였다. 트리 구조 말고도 평균적으로 O(log n)을 띄면서 정렬된 자료구조가 있다는 것을 처음알았다.
내가 이해한 바로는 아래 2개의 특성을 통해 sorted-set이 구현이 가능하다.
  1. skip-list는 log의 시간으로 score 순서대로 정렬하고 조회 목적으로 사용된다.
  1. hash-table을 통해 어떠한 데이터에 대한 등수 정보를 O(1)로 접근이 가능하다.
문득 위의 1번 조건에 맞는 자료 구조를 생각해보니 나는 마지막 노드들이 서로 연결된 B+Tree가 떠올랐다. B+Tree는 range연산에도 효과적이므로 Redis의 ZRANGE같은 연산에도 좋을 것 같다. B+tree와 hash-table로도 구현이 가능할 것 같은데, redis는 왜 skip-list로 했는지 나중에 확인해봐야 할 듯 하다.
이처럼 Redis의 sorted-set log시간의 조회/삽입/삭제가 가능하면서, 데이터의 uniqueness데이터를 통해 index position접근까지 모두 가능하므로 전체 유저의 실시간 랭킹 업데이트에 최선의 선택이다.

Redis sorted-set을 이용하여 log 스케일로 유저 등수 산정

등수 캐싱에 사용되는 각 명령의 시간복잡도는 다음과 같다.
  • ZADD O(log(N))
  • ZREM O(M*log(N))
  • ZRANK O(log(N))
추가적으로 O(log(N))의 시간복잡도를 갖는 ZRANGE 명령이 있는데, 이는 사용하지 않기로 했다.원래는 ZRANGE의 개수를 랭킹의 페이지네이션 단위와 일치시켜서 최적의 시간복잡도로 등수를 제공하려 했다.
하지만 우리 서비스는 랭킹 페이지에 사용자 이름 전방일치 필터링이 존재한다. 이렇게 사용자 이름 필터링이 추가되면 ZRANGE를 통해 특정 이름을 가진 사용자들의 순위를 가져오는 것은 불가능하다.
따라서 페이지네이션을 통해 걸러진 유저들이 10명밖에 안되므로, 각 10명에 대해서 ZRANK명령을 실행하여 구하는 방향으로 결정했다. 결국 최종적으로 O(10 * log(N)) ≒ O(10 * log(N))의 시간복잡도를 갖게 되었다. (사실 O(N)단위의 연산만 아니라면 병목은 걱정하지 않아도 될 듯하다.)
 
다른 해결해야하는 문제는 전체 유저 기준의 등수를 알기 위해선, Redis에 모든 유저에 대한 score정보가 입력되어야 한다는 것이다. score에 대한 rank는 결국 각 값에 대한 index position이기 때문이다.
만약 유저의 일부만 캐싱된 상태에서 HTTP 요청을 받게 된다면 sorted-set에 있는 모든 유저들보다 실제 유저가 더 많기 때문에, 전체 등수 결과에 왜곡이 생긴다.
따라서 nest.js앱이 HTTP 요청을 받아들이기 전에, Redis에 모든 유저가 캐싱되는 것을 보장하는 방법이 필요하다.

nest.js앱 로드 시, Redis 캐싱 전까지 HTTP 요청 막기

위에서 말한 것처럼 모든 유저의 점수 정보를 Redis의 sorted-set에 캐싱하는 과정이 필요하다.
이전에 blue-green 무중단 배포를 구현하면서, docker container에 health check를 할 때 curl을 통해 요청을 보냈다. 즉 새로운 docker container로 뜬 node.js App은 Redis에 등수정보가 없다면, 캐싱하기 전까지 HTTP 요청을 받지 말아야한다. 다른 말로 말하면 health check가 성공하는 시점은 Redis에 등수 정보 캐싱이 완료된 상태일 때다.
이를 해결하기 위해 nest.js앱의 life-cycle을 찾아보았다.
모든 모듈이 초기화된 후 연결을 수신하기 전, 어플리케이션이 부트스트랩될 때 호출되는  onApplicationBootstrap 생명 주기가 가장 적합해보였다.이를 통해 HTTP 요청을 받아들이기 전에 Redis를 초기화하면서 무중단 배포까지 유지할 수 있었다.
async onApplicationBootstrap() {
  const allUsers = await this.userRepository.findAll({}, false, ['lowerUsername', 'score', 'tier', 'history']); // get all users
  await Promise.all(
    allUsers
      .filter((user) => user.history)
      .map(({ lowerUsername, score, tier }) => this.userRepository.updateCachedUserRank(tier, score, lowerUsername)), // caching to Redis
  );
  logger.log('all user score loaded in Redis.');
}
notion image
위처럼 all user score loaded in Redis가 로그에 찍힌 후, Nest App의 모든 초기화 과정이 끝나고 HTTP요청을 받아들이는 것을 볼 수 있다.

3️⃣ 기타 기능 구현에서 Redis 활용

조회수 어뷰징 막기

동일한 IP에 대해서는 특정 유저에 대해 하루에 1번만 조회수를 올리고 싶었다.이유는 메인페이지에서 조회수 TOP3인 유저들을 보여주는데, 조회수 어뷰징이 가능하다면 문제가 다분하기 때문이다.
Redis의 set자료구조와 ttl을 이용하면 생각보다 구현은 간단했다.
  1. IP를 key로 하고 해당 IP가 조회한 username을 set에 넣어는다.
  1. 해당 IP key에 대해서 ttl을 금일 자정까지 설정하면 된다.

Refresh Token 저장

JWT자체가 어느정도 성능과 보안의 trade off이기 때문에 최소한의 보안을 위해 refresh token을 위한 I/O까지는 반드시 필요하다.
우리는 refresh token의 만료 기간을 15일로 잡았으므로 Redis에 username을 key로 하고 value로 refresh token을 저장한다.
  • 현재 accessToken은 클라이언트(next.js)에 state로 저장되고, refreshToken을 XSS를 방지하기 위해 HttpOnloy cookie에 저장하고 있다.
  • 만약 사용자가 브라우저를 껏다키거나, 새로 고침을 하면 클라이언트 상태가 날라가게되고, cookie에 있는 refreshToken을 실어서 요청을 보낸다.
  • 이를 통해 사용자가 브라우저를 나가도 refreshToken의 만료 기간인 15일 간 로그인이 유지된다.
  • 그리고 사용자의 (브라우저 껐다 킴 / 새로고침)마다 I/O가 발생하므로 MongoDB보다는 인메모리인 Redis에 저장하는게 좋아보였다.

사용자의 무분별한 수동 업데이트 막기

notion image
Github API는 1시간동안 REST API, GraphQL 각각 5000번의 호출 제한이 존재한다.
따라서 무분별하게 API요청을 허용한다면 요청 제한 횟수를 넘게되어 업데이트가 불가능해진다.이러한 제한을 극복하기위해 우리는 아래와 같은 방법을 활용했다.
  • GraphQL 쿼리를 요청 1번에 많은 정보를 가져올 수 있도록 길게 짜게 되었고, 단순한 요청은 REST API를 활용한다.
  • 사용자의 Github 로그인을 유도하여, 해당 사용자 스스로의 업데이트는 발급받은 Github AccessToken을 통해 한다.
하지만 이 방법만으로는 유저의 무차별적인 업데이트로 인한 Github API호출 횟수 고갈을 해결할 수는 없었고 이를 막을 필요가 있었다.
따라서 특정 유저에 대한 업데이트 요청 딜레이를 Redis를 이용하여 주기로 했다.
유저의Github 고유ID값을 key로 Redis에 아무 값이나 등록한다.해당 key에 ttl을 120초로 설정하여, key가 살아있는 동안은 업데이트가 불가능하도록 한다.

근데 이런 API 딜레이주는 기능은 nest.js 자체에서도 지원하는 기능 아닌가요?

nest.js에서 API 호출 딜레이를 주는 기능이 존재하긴 한다. 하지만 하나의 콜스택을 갖는 node.js를 생각해봐야 한다.
  • 싱글 스레드 기반이므로 코어 수에 맞는 클러스터링이 필수적이다.
  • 그럼 node.js 프로세스가 여러 개 뜨게 된다.
    • 각각에 프로세스가 서로 다른 딜레이를 가질 것이다. 프로세스라는 것은 결국 서로 독립적인 가상 메모리 공간을 가질 것이고, 이러한 메모리 공간에 ttl에 관련한 정보를 저장할 것이기 때문이다.
결론적으로 Redis 서버를 공통의 메모리로 이용하면, 모든 프로세스가 동일한 딜레이에 맞춰서 요청을 제한할 수 있게 된다고 판단했다.

4️⃣ Redis를 사용해본 후기

처음에 깊이있는 학습을 하지 않고 적용하려 해서 삽질이 많았다. 특히 초기에 등수를 캐싱할 때 sorted-set을 안쓰고 hash로 구현했던 부분을 걷어낼 때 내 무지함에 실망이 컸다.
지금은 단순히 기능의 효울적인 구현에 초점을 맞춰서 사용해보았지만, 네이버 부스트 캠프가 끝난 후 RDB/AOF가 성능에 미치는 영향, 클러스터링 등 Redis를 조금 더 깊게 들여봐야 할 듯하다.
 

참고

[5] ZADD