Allen Dev Blog

Redis Sorted Set 을 이용한
실시간 랭킹 시스템 구축

Redis Sorted Set 을 이용한<br>실시간 랭킹 시스템 구축

우리는 일반적으로 Redis 를 캐싱 서버 용도로 사용하는데, 캐싱 서버 외에도 실시간 랭킹 시스템을 구축하는데도 유용하게 사용할 수 있습니다. 이번 포스트에서는 Redis Sorted Set 를 사용하여 실시간 랭킹 시스템을 구축해보겠습니다.

관계형 데이터베이스로 랭킹을 구축하면?

우리가 흔히 사용하는 MySQL, MariaDB, PostgreSQL 등 RDMS 로 랭킹 시스템을 한 번 구현해볼까요? 아마 구현 자체는 어렵지 않을 것입니다. 예를 들어 “영화의 관객수” 를 가지고 랭킹을 매긴다고 가정해봅시다.

영화 이름 관객 수(명)
명량 17,615,686
극한직업 16,266,338
신과함께-죄와벌 14,414,658
국제시장 14,263,980
어벤져스: 엔드게임 13,977,602
겨울왕국 2 13,747,792
아바타 13,486,963
베테랑 13,414,484

위는 네이버에서 2021.08.12 기준 “역대 영화 관객수 순위”를 검색했을 때 나오는 영화 순위에서 8위까지 영화를 나타냅니다. 이러한 순위는 관계형 데이터베이스로도 간단하게 산출해낼 수 있을 것입니다. 아래와 같이 말이죠.

SELECT * FROM `movie` ORDER BY `views` DESC

만약 랭킹을 101위에서 200위까지 가져올 것이라면

SELECT * FROM `movie` ORDER BY `views` DESC LIMIT 101, 100

여기서 Late Row Lookup 등의 추가적인 최적화 방법을 사용하여

SELECT `m`.* FROM `movie` `m`
INNER JOIN (
    SELECT id FROM `movie`
    ORDER BY `views` DESC LIMIT 101, 100
) `lu` ON (`m`.`id` = `lu`.`id`)

이렇게 쿼리를 구성하면 데이터가 수백만 건이라도 짧은 시간 안에 특정 랭킹 범위의 데이터를 가져올 수 있습니다.

그런데 이번에는 반대로 특정 영화의 랭킹이 몇 위인지를 구해볼까요? 조금만 생각해보면 간단하게 COUNT(*) 쿼리를 통해서 구할 수 있을 것입니다.

SELECT COUNT(*) + 1 FROM `movie` WHERE `views` > (
  SELECT `views` FROM `movie`
  WHERE `name` = '어바웃 타임' 
)

“어바웃 타임” 의 관객수 랭킹을 구하려고 한다면 어바웃 타임보다 관객 수가 더 많은 영화의 개수를 구한 후 여기서 1을 더하면 “어바웃 타임” 의 랭킹이 나올 것입니다. 추가적으로 “관객 수가 동점일 때 어떻게 처리할 것인가?” 까지 고려하게 된다면 쿼리는 좀 더 복잡해질 수 있겠죠.

위의 쿼리는 실제로 대부분의 시스템에서 잘 동작할 것입니다. 하지만 수백만 또는 그 이상의 Row 가 있는 경우에도 잘 작동할까요? 실제로 한 번 테스트를 해보겠습니다.

먼저 docker 로 MariaDB 를 로컬에서 실행합니다.

docker run -it --rm -p 3306:3306 -e MYSQL_ROOT_PASSWORD=mariadb mariadb:latest

그리고, 테이블을 생성해볼까요? “test” 데이터베이스를 생성한 후에 “movie” 테이블을 정의해보겠습니다.

CREATE DATABASE test;
USE test;

CREATE TABLE `movie` (
	`id` INT NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(128) NOT NULL DEFAULT '0',
	`views` INT NOT NULL DEFAULT '0',
	PRIMARY KEY (`id`),
	INDEX `views` (`views`),
	INDEX `name` (`name`)
);

그리고, 1000만 개의 랜덤 데이터를 생성해봅시다. 파이썬 SQLAlchemy 를 사용하여 랜덤한 1000만 개의 Movie 데이터를 생성하였습니다.

pip install SQLAlchemy pyMySQL
from random import randint

from sqlalchemy import create_engine, Integer, Column, VARCHAR
from sqlalchemy.engine import URL
from sqlalchemy.orm import declarative_base, sessionmaker

url = URL.create(
    drivername='mysql+pymysql',
    username='root',
    password='mariadb',
    host='127.0.0.1',
    database='test'
)

engine = create_engine(url)
Base = declarative_base()


class Movie(Base):
    __tablename__ = 'movie'

    id = Column(Integer, primary_key=True)
    name = Column(VARCHAR(128), index=True)
    views = Column(Integer, index=True)


Session = sessionmaker(bind=engine)

session = Session()

TOTAL = 10000000
SUB = 50000

for i in range(TOTAL // SUB):
    objects = [
        Movie(name=str(i * SUB + x), views=randint(0, 100000000))
        for x in range(SUB)
    ]

    session.bulk_save_objects(objects)
    session.commit()
    print(f'{(i + 1) * SUB} / {TOTAL}')

이제 특정 영화 관객 수의 랭킹 쿼리를 날려봅시다.

보시다시피 데이터가 1000만 개 정도일 때 2초 이상의 굉장히 느린 속도를 보여줍니다. 이런 속도로는 API 로 랭킹을 보여주다가는 서버가 터질 수도 있을 것 같네요. 일반적으로 MariaDB 에서 COUNT(*) 쿼리는 최적화가 되지 않기 때문에 이런 문제가 발생합니다. 시간 복잡도로 본다면 평균적으로 O(n) 의 시간이 걸린다고 볼 수 있습니다.

위에서 봤듯이 “랭킹 X위부터 Y위까지 쿼리” 는 최적화가 잘 되지만 “특정 X 의 랭킹이 몇 위인지 쿼리” 는 최적화가 어렵습니다. 최적화를 위해 랭킹 순위 컬럼을 따로 두어서 관객 수가 업데이트 될 때마다 랭킹 순위를 수정하는 방법 등 최적화할 수 있는 방법들이 더 있겠지만 멀티 쓰레드 또는 멀티 프로세스 환경에서 랭킹에 대한 업데이트가 일어나는 경우 데드락 등의 문제가 발생할 가능성도 있기 때문에 최적화 작업이 매우 까다롭습니다.

Redis 의 Sorted Set

레디스에서는 “Sorted Set” 이라는 자료구조를 지원합니다. 레디스는 Key-Value 저장소인데 “Set” 는 이 Key-Value 저장소에서 Key 값의 중복을 허용하지 않습니다. “Sorted Set” 은 “Set” 의 업그레이드된 버전이라 할 수 있으며, 기본적으로 "Set" 이지만 Value 에 정렬된 형태로 저장을 하기 때문에 특정 Key 의 순위가 어떻게 되는지 빠르게 구할 수 있습니다. 시간복잡도로 따진다면 O(log n) 의 시간으로 특정 Key 값의 랭킹값을 얻을 수 있게 됩니다.

우선 Redis 명령어를 통해 Sorted Set 데이터 추가, 랭킹 순위 조회, 데이터 삭제하는 방법을 살펴보겠습니다.

먼저 레디스의 Sorted Set 을 사용하기 위해 docker 로 레디스를 실행해봅시다.

docker run -it --rm -p 6379:6379 redis:latest

ZADD

redis-cli 를 통해 Redis 에 접속한 후에 ZADD 명령어를 통하여 데이터를 추가할 수 있습니다. ZADD 뒤에 각각 Sorted Set 의 이름(Redis 키 값), 점수(Score), Sorted Set 내의 키값을 입력하면 됩니다.

ZADD movie 17615686 "명량"
ZADD movie 16266338 "극한직업"
ZADD movie 14414658 "신과함께-죄와벌"
ZADD movie 14263980 "국제시장"
ZADD movie 13977602 "어벤져스: 엔드게임"
ZADD movie 13747792 "겨울왕국 2"
ZADD movie 13486963 "아바타"
ZADD movie 13414484 "베테랑"

예시로 8개의 영화 데이터를 추가하였습니다. Redis 내에 movie 라는 Key 값을 사용했으며, 각각 명량은 17615686, 극한직업은 16266338, … 등 관객 수 데이터를 추가했습니다.

ZADD movie 17615686 "명량" 16266338 "극한직업" 14414658 "신과함께-죄와벌" 14263980 "국제시장" 13977602 "어벤져스: 엔드게임" 13747792 "겨울왕국 2" 13486963 "아바타" 13414484 "베테랑"

ZADD 는 Bulk Insert 를 지원합니다. 위와 같이 하나의 명령어를 사용하여 여러 개의 데이터를 추가할 수 있습니다. 대량의 데이터를 추가할 때 명령어를 여러 개 보내는 것보다 위와 같이 하나의 명령어를 보내는게 더 효율적입니다.

ZADD 를 사용할 때 주의할 점은 Sorted Set 도 일종의 Set 이기 때문에 Key 값의 중복을 허용하지 않습니다. 따라서 실제 개발 환경에서는 DB 의 Primary Key 등을 사용하여 중복되지 않는 키 값을 사용하여야 합니다.

ZSCORE

특정 키 값의 점수(Score) 를 구하기 위해서는 ZSCORE 명령어를 사용하면 됩니다.

ZSCORE movie "베테랑"

결과값으로 해당 키값의 점수가 나오는 것을 다음과 같이 확인할 수 있을 것입니다.

"13414484"

ZRANGE, ZREVRANGE

ZRANGE 명령어 및 ZREVRANGE 명령어를 통해 특정 범위의 순위 데이터를 구할 수 있습니다. ZRANGE 의 경우 오름차순 기준으로 랭킹을, ZREVRANGE내림차순 기준으로 랭킹 정보를 가져옵니다. 영화 관객 수의 경우 내림차순으로 랭킹이 매겨져야 하므로 ZREVRANGE 명령어를 사용해야겠죠?

예를 들어 1등부터 3등까지의 데이터를 구하고자 한다면

ZREVRANGE movie 1 3

결과값은 다음과 같이 나오는 것을 확인할 수 있을 것입니다.

1) "명량"
2) "극한직업"
3) "신과함께-죄와벌"

ZRANK

이번에는 특정 영화의 랭킹이 몇 위인지를 구해볼까요? ZRANK 명령어를 통해 쉽게 구할 수 있습니다.

ZRANK movie "아바타"

결과값으로 다음과 같이 나오는 것을 확인할 수 있을 것입니다.

(integer) 7

실시간 랭킹 시스템 구현하기

앞서 Redis Sorted Set 을 사용하기 위한 명령어를 알아보았습니다. 이번에는 실제로 이를 가지고 어떤 식으로 구현해야하는지 예제를 통하여 알아보겠습니다. 우리는 아까 위에서의 파이썬 코드에서 일부를 수정할 것입니다.

이제 기존의 파이썬 코드에서 우리는 movie 테이블의 views 컬럼을 제거하고, 이 views 는 redis 에서 관리를 할 것입니다. 레디스는 key-value 저장소이기 때문에 movie 의 views 를 저장하는 키가 필요합니다. redis 의 키값은 movie 로 하겠습니다.

pip install redis
from random import randint

from sqlalchemy import create_engine, Integer, Column, VARCHAR
from sqlalchemy.engine import URL
from sqlalchemy.orm import declarative_base, sessionmaker

import redis

url = URL.create(
    drivername='mysql+pymysql',
    username='root',
    password='mariadb',
    host='127.0.0.1',
    database='test'
)

engine = create_engine(url)
Base = declarative_base()

r = redis.StrictRedis(host='127.0.0.1', port=6379)


class Movie(Base):
    __tablename__ = 'movie'

    id = Column(Integer, primary_key=True)
    name = Column(VARCHAR(128), index=True)
    # views 는 redis 에 따로 저장
    # views = Column(Integer, index=True)
    
    """
    이 영화의 views 를 가져옵니다.
    """
    @property
    def views(self):
        return r.zscore('movie', str(self.id))
    
    """
    이 영화의 views 에 대한 랭킹 값을 가져옵니다.
    """
    def rank(self):
        return r.zrank('movie', str(self.id))


Session = sessionmaker(bind=engine)

session = Session()

TOTAL = 10000000
SUB = 50000

for i in range(TOTAL // SUB):
    objects = [
        Movie(name=str(i * SUB + x))
        for x in range(SUB)
    ]

    session.bulk_save_objects(objects)
    session.commit()
    
    # views 를 redis 에 따로 저장 (Bulk ZADD with Redis)
    r.zadd('movie', {
        str(i * SUB + x): randint(0, 100000000)
        for x in range(SUB)
    })
    
    print(f'{(i + 1) * SUB} / {TOTAL}')

기존의 코드와 달라진 점은 views 가 이제 더이상 MariaDB 에 저장하는 것이 아니라 redis 에 저장하고 있습니다. 따라서 영화의 views 를 가져오기 위해서 다음과 같이 레디스에서 따로 가져오는 프로퍼티 함수를 정의했습니다. Redis 명령어에서 봤듯이 ZSCORE 명령어로 스코어(=관객 수)를 가져올 수 있을 것입니다.

    @property
    def views():
        return r.zscore('movie', str(self.id))

또한 랭킹을 가져오는 함수를 추가하였습니다. 랭킹은 ZRANK 명령어를 사용하여 가져올 수 있습니다.

    def rank():
        return r.zrank('movie', str(self.id))

데이터를 추가할 때는 이제 views 를 MariaDB 에 저장하는 게 아니라 Redis 에 저장해야 하므로 ZADD 를 써야할 것입니다. 한가지 좋은 점은 ZADD 는 bulk insert 를 지원하기 때문에 명령어 하나로 대량의 데이터 입력이 가능합니다. 데이터를 1000만 개 넣어야 할텐데 ZADD 명령어를 하나씩 보내면 시간이 굉장히 오래걸릴 것이기 때문에 bulk insert 를 하여 데이터를 추가하였습니다.

    r.zadd('movie', {
        str(i * SUB + x): randint(0, 100000000)
        for x in range(SUB)
    })

이번에는 다시 redis-cli 를 와서 특정 영화의 랭킹을 구해볼까요? Key 가 1234 인 영화의 랭킹을 구해보려면

ZRANK movie 1234

결과는

(integer) 1296946

와 같이 나왔음을 확인할 수 있었습니다. (랭킹 점수는 스크립트를 통해서 랜덤으로 생성했으므로 테스트를 할 때 달라질 수 있습니다) 관계형 데이터베이스를 통해 랭킹을 구하는 방법보다 훨씬 준수한 속도를 보여줄 뿐만 아니라 최적화 작업 필요없이 사용법 또한 간단합니다.

Conclusion

이번 포스트에서는 레디스를 통해 실시간 랭킹 시스템을 구축하는 방법에 대해서 알아보았습니다.

관계형 데이터베이스에서는 특정 범위의 랭킹 유저를 구하는 것은 최적화하기 쉽지만 특정 유저의 순위를 구하는 쿼리는 최적화하는게 어렵고, 최적화하면서 멀티쓰레드 또는 멀티프로세스 환경에서는 데드락 등이 발생할 수 있는 경우가 많이 있습니다.

그에 반해 Redis Sorted Set 은 사용 방법도 간단할 뿐만 아니라 O(log n) 의 시간복잡도를 보여주기 때문에 좋은 성능을 보여줍니다. 따라서 랭킹 시스템을 구축할 때 Redis 를 사용하는 것을 한 번 고려해봐도 좋을 것 같습니다.

Redis Sorted Set 을 이용한<br>실시간 랭킹 시스템 구축
Prev post

탈중앙화 신원증명 DID
(3) Verifiable Credential

Next post

React Native 에서 프레임 드랍없는 애니메이션 만들기

Redis Sorted Set 을 이용한<br>실시간 랭킹 시스템 구축

Get in touch