[REDIS] Redis 트랜잭션 트러블 슈팅

Damongsanga·2024년 2월 11일

레디스 트랜잭션 관리하며

레디스를 이용하여 현재 방에 입장한 인원 정보를 저장하면서 트랜잭션 관리를 해보게되었다.

그런데 기존 JPA, RDBMS의 트랜잭션 관리와는 차이가 있어 공부(뻘짓)해보며 알게 된 내용을 정리해보았다.

호무룩..

📌 MULTI (트랜잭션 시작)

Redis의 트랜잭션을 시작하는 커맨드로 트랜잭션을 시작하면 Redis는 이후 커맨드는 바로 실행되지 않고 queue에 쌓이며, 이후에 EXEC 호출 시 명령어를 순차적으로 실행.

📌 EXEC (커밋)

정상적으로 처리되어 queue에 쌓여있는 명령어를 순차적으로 실행. RDBMS의 Commit과 동일한 역할.

📌 DISCARD (명시적 롤백)

queue에 쌓여있는 명령어를 실괄적으로 폐기. RDBMS의 Rollback과 “유사”.

📌 WATCH (낙관적 락)

Redis에서 Lock을 담당하는 명령어로, 낙관적 락(Optimistic Lock) 기반.

WATCH 명령어를 사용하면 UNWATCH 되기 전까지 1번의 EXEC 또는 Transaction 아닌 다른 커맨드만 허용

⌨️ 정상 작동 시

127.0.0.1:6379(TX)> SET TESTDATA TEST1
QUEUED
127.0.0.1:6379(TX)> SET TESTDATA@ TEST!
QUEUED
127.0.0.1:6379(TX)> GET TESTDATA
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
3) "TEST1"

⌨️ 에러발생시 롤백?

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> GET TESTDATA
QUEUED
127.0.0.1:6379(TX)> SET TESTDATA TEST1
QUEUED
127.0.0.1:6379(TX)> dasd  (잘못된 커맨드 입력)
(error) ERR unknown command 'dasd', with args beginning with: 
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

📌 트랜잭션의 특징

  • Redis의 트랜잭션은 잘못된 명령어가 있어도 정상적으로 사용한 명령어에 대해서는 잘 적용된다! 참고
  • 즉 Redis는 보통 RDBMS의 트랜잭션 롤백형태를 채택하지 않는다
  • rollback을 채택하지 않음으로써 빠른 성능을 유지
  • 롤백 지원은 Redis의 단순성과 성능에 큰 영향을 미치기 때문에 Redis는 트랜잭션 롤백을 지원하지 않음
  • 하지만 잘못된 명령어가 아닌, 중간에 에러 발생시 롤백하는 형태로 트랜잭션을 관리할 수 있다.

그러하다.. 호오.. 설명만으로는 잘 이해가 가지 않을 수 있을 것 같다. 그러면 직접 구현해본 아래 코드를 보면서 더 알아보자.
@Transactional 을 사용하는 방법도 있다고 하지만, 보다 Redis 트랜잭션을 더 잘 이해해보기 위해서 필자는 직접 SessionCallback 익명 클래스를 구현하여 트랜잭션을 관리해보았다.

📌 어노테이션 안쓰고 WATCH, MULTI, EXECUTE 하기

트랜잭션이 필요한 메서드를 CustomRedisRepository 로 따로 구현하였다.
multi() → execute() 사이를 트랜잭션 관리해준다

원리

MULTI 선언 후 EXECUTE 전까지는 쿼리가 queue에 쌓이게 된다
만약 그 사이에 에러가 발생하면 queue에 쌓인 쿼리가 DISCARD 되어 삭제된다
즉, 쿼리가 적용되지 않는다!

  • 여기서 주의할 점은, redisTemplate에서 MULTI, EXECUTE 사이의 GET 요청은 NULL 값을 가져올 것이라는 것이다.
  • 이는 EXECUTE 가 실행되기 전까지는 실제 쿼리가 날아가지 않는데, 쿼리를 실행해서 조회하는 코드를 넣었으니 리턴값을 미리 읽으려고 하면 Null 값이 들어가 있는 게 당연하다!
  • Returns: null when key or hashKey does not exist or used in pipeline / transaction. (java doc 설명)

번외로 만약 multi() 없이 execute() 메서드 사용하면 Error 발생한다.. (심지어 Unknown Error 이렇게 살벌하게 발생한다 이뇨속)

⌨️ 방 들어오는 로직

public void enterMember(String memberId, Long roomId, LocalDateTime enterTime) {
        String currentMemberKey = KeyUtil.getCurrentMemberKey(memberId);
        String roomKey = KeyUtil.getRoomKey(roomId);

        // 트랜잭션 관리
        redisTemplate.execute(new SessionCallback<>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.watch(roomKey); // 공통자원에 낙관적 락
                operations.multi();
                // 현재 멤버가 어디에 있고, 언제 들어갔는지 저장
                operations.opsForHash().put(currentMemberKey, "roomId", roomId.toString());
                operations.opsForHash().put(currentMemberKey, "enterTime", DateParser.stringParse(enterTime));
                // 현재 어떤방에 어떤 멤버가 들어왔는지 저장
                operations.opsForSet().add(roomKey, memberId);
                return operations.exec();
            }
        });
    }

⌨️ 방 나가는 로직

public void leaveMember(String memberId, Long roomId) {
        String currentMemberKey = KeyUtil.getCurrentMemberKey(memberId);
        String roomKey = KeyUtil.getRoomKey(roomId);

        // 트랜잭션 관리
        redisTemplate.execute(new SessionCallback<>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.watch(roomKey); // 공통자원에 낙관적 락
                operations.multi();
                // 해당 방 들어있는 유저정보에서 유저를 삭제
                operations.opsForSet().remove(roomKey, memberId);
                // 현재 유저의 시간 정보를 추출하고 위치정보와 시간정보를 삭제
                operations.opsForHash().delete(currentMemberKey, "roomId");
                operations.opsForHash().delete(currentMemberKey, "enterTime");
                return operations.exec();
            }
        });
    }

⌨️ 테스트

중간에 일부로 에러를 던져보았다

redisTemplate.execute(new SessionCallback<>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.watch(roomKey);             
								operations.multi();
                operations.opsForSet().remove(roomKey, memberId);
								
                if (true){
                    throw new RestApiException(CommonErrorCode.INTERNAL_SERVER_ERROR);
                }
                
                operations.opsForHash().delete(currentMemberKey, "roomId");
                operations.opsForHash().delete(currentMemberKey, "enterTime");
                return operations.exec();
            }
        });

Redis CLI을 조회해보면 ROOM에 들어있는 유저정보의 Key인 “ROOMID:19”안에 유저 데이터가 살아있음을 확인할 수 있다

📌 이슈 (짧은 트러블슈팅)

한창 디버깅을 하는 중이었는데 서비스 로직에서 key 를 제대로 읽어오지 못하는게 이상해서 Redis 의 모든 값을 초기화하고 모든 키를 불러왔는데… (FLUSHDB 후에 KEYS * 명령어 입력하니)

이.. 이게뭐누...

RedisConfig에서 Serialize가 제대로 구현되어있지 않아 직렬화 과정에서 문제가 있었었다.

@Bean
public RedisTemplate<String, String> redisTemplate() {
    RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
		redisTemplate.setEnableTransactionSupport(true); // redis @Transaction 사용시
    return redisTemplate;
}

해결방법

어자피 key, value를 String, String으로 가져올 것임으로 StringRedisTemplate을 쓰자..!

@Bean
    public RedisTemplate<String, String> redisTemplate() {
        StringRedisTemplate redisTemplate = new StringRedisTemplate();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
				redisTemplate.setEnableTransactionSupport(true); // redis @Transaction 사용시
        return redisTemplate;
    }

참고 자료

공식문서
https://jjeda.tistory.com/13
https://sabarada.tistory.com/178
https://velog.io/@worldicate/Redis에서-Transactional-iwrn8fxz
https://data-make.tistory.com/757#recentComments
https://wildeveloperetrain.tistory.com/32
https://docs.spring.io/spring-data/redis/reference/redis/transactions.html

https://velog.io/@leehyeonmin34/dambae200-redis-transactional

profile
향유하는 개발자

0개의 댓글