Post

불안정한 Redis 트랜잭션의 원자성과 AOP Proxy 해결책

Redis는 트랜잭션 기능을 제공하지만, 일반적인 관계형 데이터베이스(RDBMS)와는 달리 Rollback 기능이 제공되지 않기 때문에 주의 없이 사용하면 데이터 불일치 문제가 발생할 수 있습니다.

이번 포스팅에서는 Redis 트랜잭션의 이러한 단점을 보완할 수 있는 방안을 작성해보겠습니다.


Redis 트랜잭션 동작과 RDBMS 차이점


Redis 트랜잭션 커맨드

CommandDescription
MULTIRedis의 트랜잭션을 시작하는 커맨드 입니다. 시작 시 바로 실행되지 않고 명령은 큐에 쌓입니다.
EXEC큐에 쌓인 명령을 일괄 실행합니다. RDBMS의 Commit과 유사합니다.
DISCARDqueue에 쌓여있는 명령어를 폐기합니다. RDMS의 Rollback과 비슷합니다.
WATCH낙관적 락(Optimistic Lock)을 위한 명령어입니다.

Redis의 트랜잭션은 MULTI 명령어로 명령을 큐에 저장하고, EXEC 명령어로 이를 일괄 실행하여 데이터를 저장합니다. DISCARD 명령어로 큐의 명령을 취소할 수 있으며, WATCH 명령어로 분산 락을 적용할 수 있습니다.


RDBMS 와 Redis Rollback의 차이

Untitled

RDBMS와 비슷하지만 Redis 트랜잭션의 Rollback은 이미 처리되어 버린 명령어에 대해선 Rollback 되지 않는 일관성 문제를 가지고 있습니다.


Rollback 불안정성 문제 & 개선 시나리오

Untitled

문제 시나리오

  1. Redis 작업을 호출하여 트랜잭션을 실행하고, EXEC로 작업을 완료합니다.
  2. 다음 비즈니스 로직 실행 중 예외가 발생합니다.
  3. 예외로 인해 Redis Rollback 을 실행했지만 이미 Commit 되어 Rollback이 불가능합니다.

Untitled

개선 시나리오

  1. @RedisTransactional 어노테이션이 붙은 메소드의 객체는 애플리케이션 초기화 시점 Proxy로 빈을 등록합니다.
  2. 실제 Redis 작업 호출 전 Proxy가 MULTI 커맨드로 트랜잭션을 시작합니다.
  3. Redis가 작업을 시작하고 Proxy에게 결과를 반환합니다.
  4. 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 명령어로 레디스에 저장된 값을 롤백시킵니다.

실행 결과

Untitled

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 명령어로 레디스 큐에 저장된 값을 폐기시킵니다.

실행 결과

Untitled

해당 결과로 인하여 개선된 점

  • Redis 트랜잭션 데이터 원자성 및 일관성 보장
  • 어노테이션을 통해 공통 관심사와 핵심 비즈니스를 명확히 분리하여 간결한 코드 유지

마무리하며..

Redis 트랜잭션 문제를 AOP를 활용하여 데이터의 일관성과 신뢰성을 확보할 수 있었습니다.

Proxy 패턴의 도입으로 인해 더욱 확장 가능한 아키텍쳐를 만들 수 있는 기반이 마련되었다 생각하지만, AOP의 러닝커브와 디버깅의 어려움이 있을 수 있다는 것을 고려하여 도입해야할 것 같습니다.

This post is licensed under CC BY 4.0 by the author.

Trending Tags