본문 바로가기
Java & Kotlin/Spring Data

Redis를 데이터베이스 답게 사용하는 다양한 방법

by heekng 2025. 1. 23.
반응형

안녕하세요. POD 백엔드팀 고희광입니다.
이전에 redis를 이용한 분산락 적용으로 이벤트 참여 동시성 이슈를 개선한 것과 더불어 다양한 방법으로 redis를 활용하는 방법을 소개하고자 합니다.

먼저 사내에서 redis를 사용하는 사례에는 대표적으로 세가지가 있습니다.

  1. JWT 토큰 저장
  2. 캐시 저장
  3. 분산락

흔히 redis가 뭔지 물어보면 메모리기반이라 빠르다, 싱글스레드이다 등의 답변을 받습니다.
하지만 redis가 휘발성 데이터만 저장한다 생각하며 NoSQL 데이터베이스라는 성격을 띄고있는 것에 관심가지지 않는 것 같아 몇가지 사례를 예로 들고자 합니다.

이벤트 중복 참여 제한하기

이벤트 중복 참여의 경우 분산락을 통해 중복참여 체크 로직에서 걸러지게 되어있습니다.
하지만 락을 사용하지 않은 경우 한 회원의 연속된 쿠폰 발급 요청이 모두 허용되는 상황이 발생할 수 있습니다.
이러한 경우엔 이벤트의 참여여부를 SET에 추가해 해결할 수 있습니다.

SADD event:E0001 member_id_1
public Long eventParticipate(String eventId, long userId) {
    return redisTemplate.opsForSet()
            .add("event:" + eventId, String.valueOf(userId));
}

각 이벤트 참여 비즈니스 로직 이전에 이벤트 참여 SET에 회원 식별자를 value로 추가하면, 성공시 1을 실패시 0을 응답합니다.
레디스는 싱글스레드로 동작하기 때문에 동시에 많은 요청이 있더라도 순차적으로 요청을 처리할 수 있습니다.

이벤트 특정 시간 내 중복 참여 제한

이벤트 중복 참여와 다르게 중복 참여에 대한 시간 제한이 필요할 때에는 String 형태로 저장하며 만료시간을 지정해 구현할 수 있습니다.

SET event:E0001:member_id_1 1 NX EX 3600
public Boolean eventParticipateWithExpire(String eventId, long userId, long expiresIn) {  
    String key = "event:" + eventId + ":" + userId;  
    Boolean result = redisTemplate.opsForValue()  
            .setIfAbsent(key, "1");  
    redisTemplate.expire(key, expiresIn, TimeUnit.SECONDS);  
    return result;  
}

memberId가 포함된 키에 1이라는 값을 지정한 후, 만료시간을 3600초로 지정합니다.

NX 옵션은 키가 존재하지 않는다면 등록을, 키가 존재한다면 아무 작업도 진행하지 않습니다.
EX 옵션은 만료시간을 초 단위로 지정할 수 있습니다.

이전에 사용한 SET을 사용하지 않은 점이 의아할 수 있는데요.
SET은 Value에 별도의 expire을 걸 수 없고 key에만 만료시간을 설정할 수 있습니다.
때문에 key-value 형태로 데이터를 저장하고 key에 만료시간을 지정합니다.

실시간 랭킹

실시간 랭킹의 경우 Sorted Set을 사용하면 손쉽게 구현할 수 있습니다.
예를 들어 상품, 디자인템플릿의 순위를 매겨 사용자에게 제공해야 할 경우 활용할 수 있습니다.

ZINCRBY product-sales-count 1 PRODUCT_1
-- 1 응답
ZINCRBY product-sales-count 1 PRODUCT_1
-- 2 응답
ZINCRBY product-sales-count 1 PRODUCT_2
-- 1 응답
ZINCRBY product-sales-count 1 PRODUCT_2
-- 2 응답
ZINCRBY product-sales-count 1 PRODUCT_2
-- 3 응답
ZINCRBY product-sales-count 1 PRODUCT_2
-- 4 응답
ZINCRBY product-sales-count 1 PRODUCT_3
-- 1 응답
ZINCRBY product-sales-count 1 PRODUCT_3
-- 2 응답
ZINCRBY product-sales-count 1 PRODUCT_3
-- 3 응답

ZREVRANGE product-sales-count 0 -1
-- 순위 순서대로 출력
ZREVRANGE product-sales-count 0 -1 WITHSCORES
-- 점수까지 함께 출력
public Double putRankPoint(String rankTarget, String product) {
        return redisTemplate.opsForZSet().incrementScore("ranking:" + rankTarget, product, 1);
    }

    public Set<String> getRealtimeRankings(String rankTarget) {
        return redisTemplate.opsForZSet().reverseRange("ranking:" + rankTarget, 0, -1);
    }

    public Set<ZSetOperations.TypedTuple<String>> getRankingWithScore(String rankTarget) {
        return redisTemplate.opsForZSet().reverseRangeWithScores("ranking:" + rankTarget, 0, -1);
    }

ZINCRBY 명령어는 Sorted Set의 value에 가중치를 추가합니다.
특정 이벤트시 value에 1씩 값을 추가하면 오름차순으로 정렬됩니다.

ZREVRANGE 명령어는 특정 범위의 value를 역순으로 출력합니다.
이 경우 오름차순으로 정렬된 데이터를 내림차순으로 확인할 수 있어 가중치가 높은 순서대로 데이터를 확인할 수 있습니다.
또한 WITHSCORES 옵션과 함께하면 가중치도 함께 확인할 수 있습니다.

좋아요

게시물에 좋아요 하거나 좋아요 취소를 하는 경우도 SET을 활용할 수 있습니다.
저희 서비스의 경우 클립아트와 같은 리소스에 좋아요 처리를 하는 경우를 예로 들 수 있습니다.

-- MEMBER_1 이 1번 게시물에 좋아요 시도
SADD post:1:like MEMBER_1
-- 1 응답 (성공)

-- MEMBER_1 이 1번 게시물에 좋아요 시도
SADD post:1:like MEMBER_1
-- 0 응답 (실패)

-- MEMBER_2 이 1번 게시물에 좋아요 시도
SADD post:1:like MEMBER_2
-- 1 응답 (성공)

-- MEMBER_1 이 1번 게시물에 좋아요 취소 시도
SREM post:1:like MEMBER_1
-- 1 응답 (성공)

SMEMBERS post:1:like
-- 1) "MEMBER_2"

SCARD post:1:like
-- (integer) 1
public Long thumbsUp(Long postId, String memberId) {  
    String key = "post:" + postId + ":like";  
    return redisTemplate.opsForSet().add(key, memberId);  
}  

public Long cancle(Long postId, String memberId) {  
    String key = "post:" + postId + ":like";  
    return redisTemplate.opsForSet().remove(key, memberId);  
}  

public Long count(Long postId) {  
    String key = "post:" + postId + ":like";  
    return redisTemplate.opsForSet().size(key);  
}  

public Boolean exists(Long postId, String memberId) {  
    String key = "post:" + postId + ":like";  
    return redisTemplate.opsForSet().isMember(key, memberId);  
}

SREM 명령어는 SET에 있는 value를 제거합니다.
SMEMBERS 명령어는 key에 추가된 값들을 확인할 수 있습니다.
SCARD 명령어는 SET에 존재하는 value의 개수를 확인할 수 있습니다.

만약 게시물과 회원 사이의 좋아요에 대한 추적이 모두 가능해야 한다면 어떻게 하면 좋을까요?

  1. 게시물에 좋아요한 회원들
  2. 회원이 좋아요한 게시물들
    두가지 데이터를 관리해야 합니다.

이 경우에는 동시성을 신경써야 하는데 클라이언트에서 연속적으로 같은 게시물에 좋아요를 한다면 문제가 발생할 수 있습니다.

  1. 회원 1이 게시물에 좋아요.
  2. 회원 1이 게시물에 좋아요. (동시에 발생)
  3. 게시물 좋아요 SET에 회원 1 추가 (성공)
  4. 게시물 좋아요 SET에 회원 1 추가 (실패)
  5. 회원 좋아요 SET에 게시물 추가 (첫 요청 성공)
    과 같이 흔하지 않을 수 있지만 싱글 스레드에서 요청 순서로 발생하는 여러 문제 상황이 존재합니다.
MULTI
-- OK
SADD post:3:like MEMBER_1
-- QUEUED
SADD user:MEMBER_1:post:like 3
-- QUEUED
EXEC
-- 1) (integer) 1
-- 2) (integer) 1
public List<Object> thumbsUpBetween(Long postId, String memberId, Boolean error) {  
    Boolean postLike = redisTemplate.opsForSet().isMember("post:" + postId + ":like", memberId);  
    Boolean memberPostLike = redisTemplate.opsForSet().isMember("member:" + memberId + ":like:post", postId.toString());  

    if (postLike && memberPostLike) {  
        throw new IllegalStateException("이미 좋아요 한 게시물입니다.");  
    }  

    List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() {  
        @Override  
        public List<Object> execute(RedisOperations operations) throws DataAccessException {  
            operations.multi();  
            operations.opsForSet().add("post:" + postId + ":like", memberId);  
            operations.opsForSet().add("member:" + memberId + ":like:post", String.valueOf(postId));  

            if (error) {  
                throw new IllegalAccessError("트랜잭션 내에서 에러가 발생했습니다.");  
            }  

            return operations.exec();  
        }  
    });  
    return results;  
}

이렇게 명령의 순서를 순차적으로 실행하기 위해 redis에서는 MULTIEXEC 명령어를 제공합니다.
MULTI 명령어는 일종의 트랜잭션을 시작하는 명령어입니다.
MULTI 명령어 이후의 명령어 입력은 바로 실행되지 않고 큐에 저장됩니다.
EXEC 명령어는 큐에 저장된 명령어를 한번에 실행합니다.(명령 사이에 다른 요청이 끼어들지 않음)

이외에 DISCARD, WATCH 등 트랜잭션을 지원하기 위한 명령어도 존재합니다.
DISCARD 명령어는 MULTI 명령어로 시작한 트랜잭션을 취소합니다.
WATCH 명령어는 특정 키를 모니터링하도록 지원합니다.
WATCH post:1:like 와 같이 명령어를 입력하면 외부의 다른 클라이언트가 post:1:like의 값을 변경했을 때 트랜잭션에 실패합니다.

최근 본 상품 리스트

많은 커머스 등의 서비스에서 최근 본 상품 리스트를 제공하는 경우가 있습니다.
이러한 경우 또한 실시간 랭킹처럼 Sorted Set을 활용해 구현할 수 있습니다.

ZADD last-seen-product:MEMBER_1 20240302153730 product_1
-- (integer) 1
ZADD last-seen-product:MEMBER_1 20240302153750 product_2
-- (integer) 1
ZADD last-seen-product:MEMBER_1 20240302153800 product_4
-- (integer) 1
ZADD last-seen-product:MEMBER_1 20240302153830 product_3
-- (integer) 1

-- 최근 본 상품 리스트 조회
ZREVRANGE last-seen-product:MEMBER_1 0 -1
-- 1) "product_3"
-- 2) "product_4"
-- 3) "product_2"
-- 4) "product_1"

위와 같이 가중치를 상품을 조회한 시간으로 입력하면 역순 조회시 최근 본 상품을 확인할 수 있습니다.
하지만 사용자가 많은 상품을 조회한 경우 무한정으로 메모리에 데이터가 쌓일 수 있기 때문에 Sorted Set에 저장된 데이터의 개수를 조절해야 합니다.

ZREVRANGE last-seen-product:MEMBER_1 0 -1
-- 1) "product_3"
-- 2) "product_4"
-- 3) "product_2"
-- 4) "product_1"
MULTI
-- OK
ZADD last-seen-product:MEMBER_1 20240302153850 product_5
-- QUEUED
ZREMRANGEBYRANK last-seen-product:MEMBER_1 0 -2
-- QUEUED
EXEC
-- 1) (integer) 1
-- 2) (integer) 4
ZREVRANGE last-seen-product:MEMBER_1 0 -1
-- 1) "product_5"
public List<Object> addRecentView(String productId, String userId) {  

    String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));  
    String key = "last-seen-product:" + userId;  

    return redisTemplate.execute(new SessionCallback<List<Object>>() {  
        @Override  
        public List<Object> execute(RedisOperations operations) throws DataAccessException {  
            operations.multi();  

            operations.opsForZSet().add(key, productId, Double.parseDouble(now));  
            operations.opsForZSet().removeRange(key, 0, -6);  

            return operations.exec();  
        }  
    });  
}
  1. value 추가
  2. 최근 본 상품 최대치 이외의 상품 정보 제거
    위 두가지 쓰기 작업을 연속으로 수행해야 하기 때문에, 하나의 트랜잭션 내에서 작업을 수행해야 합니다.이외의 활용 방법지금까지는 String, Set, Sorted Set을 활용한 예를 보여드렸습니다.
    redis에서 제공하는 데이터 구조에는 이외에도 List, Hash 등이 있습니다.
    이중 List를 사용하면 간단하게 Queue를 구현할 수 있습니다.
  3. queue

redis에서는 List를 다루는 명령어를 아래와 같이 제공합니다.

  • LPUSH: 리스트 왼쪽에 추가
  • RPUSH: 리스트 오른쪽에 추가
  • LPOP: 리스트 왼쪽에서 요소 POP
  • RPOP: 리스트 오른쪽에서 요소 POP
  • BLPOP: 리스트 왼쪽에서 요소 POP, POP할 요소가 없다면 timeout 동안 대기
  • BRPOP: 리스트 오른쪽에서 요소 POP, POP할 요소가 없다면 timeout 동안 대기
  • LLEN: 리스트 길이 확인

이를 활용하면

  1. LPUSH 명령어로 queue에 데이터 추가
  2. BRPOP 명령어로 queue에 데이터 POP, 데이터가 없다면 대기
    와 같은 형태로 queue를 사용할 수 있습니다.Pub/Subqueue에는 치명적인 단점이 있습니다.
    바로 queue의 consumer 측에서 지속적으로 데이터가 있는지 조회명령어를 입력한다는 것인데요.
    이를 해결하기 위해 Pub/Sub을 사용하는 방법이 있습니다.

redis의 Pub/Sub 개념에는 세가지가 있습니다.

  • Channel: 메시지가 전달되는 경로
  • Publisher: 메시지 발행자
  • Subscriber: 메시지 구독자
    Publisher은 Channel에 메시지를 발행하고, Subscriber은 Channel을 구독하는 방식입니다.

대표적으로 분산락 구현시 사용한 Redisson이 Pub/Sub 방식을 활용한 예시입니다.

Pub/Sub을 사용하는 명령어는 대표적으로 아래와 같이 있습니다.

  • SUBSCRIBE channel1, channel2 ...: 채널 N개를 구독
  • UNSUBSCRIBE channel1: 채널 구독 해제
  • PUBLISH channel1 "poobar": 채널에 메시지 발행

Pub/Sub 사용시 Publisher가 메시지 발행시 해당 채널을 구독하는 Subscriber에게 즉시 메세지가 전달됩니다.
또한 Subscriber이 채널을 조회하는 것이 아닌, 채널에서 Subscriber에게 메시지를 전달한다는 특징이 있습니다.

하지만 메시지를 구독하는 Subscriber이 없을 경우 메세지가 유실된다는 단점이 있습니다.

Streams

Pub/Sub의 메세지 유실 문제를 해결하기 위해서 Streams를 사용할 수 있습니다.
Streams는 Kafka, RabbitMQ와 유사한 메세지 큐 시스템입니다.

Streams의 개념에는 네가지가 있습니다.

  • Stream: 시간순으로 저장되는 redis의 데이터구조
  • Entry: 데이터 단위, 고유 ID와 field-value 쌍으로 엔트리가 구성된다.
  • Consumer: 데이터를 읽는 소비자
  • Consumer Group: 여러 Consumer을 묶어 데이터 병렬처리를 도와준다.

Streams의 대표적인 장점을 꼽으면

  1. 메시지 저장
  2. 재처리 가능
  3. Consumer Group
    이 있습니다.

예를 들어, 메시지가 발행되었을 때, Consumer Group이 다른 Consumer가 있다면
각각의 Consumer A 에서 처리한 메시지더라도 Consumer Group이 다른 Consumer B는 다시 메시지를 처리할 수 있습니다.
또한 Consumer가 XACK 명령어로 메시지 확인 처리를 하지 않았을 때, 메시지의 상태가 PENDING 상태로 들어가게 되며, 정기적으로 확인해 오랫동안 PENDING 상태로 머무르는 메시지는 재처리하도록 구성해야 합니다.

주의할 점

단순히 redis 명령어만 사용하는 서비스 로직은 없다고 생각합니다.
redis가 싱글스레드에서 동작하고, 트랜잭션 롤백을 지원하지 않기 때문에 비즈니스 로직 진행 중 에러가 발생한다면 상황에 따라 수동으로 데이터를 보정하는 경우가 있을 수 있습니다.

MULTI, EXEC 명령어로 원자성을 보장해 명령을 수행할 경우 큐에 명령을 보관하고 한번에 명령을 처리하기 때문에 MULTI 명령어 이후에는 조회성 명령어를 수행하지 않는게 좋습니다.
만약 MULTI 명령어 이후 조회성 명령을 할 경우 null이 응답되어 의도한 대로 동작하지 않을 가능성이 있습니다.
또한 MULTI, EXEC 명령은 N개의 명령 중 중간에 잘못된 명령을 수행한다면 뒤에 작성된 명령은 정상 수행됩니다.
이러한 상황을 고려해 기능구현을 해야 합니다.

마치며

redis는 많은 서비스에서 RDB와 같이 서비스를 구성하는 기본과 같은 요소가 되었습니다.
위에서 설명드린 내용 이외에도 Lua 스크립트를 활용해 더 복잡한 연산을 트랜잭션 내에서 실행할 수도 있습니다.
이렇게 Redis는 저희가 생각하는 것보다 더 많은 작업을 도와줄 수 있는 인메모리 데이터베이스입니다.
새로운 기술을 사용해보기 위해 kafka, mongoDB 등을 도입하는 것도 좋지만, 새로운 인프라를 도입하기 위한 비용을 소비하거나, 서비스에 도입하기 적절한지 테스트 하는 과정을 겪는것 보다는 잘 도입되어있는 redis를 활용해보는 것은 어떨까 합니다.
위에서 작성한 것과 같이 JWT 토큰 저장, 캐시 저장, 분산락 외에도 활용할 수 있는 방법이 다양하게 존재하기 때문에 새로운 과제가 생겼을 때, 사용해볼만한 인프라라고 생각합니다.
감사합니다.

반응형