In-Memory DataBase Redis에 대해 알아보기

Redis에 대해서 학습하고 정리하는 글입니다.

Redis

Redis(REmote DIctionary Server)는 Key-Value 기반의 캐시 저장소(In-Memory DB)이다. 또한 캐시 기능 외에도 다양하게 사용되는 오픈 소스이다. Pub/Sub의 구조로도 사용할 수 있다. 또한 다양한 자료구조로 저장이 가능하다.

Redis를 왜 사용할까?

파레토의 법칙

파레토의 법칙이란, 우리 사회에서 일어나는 현상의 80%는 20%의 원인으로 인해 발생됨을 뜻하는 법칙이다. 웹 사이트에 대한 접근도도 이 파레토의 법칙에 딱 들어맞는다.

인터넷 통신 80%가 불과 20%의 사이트에 대한 액세스로 추정되며, 이 20%의 웹사이트 데이터를 캐시해두면 효율을 극적으로 향상시킬 수 있다고한다. 따라서 공통으로 사용되는 데이터는 레디스를 이용하여 캐시로 저장해 두는 것이 리소스 를 효율적으로 이용할 수 있는 방법이 될 수 있다.

Collection

레디스는 In-Memory 데이터베이스이다. 즉, 모든 데이터를 메모리에 저장하고 조회한다. 기존 RDMS보다 훨씬 빠른데 그 이유는 메모리 접근이 디스크 접근보다 빠르기 때문이다. 하지만 빠르다는 것은 레디스의 여러 특징 중 일부분이다. 다른 In-Memory DB(ex. Memcached)와의 가장 큰 차이점은 다양한 자료구조를 지원한다는 것이다. 레디스는 아래처럼 다양한 자료구조를 Key-Value 형태로 저장할 수 있다.

Redis Collections

“이렇게 다양한 자료구조를 제공하는 것이 왜 좋을까?” 를 생각해볼 수도 있을 것이다. 이것의 답은 개발의 편의성과 난이도라고 할 수 있다.

예를 들어 실시간 랭킹 서버를 구현할 때 RDMS를 이용한다면 DB에 데이터를 저장하고, 저장된 SCORE 값으로 정렬하여 다시 읽어오는 과정이 필요하다. 개수가 많아지면 속도가 느려지기도 하게된다. 그 이유는 이 과정에서 디스크를 사용하기 때문이다. In-Memory 기반으로 서버에서 데이터를 처리하도록 직접 코드를 짤 수도 있겠지만 레디스의 Sorted-Set(정렬까지 해주는 집합 자료구조)을 이용하는게 더 빠르고 간단한 방법일 것이다.

또한 레디스는 트랜잭션의 문제도 해결해줄 수 있다. 그 이유는 싱글 스레드로 동작하는 서버의 모든 자료구조는 atomic하기 때문에, race condition을 피해 데이터의 정합성을 보장하기 쉽게된다.

즉, 외부의 Collections을 잘 이용하는 것만으로 개발 시간 단축이 가능하고, 생각하지 못한 여러가지 문제를 줄여줄 수 있으므로 개발자는 비지니스 로직에 집중할 수 있게된다는 큰 장점을 갖고있다.

Publish/Subscribe 아키텍처는 무엇일까?

위에서 Redis는 Pub/Sub 구조로도 사용할 수 있다고 했다. 이에 대해서 살펴보자.

Pub/Sub 아키텍처는 메시징 방법 중 하나이다. 일반적으로 시스템을 통합하는 방법은 아래와 같다.

  • 파일 전송
  • 공유 DB
  • HTTP Request/Response
  • 메시징

HTTP Request/Response

아마 대부분 개발자는 HTTP 방식을 많이 사용할 것이다. Rest API, GraphQL 등이 HTTP 방법의 대표적인 적용 예이다.

이러한 Request/Response 방식은 HTTP 프로토콜을 사용하기 때문에 Stateless한 특징이 있고 심플하고 단순하기 때문에 구현하기 아주 쉽다. 하지만, 클라이언트-서버 사이에 강한 의존성이 생기며 서버가 반드시 실행 중일 때만 데이터를 전달받을 수 있다. 그리고, 클라이언트는 서버가 다음 메시지를 보내기 전까지 응답을 기다리고 있따. 즉, 동기식 통신 방식으로 동작한다.

즉, HTTP Request/Response 통신은 단단하게 결합된 시스템 아키텍처이다.

Messaging

HTTP 통신과는 반대로, 느슨하게 결합된 비동기 시스템 통합 방식 중 대표 기술은 바로 메시징이다. 메시징은 중간 시스템을 통해 발신자에게 수신자로 데이터를 전송하는 포괄적인 용어이다.

Messaging System

중간 시스템을 통해서 전송하기 때문에, 발신자(Server or Sender)는 데이터를 전송받은 수신자(Client)에 대해서 전혀 알지 못한다. 메시지 채널만 알고 있으면 된다.

장점

  • 수신자를 확장하기 쉽다.
  • 마이크로서비스 아키텍처에 적합하다.
  • 느슨한 연결

단점

  • 복잡도 증가
  • 기술 스택 추가

Messaging Pub/Sub VS Message Queuing

메시징이라는 단어는 매우 포괄적인 용어이며, 메시징을 구현하는 방법은 매우 다양하다. 다양한 메시징 방법중 대표적으로 Pub/Sub, 메시징 큐잉으로 구분할 수 있다.

메시징 큐잉은 Point-toPoint Channel 방식으로, 오직 한 수신자만 메시지를 수신(Consume)하게된다. 주문에 대한 처리를 수신하는 클라이언트가 있다고 가정하면 순차적으로 메시지를 받게될 것이다.

순차적으로 메시지를 소비할 때, 처리 시간이 너무 느려서 메시지들이 채널에 쌓이게 되는 경우가 발생한다면, 심각한 병목현상이 발생할 수 있다. 이를 해결하기 위해, 소비자(Client)를 늘려주면 동시에 여러 메시지를 처리할 수 있게 구축할 수 있다.

Messaging Queuing

하지만 이러한 상황에 경우 소비자들은 경쟁 소비자가 되어 RaceCondition이 된다. 이런 경우 각 메시지에 대한 처리 순서가 보장되지 않는다. 예를 들어, 네트워크 장애가 발생해서 첫 번째 소비자에 전송된 첫 번째 메시지가 제대로 처리되지 않았다면 발신자(Server or Sender)에서는 주문1을 재전송하게 되며, 해당 이벤트는 메시지2, 메시지3 이후에 처리가 될 수도 있다. 이를 위해 별도의 순서를 위한 작업이 필요할 것이다.

해당 방법은 메시지 브로커 및 아키텍처 마다 다양한 방법이 존재한다. 예를 들어 kafka 브로커의 경우에는 파티션을 지정해서 메시지를 전송하는 방법 등이 있다.

이와 달리 Pub/Sub은 수신자(Client or Consumer) 모두에게 메시지를 전송하게 된다. 따라서 모두 같은 데이터를 받는다.

Pub/Sub

RabbitMQ, Apache ActiveMQ, Amazon SQS 등은 사실 메시징 큐잉 용도로 초기 설계가 되었고, Apach Kafka는 Pub/Sub 사용을 위해서 초기에 설계가 되었다고 한다. (정확하지는 않다.)

하지만, 이 모두가 Pub/Sub 방식 또는 메시지 큐잉 방식으로 사용할 수 있다.

Redis Pub/Sub

다시 Redis로 돌아오면 Redis 또한 Pub/Sub 방식을 제공한다. 하지만 다른 메시지 브로커와는 다르게, 메시지 지속성이 없다. 즉, 메시지를 전송한 후 해당 메시지는 삭제되는데, Redis 어디에도 저장되지 않는다. 따라서 실시간 데이터 처리에는 매우 적합합지만, 메시지가 저장되지 않는다는 점은 개발자가 반드시 인지하고 있어야한다.

또한, 수신자(Client)가 메시지를 받는 것을 보장하지 않는다. (Redis 5.0에서 도입된 Streams이나 Redis list가 대안이 될 수도 있다.)

Redis-Cli

Docker와 Redis-cli를 이용하면 쉽게 Pub/Sub을 체험해볼 수 있다.

Redis-cli

캐시로서의 Redis

대부분의 서비스에서는 레디스를 단순 캐시용도로 사용하길 원할 것이다. 이 때 레디스를 어떻게 배치하는지가 시스템의 성능에 큰 영향을 미치는데 이에 대해서 살펴보자

Look Aside (= Lazy Loading)

이름에서 알 수 있듯이 이 구조는 캐시를 옆에 두고 필요할 때만 데이터를 캐시에 로드하는 캐싱 전략이다. 캐시는 데이터베이스와 애플리케이션 사이에 위치하여 단순 Key-Value 형태를 저장한다. 애플리케이션에서 데이터를 가져올 때 레디스에 항상 먼저 요청하고, 데이터가 캐시에 있을 때에는 레디스에서 데이터를 반환한다. 데이터가 캐시에 없을 경우 애플리케이션에서 데이터베이스에 데이터를 요청하고, 애플리케이션은 이 데이터를 다시 레디스에 저장한다.

Look Aside

위 구조를 사용하면 아래와 같은 장점이 있다.

  • 실제로 사용되는 데이터만 캐시한다.
  • 레디스의 장애가 애플리케이션에 치명적인 영향을 주지 않는다.

하지만 아래와 같은 단점들이 생긴다.

  • 캐시에 없는 데이터를 쿼리할 때 더 오랜 시간이 걸린다.
  • 캐시가 최신 데이터를 가지고 있다는 것을 보장하지 못한다. 캐시에 해당 key 값이 존재하지 않을 때만 캐시에 대한 업데이트가 일어나기 때문에 데이터베이스에서 데이터가 변경될 때에는 해당 값을 캐시가 알지 못한다.

Write-Through

Write-Through

Write-Through 구조는 데이터베이스에 데이터를 작성할 때마다 캐시에 데이터를 추가하거나 업데이트한다. 이로 인해 캐시의 데이터는 항상 최신 상태로 유지할 수 있다. 하지만, 데이터 입력 시 두번의 과정을 항성 거쳐야 하기 때문에 지연 시간이 증가하게 된다는 단점이 있다. 또한, 사용되지 않을 수도 있는 데이터도 일단 캐시에 저장하기 때문에 리소스 낭비가 발생한다. 이를 해결하기 위해 데이터 입력시 TTL(Time to live)을 꼭 사용하여 사용되지 않는 데이터를 삭제하는 것을 권장한다.

Usage

좋아요 처리하기

게시물에 달린 댓글에 좋아요를 하는 기능에 사용 될 수 있다. 사용자가 댓글에 좋아요(👍)를 누를 수 있다고 생각해보자.

좋아요 기능의 가장 중요한 것은 한 사용자가 하나의 댓글에 한번만 👍를 할 수 있도록 제한하는 것이다. RDMS에서는 유니크 조건을 생성해서 처리할 수 있다. 하지만 만약 많은 입력이 발생하는 환경에서 RDMS를 이용한다면 insert와 update에 의한 성능 저하가 필연젹으로 발생하게 된다.(디스크이기 때문에)

레디스의 set을 이용하면 이 기능을 간단하게 구현할 수 있으며 빠른 시간안에 처리할 수 있다. set은 순서가 없고, 중복을 허용하지 않는 집합이다. 댓글의 번호를 사용해서 key를 생성하고 해당 댓글에 👍를 누른 사용자의 ID를 아이템으로 추가하면 동일한 ID값을 저장할 수 없으므로 한 명의 사용자는 하나의 댓글에 한번만 좋아요를 누를 수 있게 된다.

Redis set

Jedis(Java의 Redis lib)를 통한 파이프라인을 사용하여 이 기능을 구현한다고 가정하면, 초당 약 16만건의 커맨드를 처리할 수 있다고한다. (RDBMS와 비교했을 때 확연히 빠른 속도이다.)

게임 서비스의 하루 순 방문자수(Unique Visitor) 구하기

순 방문자수(UV)는 서비스에 사용자가 하루 여러번 방문했다 하더라도 한번만 카운팅되는 값이다. 즉, 중복 방문을 제거한 방문자의 지표라고 생각할 수 있다. (많은 서비스에서 이 수치를 이용해 사용자의 동향을 파악하고, 마케팅을 위한 자료로 활용하기도 한다.) 실제 서비스에서는 이를 구하기 위해서 대표적으로 세 가지 방법을 사용한다.

  • 액세스 로그를 분석하는 방법
  • 외부 서비스(Google Analytics 등)의 도움을 받는 방법
  • 접속 정보를 로그파일로 작성하여 배치 프로그램을 돌리는 방법

이 세 가지 방법 중 GA를 제외하고는 정보를 실시간으로 조회할 수 없다.

그렇다면 이제 레디스의 비트 연산을 활용하여 간단하게 실시간 순 방문자를 저장하고 조회하는 방법을 살펴보자. 게임의 유저는 천만명이라 가정하고, 일일 방문자 횟수를 집계하여 이 값을 0시를 기준으로 초기화 된다.

사용자 ID는 0부터 순차적으로 증가된다고 가정하고, string의 각 bit를 하나의 사용자로 생각할 수 있다. 사용자가 서비스에 방문할 때 사용자 ID에 해당하는 bit를 1로 설정한다. 1개의 bit가 1명을 의미하므로, 천만명의 유저는 천만개의 bit로 표현할 수 있고, 이는 곧 1.2MB정도의 크기이다. 레디스의 string의 최대 길이는 512MB이므로 천만명의 사용자를 나타내는 것은 충분하다.

Redis BITCOUNT

2020년 1월 29일에 ID가 7인 사용자가 방문했다면 위 그림처럼 일곱번째 인덱스를 1로 설정한다. 이날에 서비스에 총 방문자 수를 조회하기 위해서는 문자열에서 1로 설정된 bit의 개수를 구하는 BITCOUNT연산을 사용하여 간단히 구할 수 있다.

만약 출석 이벤트 등을 진행하기 위해 정해진 기간동안 매일 방문한 사용자를 구하고 싶을 수 있다. 이 때는 레디스의 BITOP 커맨드를 사용하면 간단하다. 레디스 서버에서 바로 AND, OR, XOR, NOT 연산을 할 수 있으므로, 레디스에서 개별 비트를 가져와서 서버에서 처리하는 번거로움을 줄여준다.

Redis BITOP

2020년 1월 29일부터 31까지 매일 접속한 사용자는 id가 7인 사용자와 11인 사용자라는 것을 BITOP을 이용한 AND 연산을 통해 쉽게 구할 수 있다.

최근 검색 목록 표시하기

최근 검색된 내역을 조회하는 것도 레디스로 간단하게 구현이 가능하다. 협업 도구 앱에 최근 검색했던 담당자를 볼 수 있는 기능을 추가한다고 생각해보자.

이 기능을 관계형 데이터베이스를 이용해 구현하려면 아래와 비슷한 쿼리문이 필요하다.

select * from KEYWORD where ID = 123 order by reg_date desc limit 5;

이 쿼리는 사용자가 최근에 검색했던 테이블에서 최근 5개의 데이터를 조회한다. 하지만 이렇게 RDMS의 테이블을 이용해서 데이터를 정한다면 중복 제거도 해야하고, 멤버별로 저장된 데이터의 개수를 확인하고, 오래된 검색어는 삭제하는 작업까지 이루어져야한다.

따라서 애초에 중복을 허용하지 않고, 정렬되어 저장되는 레디스의 sorted set을 사용하면 간단하게 구현할 수 있다. sorted set은 가중치를 기준으로 오름차순 정렬되기 때문에, 가중치로 시간을 사용한다면 이 값이 가장 큰, 나중에 입력된 아이템이 맨 마지막 인덱스에 저장된다.

최근 검색 목록 Sorted Set - 1

멤버 ID가 123인 사람이 최근 검색한 사람은 위 그림처럼 정렬되어 저장된다. 이때 가중치는 입력 순간의 nano seconds이고, 가장 처음 검색한 사람의 ID는 46, 가장 마지막 검색한 사람은 50이다. 이 때 ID가 51인 사람을 검색하면 아래처럼 마지막 데이터가 추가된다.

최근 검색 목록 Sorted set - 2

항상 다섯명만 저장하기 위해서는 인덱스가 0인 아이템을 지우면 된다. 하지만 아이템 개수가 6보다 작을때에는 0번째 인덱스를 삭제하면 안되기 때문에 매번 아이템의 수를 먼저 확인해야하는 번거로움이 있다. 이 때 sorted set의 음수 인덱스를 사용한다면 더 간단해진다. 음수 인덱스는 인덱스의 마지막부터 큰 값에서 작은 값 순으로 매겨진다.

음수 인덱스

> ZREMRANGEBYRANK recent:member:123 -6 -6

데이터에 멤버를 추가한 뒤, 항상 -6번째 아이템을 지운다면 특정 개수 이상의 데이터가 저장되는 것을 방지 할 수 있게된다. 인덱스로 아이템을 지우려면 ZREMRANGEBYRANK 커맨드를 사용하면 간단하다. 이렇게 레디스의 sorted set을 이용하면 많은 공수를 들이지 않고 최근 검색한 담당자를 보여줄 수 있다.

References

https://brunch.co.kr/@springboot/374

https://meetup.toast.com/posts/224

⤧  Next post RabbitMQ에 관하여 ⤧  Previous post Swagger를 이용한 API 명세 자동화