레디스 트랜잭션 적용 방법 (@Transactional, SessionCallback, LuaScript)

초록·2023년 11월 23일
2
post-thumbnail

요약

Redis에 @Transactional이 안 먹혀서 설정하는 법을 찾아보게 되었고, 그 과정에서 레디스 트랜잭션의 작동방식과 SessionCallback, LuaScript, 분산락 등 원자성을 보장하는 다양한 방법에 대해서도 알게 되었습니다.

문제 : Redis는 @Transactional이 안 되네?

사용자가 사용하는 담배목록 안에는 약 200개의 담배가 있는데, 이 순서를 조정하면 200개의 순서정보를 DB에 업데이트합니다. 그런데 만에 하나 업데이트 중간에 문제가 생기면 일부정보만 DB 혹은 캐시에 반영되고, 일부정보는 예전정보가 그대로 남아있을 수 있습니다. 이렇게 되면 순서정보가 꼬이기 때문에, 200개의 순서정보를 업데이트하는 과정은 Atomic하게 진행되어야합니다. 그래서 @Transactional 애너테이션으로 원자성을 보장하고자 했습니다. 그런데 트랜잭션이 롤백되어도 Redis에 저장한 캐시 내용은 롤백되지 않는 문제가 있었습니다.

해결 : Redis 트랜잭션 설정

redisTemplate의 enableTransaction을 true로 설정해주고, PlatformTransactionManager 빈이 필요한데 Lettuce는 해당 빈을 자동 등록하지 않기 때문에 JpaTransactionManager를 직접 빈으로 등록해주는 등의 설정을 해주고 테스트를 해보니 @Transactional이 레디스 트랜잭션 또한 지원함을 확인할 수 있었습니다. 이는 레디스에 원자성을 보장해주었습니다.

대신 @Transactional은 Redis WATCH를 사용할 수 없어 두 사용자가 동시에 업데이트를 요청하면 경신 이상이 발생할 수 있는데, 둘 중 누구라도 한 명의 순서정보가 모든항목을 모두 덮어씌우기만 하면 문제가 없으므로 @Transactional 만으로도 괜찮다고 판단했습니다.

@Configuration
@EnableTransactionManagement                                  // (1)
public class RedisTxContextConfiguration {

  @Bean
  public StringRedisTemplate redisTemplate() {
    StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory());
    template.setEnableTransactionSupport(true);               // (2)
    return template;
  }

  @Bean
  public RedisConnectionFactory redisConnectionFactory() {
    // jedis || Lettuce
  }

  @Bean
    public PlatformTransactionManager transactionManager() {  // (3)
        return new JpaTransactionManager();
    }
}

알게된 점

레디스에 트랜잭션을 적용하면서 레디스 트랜잭션에 대해 여러가지를 배웠습니다.

레디스 트랜잭션 작동방식 WATCH-MULTI-EXEC

레디스 트랜잭션이 '모아서 실행된다'는 것은 알고있었지만, 어디에 모이는지, 다른 클라이언트들의 명령어와 섞이기도 하는지, WATCH는 왜 필요한지 궁금했었습니다.

아래는 MULTI-EXEC 사이에 일어나는 일입니다.

MULTI # 트랜잭션 시작

SET key 1 # 레디스의 클라이언트 전용 큐에 쌓임

SET key2 3 # 레디스의 클라이언트 전용 큐에 쌓임

SETNX key 3 # 레디스의 클라이언트 전용 큐에 쌓임

GET key

EXEC # 큐의 명령어 모두 실행 (다른 명령어 끼어들 수 X)

# 결과는 1

하지만 MULTI와 EXEC 사이에 명령어들을 큐에 넣는 동안, 다른 클라이언트가 관련 값을 변경해버릴 수 있는 문제가 있습니다. 즉, 일관성이 부족합니다.

MULTI # 트랜잭션 시작

SET key 1 # 레디스의 클라이언트 전용 큐에 쌓임

SET key2 3 # 레디스의 클라이언트 전용 큐에 쌓임

# 다른 클라이언트가 DEL key 1 실행 

SETNX key 3 # 레디스의 클라이언트 전용 큐에 쌓임

GET key

EXEC # 큐의 명령어 모두 실행 (다른 명령어 끼어들 수 X)

# 결과는 3

그래서 Redis는 WATCH 명령어로 낙관락을 사용함으로써 이를 방지하는 것입니다.
WATCH key key2

SET key 2

MULTI # 트랜잭션 시작

SET key 1 # 레디스의 클라이언트 전용 큐에 쌓임

SET key2 3 # 레디스의 클라이언트 전용 큐에 쌓임

# 다른 클라이언트가 DEL key 1 실행 

SETNX key 3 # 레디스의 클라이언트 전용 큐에 쌓임

GET key

EXEC #'key'에 해당하는 값이 MULTI 했을 때와는 달라 트랜잭션이 DISCARD됨 

@Transactional과 SessionCallback의 read/write 명령어의 실행과정 차이

레디스 트랜잭션을 공부하며 또 인상적이었던 부분은, 레디스 트랜잭션은 롤백개념이 없고 에러가 발생하지 않는다면 큐 안의 명령어들을 실행하는 방식이기 때문에, @Transactional이 끝나기 전까지는 반영이 되지 않는다는 점과 read/write 명령어가 실행되는 시점이 다르다는 것을 알게되었습니다. 그래서 트랜잭션 중간에 set한 key에 대해 get해와서 그 값을 기준으로 분기를 나눈다거나 하는 연산이 안된다는 점이 새로웠습니다.

공식문서에서 해당 내용에 대해 설명하는 글귀입니다.

Spring Data Redis distinguishes between read-only and write commands in an ongoing transaction. Read-only commands, such as KEYS, are piped to a fresh (non-thread-bound) RedisConnection to allow reads. Write commands are queued by RedisTemplate and applied upon commit.

Spring Data Redis는 진행중인 트랜잭션 내의 read-only 명령과 write 명령을 다르게 처리한다. KEYS와 같은 read-only명령은 fresh(non-thread-bound) 레디스커넥션을 사용한다. Write 명령은 RedisTemplate에 의해 큐에 쌓이며 커밋 시에 반영된다.

테스트 결과, read-only 명령어는 큐에 쌓이지 않고 바로 실행되며, write 명령어는 큐에 쌓여있다가 @Transactional 섹션이 끝나고 commit된 후에 반영이 되는 걸 알 수 있었습니다. 다른 사람들의 블로그 글을 살펴봤을 땐 @Transactional 내에서 read하면 무조건 null이 반환되는 것으로 설명되어있어, 이 부분에서 조사된 내용과 달라서 혼란이 있었는데 공식문서를 보니 아무래도 변경이 있던 것 같습니다.

반대로 redisTemplate.execute(SessionCallback) 내부에서 MULTI-EXEC을 사용해 트랜잭션을 만드는 방식은 하나의 커넥션에서 모든 명령어가 수행되기 때문에, 트랜잭션 내부에서 get을 해도 반영이 안되어 null을 반환합니다.

Spring Data Redis provides the SessionCallback interface for use when multiple operations need to be performed with the same connection, such as when using Redis transactions.

Spring Data Redis는 SessionCallback 인터페이스를 제공한다. 이는 트랜잭션처럼, 같은 커넥션에서 여러개의 명령어를 수행하기 위함이다.

LuaScript를 활용한 트랜잭션

이건 직접 해보진 않고 말로만 들었는데, 스크립트를 레디스로 보내 바로 일련의 명령어들을 싱글스레드로 Atomic하게 처리하도록 하는 것이라고 합니다. read도 정상적으로 처리되어 read값으로 분기를 나눈다던가 하는 작업들이 가능해집니다.

원자성을 보장하는 다양한 방법

그 외에도 LuaScript과 분산락 등을 사용해서 원자성을 보장하는 방법을 알게 되었습니다. 트랜잭션 전파가 필요하면 @Transactional, 낙관적 락이 필요한 경우엔 SessionCallback, 트랜잭션 중간에 set한 키에 대해 get하려면 LuaScript, 분산환경에서 비관적 락이 필요한 경우에 분산락, ACID 필요없이 bulk작업하려면 파이프라인을 쓰면 되니 필요할 때 적절히 사용할 수 있을 것 같습니다.

@TransactionalSessionCallbackLuaScript
전파가능 👍불가불가
독립성낙관적락 X낙관적락 👍독립성 보장 👍
read값 활용명령어를 읽은 시점의 read값 반환
(write는 exec된 시점에 적용)
무조건 null 반환가능 👍
의존성필요(TxManager)불필요 👍불필요 👍

마무리

레디스 트랜잭션 그거 그냥 뭐 기존에 하던거처럼 되는거 아닌가? 하고 막연하게 생각했었는데, 이렇게 알아보고 나니 레디스 트랜잭션에 대해 잘 몰랐다고 생각이 들었습니다. 이렇게 다양한 얘기들이 있다는 걸 알고보니, 기술에 대해 잘 이해하고 사용하는 것이 문제를 빠르게 파악하고 삽질을 예방하는 중요한 부분이라 생각되었습니다.

profile
몰입하고 성장하는 삶을 동경합니다

0개의 댓글