불안정한 Redis 트랜잭션의 원자성과 AOP Proxy 해결책
Redis는 트랜잭션 기능을 제공하지만, 일반적인 관계형 데이터베이스(RDBMS)와는 달리 Rollback 기능이 제공되지 않기 때문에 주의 없이 사용하면 데이터 불일치 문제가 발생할 수 있습니다.
이번 포스팅에서는 Redis 트랜잭션의 이러한 단점을 보완할 수 있는 방안을 작성해보겠습니다.
Redis 트랜잭션 동작과 RDBMS 차이점
Redis 트랜잭션 커맨드
Command | Description |
---|---|
MULTI | Redis의 트랜잭션을 시작하는 커맨드 입니다. 시작 시 바로 실행되지 않고 명령은 큐에 쌓입니다. |
EXEC | 큐에 쌓인 명령을 일괄 실행합니다. RDBMS의 Commit과 유사합니다. |
DISCARD | queue에 쌓여있는 명령어를 폐기합니다. RDMS의 Rollback과 비슷합니다. |
WATCH | 낙관적 락(Optimistic Lock)을 위한 명령어입니다. |
Redis의 트랜잭션은 MULTI 명령어로 명령을 큐에 저장하고, EXEC 명령어로 이를 일괄 실행하여 데이터를 저장합니다. DISCARD 명령어로 큐의 명령을 취소할 수 있으며, WATCH 명령어로 분산 락을 적용할 수 있습니다.
RDBMS 와 Redis Rollback의 차이
RDBMS와 비슷하지만 Redis 트랜잭션의 Rollback은 이미 처리되어 버린 명령어에 대해선 Rollback 되지 않는 일관성 문제를 가지고 있습니다.
Rollback 불안정성 문제 & 개선 시나리오
문제 시나리오
- Redis 작업을 호출하여 트랜잭션을 실행하고, EXEC로 작업을 완료합니다.
- 다음 비즈니스 로직 실행 중 예외가 발생합니다.
- 예외로 인해 Redis Rollback 을 실행했지만 이미 Commit 되어 Rollback이 불가능합니다.
개선 시나리오
@RedisTransactional
어노테이션이 붙은 메소드의 객체는 애플리케이션 초기화 시점 Proxy로 빈을 등록합니다.- 실제 Redis 작업 호출 전 Proxy가 MULTI 커맨드로 트랜잭션을 시작합니다.
- Redis가 작업을 시작하고 Proxy에게 결과를 반환합니다.
- Proxy 역할
- 정상 응답 -> Proxy는 EXEC 커맨드로 Commit 합니다.
- 예외 발생 -> DISCARD 커맨드로 Redis Queue 에 담긴 작업을 모두 폐기합니다.
코드 구현
특정 Redis 작업 메소드에 적용할 어노테이션을 생성합니다.
1
2
3
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisTransactional {}
Proxy 빈 등록을 위해 @Aspect를 지정하여 Advisor로 등록하고 RedisTemplate의 SessionCallback을 활용해 MULTI 실행 후 명령어를 하나로 묶어 처리합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Aspect
@Component
public class RedisLockAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(carrot.market.common.annotation.RedisTransactional)")
public Object aroundRedisTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
final Object[] result = {null};
List<Object> transactionResults = redisTemplate.execute(new SessionCallback<>() {
@Override
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi(); /* Redis 트랜잭션 시작 (MULTI) */
try {
result[0] = joinPoint.proceed(); /* 실제 메서드 호출 */
} catch (BaseException e) {
/* 예외 발생 시 Rollback 후 호출한 쪽에서 예외 처리 */
operations.discard();
throw e;
}
return operations.exec(); /* 트랜잭션 커밋 (EXEC) */
}
});
/* 트랜잭션 성공 (커밋 후 한번 더 null 체크) */
if (transactionResults == null || transactionResults.isEmpty()) {
throw new BaseException(REDIS_TRANSACTION_FAIL);
}
/* 성공 시, 실제 메서드의 반환값 전달 */
return result[0];
}
}
AOP 동작 원리에 대한 자세한 내용은 생략하였습니다.
테스트 실행
Redis 어노테이션 적용 전
테스트 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public void redisTest() {
String key = "key";
ListOperations<String, String> nameList = redisTemplate.opsForList();
/* Redis 트랜잭션 시작 */
redisTemplate.multi();
for (int i = 1; i <= 10; i++) {
nameList.rightPush(key, "홍길동" + i);
}
/* 레디스 작업이 끝나서 Commit */
redisTemplate.exec();
try {
/* 이 때 예시로 사용자를 찾을 수 없다는 예외가 발생*/
throw new BaseException(NOT_EXISTED_USER);
} catch(BaseException e) {
redisTemplate.discard();
}
}
- Redis에 10명의 사용자 이름을 저장합니다.
- 저장 후 다른 로직을 진행 중 예외가 발생합니다.
- 예외 발생하여 DISCARD 명령어로 레디스에 저장된 값을 롤백시킵니다.
실행 결과
Rollback 해도 이미 값이 저장되어 있는 것을 확인할 수 있습니다.
또한 애초에 EXEC를 했기 때문에 DISCARD 커맨드 실행시 시스템 예외가 발생하여 애초에 불가능합니다.
Redis 어노테이션 적용 후
테스트 코드
1
2
3
4
5
6
7
8
9
10
11
@Override
@RedisTransactional
public void redisTest() {
String key = "key";
ListOperations<String, String> nameList = redisTemplate.opsForList();
for (int i = 1; i <= 10; i++) {
nameList.rightPush(key, "홍길동" + i);
}
/* 이 때 예시로 사용자를 찾을 수 없다는 예외가 발생*/
throw new BaseException(NOT_EXISTED_USER);
}
- Proxy가 Redis 작업 호출 전 미리 MULTI 트랜잭션을 실행합니다.
- Redis에 10명의 사용자 이름을 저장합니다.
- 저장 후 다른 로직을 추가로 실행 중 예외가 발생합니다.
- 예외 발생해 Proxy가 예외를 낚아채어 DISCARD 명령어로 레디스 큐에 저장된 값을 폐기시킵니다.
실행 결과
해당 결과로 인하여 개선된 점
- Redis 트랜잭션 데이터 원자성 및 일관성 보장
- 어노테이션을 통해 공통 관심사와 핵심 비즈니스를 명확히 분리하여 간결한 코드 유지
마무리하며..
Redis 트랜잭션 문제를 AOP를 활용하여 데이터의 일관성과 신뢰성을 확보할 수 있었습니다.
Proxy 패턴의 도입으로 인해 더욱 확장 가능한 아키텍쳐를 만들 수 있는 기반이 마련되었다 생각하지만, AOP의 러닝커브와 디버깅의 어려움이 있을 수 있다는 것을 고려하여 도입해야할 것 같습니다.