Spring Redis Template Transaction
Development/Spring

Spring Redis Template Transaction

반응형

Spring에서 사용하는 Transaction을 RedisTemplate에도 쓰고 싶을 때가 있는데,


Redis에서 지원하는 Transaction의 구현이 기존에 익히 알고있던 RDBMS(MySQL, MS-SQL...) 과는 동작이 달라서

정확한 동작방식을 알고 있어야 로직을 짜는데 도움이 될 것 같아서 작성해본다.

Trsnaction

익히 알고있는 트랜잭션은 데이터의 불일치를 방지하고, 트랜잭션 내에 있는 로직이 원자성으로 실행됨을 보장해준다.

예컨데 아래와 같은 로직이 있다고 치자.

@Transactional
    void logic(User user, List<Role> roles){
        userService.save(user);
        roleService.save(roles);
    }

User 엔티티와 Role 엔티티는 1:N 관계라 가정하고,
User가 저장될 때, Role도 저장되어야 한다고 해보자.

 

그럼 위와 같은 코드에서는 user와 roles 저장에 대한 atomic operation이 보장이 된다.
즉, user는 저장이 됐는데 roles가 저장이 안됐을 경우

user에 대한 저장부분을 rollback 하는 로직으로 구성이 되어 있다.

 

대부분의 RDBMS에서는 트랜잭션 관련된 명령을 지원하는데, MySQL 기준으로 정상 동작 시 COMMIT 명령을 수행하고
로직 상 Unchecked Exception이 발생하게 되면 rollback을 수행하게 된다.

위의 코드는 사실상 아래와 같이 동작한다고 봐도 된다.

-- BEGIN TRANSACTION
@Transactional
    void logic(User user, List<Role> roles){
        userService.save(user); -- COMMIT
        roleService.save(roles); -- COMMIT
    }
-- END TRANSACTION

 

따라서 항상 트랜잭션의 전파범위(propagation) 와 격리레벨(isolation)을 잘 지정해주어야
비즈니스 로직 상 문제가 발생했을 때 어느범위까지 롤백할 것인지를 정할 수 있다.

Redis Transaction

RDBMS 와는 달리 Redis 에도 Transaction을 보장할 수 있는 방법이 제공되는데, 좀 유의해야할 사항이 있다.

RedisTemplate를 사용한 Transaction은 롤백을 지원하지 않는다.

MULTI
SET keyA "A"
SET keyB "B"
EXEC

와 같은 명령을 전송하게 되면 레디스에서는 A와 B에 대한 key set 명령어를 한번에 처리해준다.

고려해야 할 부분은, rollback이 명시적으로 없기 때문에 값을 반영한 뒤 특정 부분만 롤백이 안된다는 부분이다.

 

Redis Command 중 MULTI-EXEC 라는 구문의 명령을 제공하는데, 이는 여러개의 명령 실행의 원자성을 지켜주는 명령어이다.

 

방법은 2가지가 있는데,

1) 직접 RedisTemplate의 multi-exec 구문을 작성하는 방법

        Object txResults = redisTemplate.execute(new SessionCallback<List<Object>>() {
            public List<Object> execute(RedisOperations operations) throws DataAccessException {
                operations.multi();
                operations.opsForSet().add("key", "value1");

                // This will contain the results of all operations in the transaction
                return operations.exec();
            }
        });

2) @Transactional 어노테이션으로 method에 걸어주는 방법.

 

2번을 주로 사용하게 될 것인데, 상황에 따라 직접 제어가 필요하게 되면 1번 또한 꼭 쓰게되는 것 같다.

 

1번을 기준으로 설명을 하게 되면 예컨데 또 예를 들어서 아래와 같은 코드가 있다고 가정하자.

@Transactional
    void updateUser(User user, Role role){
        redisTemplate.opsForValue().set(user.getId(), user);
        redisTemplate.opsForValue().set(role.getId(), role);
    }

 

위 코드를 실행하면 아마도 아래와 같은 Redis Command가 날아갈 것이다

MULTI
SET userId Object(user)
SET roleId Object(role)
EXEC

 

그럼 원하던대로 잘 동작을 한다.

 

하지만, 트랜잭션 범위 내에 데이터를 가져오는 비즈니스 로직이 섞여있다면 어떨까?

    @Transactional
    void updateUser(User user, Role role) {
        User existsUser = redisTemplate.opsForValue().get(user.getId());
        if (existsUser == null) throw new UserPrincipalNotFoundException(user.getId());

        redisTemplate.opsForValue().set(user.getId(), user);

        redisTemplate.opsForValue().set(role.getId(), role);
    }

위와 같이 비즈니스 로직 상 검증하는 부분의 로직이 들어가게 된다면 과연 제대로 동작할까?

코드를 실행해보면 계속 UserPrincipalNotFoundException이 발생할 것이다.

 

Redis에 데이터가 정확히 존재하는데도 말이다.

왜그럴까? 코드를 뜯어보자.

 

    /**
     * Get the value of {@code key}.
     *
     * @param key must not be {@literal null}.
     * @return {@literal null} when used in pipeline / transaction.
     * @see <a href="https://redis.io/commands/get">Redis Documentation: GET</a>
     */
    @Nullable
    V get(Object key);

return {@literal null} when used in pipeline / transaction.

 

이 부분을 간과하게 되면 로직이 잘 맞다고 생각하는 데도 불구하고 계속적인 value의 null 문제를 겪을 수 있다.

트랜잭션 범위에 있는 get 메서드는 null을 반환한다고 되어 있다.

 

왜 null을 반환할까? 이는 아까 위에서 언급했던 multi-exec 구문과 연관되어 있다.

Spring에서 Redis Transaction 처리를 하게 되면 메서드 전체에 multi-exec가 걸리게 되고
그 사이에 get을 해온다면 exec 구문이 끝난 뒤에 return이 되기 때문에 의미가 없어져서 null을 리턴한다고 볼 수 있다.

 

따라서 Redis Transaction을 다룰 때에는 Transaction의 propagation 및 내부 로직에 저렇게 무언가 get해서 검증하는 로직이 겹치지 않도록 미리 validate를 하고 원자성 보장이 필요한 update 혹은 insert 부분에만 transaction을 지정하는 게 좋다.

 

propagation도 또한 중요한데, 아래와 같은 경우는 어떨까?

// UserController
    @Transactional
    void updateUser(User user, Role role) {
        // processing incoming requests...
        userService.update(user, role);
    }

 ...

// UserService
    void update(){
        User existsUser = redisTemplate.opsForValue().get(user.getId());
        if (existsUser == null) throw new UserPrincipalNotFoundException(user.getId());
        redisTemplate.opsForValue().set(user.getId(), user);
        redisTemplate.opsForValue().set(role.getId(), role);
    }

분명 service에는 Transaction이 없기 때문에 당연히 정상동작 할 것이라고 예상하겠지만..


상위 레벨에 Transaction이 걸려 있기에 이 또한 get command 시 null이 return된다.

 

위 코드를 수정하자면 아래와 같겠다

    @Transactional
    void updateUser(User user, Role role) {
        // processing incoming requests...

        // validation
        User existsUser = redisTemplate.opsForValue().get(user.getId());
        if (existsUser == null) throw new UserPrincipalNotFoundException(user.getId());

        // update
        userService.update(user, role);
    }

    void update(){
        redisTemplate.opsForValue().set(user.getId(), user);
        redisTemplate.opsForValue().set(role.getId(), role);
    }

이런식으로 Read 로직과 Write로직을 분리해야 생각한대로 코드가 동작할 수 있으니


Redis Transaction을 사용할 땐 참고하도록 하면 좋겠다.

반응형

'Development > Spring' 카테고리의 다른 글

Spring RequestContextHolder  (8) 2020.07.05
Spring Password Encoder  (4) 2019.04.06
Dispatcher Servlet  (0) 2019.04.06
Spring Boot Prometheus Converter 406 Not Acceptable  (0) 2019.04.06
[JAVA/Spring] BeanUtils 관련  (2) 2018.07.20