Post

레디스로 좋아요 기능을 atomic하게 구현해보기

사이드 프로젝트에서 레디스로 좋아요 기능을 구현하는 과정과 RedisTemplate에서 커넥션과 커넥션 풀이 어떻게 사용되는지 정리해본다.

사용된 개발 환경

  • spring boot 3.2.4
  • elasticache 7.1.0

Body

side-project-example-ui 사이드 프로젝트에 대해서 간략히 소개를 해보면 유저들이 작성하는 게시글이 공유되는 일종의 커뮤니티 앱이다. 게시글 상세화면에서 구현해야하는 좋아요 기능의 요구사항을 정리해보자

  • 유저는 게시글 상세화면에서 좋아요를 할 수 있다.
  • 유저는 게시글 상세화면에서 본인이 좋아요를 했는지 알 수 있다.
  • 유저는 게시글 상세화면에서 좋아요를 취소할 수 있다.
  • 유저는 게시글 상세화면에서 게시글의 총 좋아요 갯수를 조회할 수 있다.
  • 유저는 유저 프로필 화면에서 해당 유저가 작성한 게시글에서 받은 좋아요 총 갯수를 조회할 수 있다.

좋아요 기능을 구현하기 위해서 어떤 데이터베이스를 사용할지 고민했는데 처음에는 RDBMS를 메인 데이터베이스로 사용하고 있고 RDBMS로 좋아요 기능을 구현해도 사이드 프로젝트의 수준에서는 충분하다고 생각해서 RDBMS로 구현하려고했다. 하지만 실무에서 레디스를 많이 활용해볼 기회가 없었고 사이드 프로젝트에서 사용해보면서 실무에서도 사용해볼 수 있는 경험을 얻고 싶었기에 레디스를 사용해보기로 했다.

좋아요 기능을 구현하는데 레디스가 활용된 사례는 굉장히 많고 쉽게 찾아볼 수 있다. 레디스가 기능 구현에 채택되는 이유는 빠른 연산 속도와 효율적인 자료구조를 활용할 수 있기 때문이라고 생각한다.

Spring 생태계에서는 레디스를 사용하는 프로젝트를 지원하는 spring-data-redis 프로젝트가 있다. spring-data-redis 프로젝트는 레디스에 명령을 요청하는 high level abstractions인 RedisTemplate을 제공하는데 이를 활용하면 쉽고 편리하게 개발을 할 수 있다.

RedisTemplate에서 사용할 수 있는 클라이언트로는 Jedis, Lettuce가 있는데 최근에는 Lettuce가 많이 채택되고 있는 추세로 보인다. 이 글에서는 Lettuce를 기준으로 이야기하는 부분이 많기 때문에 Lettuce에 대해서 조금 더 이야기해보려고 한다.

Lettuce가 채택될 수 있었던 기준에 대해서는 개발 환경에 따라 다양할 수 있기 때문에 모두에게 해당되지는 않겠지만 개인적인 의견은 몇 가지 있다.

  • Lettuce는 NIO 네트워크 기반의 Netty를 사용하여 더 높은 성능과 처리량을 기대해볼 수 있다.
  • Lettuce는 thread-safe하고 명령을 single thread 기반으로 처리하는 레디스와 통신하기 위해 공유되는 단일 커넥션을 사용할 수 있다. (파이프라인, 트랜잭션 등 제외)
  • Jedis는 최근에는 클러스터링, 센티넬, 파이프라인 등의 기능이 일부 지원되기는하지만 async API는 아직 지원되지 않으며 Lettuce에 비해서는 일부 고급 기능들이 지원되지 않아 기능적으로 부족한면이 있다.
  • Jedis는 코드가 사용하기 쉽게 디자인되어 사용하기 편하고 Lettuce는 그에 비해 사용하기 어렵다는 의견이 있지만 high level abstractions을 사용하는 입장에서는 그 장점이 잘 느껴지지 않는다.
  • Lettuce는 문서가 더 잘 정리되어 있다. 잘 정리된 문서는 디버깅 속도과 생산성을 향상시켜주는데 이는 굉장히 중요한 부분이라고 생각한다.

성능적인면에서 Lettuce가 많이 앞선다는 의견과 인식이 있지만 Jedis도 중단되었던 릴리즈가 다시 릴리즈되면서 현재는 많이 개선된 것으로 보인다. 최근 버전을 기준으로 다양한 조건에서 성능을 비교한 벤치마크를 찾아볼 수는 없었지만 각자의 개발 환경을 기준으로 고려해본다면 적합한 클라이언트를 선택할 수 있을 것이다. 개인적인 의견으로는 위의 기준들을 이유로 특별한 경우가 아니라면 앞으로도 Lettuce를 사용하게 될 것 같다.

레디스로 좋아요 기능을 구현하는 과정에서 어떤 자료구조와 연산을 사용할지 고민했는데, 게시글 상세화면에서는 좋아요 총 갯수와 유저의 좋아요 여부를 조회하고 저장하기 적합한 Set 자료구조를 선택했고 유저가 게시글에서 받은 좋아요 총 갯수는 증감만 처리하면 되기 때문에 String 자료구조를 선택했다.

  • 좋아요
1
2
3
4
5
-- 유저 A(id: 10)가 유저 B(id: 20)가 작성한 게시글(id: 1)에 좋아요
SADD post:1:likes user_10

-- 유저 B(id: 20)가 게시글에서 받은 총 좋아요의 갯수 증가
INCR user:20:post-like-count
  • 좋아요 취소
1
2
3
4
5
-- 유저 A(id: 10)가 유저 B(id: 20)가 작성한 게시글(id: 1)에 좋아요 취소
SREM post:1:likes user_10

-- 유저 B(id: 20)가 게시글에서 받은 총 좋아요의 갯수 감소
DECR user:20:post-like-count
  • 게시글에 좋아요한 이력 조회
1
2
-- 유저 A(id: 10)가 게시글(id: 1)에 좋아요한 이력이 있는지 조회
SMEMBERS post:1:likes user_10
  • 게시글의 좋아요 총 갯수 조회
1
2
-- 게시글(id: 1)의 좋아요 총 갯수 조회
SCARD post:1:likes
  • 유저가 작성한 게시글에서 받은 좋아요 총 갯수 조회
1
2
-- 유저 B(id: 20)가 작성한 게시글에서 받은 총 좋아요의 갯수 조회
GET user:20:post-like-count

작성된 레디스 명령어를 RedisTemplate을 사용해서 레디스에 명령을 보내 수행할 수 있도록 코드를 작성했다.

  • 좋아요
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void like(
    Long postId,
    Long creatorId,
    Long actorId
) {
    // 유저 A(actorId)가 유저 B(creatorId)가 작성한 게시글(postId)에 좋아요
    Long result = stringRedisTemplate.opsForSet()
        .add(
            "post:" + postId + ":likes",
            "user_" + actorId
        );

    // 이미 좋아요한 경우에는 증가하지 않음
    if (result != null && result == 1) {
        // 유저 B(creatorId)가 게시글에서 받은 총 좋아요의 갯수 증가
        stringRedisTemplate.opsForValue()
            .increment("user:" + creatorId + ":post-like-count");
    }
}
  • 좋아요 취소
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void cancelLike(
    Long postId,
    Long creatorId,
    Long actorId
) {
    // 유저 A(actorId)가 유저 B(creatorId)가 작성한 게시글(postId)에 좋아요 취소
    Long result = stringRedisTemplate.opsForSet()
        .remove(
            "post:" + postId + ":likes",
            "user_" + actorId
        );

    // 좋아요한 이력이 없는 경우에는 감소하지 않음
    if (result != null && result == 1) {
        // 유저 B(creatorId)가 게시글에서 받은 총 좋아요의 갯수 감소
        stringRedisTemplate.opsForValue()
            .decrement("user:" + creatorId + ":post-like-count");
    }
}
  • 게시글에 좋아요한 이력 조회
1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean isLikedByUserId(
    Long postId,
    Long userId
) {
    // 유저 A(userId)가 게시글(postId)에 좋아요한 이력이 있는지 조회
    Boolean isLiked = stringRedisTemplate.opsForSet()
        .isMember(
            "post:" + postId + ":likes",
            "user_" + userId
        );

    return Boolean.TRUE.equals(isLiked);
}
  • 게시글의 좋아요 총 갯수 조회
1
2
3
4
5
6
7
public long countLikeByPostId(Long postId) {
    // 게시글(postId)의 좋아요 총 갯수 조회
    Long count = stringRedisTemplate.opsForSet()
        .size(parsePostLikeKey(postId));

    return count != null ? count : 0;
}
  • 유저가 작성한 게시글에서 받은 좋아요 총 갯수 조회
1
2
3
4
5
6
7
public long countUserPostLikeByUserId(Long userId) {
    // 유저 B(userId)가 작성한 게시글에서 받은 총 좋아요의 갯수 조회
    String count = stringRedisTemplate.opsForValue()
        .get(parseUserPostLikeCountKey(userId));

    return count != null ? Long.parseLong(count) : 0;
}

좋아요 기능의 요구사항대로 동작하는 코드를 작성했지만 한 가지 문제점이 있다. 레디스의 단일 명령어는 atomic하게 동작하지만 여러 개의 명령어는 atomic하게 동작하지 않아 정합성이 깨지는 문제가 발생할 수 있다.

not-rollback-on-fail.png

만약 좋아요를 처리하는 도중에 예외가 발생하면 유저는 좋아요한 이력이 있지만 게시글 작성자가 받은 좋아요 총 갯수는 증가하지 않아 정합성이 깨지게된다. 애플리케이션에서 롤백을 처리하는 보상 코드를 구현해볼 수는 있겠지만 불필요하고 복잡한 코드가 추가되는 것이고 롤백 또한 항상 처리되었다고 보장할 수는 없다. 또한 좋아요 기능에서 레디스에 요청하는 명령이 추가됨에 따라 동시성 문제가 발생할 여지도 있기 때문에 레디스에서 여러 개의 명령어를 atomic하게 처리할 수 있는 방법을 찾아보게 되었다.

레디스는 atomic한 연산을 지원하기 위해 트랜잭션과 Lua Script를 지원한다. 두 방식 모두 atomic한 처리를 할 수 있지만 우리가 흔히 알고 있는 RDBMS에서의 트랜잭션과는 다르게 롤백이 지원되지 않는다. 롤백을 구현하는데 필요한 스냅샷 메커니즘은 상당한 컴퓨팅 비용이 소요되며 이러한 복잡성은 레디스의 철학과 생태계에 어울리지 않고 롤백의 유용성이 성능과 추가 복잡성보다 크지 않기 때문에 롤백을 지원하지 않는다고 한다.

Lua Script는 레디스 2.6 버전부터 사용가능하며 내장된 Lua 인터프리터는 스크립트를 하나의 큰 명령어로 해석하고 atomic하게 처리된다. 하나의 명령어로 해석되기 때문에 스크립트 내부의 모든 명령어들이 모두 성공하거나 실패하게되어 롤백 기능이 필요한 경우에도 사용할 수 있다.

샤딩을 사용하면 파이프라인, 트랜잭션, Lua Script에서 접근하는 모든 key들이 동일한 노드에 존재해야하는 조건이 있다. 접근하는 key들이 같은 노드에 저장되려면 동일한 슬롯으로 해시되어야 하는데 클러스터에서는 다중 키 작업을 위해서 해시태그 기능을 지원한다. 여기서는 클러스터가 아닌 단일 노드를 사용했기 때문에 해시태그 기능을 사용할 필요는 없었고 atomic한 처리를 위해 Lua Script를 사용하도록 코드를 수정했다.

  • 좋아요
1
2
3
4
5
local result = redis.call("SADD", KEYS[1], ARGV[1])

if result == 1 then
    redis.call("INCR", KEYS[2])
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void like(
    Long postId,
    Long creatorId,
    Long actorId
) {
    stringRedisTemplate.execute(
        likeScript,
        Arrays.asList(
            "post:" + postId + ":likes",
            "user:" + creatorId + ":post-like-count"
        ),
        "user_" + actorId
    );
}
  • 좋아요 취소
1
2
3
4
5
local result = redis.call("SREM", KEYS[1], ARGV[1])

if result == 1 then
    redis.call("DECR", KEYS[2])
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void cancelLike(
    Long postId,
    Long creatorId,
    Long actorId
) {
    stringRedisTemplate.execute(
        cancelLikeScript,
        Arrays.asList(
            "post:" + postId + ":likes",
            "user:" + creatorId + ":post-like-count"
        ),
        "user_" + actorId
    );
}

변경된 코드는 처리 도중에 에러가 발생해도 atomic하게 동작하고 동시성 문제가 발생하지 않아 정합성이 보장되었고 문제를 해결할 수 있었다.

이번에 레디스를 사용하면서 커넥션 풀의 설정에 대해서도 고민을 했는데, Lettuce는 트랜잭션, 파이프라인 등 일부 커넥션을 공유할 수 없는 명령을 제외하면 단일 커넥션을 공유해서 사용하기 때문에 커넥션 풀의 설정이 필수적이지는 않고 전용 커넥션이 필요한 경우에 커넥션 풀을 사용하면 좋다고 생각한다.

get-shared-connection.png

RedisTemplate은 명령을 수행하기 전 RedisConnectionUtils 클래스를 사용해서 RedisConnectionFactory를 통해 커넥션을 획득한다.

shared-native-connection.png

sharedNativeConnection 값은 기본 값으로 활성화되어 있으며 공유 커넥션의 사용 여부를 결정하는 값이다.

shared-connection.png

RedisConnectionFactory 구현체인 LettuceConnectionFactory는 요청이 들어오면 lazy하게 공유 커넥션 객체를 생성한다. 생성된 후에는 객체 내부의 필드에 주입되어 공유 커넥션으로 지속적으로 사용된다.

get-shared-native-connection.png

공유 커넥션 객체는 ConnectionProvider를 통해 커넥션을 생성한다. 커넥션 풀과 클러스터 설정이 되어있지 않다면 구현체로 StandaloneConnectionProvider을 사용하지만 설정된 값에 따라 다를 수 있다.

레디스의 명령은 single thread 기반으로 처리되기 때문에 여러 개의 커넥션을 사용하더라도 성능적으로 효과를 보기 어렵다. 그래서 커넥션을 공유할 수 없는 경우를 제외하고는 단일 커넥션을 공유해서 자원을 효율적으로 사용하는 방법을 기본으로 채택한 것으로 보인다.

is-pool-enabled.png

프로젝트 설정 프로퍼티에서 spring.data.redis.lettuce.pool.enabled 값이 활성화되어 있지 않거나 org.apache.commons:commons-pool2 의존성이 없다면 기본적으로 커넥션 풀을 사용하지 않는다. 만약 의존성이 없는 상태에서 풀 사용을 활성화하더라도 커넥션 풀 생성에 필요한 관련 클래스가 로드되지 않아 예외가 발생한다.

create-connection-provider.png

커넥션 풀을 사용하면 ConnectionProvider 구현체로 LettucePoolingConnectionProvider를 사용하도록 설정된다.

connection-pool-get-connection.png

LettucePoolingConnectionProvider는 커넥션 타입에 맞는 풀을 lazy하게 생성하고 객체 내부에서는 StandaloneConnectionProvider을 사용해서 커넥션을 생성한다. 설정된 커넥션 풀링은 공유 커넥션 또는 전용 커넥션을 생성하는 과정에서 사용된다.

get-or-create-dedicated-connection.png

전용 커넥션은 필요한 경우에 LettuceConnection 객체 내부에서 획득을 시도하고 주입된다.

do-get-async-dedicated-connection.png

마찬가지로 커넥션 생성시에 ConnectionProvider를 통해 커넥션을 생성한다. 앞서 말했듯이 이 과정에서 커넥션 풀의 설정이 효과적으로 사용될 것이고 빈도에 따라 사용을 결정하는 기준이 될 것이다.

connection-close-and-reset.png

전용 커넥션은 사용을 마치면 종료되거나 풀에 반납되는 형태로 사용되지만 공유 커넥션은 LettuceConnectionFactory에서 공유 커넥션으로 주입되어 지속적으로 사용되기 때문에 공유하지 않는 상태 정보만 초기화하고 반납되지 않는다.

open-pipeline.png

파이프라인을 사용하면 LettuceConnection 객체에서 파이프라인을 열기 위한 준비를 하는데 이 과정에서 전용 커넥션을 획득하여 필드에 주입한다.

multi.png

트랜잭션을 사용하면 LettuceConnection 객체에서 트랜잭션을 시작하기 위한 준비를 하는데 이 과정에서 전용 커넥션을 획득하여 필드에 주입한다.

invoke.png

RedisTemplate으로 요청되는 모든 명령은 LettuceInvoker에 의해 요청되는데 이때 명령을 요청할 커넥션을 획득한다.

get-async-connection.png

파이프라인과 트랜잭션은 모든 명령이 종료되기 전까지 다른 스레드와 커넥션을 공유할 수 없기 때문에 전용 커넥션이 사용된다. 일반적인 명령과 Lua Script는 하나의 명령어로 해석되기 때문에 공유 커넥션이 사용된다.

레디스에서 atomic한 처리를 하기 위해서 Lua Script를 사용해보았다. 레디스에서는 트랜잭션에서 처리할 수 없는 부분을 처리할 수 있고 트랜잭션과 파이프라인의 장점들을 가지고 있는 Lua Script를 사용하는 것을 권장하고 있다. 스크립트를 작성하는데도 큰 어려움이 없기 때문에 필요하다면 적극적으로 사용하는 것이 좋다고 느껴진다.

레디스를 실무에서 적극적으로 활용해볼 수 있는 기회가 많이 없어서 이해도가 많이 낮은 상태였는데 사이드 프로젝트에서 사용해보면서 공부가 되는 좋은 계기가 되었다. 싱글 노드가 아닌 센티넬이나 클러스터 환경에서도 레디스를 사용해보면서 다양한 상황에서 어떻게 동작하는지에 대해서도 경험할 수 있는 기회가 있었으면 좋겠다.

매 번 느끼는거지만 꼭 실무에서 경험하지 않아도 개인적인 토이 프로젝트나 사이드 프로젝트에서도 좋은 경험과 학습을 할 수 있다고 생각한다. 물론 실무에서 더 좋은 경험과 학습을 할 수 있는 기회가 많겠지만 결국 실무에서는 기술 스택에 대한 이해도가 충분한 상태여야하는데 이해도를 높이는 과정에서는 항상 시행착오가 동반되는 것 같다. 시행착오의 리스크를 실무에서 겪기 보다는 개인적으로 적용해보고 공부하면서 미리 시행착오들을 겪고 이해도를 높여두면 기회가 찾아왔을 때 경험을 토대로 자신감있게 사용해볼 수 있을 것 같다.

This post is licensed under CC BY 4.0 by the author.