본문 바로가기

Redis

Redis의 동시성(Concurrency)개념과 고립성(Isolation)을 위한 Transaction 처리

 Redis는 AOF와 몇몇 명령어를 제외하고 Single-thread 기반으로 데이터를 처리한다. 단일 스레드로 여러 명의 클라이언트의 요청에 동시에 응답하는 동시성에 대해 알아보고, 이때 발생할 수 있는 문제점과 해결방법에 대하여 알아보도록 하자. 

 

 Redis 의 거의 모든 명령어 처리는 단일 스레드로 동작한다. 어떻게 Single-thread 기반의 하나의 redis 서버에서 동시에 많은 사용자들에 응답할 수 있을지 의문스러웠다. 다행히도 이런 부끄러운 생각은 글로벌했다😄

 

 

 

 나와 비슷한 글로벌 무지랭이에게 친절한 답변이 달려있다. concurrency(동시성), parallelism(병렬성) 은 다른 개념이라고 알려주고있다.

 

 한 명의 바텐더가 순서대로 여러 명의 손님에게 대응할 수 있는 개념(동시성)과 여러 잔의 음료를 동시에 제조하는 것(병렬성)은 분명히 다르다. 손님과 음료라는 다른 예시를 보인 이유는 같은 개념에 대하여 구분되는 특징이 아니라 독립적인 성질이기 때문이다.

 

 결론적으로 Redis는 여러 클라이언트들에게 시간의 차이는 조금 있겠지만 같은 서비스를 제공하되, 여러 스레드를 통해 데이터를 처리하지는 않는다. 즉, 동시성은 있지만 병렬성은 없는 것이다.

 

 

1. 문제 상황


  동시에 여러 클라이언트들에게 대응하면서 생길 수 있는 문제에 대해 살펴보자.

 

 장바구니 서비스를 레디스로 구현해보았다.

스프링에서는 Spring data에서 제공하는 CrudRepository를 상속받아, Redis의 CRUD를 아래 코드처럼 간단하게 처리할 수 있다.

 

@Service
@RequiredArgsConstructor
public class CartService {
    private final CartRedisRepository cartRedisRepository;    
    ...
    
    public Cart addItem(String id, CartItem cartItem) {
        final Cart cart;

        if (!cartRedisRepository.existsById(id)) {
            cart = Cart.of(id);
        } else {
            cart = cartRedisRepository.findById(id).orElseThrow(EntityNotFoundException::new);
        }

        List<CartItem> cartItemList = cart.getCartItemList();
        cartItemList.add(cartItem);
        return cartRedisRepository.save(cart);
    }
...
}

 

 

 위의 addItem 은 장바구니가 없으면 새로 만들고, 기존에 만들었으면 사용자의 id를 통해 불러와서 상품을 추가하는 메서드이다.

 

        if (!cartRedisRepository.existsById(id)) {
            cart = Cart.of(id);
        } else {
            cart = cartRedisRepository.findById(id).orElseThrow(EntityNotFoundException::new);
        }

 

 이 if ~ else 블록을 살펴보면,

A라는 사용자가 레포지토리를 통해 장바구니가 없는 것을 확인하고 새롭게 생성하였는데, 그 사이에 B라는 사용자가 물건을 추가했다고 가정해보자. 

 

A B Cart
  카트 생성 x
카트 생성   x
  b 상품 저장 x
a 상품 저장   x
  write b
write   a

 

다음과 같은 시나리오가 발생할 수 있고 원래대로라면 카트에는 a, b 가 담겨있어야 하지만 카트에는 a상품만 남아있다.

 

물론! 장바구니 특성상 하나의 아이디로 여러 명이 다른 요청을 보내는 경우는 희박할 것이다^^ 이 부분은 넘어가도록 하자ㅎㅎ

 

 하나의 논리적인 실행단계인 트랜잭션은, 잘 알려져 있는 은행에서 송수신 계좌의 일괄처리(원자성) 예시뿐만 아니라, 여러 트랜잭션 사이에서 다른 트랜잭션의 연산에 끼어들지 못하도록 고립성이 보장되어야 한다.(ACID) 

 

 따라서, 위의 코드는 트랜잭션이 안전하게 처리되는 것을 보장하지 못하는 코드이다. 당연하게도 Redis는 안전한 트랜잭션을 위한 여러 기능을 제공한다.

 

2. Redis Transactions


 Redis에서는 여러 명령어를 통해 안전한 트랜잭션을 보장한다. 먼저 Spring Data Redis 에서 제시하는 방법들에 대하여 알아보고 Redis 공식문서를 통해 명령어들에 대하여 자세히 알아보도록 하자.

 

  스프링의 PSA덕분에 일관된 방법으로 트랜잭션 처리를 할 수 있다. 그중에서도 가장 간단하게 사용되는 방법은 @Transactional 어노테이션을 통한 선언적 트랜잭션 방법이다.

 

2.1 @Transactional

 

Spring Data Redis 5.11.1

 

 트랜잭션 매니저를 찾는 1. @EnableTransactionsManagement 과 2. RedisTemplate에 트랜잭션 기능을 true로 바꿔주고,

3. PlatformTransactonManager 를 Bean으로 등록해주면 @Transactional 어노테이션을 사용할 수 있다.

 

 하지만 문제가 있다..

 

현재 개발 중인 서비스에서는 JPA를 사용하고 있고, 이미 트랜잭션 매니저로 Hibernate 트랜잭션 매니저를 사용하고 있다. 레디스의 트랜잭션 매니저는 Hibernate와 달라서 사용하지 못하기 때문에, 여러 개의 트랜잭션 매니저를 등록하여 @Transactional 의 value 옵션을 통해 호출해서 사용해야 한다.

 

 아쉽게도 Spring에서 공식적으로 채택하고 있는 Redis Client 인 Jedis와 Lettuce에 대하여 PlatformTransactionManger 구현체를 제공하지 않는다. 구현체를 제공하고 있는 다른 Client 인 Redisson의 @Transactional 사용방법은 이곳을 통해 확인하자. 

 

2.2 SessionCallback

 

Spring Data Redis 5.11

 

 Spring Data Redis에서는 SessionCallback 인터페이스를 통해 여려 명령을 동시에 처리하는 기능을 제공한다. 이 인터페이스를 통해 직접적으로 Redis 명령어를 사용하여 트랜잭션 경계를 설정해보자.

 

@Service
@RequiredArgsConstructor
public class CartService {
    private final CartRedisRepository cartRedisRepository;    
    ...
    
    public Cart addItem(String id, CartItem cartItem) {
        final Cart cart;

        if (!cartRedisRepository.existsById(id)) {
            cart = Cart.of(id);
        } else {
            cart = cartRedisRepository.findById(id).orElseThrow(EntityNotFoundException::new);
        }

        List<CartItem> cartItemList = cart.getCartItemList();
        cartItemList.add(cartItem);
        return cartRedisRepository.save(cart);
    }
...
}

 

 문제의 코드를 다시 보도록 하자.

 

기존에는 장바구니 객체를 불러와서 수정 후 -> 다시 저장하는 형태의 로직이었는데, 직접적으로 명령어를 사용한다면 객체를 불러올 필요 없이 Redis 서버에 값을 write 하면 된다.

 

 또한, Spring Data Redis 가 제공하는 레포지토리를 이용한 코드에서 직접적으로 명령어를 사용하기 위해 몇 가지 고려사항이 있다.

 

1. id를 통해 레포지토리에 조회했다면, key 값을 통해 직접적으로 레디스 서버에 접근해야 한다.

 

 key는 @RedisHash 를 통해 엔티티로 등록된 도메인이 prefix로 붙고 뒤에 아이디로 되어있다. String.format() 을 이용하여 key를 생성하자.

 

2. Spring Data Redis는 Java 객체를 Redis에 저장할 때 Hash 자료구조 형태로 저장한다. (자바에서는 HashMap 을 이용) 그냥 객체를 통으로 저장할 수도 있겠지만 기존의 데이터와의 호환성을 위해 다음과 같이 상품 객체를 받아와서 Parsing 후 HashMap 에 넣어주려고 한다.

 

 

Parsing 하지않고 직렬화 상태 그대로 넣었을경우
Parsing 해서 저장

각각의 field는 hashkey로 작동하고, 이를 통해 조회나 수정할 수 있어 데이터를 빠르게 처리할 수 있겠다.

 

3. MULTI, EXEC 트랜잭션 경계설정 

 MULTI 를 통해 트랜잭션을 시작하고, EXEC 명령어를 통해 트랜잭션 경계 안에 있는 쿼리들을 일괄적으로 처리한다.

자세한 내용은 이곳을 참고하자. 

 

위의 내용들이 반영된 코드는 다음과 같다.

 

@Service
@RequiredArgsConstructor
public class CartService {
    private final CartRedisRepository cartRedisRepository;    
    ...
       
    public Cart addItem(String id, CartItem cartItem) {

        redisTemplate.execute(new SessionCallback() {
                @Override
                public Object execute(RedisOperations redisOperations) throws DataAccessException {
                    
                    final String key = String.format("cart:%s", id);
                    final Map<String, Object> map;
					
                    redisOperations.multi(); // MULTI : 트랜잭션 경계설정
                   
                    if (!redisOperations.hasKey(key)) {
                        map = convertCartItemToMap(0,cartItem);
                    } else {
                        // "_class", "id" 필드를 제외하고 "cartItemList[n]" 의 필드개수 6개로 나누어주면 상품개수
                        int cartItemSize = 
                           (int)((redisOperations.opsForHash().size(key) - DEFALUT_SIZE) / FIELD_SIZE);
                        map = convertCartItemToMap(cartItemSize,cartItem);
                    }
                    
                    redisOperations.opsForHash().putAll(key,map);
                    return redisOperations.exec(); // EXEC : commit
                }
        });

        return getCart(id);
    }
...
}

 

그럴싸하게 바꾼 듯? 했지만.. 이 코드에는 몇 가지 문제가 있다.

 

테스트 코드를 실행한 결과..

 

 

 

if(!redisOperations.hasKey(key)) {
...

이 부분에서 NullPointerException 이 발생했다..

 

생각해보면 당연한데,

일괄적으로 처리하기 위해 트랜잭션 경계를 설정했다. 즉, EXEC 가 실행되기 전까지는 실제 쿼리가 날아가지 않는데, 쿼리를 실행해서 조회하는 코드를 넣었으니 Null 값이 들어가 있는 게 당연하다.

 

여기서 다시 생각해보자. Atomic 한 결과를 위해 여러 쿼리를 하나의 동작처럼 묶는다. 이는 write 동작으로 인하여 값이 바뀌는 여러 쿼리들을 일부만 동작하는 것을 방지하기 위한 처리일 것이다.

 

그렇다면 위의 상황에서 단순히 조회하는 쿼리는 트랜잭션 경계 안에 둘 이유가 없기 때문에 밖으로 빼내어 NullPointerException 을 방지하자.

 

또 다른 문제점은 위에서 계속 설명한 동시성 문제이다. 단순히 MULTI, EXEC 만으로는 트랜잭션의 고립성을 보장할 수 없다!

이를 위해 WATCH 라는 명령어에 대해 알아보자.

 

Redis의 WATCH 명령어는 Optimistic Locking을 통해 동시성 이슈를 해결한다.
공유하고 있는 자원에 대하여 실제로 락을 걸어 다른 접근을 허용하지 않는 Pessimitic Locking 개념과 대조되는 Optimistic Locking 은 실제로 락을 걸지 않고 timestamp 나 checksum 같은 버전 정보를 이용한다. 데이터에 접근하고 있는 도중 다른 트랜잭션으로 인해 해당 데이터의 버전이 바뀌어 처음과 일치하지 않으면 문제 상황을 알려준다. 

 

WATCH 로 인하여 예외가 발생했을 때 트랜잭션 쿼리들을 실행하지 않는 DISCART 명령어 등을 통해 적절하게 처리하자.

 

최종적으로 변경된 코드는 다음과 같다.

 

@Service
@RequiredArgsConstructor
public class CartService {
    private final CartRedisRepository cartRedisRepository;    
    ...
       
    public Cart addItem(String id, CartItem cartItem) {

        final String key = String.format("cart:%s", id);
        // "_class", "id" 필드를 제외하고 "cartItemList[n]" 의 필드개수 6개로 나누어주면 상품개수
        int cartItemSize = (int)((redisTemplate.opsForHash().size(key) - DEFALUT_SIZE) / FIELD_SIZE);
        boolean hasKey = redisTemplate.hasKey(key);
        final Map<String, Object> map;

        if (!hasKey) {
            map = convertCartItemToMap(0,cartItem);
        } else {
            map = convertCartItemToMap(cartItemSize,cartItem);
        }

        redisTemplate.execute(new SessionCallback() {
                @Override
                public Object execute(RedisOperations redisOperations) throws DataAccessException {
                    try {
                        redisOperations.watch(key);
                        redisOperations.multi();
                        redisOperations.opsForHash().putAll(key,map);
                    } catch (Exception e) {
                        redisOperations.discard();
                        return null;
                    }
                    return redisOperations.exec();
                }
        });

        return getCart(id);
    }
...
}

 

 

마무리

 

참고

- Redis.io

- Spring Data Redis

- ACID Wiki

 

'Redis' 카테고리의 다른 글

Why Redis? (Redis vs Memcached)  (0) 2019.12.31