SLASH 기술 블로그

Redis 실전 활용 예시 - LIST를 활용한 PUB/SUB 구조 본문

백엔드

Redis 실전 활용 예시 - LIST를 활용한 PUB/SUB 구조

SLASH 2021. 4. 24. 23:41
반응형

 

이전 글에서는 Redis의 키 관리나 자료 구조의 선택을 어떻게 할 것인지에 대해 간단한 생각을 이야기했다.

 

오늘은 실제로 BSER.FUN 사이트를 구현하면서, 유저가 전적 갱신을 요청했을 때 이를 처리하기 위해 Redis를 적극적으로 활용했던 경험이 있어, 이를 공유하고자 한다.

 

Redis가 캐시로 많이 쓰이기는 하지만, 꼭 캐시로만 써야하는 것도 아니다. 그동안 캐시로만 Redis를 사용했던 분들이라면 Redis를 활용하는 새로운 방법에 대한 힌트를 얻어갈 수 있지 않을까 싶다.

 

데이터 갱신을 위한 Queue - List 활용

게임 전적 사이트에서는 유저마다 전적을 보여주고 동시에 다양한 통계를 보여주기 위해서 유저가 플레이한 게임 데이터를 갱신하는 로직이 반드시 필요하다. 어떤 방식을 취하든, 기본적으로 어떤 유저로부터 갱신 요청을 받았을 때 서버가 게임 측 API를 호출해 가지고 있는 데이터(DB나 Redis 혹은 다른 무언가)와 비교해 현재 없는 게임 데이터를 추가한다는 골자는 같다.

 

하지만 API 서버가 여러 대라면 어떨까? 초기에는 API 서버가 갱신 요청을 받고, 백그라운드로 갱신 작업을 진행한 다음 완료되었을 때 유저에게 알림을 보내는 방식으로 구현을 했다. 서버가 하나라면 이정도로도 충분했겠지만, 가용성을 위해 서버를 둘 이상으로 둘 경우 문제가 될 수 있다.

 

각 서버가 갱신 큐를 따로 가지고 있다면 중복이 발생할 수 있다.

어떤 유저가 A 인스턴스에 갱신 요청을 하고, 몇 초 뒤에 새로고침을 해서 B 인스턴스에 또 다시 갱신 요청을 했다고 가정해보자. 동일한 유저에 대해서 두 번 갱신하는 비효율이 발생한다. 운이 나쁘다면 데이터가 중복으로 쌓이는 등의 문제가 발생할 수도 있다. (이런 건 unique key 등을 통해서 미연에 방지해야 함)

 

더군다나 갱신을 하는 도중 서버가 죽는다면 갱신 작업의 진행 상황도 잃어버리고, 기존에 갱신 Queue에 들어있던 다른 유저들까지도 갱신받지 못하는 상황이 발생한다.

 

결론적으로 이렇게 다양한 문제를 피하기 위해, 갱신 Queue는 서버 외부에 위치해야 하고, 갱신 작업 역시 이를 위한 별도 인스턴스를 사용하는 것이 바람직하다. 아래 그림을 확인해보자.

 

서버 외부로 큐를 이동시키고, 갱신을 위한 별도의 인스턴스를 추가했다.

위 그림에서 Queue가 충족해야 하는 조건들에는 무엇이 있을까?

 

우선은 여러 개의 인스턴스가 갱신하려는 유저 ID를 추가하고 뺄 수 있어야 하기 때문에, 여러 인스턴스가 동시에 접근하더라도 모든 명령이 올바르게 처리되어야 한다. 그리고 선입선출 - 먼저 추가한 유저 ID를 먼저 처리할 수 있는 구조여야 한다.

 

Redis의 리스트 구조를 사용하면 Queue를 자연스럽게 구현할 수 있다. Redis는 이전 글에서도 언급했던 것과 같이 싱글스레드로 동작하기 때문에 여러 명령이 들어왔을 때 한 번에 한 명령씩 처리하는 것이 보장된다. 리스트 구조를 사용하면 PUSH/POP 명령을 통해서 선입선출 구조를 구현할 수 있고, 필요하다면 LPUSH 등으로 먼저 처리하고 싶은 것들을 앞으로 보내는 것도 가능하다. (Deque)

 

Batch Job을 위한 임시 공간 - Set 활용

갱신을 통해 불러온 데이터는 최종적으로 앱 내, 데이터 분석 등 다양한 방면으로 활용하기 위해 DB에 저장하게 된다. 그렇다면 유저 갱신을 완료할 때마다 DB에 갱신된 데이터를 추가하면 될까? 가장 간단하고 직관적으로 떠올릴 수 있는 방법이긴 하다. 초기에는 이처럼 각 인스턴스가 갱신을 하고, 갱신을 완료했을 때 DB에 직접 INSERT를 하는 방식을 취했다. 하지만 금세 문제점이 드러나게 되었다.

 

우선 첫 번째로, DB INSERT 횟수가 많아져 단순 DB의 부하가 커지게 되었다. 주기적으로 유저 데이터를 갱신하기 위해서 초당 20명 정도의 유저를 갱신했는데, 결과적으로 1분에 2000~3000회 정도 트랜잭션이 발생했고 이는 서비스의 규모를 생각했을 때 터무니없는 숫자였다. (일반적인 웹 서비스의 DB 로드가 어느 정도인지는 잘 모르지만, API 서버가 만들어내고 있는 트랜잭션 수와 비교했을 때 비정상적으로 높은 수치였다는 건 확실하다.)

 

두 번째로 캐싱이 문제가 되었다. API 서버에서는 DB Hit을 줄이기 위해서 유저의 전체 게임 데이터를 Redis에 캐싱해서 사용하고 있었는데, 각 워커가 DB에 직접 INSERT를 날리면서, 함께 캐시도 업데이트를 해줘야 하는 문제가 있었다.

 

이 문제를 해결하기 위해서는 결국 갱신한 데이터를 모았다가 한 번에 DB에 쿼리를 날리는 것 외에는 없었기 때문에, "갱신한 데이터"를 임시로 모으기 위한 별도의 구조를 도입했다.

 

배치 작업을 위한 Set과 캐싱을 위한 Hash

먼저, 갱신을 담당하는 각 인스턴스들이 갱신을 완료하면 이를 곧바로 DB에 넣는 것이 아니라 pending games라는 Redis Set에 추가를 했다. 동시에, API 서버에서 사용할 games라는 Redis Hash의 데이터도 새로 업데이트를 해주었다.

 

스케줄러는 주기적으로 이 pending games에 있는 데이터를 읽어와서(대략 10초~20초에 한 번씩), DB에 추가한다. SPOP 명령어를 사용하면 원하는 만큼 랜덤으로 데이터를 꺼내올 수 있다. 나는 단순히 모든 데이터를 꺼내오도록 했다.

 

API 서버에서는 이러한 스케줄러의 로직을 알 필요 없이, 유저 ID에 따라 나누어져있는 games Hash만을 사용한다. 만약 데이터가 비어있으면 그 때 DB를 조회해 캐싱하는 식이다. 유저 갱신 시에는 이 Hash의 데이터가 이미 최신이기 때문에 갱신 이후 별도의 DB 쿼리 작업은 필요하지 않다.

 

마지막으로, 위 그림을 보면 pending games Set과 games Hash가 서로 다른 색으로 칠해져있는 것이 보일 것이다. 이전 글에서 설명했던 Namespace 구분이다. 만약 유저가 갱신을 요청하거나 자체적으로 서버 앱 캐시를 날릴 필요가 있을 때는 app Namespace만 정리해 pending games는 영향을 받지 않도록 구성했다.

 

갱신 여부 알림 - Channel 활용

갱신을 위한 별도의 Queue도 마련 했고, 배치 작업을 위한 Set과 스케줄러도 무사히 추가했다. 그런데 한 가지 문제가 있다. 유저에게 갱신이 되었다는 사실을 어떻게 알려야 할까?

 

API 서버가 자체적으로 처리하던 전적 갱신을 별도의 워커들이 처리하기 때문에, 유저에게 갱신 완료 여부를 알릴 방법이 없다. 워커들이 사용할 수 있는, 갱신 여부를 공유할 수 있는 채널이 없다면 말이다.

 

Redis의 PUB/SUB 매커니즘을 활용하면, 이 문제를 정말 간단하게 풀 수 있다.

 

워커는 갱신 여부를 알리고, 서버는 듣는다.

먼저, 갱신 작업을 하는 Worker는 갱신이 완료된 시점에 PUBLISH 명령을 통해 채널에 갱신한 유저의 ID를 알린다. 받는 쪽에서 어떻게 활용할 지는 알 필요 없이, 갱신이 완료되었을 때 단순히 그 사실을 Publish만 해준다.

 

API 서버는 SUBSCRIBE 명령을 통해 이 채널에 구독을 한다. Redis 클라이언트가 SUBSCRIBE 명령을 날리면, 해당 클라이언트는 구독 모드에 들어가 지정된 몇 가지 명령 외에는 실행하지 못하는 상태가 된다. (UNSUBSCRIBE, PING, QUIT 등) 때문에 캐싱이나 갱신을 할 때 사용했던 클라이언트 외의 별도의 클라이언트를 하나 더 만든다.

export const redis = new Redis(REDIS_URI);
export const subscriber = new Redis(REDIS_URI);

 

유저가 갱신 요청을 보냈을 때, 서버는 갱신을 위한 Queue에 유저 ID를 넣고, 이 채널의 메시지를 보면서 해당하는 유저의 ID가 갱신이 완료되었는지를 체크하면 된다. 다음은 실제 갱신을 위해서 사용하는 코드다.

const requestRenewUser = async (userNum) => {
  await pushToUserQueueFirst(userNum);
  return new Promise((resolve, reject) => {
    const handle = setTimeout(() => {
      reject(new ApiError(`Timeout for renewing user [${userNum}]`, 500));
    }, 30000);
    subscriber.on("message", (_channel, message) => {
      if (message == userNum) {
        clearTimeout(handle);
        resolve(message);
      }
    });
  });
}

 


이렇게 갱신 요청에서부터 DB에 데이터를 넣고 갱신 여부를 알리는 일련의 흐름을 살펴보았다.

 

여러분이 본 것과 같이, 캐싱에서부터 이러한 다소 복잡한 태스크까지, Redis가 사용되지 않는 부분은 없다고 해도 과언이 아니다. 개인적으로 Redis를 사용하면서 가장 좋았던 점은, 어떤 식으로 접근을 하든 race 상태가 발생하지 않는다는 것이 보장된다는 점이었다. 이 때문에 사용하면서도 큰 걱정 없이 다양한 기능들을 시도하고, 테스트해볼 수 있었던 것 같다.

 

중요한 건 그러한 시도를 하면서 현재 주어진 상황에서 가장 적합한 방법을 선택하는 것이라고 생각한다. 내 사례가 여러분의 고민에 도움이 되었기를 바라면서, 이만 글을 마친다.

 

@turastory

반응형
Comments