haileyjpark

Redis의 SCAN과 scanStream 본문

소프트웨어 공학

Redis의 SCAN과 scanStream

개발하는 헤일리 2023. 4. 21. 02:36
반응형

최근 Redis의 SCAN 명령어에 대해 알아보고 scanStream을 적용해보았는데, 새롭게 알게 된 것이라 정리해보고자 한다.

* 글에 사용된 예시 코드는 TypeScript, NestJS로 작성되었다.

 

배경

  유저가 로그인을 특정 횟수(여기서는 10번이라고 하겠다.)만큼 실패할 경우, 유저의 상태를 블락시키고 유저가 로그인을 할 수 없도록 제한하는 기능을 구현했었다.

 

  이 과정에서 유저 로그인 시에 토큰과 sessionId가 발급되고, Redis와 cacheManager를 사용하여 <키:sessionId, 값: 토큰>의 형태로 저장했다. 이 sessionId를 서버와 클라이언트가 주고받다가 로그아웃 또는 유저 블락 시에 해당 sessionId를 redis에서 제거하는 방식이 사용되었다.

 

 

  이 서비스에서는 동시접속을 허용하고 있었는데,  브라우저마다 각각의 sessionId를 발급해서 가지고 있기 때문에 한 명의 유저가  N개의 sessionId를 가지고 있을 수 있고, 그렇다보니 한 브라우저에서 유저가 블락되어 로그아웃 상태로 바뀌어도 다른 브라우저에서는 해당 유저가 모든 기능을 정상적으로 사용할 수 있는 상황이 발생했다. 그래서 이 상황을 해결하기 위해 해당 유저의 모든 sessionId를 Redis에서 삭제해주기로 했다.

 

구현 과정

해당 유저의 모든 sessionId를 Redis에서 삭제해주기 위해,

로그인 시에 발급하던 UUID형태의 sessionId를 UUID와 userId를 결합한 형태로 변경했고,

아래와 같이 keys()를 사용해서 Block된 userId를 포함하고 있는 토큰의 key들을 LIKE 검색으로 모두 조회하여 해당 유저의 모든 토큰을 삭제하는 방법을 적용했다.

const client = this.cacheManager.store.getClient();
const userKeys = await client.keys(`*serviceName:${userId}*`);

userKeys.forEach((key: string) => {
        client.del(key, (err: Error | null) => {
        	if (err) {
                this.logger._error(
                  err,
                  'failed to delete sessionIds',
                );
              }
            });
          });
        }
      }

 

 


이후, 코드 리뷰 과정에서 아래와 같은 은혜로운 리뷰를 받아서 코멘트 주신 참고링크를 확인해보고, keys()를 사용하는 것과 scan()을 사용하는 것의 차이를 알아보았다.

 

 

KEYS 사용

keys()는 주어진 패턴과 일치하는 모든 키를 조회한다.

예를 들어, keys("*")는 DB에 저장된 모든 키를 반환하고, keys("user:*")는 "user:"로 시작하는 모든 키를 반환한다.

keys()는 모든 키를 한 번에 조회하여 결과를 반환하기 때문에, 대량의 키가 존재할 경우 처리 시간이 많이 소요되며 그 동안 다른 명령을 처리하지 못하는 블로킹이 발생하여 성능 저하를 일으킬 수 있다. 이는 실시간 시스템에서 사용하기에는 적합하지 않을 수 있다.

 

SCAN 사용

이러한 keys()의 단점을 보완하기 위한 대안으로 나온 것이 scan()이다.

scan()은 keys()와 같이 키를 조회하지만, 커서 기반의 반복자를 사용하여 부분적으로 키를 조회한다.

SSCAN, ZSCAN, HSCAN은 key에 속한 member를 조회하는 반면, SCAN은 DB안에 있는 모든 key를 조회한다.

사용자가 지정한 커서 값부터 시작하여 지정된 개수만큼의 키를 조회한 후 다음 커서 값을 반환하고, 이를 반복해서 모든 키를 조회할 수 있다.

 

명령어는 SCAN cursor [MATCH pattern] [COUNT count] 이다.

* 이 명령은 version 2.8.0 부터 사용할 수 있다고 한다. 이 명령어의 논리적 처리 소요시간은 O(1)이다.

* count의 경우 조회되는 key의 수가 지정한 count와 항상 딱 맞지는 않기 때문에 처리시간을 고려해서 개수를 조절해야 한다.

예를 들어, scan(0, "MATCH", "user:*", "COUNT", 10)은 "user:"로 시작하는 키 중 처음 10개를 반환하고 다음 커서 값을 제공한다.

 

scan()은 사용이 keys()보다 복잡하고 직관적이지 않을 수 있고, 조회 과정이 반복적으로 수행되므로 완료되기까지 시간이 소요될 수 있다는 단점이 있을 수 있다. 하지만, 일정 개수의 키만 반환하므로 여러 번 호출하여 키를 조회하면서 다른 작업을 병렬 처리할 수 있기 때문에 대량의 키가 존재할 경우에도 서버 블로킹 문제를 최소화하고 성능 저하를 줄일 수 있다는 장점이 있다. 그렇기 때문에 실시간 시스템에서 사용하기에도 적합하다.

 

따라서, 성능 문제를 최소화하면서 scan을 사용하는 것이 더 안전하고 성능에 좋다고 판단하여 scan을 적용하는 방향으로 수정했고, 코드를 수정하는 과정에서 scanStream을 지원한다는 것을 알게 되어 scanStream을 적용했다.

 

 

ScanStream

scanStream은 Redis의 scan() 명령을 기반으로 한 Node.js의 스트림 방식을 사용하며, 아래와 같은 특징을 가진다.

  • 스트림은 높은 수준의 추상화를 제공하여, 대용량 데이터를 처리할 때 메모리 사용량이 적고 성능이 좋다.
  • 사용자 정의 로직을 쉽게 적용할 수 있다. 스트림의 'data' 이벤트를 사용하여 각 키에 대해 원하는 처리를 수행할 수 있다.
  • 코드가 복잡해질 수 있다. (스트림의 비동기 특성을 관리해야 하며, 이벤트 리스너를 사용하여 로직을 구현해야 한다.)

 

scanStream을 적용하여 수정한 코드를 살펴보면,

const client = this.cacheManager.store.getClient();
const stream = client.scanStream({ match: `serviceName:${userId}:*` });

 

 

코드에서 cacheManager를 사용해서 Redis 클라이언트 인스턴스를 가져오고, 가져온 인스턴스를 사용해서 Redis 라이브러리의 기능 중 scanStream을 사용할 수 있다.

 

private async deleteSessionIds(
    userId: number,
    sessionId: string,
  ): Promise<void> {
      const client = this.cacheManager.store.getClient();
      const stream = client.scanStream({ match: `serviceName:${userId}:*` });

      stream.on('data', (resultKeys: string[]) => {
        stream.pause();
        client.del(resultKeys, (err: Error | null) => {
          if (err) {
            this.logger.error(
              err,
              '[deleteBlockedUserSessionIds] failed to delete sessionIds',
            );
          }
          stream.resume();
        });
      });
   }

stream 객체는 Redis에서 찾은 키를 스트림 형태로 전달한다. 'data' 이벤트가 발생하면 결과 키를 배열로 받게 된다.

이때, 스트림을 일시적으로 중단(Pause)하고, 결과 키를 삭제하는 작업을 수행한다. 

 

찾은 키들을 삭제하기 위해 'client.del()' 함수를 호출하는데, 이 함수는 여러 키를 동시에 삭제할 수 있고, 삭제 작업이 완료되면 콜백 함수가 실행된다. 콜백 함수에서는 오류가 발생했는지 확인하고, 오류가 있으면 로그를 남기도록 했다.

error레벨의 로그가 발생하면 슬랙으로 알림이 오기도 하고, 다음 작업에 영향을 주지 않게 하기 위해 따로 error를 throw하지는 않았다. 

그리고 스트림을 다시 시작(resume)하여 나머지 키를 처리할 수 있게 했다.

 

이렇게 스트림을 통해 찾은 모든 키들이 삭제되면, userId에 해당하는 유저의 모든 세션ID가 삭제되며 메서드가 종료된다.

 

 

결론

데이터베이스 크기와 성능 요구 사항에 따라 적절한 명령을 선택하여 사용하는 것이 중요한데, 실제 서비스에서는 대부분의 경우 scan() 함수를 사용하여 성능 문제를 해결하고 서버 블로킹을 방지하는 것이 좋을 것 같다. 작은 프로젝트나 개발 단계에서는 keys() 함수를 사용하여 키를 조회할 수 있지만, 데이터가 증가할수록 성능 문제를 염두에 두어야 할 것이다.

 

scanStream을 사용하면 스트림을 일시적으로 중지하고 다시 시작할 수 있어, 작업 간에 다른 작업도 실행할 수 있는 기회를 제공하는 것이 좋은 것 같다.

 

또 하나 새로운 것을 알게 되었다! KEYS보다는 SCAN을 사용하자! 

 

 

참고 링크

https://redis.io/docs/data-types/streams-tutorial/

https://github.com/luin/ioredis#streamify-scanning

http://redisgate.kr/redis/command/scan.php

https://redis.io/commands/scan/