redisTemplate.hasKey() -> null ???

Coodori·2024년 2월 4일

Back-end

목록 보기
3/4

문제 상황

Redis를 이용해서 값을 저장하는 경우이다.

pushUserChatRoom을 통해 redis 에 원하는 key,value를 넣고
아래서 값을 활용하여 채팅 메세지를 보내는 메소드이다.

원하는 요구 상태 코드

  1. 현재 Redis 상태
$ keys *
(empty list or set) 
  1. 우리가 원하는 기능
  • 만약 이미 유저가 채팅방이 있다면 채팅방 유저로 생성하면 안되니깐 CustomException(CustomError.CHATROOM_USER_ALREADY_JOINED);를 발생시킨다.

  • 유저가 채팅방이 없다면 새롭게 채팅방에 포함시켜준다.

문제 발생

  1. (empty list or set) 가 나오는 Redis에 해당 메소드를 실행시키면 Redis에 저장이 되어야하는데 CustomException(CustomError.CHATROOM_USER_ALREADY_JOINED);가 발생한다.

  2. 이미 저장되어있는 key도 똑같이 CustomException(CustomError.CHATROOM_USER_ALREADY_JOINED); 발생

ex) 36_CHATROOM , 37
실행후 => 36_CHATROOM, 36

문제 고민

1. 로그와 디버깅을 통해서 값을 확인한다.

기본 Redis 엔 아무런 값이 없다고 생각하자

public void pushUserChatroom(String userId, String roomId) {
        String key = userId + RedisConstants.CHATROOM;
        log.info("redis key  :: {}", key);
        String value = valueOps.get(key);
        log.info("redis hasKey value :: {}", redisTemplate.hasKey(key));
        
        if (Objects.equals(Boolean.FALSE,redisTemplate.hasKey(key)) {
            valueOps.set(key, roomId);
            valueOps.getOperations().expire(key, 10, TimeUnit.MINUTES);
        } else {
            throw new CustomException(CustomError.CHATROOM_USER_ALREADY_JOINED);
        }
    }
    
    
>>> redis key :: 36_CHATROOM
>>> redis hasKey value :: null

우리가 원하는 건 값이 없었을때 여서 false가 반환되어야한다.

음 왜 null 일까

한번 그렇다면 redis에 값을 미리 넣어보고 진행해보자

SET 36_CHATROOM 22

우리가 원하는대로라면 에러가 나와야한다.

정상적으로 에러가 나왔다....

2. 두개가 다르지 않으므로 한번 null이 나온 것을 기반으로 hasKey()를 제외하고 .get()을 사용해서 코드를 짜보자

(혹시 내가 아는 hasKey()가 잘못되었을 수도 있으니깐)

public void pushUserChatroom(String userId, String roomId) {
        String key = userId + RedisConstants.CHATROOM;
        String value = valueOps.get(key);
        log.info("redis hasKey value :: {}", value);
        if (value == null) {
            valueOps.set(key, roomId);
            valueOps.getOperations().expire(key, 10, TimeUnit.MINUTES);
        } else {
            throw new CustomException(CustomError.CHATROOM_USER_ALREADY_JOINED);
        }
    }
  • 1번 케이스 = null
  • 2번 케이스 = null

하지만 직접 Redis에 조회를 해보면 우리가 원하는대로 값이 들어간 것을 볼 수 있다.
즉, 둘 다 value 값이 바뀌었다.

  1. 번외 테스트 코드를 짜보자

결과: redisTemplate.hasKey() true"

여기서는 true가 반환되었다.

나중에 안 사실이지만 @Transactional 을 적어주지 않아서 hasKey()
당시에는 트랜잭션에 포함이 되질 않아 이미 완료가 된 상태여서 true가 나온 것이다.

번외 학습

그렇다면 테스트 코드에서 @Transactional 을 붙이는 것은 무조건 좋은가? 코드엔 무조건적인 것 은 없다.

https://jojoldu.tistory.com/761 에서 많은 인사이트를 얻었고

간단하게 테스트코드는 @Transactional을 보장하지만 만약 메인코드에 @Transactional 이 실수로 누락이 되었을 경우 테스트 코드는 통과하지만 메인 코드는 통과가 되지 않는다.

스프링팀에서는 권장한다고 하니 참고하시길.... 다음에 기회가 되면 해당 코드로 게시물을 해보고싶다.

아무튼 다시 @Transactional을 걸고하면 null이 발생된다.

문제 해결

근본적으로 해당 문제를 겪었을 때 처음은 막연했다.
왜 정확하게 값이 들어가는데 value 가 전부 null처리로 반환이 되는 걸까?

뭔가 과거에 이러한 동작을 본 것 같은데....?

과거에 Redis 공부를 하면서 Redis 트랜잭션이라는 것을 학습했었다.
이때 명령어들이 실행되지 않고 Queue에 쌓인 것이 기억 났다.

설마 그러면 트랜잭션에 포함이 되어있던 것일까?
Redis 데이터소스 빈등록을 보자

 @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;
    }

그렇다 setEnableTransactionSupport(true) 옵션을 통해 트랜잭션에 포함되어있었다.

본래 트랜잭션과 같이 움직이게 설정
Sprind Redis Transaction https://docs.spring.io/spring-data/redis/reference/redis/transactions.html

해당 글을 보면
로 명시가 되어 있다.

그래서 null로 반환이 되어있던 것이다.

그렇다면 해결을 해야하는데
해당 옵션을 지우려고 하니 다른 곳은 해당 옵션이 필요했다.

그러면 어떻게 해야할까?

문제 해결 방법

1번) 해당 옵션을 지우고 SessionCallBack을 사용하는 방법이다.(채택)

https://wildeveloperetrain.tistory.com/137#google_vignette 해당 글 처럼 필요한 곳에

예시
List<Object> results = stringRedisTemplate.executePipelined(
        new RedisCallback<Object>() {
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                StringRedisConnection stringRedisConn = (StringRedisConnection) connection;
                for (int i = 0; i < batchSize; i++) {
                    stringRedisConn.rPop("myqueue");
                }
                return null;
            }
        });

2번) 옵션을 유지 및 해제하여 여러개의 트랜잭션 매니저를 등록하여 상황에 맞게 옵션을 제거하고 설정하는 방법이다.

@Transactional(value="BBBB") 를 활용하여 호출

시도했던 실패 방법

redisTemplate.exc()도 고민을 이미 조회가 끝나서 null이 반환된 시점이라 사용해도 같은 결과가 나왔다.

왜 이러한 방식이 사용되는 것인가?

  1. 트랜잭션이 걸려있는 상태이다.
    "null when key does not exist or used in pipeline / transaction."

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

    MULTI 커맨드로 Transaction 시작 구간을 설정하고, 이후 발생하는 커맨드는 EXEC가 실행되기 전까지 Queue(커맨드 버퍼)에 계속해서 쌓이며 실제 요청이 실행되지 않는다.

    이때 get 요청 마찬가지로 실행되지 않는데 EXEC 구문이 끝난 뒤에 get 요청에 대한 값이 return 되는 것은 의미가 없기 때문에 null 이 리턴된다고 합니다.

  2. key가 존재하지 않는다.

  3. pipline 사용중인 상태이다.

코드로 확인해보자

  @Override
    public Boolean hasKey(K key) {
        byte[] rawKey = rawKey(key);
        return execute(connection -> connection.exists(rawKey), true);
    }
    
     @Nullable
    @Override
    public Long exists(byte[]... keys) {

        Assert.notNull(keys, "Keys must not be null!");
        Assert.noNullElements(keys, "Keys must not contain null elements!");

        try {
            if (**isPipelined**()) {
                pipeline(connection.newJedisResult(connection.getRequiredPipeline().exists(keys)));
                return **null**;
            }
            if (**isQueueing**()) {
                transaction(connection.newJedisResult(connection.getRequiredTransaction().exists(keys)));
                return **null**;
            }
            return connection.getJedis().exists(keys);
        } catch (Exception ex) {
            throw connection.convertJedisAccessException(ex);
        }
    }
    

키가 존재하면 true 파이프라인/트랜잭션에 사용되면 null이다.

추가적으로 template.keys("*")는 작동한다. (.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는 진행 중인 트랜잭션에서 읽기 전용 명령과 쓰기 명령을 구분합니다. 읽기 전용 명령(예: KEYS)은 읽기를 허용하기 위해 새(비스레드 바인딩) RedisConnection에 파이프로 연결됩니다. 쓰기 명령은 RedisTemplate에서 대기열에 오르고 커밋 시 적용됩니다.
https://docs.spring.io/spring-data/redis/reference/redis/transactions.html

get

>> MULTI
"OK"
>> SET KOREA JO
"QUEUED"
>> SET KOREA_K SON
"QUEUED"
>> GET KOREA # GET은 어떻게 처리되는지 확인
"QUEUED"
>> EXEC   # QUEUED 된 명령어를 실행한 후 결과를 전부 출력
1) "OK"
2) "OK"
3) "JO" # QUEUED 된 후 EXEC 했을 때 처리

최종 exec시 같이 처리가 된다.

추가 정보

  • MULTI(트랜잭션 시작)
    Redis의 트랜잭션을 시작하는 커맨드. 트랜잭션을 시작하면 Redis는 이후 커맨드는 바로 실행되지 않고 queue에 쌓입니다.
    setEnableTransactionSupport(true)
  • EXEC(정상 커밋)
    정상적으로 처리되어 queue에 쌓여있는 명령어를 일괄적으로 실행합니다. RDBMS의 Commit과 동일합 니다.
  • DISCARD(롤백
    queue에 쌓여있는 명령어를 실괄적으로 폐기합니다. RDMS의 Rollback과 동일합니다.
  • WATCH(락)
    Redis에서 Lock을 담당하는 명령어입니다. 이 명령어는 낙관적 락(Optimistic Lock) 기반입니다.
    Watch 명령어를 사용하면 이 후 UNWATCH 되기전에는 1번의 EXEC 또는 Transaction 아닌 다른 커 맨드만 허용합니다.

최종 결론

Redis 를 사용할때도 동시성 처리를 고민해야한다.
즉, ACID를 보장해야한다.
그러기 위해 하나의 논리적인 실행단계인 트랜잭션을 적용할 수 있다.
하지만 기본 레디스는 단발성으로 쿼리가 바로 바로 실행이 되며 옵션을 통해 주요 비즈니스 로직 트랜잭션에 포함시킬 수 있다.

총 2가지 방법을 알아보았다.

최종적으로 채택된 방법은 옵션을 지우고 동시성을 보장해야하는 코드에 SessionCallback() 을 사용하고 execute 메소드를 @Overring 했다.
코드의 변경은 Config에서만 이루어졌다.

동시성을 보장하게 되면

		boolean hasKey = redisTemplate.hasKey(key);

        redisTemplate.execute(new SessionCallback() {
                @Override
                public Object execute(RedisOperations redisOperations) throws DataAccessException {
                    try {
                        redisOperations.watch(key);
                        redisOperations.multi();
                        redisOperations.opsForHash().putAll(key,map);
                    } catch (Exception e) {
                        redisOperations.discard();
                        return null;
                    }
                    return redisOperations.exec();
                }
        });

이렇게 보장을 시켜줄 수 있었다.

동시성 보장이 많이 필요한 곳이 많아지면 옵션을 사용하는 방법으로 교환할 것 같다.

REF

https://stackoverflow.com/questions/70032606/redis-haskey-method-return-null
https://velog.io/@greentea/spring-RedisTemplate.hasKey%EA%B0%92%EC%9D%B4-null%EC%9D%B4-%EB%82%98%EC%98%AC-%EA%B2%BD%EC%9A%B0
https://sabarada.tistory.com/178
https://docs.spring.io/spring-data/redis/reference/redis/transactions.html
https://sabarada.tistory.com/177
https://jjeda.tistory.com/13

profile
https://coodori.notion.site/0b6587977c104158be520995523b7640

0개의 댓글