상세 컨텐츠

본문 제목

[Redis]패턴 조건을 활용해 key 집합 추출하기(SCAN)

데이터베이스/NoSQL

by moonionn 2023. 7. 10. 02:58

본문

패턴 조건을 활용한 서브셋이 필요한 사례

개발하면서 문자열을 다루다보면, 딱 떨어지는 무언가를 찾기보단 특정 조건을 충족시키는 문자열들을 찾고 싶을 때가 많습니다.

SQL에서는 like 절, 자바스크립트에서는 includes()나 match() 등이 이런 경우 사용되겠네요.

 

레디스에서도 마찬가지입니다. 특정 상황을 예로 들어 보겠습니다.

사이트의 성능 개선을 위해 가장 자주 호출되는 데이터인 카테고리 관련 데이터들은 전부 캐싱한다 가정하겠습니다.

그럼 카테고리 캐시는 아래와 같이 구성할 수 있겠습니다.

키 이름 예시 역할 값 예시
categories:all:ko 전체 카테고리(한국어) [
  { id: 1, title: "식품", status: "Normal" },
  { id: 2, title: "가전", status: "Normal" },
  ...
]
categories:1:ko 1번 카테고리(한국어) { id: 1, title: "식품",  status: "Normal" }
categories:2:ko 2번 카테고리(한국어) { id: 2, title: "가전", status: "Normal" }
categories:all:en 전체 카테고리(영어) [
  { id: 1, title: "Groceries", status: "Normal" },
  { id: 2, title: "Appliances", status: "Normal" },
  ...
]
categories:1:en 1번 카테고리(영어) { id: 1, title: "Groceries", status: "Normal" }
categories:2:en 2번 카테고리(영어) { id: 2, title: "Appliances", status: "Normal" }

 

이때 운영자가 카테고리 2번의 상태를 Deleted로 변경하려 하며, 변경사항이 즉시 서비스에 반영되길 바란다면?

그럼 전체 카테고리 및 2번 카테고리와 관련된 모든 캐시가 무효화되어야 합니다.

 

굳이 캐시 키 예시에 언어 종류를 붙인 이유는, 이때 바로 패턴을 활용해 키를 찾는 기능이 필요해지기 때문입니다.

예시에서는 고작 영어, 한국어 밖에 없기 때문에 코드상 상수로 관리할 수 있을 수도 있겠지만, 만일 언어 종류가 많거나, 혹은 언어 외 데이터를 구체화하는 리소스가 있는 경우, 우리는 2번 카테고리와 관련된 캐시가 얼마나 존재할지 쉽게 가늠할 수 없습니다.


1번 방법: KEYS (이것도 방법이라 해야하나..? 지양해야 하는 방법...)

https://redis.io/commands/keys/

127.0.0.1:6379> KEYS categories:2*
1) "categories:2:en"
2) "categories:2:ko"
127.0.0.1:6379> KEYS categories:all
2) "categories:all"

이 명령어는 데이터베이스를 풀스캔하여 주어진 패턴에 맞는 키를 추출해냅니다.

하지만 위 공식문서에도 명시되어있듯, 특정 키 집합을 구하려 할 때 사용하는 것은 추천되지 않습니다.

레디스는 기본적으로 싱글스레드로 동작합니다. 따라서 KEYS 명령어로 데이터베이스를 풀스캔하는 동안, 다른 작업들은 블라킹 처리됩니다. 이 명령어를 프로덕션 레벨에서 실제로 쓰게 된다면, 해당 데이터베이스를 공유하는 모든 작업들이 영향을 받을 수 있습니다.


2번 방법: SCAN

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

// categories:all 예시는 제외함

127.0.0.1:6379> SCAN 0 MATCH categories:2* COUNT 10
1) "10"
2) (empty array)
127.0.0.1:6379> SCAN 10 MATCH categories:2* COUNT 10
1) "62"
2) 1) "categories:2:ko"
127.0.0.1:6379> SCAN 62 MATCH categories:2* COUNT 10
1) "45"
2) (empty array)
127.0.0.1:6379> SCAN 45 MATCH categories:2* COUNT 10
1) "39"
2) 1) "categories:2:en"
127.0.0.1:6379> SCAN 39 MATCH categories:2* COUNT 10
1) "0"
2) (empty array)

이번 글에서 다룰 SCAN 명령어입니다.

위 KEYS와는 다르게 복잡하게 동작하는 것처럼 보입니다. 왜냐하면 KEYS 명령어와 다르게 SCAN 명령어는 데이터베이스를 한번에 풀스캔하지 않고 일정 부분 끊어 스캔합니다. 이를 위해 반복작업이 필요하기 때문에 이렇게 여러 줄의 명령어가 필요합니다.

 

명령어를 자세히 뜯어보겠습니다.

SCAN 0 MATCH categories:2* COUNT 10

SCAN 다음 오는 숫자는 커서(cursor)입니다. 커서는 현재 순회 구간의 위치를 나타냅니다.

SCAN 처음 시작시에는 커서에 0을 할당해야 합니다. 그리고 SCAN 명령어 실행 후 반환되는 첫번째 값이 다음 스캔해야 할 커서를 뜻합니다. 반환되는 커서가 0이 되는 시점이 있습니다. 이는 모든 데이터베이스를 조회했다는 뜻이며, 순회를 종료해도 됩니다.

설명만 들어서는 어레? 그럼 순회하면 할수록 커서값도 커져야 하는 거 아닌가? 라고 생각할 수 있지만 커서는 가중치를 나타내는 것이 아니라 일종의 유니크한 위치값을 나타내는 것이기 때문에 값이 증대하거나 하지는 않습니다.

 

SCAN 0 MATCH categories:2* COUNT 10

MATCH 옵션 다음엔 원하는 패턴을 넣으면 됩니다.

패턴 활용 방법은 https://database.guide/redis-keys-command-explained/ 여기 사이트에 잘 정리되어 있네요.

 

SCAN 0 MATCH categories:2* COUNT 10

COUNT 옵션은 한 번의 스캔당 반환할 수 있는 최대 key 수를 나타냅니다.

예시 명령어에서는 MATCH 조건을 주었으니 10개보다 적은 수의 key가 반환될 수 있습니다.

프로덕션 환경에서는 COUNT 옵션 활용을 추천합니다. 적절하게 리밋을 조절하면 SCAN 순회시 과부하를 예방할 수 있습니다.


Javascript 코드 응용

const Redis = require('ioredis');

const redis = new Redis();

async function scan(pattern) {
  let cursor = '0';
  const keys = [];

  do {
    const [newCursor, matchingKeys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 10);
    cursor = newCursor;
    keys.push(...matchingKeys);
  } while (cursor !== '0');

  return keys;
}

scan('categories:2*').then((v) => console.log(v));

// 결과 -> [ 'categories:2:ko', 'categories:2:en' ]

 

손봐줘야 할 키를 찾았다면 각자의 입맛에 flush, 혹은 invalidate 처리하면 되겠습니다.

flush, invalidate 차이에 대해서는 다음에 또 정리할 기회를... 노려보도록... 하겠... 습니.. 다..

관련글 더보기

댓글 영역