Programming/Spring Boot

Redis 동시성 처리를 위한 Transaction 사용 (MULTI, EXEC, DISCARD, WATCH)

Jan92 2022. 3. 5. 02:37

node.js event loop

Single-thread 기반의 Redis 동시성?

Redis는 싱글 스레드(Single-thread) 기반으로  데이터를 처리합니다. 싱글 스레드를 기반으로 동작하지만 여러 명의 클라이언트 요청에 동시에 응답하는 동시성도 가지고 있는데요. 이것이 가능한 이유는 레디스의 동장 원리에서 알 수 있습니다.

 

Redis의 동작 원리를 살펴보면 Redis는 이벤트 루프(Event Loop)를 이용하여 요청을 수행합니다.

즉, 실제 명령에 대한 작업(Task)은 커널 레벨에서 멀티플렉싱(Multiplexing)을 통해 처리하여 동시성을 보장합니다. 쉽게 유저 레벨에서는 싱글 스레드로 동작하지만, 커널 I/O 레벨에서는 스레드 풀을 이용하는 것입니다.

(위 이미지는 참고를 위한 node.js의 event loop 이미지입니다.)

 

동시성이 있다는 것은 동시성으로 인한 문제가 발생할 수 있다는 것이고, 따라서 동시성 문제에 대한 처리가 필요합니다.

 

 

***

동시성(Concurrency)과 병렬성(Parallelism)은 다른 개념이며,

간단하게만 이야기하면 동시성은 적어도 두 개의 스레드가 진행 중일 때 존재하는 조건이며, 두 개 이상의 알고리즘이 하나의 코어 내에서 스레드 간에 빠르게 교차되면서 실행되기 때문에 '동시'라고 느끼는 것입니다.

병렬성은 적어도 두 개 이상의 코어가 있어야 하며, 병렬성도 동시성을 의미하지만 동시성의 차이는 각 코어 내의 스레드가 실제로 동시에 명령어를 실행할 수 있음을 말합니다. 두 개의 알고리즘이 정확히 같은 시점에 실행될 때 이를 병렬적이라고 할 수 있습니다.

 

 

 


 

 

Redis 동시성 처리를 위한 Transaciton

먼저 Spring Data Redis에서 Transaction을 사용할 수 있는 두 가지 방법이 있습니다.

한 가지는 SessionCallback 인터페이스를 통해 여러 명령을 하나로 묶어서 처리하는 방법인데, 이 방법은 인터페이스를 통해 직접적으로 Redis 명령어를 사용하여 트랜잭션 경계를 설정하는 방법이고, 다른 한 가지는 간편하게 @Transactional 어노테이션을 사용하는 방법입니다.

 

***

@Transactional 어노테이션을 사용하기 위해서는 PlatfromTransactionManager를 Bean으로 등록하는 과정이 필요한데요.

Spring에서 공식적으로 채택하고 있는 Redis Client인 Jedis와 Lettuce에 대해서 PlatformTransactionManager 구현체를 제공하지 않기 때문에 JDBC의 DataSourceTransactionManager를 사용하거나 JPA의 JpaTransactionManager를 사용해야 합니다.

(두 방법 모두 @EnableTransactionManagement 적용이 필요하고, redisTemplate의 EnableTransactionSupport 값을 true로 설정해야 합니다.)

 

 

@Configuration
@EnableTransactionManagement
public class RedisConfig {
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory();
        return lettuceConnectionFactory;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setEnableTransactionSupport(true);  // 설정 필요한 부분
        return redisTemplate;
    }

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

(JPA의 JpaTransactionManager를 사용하는 방법)

 

 

@Configuration
@EnableTransactionManagement
public class RedisConfig {
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory();
        return lettuceConnectionFactory;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setEnableTransactionSupport(true);  // 설정 필요한 부분
        return redisTemplate;
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }
}

(JDBC의 DataSourceTransactionManager를 사용하는 방법)

 

 

***

@Transactional 어노테이션을 사용하지 않고 SessionCallback 인터페이스를 사용할 경우 해당 적용은 필요 없습니다.

 

 

 


 

 

SessionCallBack 사용하는 방법

SessionCallback interface

Redis의 MULTI, EXEC, DISCARD, WATCH, UNWATCH 명령어를 사용하여 직접 트랜잭션을 설정하는 방법이며, 각각의 명령어에 대해서 먼저 살펴보겠습니다.

 

MULTI

Redis의 트랜잭션을 시작하는 커맨드로 MULTI 커맨드로 트랜잭션을 시작하면 Redis는 이후에 입력되는 커맨드를 바로 실행하지 않고 Queue에 쌓습니다.

EXEC

정상적으로 처리되어 Queue에 쌓여있는 커맨드를 일괄적으로 실행합니다. RDBMS의 commit과 비슷합니다.

DISCARD

Queue에 쌓여있는 커맨드를 일괄적으로 폐기합니다. RDBMS의 Rollback과 비슷합니다.

 

WATCH, UNWATCH

Redis에서 Lock을 담당하는 커맨드로 낙관적 락(Optimistic Lock)을 기반으로 하며, WATCH 명령어를 사용하면 이후 UNWATCH가 되기 전까지는 한 번의 EXEC 또는 Trasaction이 아닌 다른 커맨드만 허용합니다.

동시성 문제에서 단순히 MULTI, EXEC 만으로 트랜잭션의 고립성을 보장할 수 없기 때문에 사용되며, WATCH로 인하여 예외가 발생했을 때 트랜잭션의 Queue에 쌓여있는 커맨드들을 폐기하는 DISCARD 명령어 등을 통해 처리할 수 있습니다.

 

 

    @GetMapping("/transaction")
    public ResponseEntity<?> transaction() {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.multi(); // transaction start
                operations.opsForValue().set("user2", "222");
                operations.opsForValue().set("user3", "333");
                return operations.exec(); // transaction end
            }
        });
        return null;
    }

MULTI 명령어와 EXEC 명령어를 사용한 기본적인 Transaction입니다.

 

 

    @GetMapping("/transaction")
    public ResponseEntity<?> transaction() {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.multi(); // transaction start
                operations.opsForValue().set("user2", "222");
                operations.opsForValue().set("user3", "3333");
                if (true) {
                    throw new RuntimeException("exception");
                }
                return operations.exec(); // transaction end
            }
        });
        return null;
    }

이 경우 중간에 Exception이 발생하게 되면 Transacion으로 인해 set이 동작하지 않는 것을 볼 수 있습니다.

 

 

    @PostMapping("/transaction")
    public ResponseEntity<?> transaction(String key) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                try {
                    operations.watch(key); // lock
                    operations.multi(); // transaction start
                    operations.opsForValue().set(key, "Someting");
                } catch (Exception e) {
                    e.printStackTrace();
                    operations.discard();
                }
                return operations.exec();
            }
        });
        return null;
    }

이 경우는 WATCH 명령어를 사용하여 Optimistic Locking을 적용하는 경우입니다.

WATCH 명령어를 이용하면 해당 Key는 트랜잭션에서 값 변경을 1번으로 제한할 수 있습니다.

 

 

    @PostMapping("/transaction")
    public ResponseEntity<?> transaction(String key) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                try {
                    operations.watch(key); // optimistic locking
                    operations.opsForValue().set(key, "abc");
                    
                    operations.multi(); // transaction start
                    operations.opsForValue().set(key, "def");
                } catch (Exception e) {
                    e.printStackTrace();
                    operations.discard();
                }
                return operations.exec(); // transaction end
            }
        });
        return null;
    }

다음 경우는 WATCH 명령어를 걸고 key에 "abc"라는 set 했기 때문에 MULTI 명령어와 EXEC 명령어 사이의 Transaction에서는 값 변경이 일어나지 않습니다.

 

 

    @PostMapping("/transaction")
    public ResponseEntity<?> transaction(String key) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                try {
                    operations.watch(key);
                    operations.multi(); // transaction start
                    String value = (String) operations.opsForValue().get(key);
                    BigDecimal bigDecimalValue = new BigDecimal(value);
                    operations.opsForValue().set(key, bigDecimalValue.add(BigDecimal.TEN).toPlainString());
                } catch (Exception e) {
                    operations.discard();
                }
                return operations.exec(); // transaction end
            }
        });
        return null;
    }

Redis에서 Transaction을 사용할 때 주의해야 할 경우 중 하나는 Transaction 내부에서 get(key)를 통해 값을 가지고 오려고 할 때입니다. 이 경우 아무리 실행하더라도 value 값이 null로 나오게 되는데요. 이유는 get(Object key) 메서드의 내용을 통해 알 수 있습니다.

 

"null when key does not exist or used in pipeline / transaction."

transaction에서 사용할 때는 null 값을 리턴한다는 것을 알 수 있습니다. 이유는 무엇일까요?

MULTI 커맨드로 Transaction 시작 구간을 설정하고, 이후 발생하는 커맨드는 EXEC가 실행되기 전까지 Queue에 계속해서 쌓이며 실제 요청이 실행되지 않는데요. 이때 get 요청 마찬가지로 실행되지 않는데 EXEC 구문이 끝난 뒤에 get 요청에 대한 값이 return 되는 것은 의미가 없기 때문에 null 이 리턴된다고 합니다.

 

 

 

* 내용 중 잘못된 부분은 댓글 달아주시면 다시 공부하고 수정하겠습니다. 감사합니다.

 

 

 

< 함께 보면 좋은 자료 >

 

Spring Boot Redis 두 가지 사용 방법 RedisTemplate, RedisRepository

https://wildeveloperetrain.tistory.com/21 Redis란? 레디스의 기본적인 개념 (인메모리 데이터 구조 저장소) Redis란? Key, Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형..

wildeveloperetrain.tistory.com

 

< 참고 자료 >

 

[redis + Spring] Spring Data Redis를 이용한 Transaction 처리

안녕하세요. 오늘은 Java의 Spring 환경에서 Transaction을 적용해보도록 하겠습니다. 의존성 먼저 Spring에서 Redis를 사용하기 위해서 아래와 같은 의존성을 부여하도록 하겠습니다. spring-boot-starter 의

sabarada.tistory.com

 

Redis의 동시성(Concurrency)개념과 고립성(Isolation)을 위한 Transaction 처리

 Redis는 AOF와 몇몇 명령어를 제외하고 Single-thread 기반으로 데이터를 처리한다. 단일 스레드로 여러 명의 클라이언트의 요청에 동시에 응답하는 동시성에 대해 알아보고, 이때 발생할 수 있는 문

jjeda.tistory.com