본문 바로가기
🏢 DB/1️⃣ Redis

[간단 장애 회고] Redis + @Transactional (트랜잭션에서 Redis Get은 항상 null을 리턴한다. 트랜잭션 없는 상위 메서드에서 트랜잭션 있는 하위 메서드 호출은 에러다.)

by kukim 2022. 12. 18.

잘못된 내용이나 의견 있다면 편하게 말씀해주세요.🙏🏻

사건의 개요

Spring Boot에서 @Transactional 어노테이션을 사용하여 Redis에 대해 트랜잭션을 사용했습니다.

하지만 문제가 발생했습니다. 하나의 트랜잭션에서 Redis Get와 Redis Set을 사용할 때 Redis Get가 조회되지 않고 항상 null을 발생시켰습니다.

 

+a 배경지식 : Spring Data Redis + @Transactional
Spring Boot에서 Redis 저장소를 사용합니다. 연결하기 위해서는 spring-boot-starter-data-redis 의존성을 사용합니다.

해당 의존성은 Redis Client인 lettuce 라이브러리를 사용합니다.

 

JDBC나 JPA에서 사용하는 @Transactional 어노테이션을 사용해 손쉽게 트랜잭션을 적용할 수 있듯이 Redis에서도 해당 어노테이션을 사용할 수 있습니다. 단, Spring Data Redis에서는 @Transactional을 제어하는 PlatformTransactionManager를 제공하지 않기 때문에 JDBC나 JPA의 PlatformTransactionManager를 추가하거나 @EnableTransactionManagement를 명시적으로 사용해야 합니다.

사건을 인지하고 해결에 이르기까지의 타임 라인

1. 통합 테스트에서 Redis값이 조회되지 않은 것을 발견

2. 검색 & 해결

사건의 근본 원인

Redis 트랜잭션일반적인 RDB의 트랜잭션과는 다릅니다. 트랜잭션 사이에 있는 조회 명령은 바로 조회되는 것이 아닌 트랜잭션 종료 후 조회됩니다. 

127.0.0.1:6379> MULTI // Redis 트랜잭션 시작 명령어
OK
127.0.0.1:6379(TX)> SET name kukim
QUEUED
127.0.0.1:6379(TX)> GET name // GET 조회가 바로 되지 않습니다.
QUEUED
127.0.0.1:6379(TX)> EXEC // Redis 트랜잭션 종료 명령어
1) OK
2) "kukim" // 트랜잭션이 종료되고(EXEC 명령어 이후) GET 조회가 완료됩니다.

따라서 하나의 트랜잭션에 Redis Get과 Set을 한다고 해도, Get에서 정상적인 조회를 할 수 없습니다.

이는 Spring Data Redis 구현체에서도 나타납니다. Spring Data Redis의 get 메서드 문서는 아래와 같이 이야기합니다.

     /**
     * Get the value of {@code key}.
     *
     * @param key must not be {@literal null}.
     * @return {@literal null} when used in pipeline / transaction.
     * @see <a href="https://redis.io/commands/get">Redis Documentation: GET</a>
     */
    @Nullable
    V get(Object key);
    
    
// @return {@literal null} when used in pipeline / transaction. 
// => 레디스 파이프라인이나 트랜잭션을 사용할 때, 리턴은 null을 한다.

네, 트랜잭션 사용 시 get 은 항상 null을 리턴합니다.

 

구현했던 코드에서는 하나의 트랜잭션에서 get과 set을 하기 때문에 에러가 발생했습니다.

@Service
public class RedisApiRateLimiterService implements ApiRateLimiterService {

    	@Transactional	
	public Boolean checkRateLimit() {
    		// findTotalApiCounts : Redis 저장소에서 Get 한다.
		long totalApiCounts = findTotalApiCounts(apiPathAndIpKey, currentDate, windowSize);

		if (totalApiCounts < apiMaximumNumber) {
                	// upsertApiCount : Redis 저장소에서 Set 한다.
			upsertApiCount(apiPathAndIpKey, currentDate, windowSize);
			return true;
		}

		throw new ApiLimitExceededException();
	}
    
	private long findTotalApiCounts() {
    	// 생략
	}
    
 	private void upsertApiCount() {
    	// 생략
	}
}

 
영향

Redis를 조회할 수 없었기에 비즈니스 로직에 에러가 발생했습니다. 

문제를 즉시 해결하기 위한 조치 항목

Read 메서드(findTotalApiCounts)와 이를 호출하는 상위 메서드(checkRateLimit)에는 트랜잭션을 붙이지 않고 CUD(upsertApiCount) 메서드에만 적용하였습니다.

@Service
public class RedisApiRateLimiterService implements ApiRateLimiterService {

	public Boolean checkRateLimit() {
		long totalApiCounts = findTotalApiCounts(apiPathAndIpKey, currentDate, windowSize);

		if (totalApiCounts < apiMaximumNumber) {
			upsertApiCount(apiPathAndIpKey, currentDate, windowSize);
			return true;
		}

		throw new ApiLimitExceededException();
	}
    
	private long findTotalApiCounts() {
    	// 생략
	}
    
    	@Transactional
 	private void upsertApiCount() {
    	// 생략
	}
}

하지만 문제는 또 발생했습니다. @Transacational 은 Spring AOP의 Proxy 방식으로 작동합니다. 따라서 상위 메서드에는 트랜잭션이 없고 하위 메서드에 트랜잭션이 존재한다면 작동하지 않거나 문제가 생길 수 있습니다. (stackoverflow)

 

위 방식을 사용하기 위해서는 호출하는 쪽(e.g. Controller, Fasade)에서 checkRateLimit() 메서드가 아닌 findTotalApiCounts()와 upsertApiCount() 메서드를 호출하는 방식으로 작동해야 합니다. 이는 각 메서드들을 private -> public으로 변경해야 하고 비즈니스 로직이 노출되어야 하는 단점이 있었습니다.

 

고민 끝에 현재 구현에서는 @Transacional 어노테이션을 사용하지 않기로 결정했습니다.

 

@Transacional 어노테이션을 사용하지 않는다면 두 가지 방법으로 트랜잭션을 보장할 수 있습니다. 

1. RedisTemplate의 SessionCallBack을 사용한다.

2. Lua Script 사용

재발 방지를 위한 조치 항목

현재 Redis 쿼리가 복잡하지 않기 때문에 RedisTemplate의 SessionCallBack을 사용하여 CUD 메서드에만 트랜잭션을 사용하기로 하였습니다. 하지만 SessionCallBack코드가 복잡하다는 단점이 있습니다. Redis 쿼리가 더 많아지거나 복잡해진다면 Fasade 패턴 + 트랜잭션 어노테이션을 사용하거나 Lua Script로 리팩터링 할 수 있습니다.

해당 경험에서 얻은 교훈

- 테스트는 여전히 중요하다. 통합 테스트를 작성했기 때문에 일찍 버그를 발견할 수 있었습니다.

- Redis에서 트랜잭션은 RDB와 다르다.

- @Transacaional, Spring AOP의 proxy 메커니즘을 잊지 말자.

 


Reference

- 2022.06.12 - [🧘🏻‍♂️ 생각, 개발 일반] - 회고 방법 : 포스트모템(postmortem)

- https://stackoverflow.com/questions/54567006/calling-transactional-method-from-non-transactional-method-in-spring-4-3

- https://github.com/spring-projects/spring-data-redis/blob/main/src/main/java/org/springframework/data/redis/core/ValueOperations.java

- https://docs.spring.io/spring-data/redis/docs/current/reference/html/

- 본문 내용이 첨부된 소스코드 & PR

댓글