최근 RedisTemplate을 사용하다, 트랜잭션 내에서 읽기 명령이 예상대로 동작하지 않는 문제를 겪었다.
get이나 keys 같은 읽기 명령이 트랜잭션 안에서 정상적인 값을 반환하지 못했다.
이 문제는 StringRedisTemplate을 사용할 때 주로 발생했다.
이는 StringRedisTemplate이 내부적으로 사용하는 DefaultStringRedisConnection 때문인데, 문제 발생 원인을 찾기까지 알아간 과정을 아래에 정리해 보려고 한다.
참고로 RedisTemplate<String, String>을 직접 구성할 경우, 이런 문제가 회피될 수 있다.
Spring Data Redis와 트랜잭션
https://docs.spring.io/spring-data/redis/reference/redis/transactions.html
Redis Transactions :: Spring Data Redis
By default, RedisTemplate does not participate in managed Spring transactions. If you want RedisTemplate to make use of Redis transaction when using @Transactional or TransactionTemplate, you need to be explicitly enable transaction support for each RedisT
docs.spring.io
위 공식 문서에서 설명하는 Redis의 트랜잭션에 대해 살펴보자.
RedisTemplate은 기본적으로 Spring 트랜잭션에 참여하지 않는다.
RedisTemplate이 @Transactional 또는 TransactionTemplate을 사용할 때 Redis 트랜잭션을 활용하도록 하려면, 각 RedisTemplate에 대해 setEnableTransactionSupport(true)를 설정해 명시적으로 트랜잭션 지원을 활성화해야 한다.
트랜잭션 지원을 활성화하면 ThreadLocal에 의해 지원되는 현재 트랜잭션에 RedisConnection이 바인딩된다.
트랜잭션이 오류 없이 완료되면 Redis 트랜잭션은 EXEC으로 커밋되고, 그렇지 않으면 DISCARD로 롤백된다.
Redis 트랜잭션은 배치 지향적으로, 진행 중인 트랜잭션동안 발행된 명령들은 큐에 저장되고 커밋 시에 한번에 적용된다.
Spring Data Redis는 진행 중인 트랜잭션에서 읽기 전용 명령과 쓰기 명령을 구별한다.
KEYS와 같은 읽기 전용 명령은 읽기를 허용하기 위해 새로운(스레드에 바인딩되지 않은) RedisConnection으로 pipe된다. 즉 트랜잭션이 진행되는 동안에도 별도 커넥션에서 읽기 작업을 수행할 수 있다.
반면, 쓰기 명령은 RedisTemplate에 의해 큐에 저장되고, 커밋 시 적용된다.
다만 이런 경우는 주의해야 한다.
// 스레드에 바인딩된 연결에서 수행되어야 함
template.opsForValue().set("thing1", "thing2");
// 읽기 작업은 자유로운(트랜잭션을 인식하지 않는) 연결에서 실행되어야 함
template.keys("*");
// 트랜잭션 내에서 설정된 값은 보이지 않으므로 null을 반환함
template.opsForValue().get("thing1");
첫 번째 명령은 큐에 저장된다.
두 번째 명령은 별도 스레드에서 즉시 실행되어 결과가 반환된다.
세 번째 명령도 즉시 실행되지만, 첫 번째 명령이 큐잉되어 아직 실행되지 않아 null이 반환된다.
Spring Data Redis는 어떻게 트랜잭션을 보장할까?
공식문서를 통해 스프링의 Redis 트랜잭션 지원에 대해 알아보았다.
조금 더 자세히 들어가 보자.
Spring Data Redis가 트랜잭션을 보장하는 방식은 우리가 흔히 접하는 JDBC 기반 트랜잭션 흐름과는 조금 차이가 있다.
JDBC 기반 트랜잭션
스프링에서 트랜잭션을 시작하면, 다음과 같은 순서로 처리된다.
- PlatformTransactionManager의 getTransaction()이 호출되면,
- 내부적으로 DataSourceUtils.getConnection(...)으로 커넥션을 획득,
- 해당 커넥션을 ConnectionHolder로 감싼 뒤
- TransactionSynchronizationManager.bindResource(dataSource, connectionHolder) 호출해 현재 스레드에 바인딩한다.
같은 스레드 내에서 동일한 DataSource로 다시 커넥션을 요청하면 기존 ConnectionHolder에 들어있는 커넥션 반환
=> 새로운 커넥션 생성 없이 기존 커넥션 재사용이 가능해진다.
현재 프로젝트의 경우 JPA 의존성이 포함되어 있어 JpaTransactionManager가 기본으로 사용된다.
이 경우 ThreadLocal에 바인딩하는 대상은 EntityManager로 좀 다르지만, 본질적으로는 EntityManager도 트랜잭션 시작 시 내부적으로 JDBC 커넥션을 획득하므로 동작 방식은 유사하다.
=> 실제 코드상으로 DI되는 EntityManager는 프록시고, 트랜잭션이 시작될 때는 프록시 로직에 의해 진짜 EntityManager가 생성되어 ThreadLocal에 저장됨.(TransactionSynchronizationManager.bindResource(this.obtainEntityManagerFactory(), txObject.getEntityManagerHolder());)
하지만 Redis의 경우 별도 RedisTransactionManager가 존재하지 않는다.
그런데 어떻게 트랜잭션을 지원하는 걸까?
ThreadLocal에 RedisConnection이 바인딩되는 방식
Spring Data Redis에서 Redis 명령 실행 코드를 살펴보면, 결국 내부적으로 RedisTemplate의 execute(...) 메서드로 귀결되고, 해당 메서드로 Redis 서버와 통신함을 확인할 수 있다.
execute(...) 내부에서는 RedisConnectionUtils.getConnection()을 통해 RedisConnection을 가져오며, 이는 다시 doGetConnection(...) 메서드로 이어진다.

public static RedisConnection doGetConnection(
RedisConnectionFactory factory, boolean allowCreate,
boolean bind, boolean transactionSupport
)
이 메서드는 다음과 같은 흐름으로 동작한다.
1. 트랜잭션 동기화 매니저를 통해 현재 스레드에 RedisConnection이 이미 바인딩되어 있는지 확인한다.

2. 없다면 새 커넥션을 생성한다.

fetchConnection(factory)는 RedisConnectionFactory의 getConnection()을 호출해 매번 호출 시마다 RedisConnection을 생성한다.

3. 현재 트랜잭션이 활성화되어 있고, 트랜잭션 지원이 활성화되어있는지를 확인한다.

해당 조건을 만족하면 createConnectionSplittingProxy(...)를 호출한다.
이는 읽기 명령과 쓰기 명령을 분리해서 처리하는 프록시를 생성한다.
프록시 부가 로직
프록시에 적용되는 주요 부가 로직은 RedisConnectionUtils 클래스의 내부 클래스인 ConnectionSplittingInterceptor의 intercept 메서드에서 확인할 수 있다.

파란색과 빨간색으로 표시해 구분했다.
실행할 명령이 잠재적으로 스레드에 바인딩되는 명령어라면 기존 커넥션을 그대로 사용해 로직을 실행하고,
그렇지 않다면 새로 생성한 커넥션에서 로직을 실행한다.
여기서 잠재적으로 스레드에 바인딩되는 명령은 읽기 전용 명령이 아닌 명령들을 의미한다.
(또는 알 수 없는 명령인 경우)
4. 프록시 커넥션이 생성되면 RedisConnectionHolder에 감싸져 ThreadLocal에 저장된다.
이때 bindSynchronization == true인 경우 다음 메서드가 호출된다.

potentiallyRegisterTransactionSynchronisation(...) 메서드는 다음과 같다.

이 메서드는 트랜잭션이 readOnly인지 확인하고, 아니라면 커넥션의 multi() 메서드를 실행한다.
트랜잭션 동기화 매니저에 트랜잭션 종료 시점에 실행될 콜백(RedisTransactionSynchronizer)을 등록한다.
RedisTransactionSynchronizer 클래스 내부에는 아래와 같은 콜백 메서드가 포함되어 있다.

multi로 시작한 트랜잭션이
커밋으로 정상 종료되면 exec()이 호출되고,
예외나 롤백이 발생하면 discard()를 호출한다.
이로써 트랜잭션의 원자성이 보장되게 된다.

더 알아보기: 쓰기 명령의 큐잉
Spring Data Redis의 기본 Redis 클라이언트는 Lettuce다. 따라서 위에서 설명한 모든 RedisConnection 객체는 사실 LettuceConnection 인스턴스이다.
트랜잭션이 시작되면 multi()가 호출되고, 이로 인해 LettuceConnection의 isMulti 플래그가 true로 설정된다. 그러면 isQueueing() 메서드도 true를 반환하게 된다.
이후 set과 같은 쓰기 명령이 실행되면 내부적으로 doInvoke(...) 메서드가 호출되는데, 이때 isQueueing()이 true이면 실제로 명령을 Redis에 전송하지 않고 transaction(...) 메서드를 통해 내부 큐에 저장하게 된다. 이 큐는 나중에 exec()가 호출될 때 한꺼번에 실행된다.
즉, 트랜잭션 중에는 set, del 등의 명령이 바로 실행되는 것이 아니라, LettuceConnection 내부의 큐에 저장되었다가 커밋 시점에 실행되는 구조임을 확인할 수 있다.
StringRedisTemplate의 커넥션 전처리
앞서 RedisTemplate은 execute(...)를 통해 redis와 통신한다고 설명했었다.
그리고 RedisConnectionUtils.getConnection(...)을 통해 커넥션을 획득하는 것까지 살펴보았다.
메서드의 나머지 부분을 확인해보자.

Redis 커넥션을 획득한 다음에는 현재 트랜잭션 컨텍스트에 이미 커넥션이 존재하는지를 확인한다.
해당 값과 커넥션을 기반으로, 노란색으로 표시한 preProcessConnection(...) 메서드를 통해 해당 커넥션에 대한 전처리 작업이 수행된다. 그 다음에 사용자가 전달한 RedisCallback의 doInRedis(...)를 실행해 실제 명령이 실행되게 된다.
기본적으로 RedisTemplate에서는 preProcessConnection(...)가 다음과 같이 별도 처리 없이 커넥션을 그대로 반환한다.

하지만 StringRedisTemplate에서는 동작이 조금 다르다.

위처럼 preProcessConnection(...) 메서드를 오버라이딩해 구현하고 있기 때문이다.
StringRedisTemplate은 RedisConnection 획득 이후, 해당 커넥션 인스턴스로 DefaultStringRedisConnection 클래스로 래핑해 반환한다.
그리고 이것이 StringRedisTemplate이 읽기 명령이 결과를 제대로 반환하지 못하는 이유가 된다.
StringRedisTemplate은 트랜잭션 중 읽기 명령의 결과를 null로 반환한다
StringRedisTemplate과 RedisTemplate이 명령을 처리하는 방식을 비교하기 위해 먼저 RedisTemplate이 어떻게 읽기 명령을 처리하는지를 알아보자.
get 명령을 사용하는 경우를 예시로 살펴보겠다.
RedisTemplate이 get 명령을 처리하는 방식
RedisTemplate을 이용해 redis get 명령을 처리하기 위해서는 보통 다음과 같은 코드를 사용한다.
redisTemplate.opsForValue().get(key);
opsForValue() 메서드가 반환하는 ValueOperations<K, V> 인터페이스에서 get 메서드를 호출하는 형식이다.

이 인터페이스 구현체인 DefaultValueOperations 클래스 get 메서드를 보면 RedisTemplate의 execute로 콜백을 실행함을 확인할 수 있다.

콜백 메서드 DefaultedRedisConnection의 default 메서드 get(...)은 다음과 같다.

즉, execute를 실행하면 위의 메서드가 실행되게 된다.
앞서 명령을 실행할 때 사용하는 커넥션이 프록시임을 설명했었다. 이 프록시는 읽기 명령과 쓰기 명령을 구분해 다른 커넥션을 사용할 수 있도록 도와주는 부가 로직을 포함한다.
그러나 사실 그 이전에 체크하는 부가 로직이 하나 더 존재한다. 이는 Spring Data Redis가 RedisStringCommands, RedisKeyCommands처럼 명령을 그룹으로 나누어 관리하기 때문인데, ConnectionSplittingInterceptor의 intercept 메서드를 다시 살펴보자.

org.springframework.data.redis.connection 패키지에 있고, 리턴 타입이 Redis로 시작해서 Commands로 끝나는 메서드 반환값에 대해서 프록시가 한 번 더 생성된다.
stringCommands()의 반환 값이 만약 일반 객체였다면, 커넥션 분기 로직은 적용되지 못하고 무조건 하나의 커넥션만 사용되었을 것이다. 그러나 위 로직처럼 stringCommands() 자체도 프록시로 감싸기 때문에 내부에서 어떤 명령이 호출되느냐에 따라 읽기 명령이면 새 커넥션을 사용하고, 쓰기 명령이면 기존 커넥션을 사용하도록 동작할 수 있게 된다.
마지막으로 LettuceStringCommands의 get(...) 메서드를 살펴보자.

이때 사용되는 invoke()는 LettuceConnection이 구현한 메서드로, 앞서 <<더 알아보기: 쓰기 명령의 큐잉>> 부분에서 언급한 doInvoke(...)를 호출한다.
get과 같은 읽기 명령은 프록시가 새 커넥션을 사용하도록 분기한다. 이때 doInvoke()는 Redis 명령을 즉시 실행하는 LettuceInvoker를 반환해 결과를 바로 받을 수 있다.

StringRedisTemplate의 get 명령 처리
StringRedisTemplate은 명령 처리를 위한 커넥션을 DefaultStringRedisConnection으로 감싸서 사용한다.
DefaultStringRedisConnection은 String 기반 명령을 처리하기 위해 RedisConnection을 감싸는 래퍼 클래스다.
StringRedisTemplate은 RedisTemplate<String, String>을 상속하고 있으므로 get 명령 실행 시에도 앞서 설명한 것과 동일한 메서드가 호출 흐름을 따른다. 다만 사용되는 RedisConnection 인스턴스가 달라 내부 동작이 조금 달라진다.

StringRedisTemplate에서의 get 호출은 preProcessConnection 메서드에 의해 앞서 설명한 흐름에 DefaultStringRedisConnection 래핑 과정이 추가되면서, 위 그림과 같은 흐름으로 변화하게 된다.
즉, DefaultStringRedisConnection이 직접 구현하고 있는 get() 메서드를 호출하게 되는데, 이 메서드는 delegate.get()을 호출하고, delegate는 커넥션 프록시이므로 읽기 명령인 get을 위한 새 커넥션을 생성해 명령을 실행한다.
실제로 프록시 로직에 breakpoint를 찍고 디버깅해보면 GET을 읽기 전용 명령으로 판단해 새로운 커넥션을 생성해 통신함을 확인할 수 있다.

그렇다면 왜 결과가 null로 반환될까?
원인은 DefaultStringRedisConnection의 convertAndReturn(...)에 있다.

메서드 첫 번째 라인의 isFutureConversion()은 내부적으로 isQueueing() || isPipelined()를 체크한다.
트랜잭션 큐잉 상태가 감지되면 컨버터를 큐에 저장하고, null을 반환하며 큐에 저장된 컨버터는 EXEC 시점에 실행된다.
즉, 트랜잭션이 적용된 경우(transactionSupport가 활성화된 상태에서 @Transactional이 적용된 메서드가 실행) multi()가 실행되어 isQueuing() == true가 되어 읽기 명령이 별도 커넥션에서 잘 수행되었음에도 결과가 null로 반환되는 문제가 발생한다.
https://github.com/spring-projects/spring-data-redis/issues/2953
`StringRedisConnection` returns `null` using read operations during transactions · Issue #2953 · spring-projects/spring-data-r
A minimize cases as belong, which expect return some value but get null。 @GetMapping("/get") @Transactional public String get(@RequestParam String key) { String value = stringRedisTemplate.opsForVa...
github.com
Spring Data Redis 깃허브 이슈 페이지에서도 이 문제에 대한 답변을 찾아볼 수 있다.
The problem originates in DefaultStringConnection. The backend connection uses a proxy mechanism to determine whether the called method is a read method that should return a value. DefaultStringRedisConnection uses manual decoration without access to command metadata for roughly 530 commands.
As workaround, you can use RedisTemplate<String, String> with StringRedisSerializer.UTF_8.
DefaultStringRedisConnection이 커맨드를 수동으로 데코레이션하고 있어 문제가 발생하며, RedisTemplate<String, String>을 사용하는 것이 해결 방법이 될 수 있다고 설명한다.
글을 작성하는 시각 기준 bug 태그가 붙어있는 것으로 보아 향후 수정될 여지가 보인다.
마치며
지금까지 StringRedisTemplate에서는 읽기 명령이 별도 커넥션에서 실행되지 않는 원인에 대해 알아보았다.
정리하면, StringRedisTemplate에서 읽기 명령을 호출하면 결과가 반환되지 않는데, 이는 StringRedisTemplate에서 사용되는 DefaultStringRedisConnection의 convertAndReturn()에서 트랜잭션 진행 중이라면 컨버터를 큐에 저장하고 null을 반환하기 때문이다. 이는 RedisTemplate<String, String>을 사용하는 방식으로 해결할 수 있다.
우연한 계기로 StringRedisTemplate에서만 읽기 명령 결과가 제대로 반환되지 않는다는 문제를 발견했는데, 이를 계기로 내부적으로 RedisTemplate이 어떻게 동작하는지까지 자세히 살펴보게 되었다. 그동안 모호하게 알고 있던 부분들도 다시 공부하고 이해할 수 있는 좋은 경험이었다.
사실 문제의 원인을 파악하고 이 글을 완성하기까지 꽤 오랜 시간이 걸렸다. 그 과정에서 Spring Redis Data의 깃허브 이슈 페이지가 제일 큰 도움이 됐다. 덕분에 앞으로 이해되지 않거나 해결되지 않는 문제가 있으면 공식 문서뿐만 아니라 이슈 페이지도 꼭 함께 찾아보게 될 것 같다.
2025-07-22 14:47 내용 오류 수정 참고