Redis 트랜잭션 중에 조회하면 NULL이 반환되어야 하는 거 아닌가?

Mando·2024년 8월 31일
0

최근 Redis 트랜잭션을 사용하면서 흥미로운 상황을 경험했다.
(이론상으로는 Redis 트랜젝션 내부에서 조회를 할 때는 NULL이 반환되어야 하지만 정상적인 값이 반환되는....)

Redis 트랜잭션의 특징

  • 롤백 지원 없음: Redis 트랜잭션은 전통적인 데이터베이스와 달리 롤백을 지원하지 않는다. 트랜잭션 내의 명령어들은 큐에 쌓이고, 실행 시점에 모든 명령어가 순차적으로 실행된다.
  • 에러 처리: 트랜잭션 EXEC 명령어 실행 시 에러가 발생하면, 해당 명령어만 실행되지 않고 나머지 명령어들은 정상적으로 실행된다.
  • 싱글 스레드 동작: Redis는 기본적으로 싱글 스레드로 동작하여, 트랜잭션 실행 중에는 다른 명령어의 개입을 막는다.

Redis에서 트랜젝션 중에 조회를 하게되면 NULL이 반환되어야 하는 이유

Redis 트랜잭션의 기본 동작에 대해서 먼저 알아보면

  • MULTI 명령: 트랜잭션의 시작
  • 명령 큐잉: MULTI 이후의 모든 명령은 실행되지 않고 큐에 쌓인다.
  • EXEC 명령: 큐에 쌓인 모든 명령을 순차적으로 실행한다.

이러한 과정에서, 트랜잭션 내부의 모든 읽기 명령(GET, HGET 등)은 실제로 데이터를 읽지 않고 단순히 큐에 쌓이기만 한다. 따라서 이론적으로는 이러한 읽기 명령에 대해 NULL이 반환되어야 한다.

> SET key value
OK
> MULTI
OK
> GET key
QUEUED
> SET key newvalue
QUEUED
> GET key
QUEUED
> EXEC
1) "value"
2) OK
3) "newvalue"

주목할 점은 MULTI와 EXEC 사이의 GET 명령에 대해 "value"나 "newvalue"가 아닌 "QUEUED"가 반환된다는 것이다. 실제 값은 EXEC 실행 후에야 얻을 수 있다.

근데 나는 왜 Redis의 트랜젝션 내부에서 조회를 했는데 정상적인 값이 나오는 거지?

Redis를 학습 했을 때는 트랜젝션 내부에서 조회를 하면 NULL이 반환되므로 조회 시에는 Transaction에서 분리해야한다. 라고 결론을 내렸지만

이상하게도 EEOS의 코드에서는 정상적인 값이 반환되는 것을 확인했다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ReissueService implements ReissueUsecase {
    private final InvalidTokenRepository invalidTokenRepository;

    @Transactional
    @Override
    public TokenModel execute(final String token) {
       validateToken(token);
       // ... 이하 생략 ...
    }

    private void validateToken(final String token) {
    // NULL 반환이 될 것이라고 예상한 부분
       Boolean isExistToken = invalidTokenRepository.isExistToken(token);
       if (Boolean.TRUE.equals(isExistToken)) {
          throw new InvalidTokenException();
       }
    }
    // ... 이하 생략 ...
}

invalidTokenRepository.isExistToken(token)이 트랜잭션 내부에 있음에도 불구하고 실제 Redis의 현재 상태를 반영하고 있었다.

RedisTemplate(Spring Data Redis)경우 트랜젝션을 지원하는 방식이 다르다.

Spring Data Redis는 트랜잭션 내에서 읽기 작업과 쓰기 작업을 다르게 처리한다.

  • 읽기 작업: 새로운 (non-transactional) 연결을 사용해 즉시 실행
  • 쓰기 작업: 트랜잭션 큐에 쌓아두었다가 EXEC 시점에 실행

공식문서 링크

위 문서의 "Redis Transactions" 섹션에서 다음과 같은 내용을 확인할 수 있다

"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."

SessionCallback을 사용하면 Redis의 본래 트랜잭션 동작과 유사하게 작동한다.

즉, 트랜잭션 내의 모든 작업(읽기 포함)이 큐에 쌓이고 EXEC 시점에 한 번에 실행된다.

테스트 코드로 확인해보기

@SpringBootTest
@ExtendWith(DataClearExtension.class)
@Tag("learning-test")
class RedisTransactionTest {

    @Autowired private RedisTemplate<String, String> redisTemplate;
    @Autowired private TransactionalService transactionalService;
    private String key = "testKey";

    @BeforeEach
    void setup() {
       redisTemplate.opsForValue().set(key, "testValue");
    }

    @Test
    void redisTransactionInRedisTemplate() {
       boolean existsInTransactional = transactionalService.checkKey(key);
       assertTrue(existsInTransactional);
    }

    @Test
    void redisTransactionInSessionCallback() {
       redisTemplate.execute(
             new SessionCallback<Object>() {
                @Override
                public Object execute(RedisOperations operations) throws DataAccessException {
                   operations.multi();
                   Boolean beforeExec = operations.hasKey(key);
                   assertNull(beforeExec);
                   operations.exec();

                   Boolean afterExec = operations.hasKey(key);
                   assertTrue(afterExec);
                   return null;
                }
             });
    }
}

@Service
public class TransactionalService {
    private final RedisTemplate<String, String> redisTemplate;

    @Autowired
    public TransactionalService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Transactional
    public Boolean checkKey(String key) {
        return redisTemplate.hasKey(key);
    }
}

RedisTemplate(Spring Data Redis) - redisTransactionInRedisTemplate()

@Transactional 어노테이션이 붙은 메서드 내에서 redisTemplate.hasKey(key)를 호출하면, 실제로 Redis의 현재 상태를 반영한 결과를 반환한다.
이는 "Spring Data Redis의 RedisTemplate은 읽기 작업을 새로운 연결을 통해 즉시 실행한다"는 내용에 의해서 설명된다.

SessionCallback을 통한 트랜젝션 관리 - redisTransactionInSessionCallback()

SessionCallback 내에서 operations.multi()를 호출한 후 operations.hasKey(key)의 결과는 null이다.
이는 SessionCallback을 사용하면 Redis의 원래 트랜잭션 동작대로, 트랜잭션 내의 읽기 작업이 실제로 실행되지 않고 큐에 쌓인다는 것을 보여준다.
operations.exec() 이후에는 operations.hasKey(key)가 true를 반환하는데, 이는 트랜잭션이 실행된 후 실제 Redis 상태를 반영한 결과이다.

결론

  • RedisTemplate: Spring Data Redis가 제공하는 추상화
  • SessionCallback: Spring Framework의 일부로, org.springframework.data.redis.core 패키지에 속해 있다. Redis 작업을 수행할 때 사용되는 콜백 인터페이스

이때 Spring Data Redis는 트랜젝션 내부에서 읽기 작업을 수행할 때는 새로운 연결을 통해 즉시 실행하기 때문에 나타나는 결과였다.

0개의 댓글