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

Damongsanga·2024년 2월 11일
0

레디스 트랜잭션 관리하며

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

그런데 기존 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개의 댓글

관련 채용 정보