SLASH 기술 블로그

Redis 실전 활용 예시 - 키 관리, 자료 구조 선택 본문

백엔드

Redis 실전 활용 예시 - 키 관리, 자료 구조 선택

SLASH 2021. 4. 23. 04:13
반응형

동일한 입력에 대해 동일한 출력을 뱉는 API라면 매 번 요청이 들어올 때마다 연산을 할 필요가 없다. 이처럼 적절한 곳에 적절한 형태의 캐싱을 사용하면, 연산량이 많은 작업이나 DB Hit을 줄일 수 있어 서버 부하를 덜고 API의 반응 속도를 올릴 수 있다.

 

현재 서비스하고 있는 BSER.FUN 사이트에서도 캐싱을 사용했는데, 얼마 전까지는 Redis가 아닌 단순 로컬 캐시로만 처리를 했다. (서버 로컬 메모리) 당시 Redis의 존재 자체는 알았지만, 막연하게 느껴지는 심리적인 장벽과 기능 개발에 집중해야하는 스탠스로 인해서 쉽사리 적용해볼 생각을 못했는데, 이후 Redis를 도입하게 되면서 캐싱뿐만 아니라 앱 전반에서 다양한 이득을 보아 그 사례를 공유하고자 한다.

 

한 편, 여러 개의 노드가 동일한 컨텍스트를 공유하면서 작업을 해야할 경우에도 도움이 된다. Redis는 싱글스레드로 동작하면서 각 명령이 Atomic하게 적용되기 때문에 기본적으로 Thread-safety를 보장하고, 자체적으로 PUB/SUB 구조의 메시징 처리를 지원하는 명령어가 존재하기도 한다.

 

Redis의 개념이나 기초적인 내용에 대해 다루는 글들은 이미 많은 것 같고, 나는 여기서 내 사례를 예시로 들어 실전에서 Redis를 어떻게 활용하면 좋을지에 대해 고민했던 내용과 결과를 공유해보려고 한다.

 

키 관리

Redis를 쓰다보면 자연스럽게 사용하는 키가 많아지고, 어떤 키가 사용되는지 관리하는 것이 어려워지는 시점이 생긴다. 그래서 Redis 도입 이후 가장 먼저 시도했던 것이 키를 효과적으로 관리하는 방법에 대한 탐색이었다.

 

키가 고정되어 있는 경우 상수를 사용해 미리 파일 하나에 정의해두고 이를 가져다 쓰면 된다. 여러 값을 가지고 키를 조합해야 하는 경우 함수를 사용하면 된다.

const renewQueue = "renewQueue";
const userGames = (userNum) => `games:${userNum}`;
const ApiCache = (userNum) => ({
  stats: (type) => `stats:${userNum}:${type}`,
  ...
})

 

핵심은 Key를 생성하는 과정을 추상화시키는 것이다. 키를 생성하는 방법을 스스로 정했다면, 그 방법 외에는 키를 생성할 수 없도록 강제를 하는 방안을 생각해볼 수 있다. 나는 Redis에서 사용하는 모든 키들을 한 파일에 정의해두고, 다시 용례(Api, DB, 혹은 다른 작업)에 따라 분류해서 정의해놓는다. 이렇게 하면 키를 잘못 주는 실수도 방지할 수 있고, 비슷하지만 용도가 다른 헷갈리는 키도 확실하게 구분할 수 있다.

 

어떻게 보면 조금 당연한 이야기일 수 있지만, Redis를 처음 사용할 때 꼭 짚고 넘어가면 좋을 것 같아서 언급해봤다.

 

Namespace로 논리적인 분리하기

Redis를 좀 더 잘 쓰기 위해서 한 번쯤 고민하게 되는 부분이 바로 키 관리라고 생각한다.

 

캐싱을 하다보면, 캐싱이 유지되는 스코프를 여러 개 관리하는 경우가 생긴다. 실제로 사이트 개발을 하면서 앱 캐시와 DB 캐시를 분리해서 관리해야만 하는 니즈가 있었는데, 보통 이럴 경우 Hash를 떠올리는게 일반적이다.

 

하지만 Hash의 경우 필드를 삭제하는 HDEL이나 모든 키를 불러오는 HKEYS 명령의 시간 복잡도가 O(n)이기 때문에, 만약 hash를 통해서 namespace를 관리하게 될 경우, 키가 많다면 연산 속도가 문제가 될 수 있다고 판단했다. 더불어 hash에 어떤 값을 설정할 때 또 다시 형태의 데이터는 추가할 수 없다는 단점도 있다.

 

이러한 이유로 키를 저장할 때 namespace를 붙여 저장하고, 이 키를 저장할 별도의 Set을 사용해서 namespace의 키를 관리하도록 했다.

 

const makeCache = (namespace: string) => {
  return {
    async get(key: string) {
      const newKey = `${namespace}:${key}`;
      const value = await redis.get(newKey);
      return JSON.parse(value);
    },
    async set(key: string, item: any) {
      const newKey = `${namespace}:${key}`;
      await redis.set(newKey, JSON.stringify(item));
      await redis.sadd(`namespace:${namespace}`);
    },
    async clear() {
      const namespaceKey = `namespace:${namespace}`;
      const size = await redis.scard(namespaceKey)
      const keys = await redis.spop(namespaceKey, size);
      for (const key of keys) {
        await redis.del(key);
      }
    },
  }
}

위 코드의 makeCache 함수는 get, set, clear 함수를 가진 캐싱 인터페이스를 제공한다. get, set 두 가지 함수의 기능은 Redis의 GET, SET 명령과 비슷한데, 차이점이 있다면 키의 prefix로 namespace를 붙이고, namespace에 해당하는 Set에 데이터를 키를 저장한다는 점이다.

 

이후 namespace에 속한 키들을 한 번에 삭제할 필요가 있을 때는 clear 함수를 사용한다. Set의 전체 크기를 가져오는 SCARD, Set에서 N개의 랜덤한 값을 제거하면서 가져오는 SPOP 둘 모두 O(1)의 시간 복잡도를 가진다.

 

아쉽게도 키를 지우는 DEL같은 경우, 여러 개의 키를 인자로 전달했을 때 제거할 키의 숫자에 비례해 시간이 소요되기 때문에 (O(n)) 한 번에 키를 전달하지 않고 for문을 통해 DEL 명령을 여러 번 실행하는 방식을 취했다. 앞서 설명한 바와 같이 Redis는 싱글 스레드로 동작하기 때문에, 시간이 많이 걸리는 하나의 명령보다 여러 명령을 나누어서 보내는 것이 다양한 노드로부터 받는 명령을 처리하기 효율적일 것이다.

 

물론 이렇게 했을 때 키를 가져오고 모든 키를 삭제하기까지의 과정이 하나의 트랜잭션으로 묶이지 않아 도중에 키가 추가되거나 하는 케이스를 처리하기 어렵다는 한계점이 있지만 (clear() 함수가 종료된 시점에 해당 namespace의 모든 키가 삭제되어 있을 것이라고 장담할 수 없음), 이 경우에는 크게 문제될 것 같지는 않아 충분하다고 생각했다.

 

이렇게 정의한 makeCaches 함수는 다음과 같이 사용해 namespace에 따라 여러 캐시를 만들 수 있다. 서버 초기화를 위해 앱 캐시를 날려야할 때는 appCache를, DB 스키마 변경으로 데이터베이스 캐시를 날려야할 때는 dbCache를 사용하면 된다.

const appCache = makeCache("app")
const dbCache = makeCache("db")

 

구조는 간단한게 최고

Redis는 사용 목적에 따라 다양한 저장 형태를 지원한다. List, Set, Hash, Sorted Set 등 다양한데 어떤 기준에 따라 이러한 구조들을 선택하는게 좋을지 고민이 된다. 캐싱한 유저 목록을 Set 형태로 저장하는게 좋을까? 아니면 Hash 형태로 저장하는게 좋을까?

 

개인적인 경험 상 구조는 나중에 도입할 수록 좋다고 생각한다. 구조는 다르게 말하면 데이터의 형식에 일련의 제약을 두는 것으로 해석할 수 있고, 이러한 제약을 통해서 사용하는 방식도 어느 정도 좁혀진다.

 

일단 처음에는 필요하다고 생각되는 데이터는 JSON 형태로 바꿔서 단순하게 Key-Value 형태로 저장해보자. 넣었던 JSON을 그대로 꺼내서 파싱하기 때문에 사용하는 입장에서는 구조적인 고민이 필요가 없다. 단순 GET, SET 이므로 속도도 빠르다.

 

그런 식으로 사용하다가 불편함을 느끼면 그 때 하나씩 구조를 도입하는게 현명하다. 내 경우에도 대부분의 API 캐시같은 경우는 입력 파라미터를 전부 하나의 키로 묶고, Response 데이터를 통째로 JSON으로 만들어 처리한다. 유저에 연관된 API의 경우, 갱신 요청이 발생하면 저장되어 있던 캐시를 모두 날릴 필요가 있어 유저 고유 ID를 키로 하는 Hash를 사용하고 있지만, 그 Hash에 저장되는 데이터는 전부 JSON 형태이다.

 

이런 식으로 Redis를 사용하는게 옳은 방향인지는 잘 모르겠지만, 일단 지금같은 소규모 서비스는 문제 없이 운영하고 있으니 문제가 생긴다면 그 때 더 나은 방법을 찾아봐도 괜찮지 않을까. 아마 이런 식으로 캐시를 활용하다보면, 메모리를 너무 많이 사용하게 되는 문제가 발생할 수 있을 것 같다.


기술을 사용하는 데 있어 사용하기에 편하고 많은 사람들이 동의하는 방법은 분명히 존재하지만, 어떤 기술을 사용하는 데에 정답이라는 건 없다. 글에서 이야기한 내용도 내 개인적인 생각을 담은 것 뿐이고, 이 경험이 모든 사례에 동일하게 적용될 수는 없다. 다만 여러분이 나와 같은 상황이라면 조금이나마 힌트를 얻을 수 있지 않을까 기대를 해본다.

 

이번 글에서 다루지 못한 이야기가 꽤 많다. 캐싱 용도로도 레디스를 사용했지만, 그보다 더 유용하게 쓰고 있다고 생각하는 부분은 전적 갱신을 위해 PUB/SUB 형태의 구조다. 다음 글에서는 이에 대한 이야기를 해볼 예정이다.

 

@turastory

반응형
Comments