최근 Redis 트랜잭션을 사용하면서 흥미로운 상황을 경험했다.
(이론상으로는 Redis 트랜젝션 내부에서 조회를 할 때는 NULL이 반환되어야 하지만 정상적인 값이 반환되는....)
Redis 트랜잭션의 기본 동작에 대해서 먼저 알아보면
이러한 과정에서, 트랜잭션 내부의 모든 읽기 명령(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를 학습 했을 때는 트랜젝션 내부에서 조회를 하면 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의 현재 상태를 반영하고 있었다.
Spring Data Redis는 트랜잭션 내에서 읽기 작업과 쓰기 작업을 다르게 처리한다.
위 문서의 "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."
즉, 트랜잭션 내의 모든 작업(읽기 포함)이 큐에 쌓이고 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);
}
}
@Transactional 어노테이션이 붙은 메서드 내에서 redisTemplate.hasKey(key)를 호출하면, 실제로 Redis의 현재 상태를 반영한 결과를 반환한다.
이는 "Spring Data Redis의 RedisTemplate은 읽기 작업을 새로운 연결을 통해 즉시 실행한다"는 내용에 의해서 설명된다.
SessionCallback 내에서 operations.multi()를 호출한 후 operations.hasKey(key)의 결과는 null이다.
이는 SessionCallback을 사용하면 Redis의 원래 트랜잭션 동작대로, 트랜잭션 내의 읽기 작업이 실제로 실행되지 않고 큐에 쌓인다는 것을 보여준다.
operations.exec() 이후에는 operations.hasKey(key)가 true를 반환하는데, 이는 트랜잭션이 실행된 후 실제 Redis 상태를 반영한 결과이다.
이때 Spring Data Redis는 트랜젝션 내부에서 읽기 작업을 수행할 때는 새로운 연결을 통해 즉시 실행하기 때문에 나타나는 결과였다.