NDM

재고 처리 로직 동시성 이슈 해결 일지 2 본문

[프로젝트] 타임딜 API 서버

재고 처리 로직 동시성 이슈 해결 일지 2

ndm.jr 2023. 6. 5. 17:40

동시성을 해결하기 위해 Redis를 선택하게 된 생각의 과정은 앞선 포스팅에서 정리하였습니다.

 

https://ndm-tech.tistory.com/34

 

4. 재고 처리 로직 동시성 이슈 해결 일지

배달 어플을 구현하는 SlowDelivery 프로젝트에서 재고를 처리하는데 동시성 이슈가 있었습니다 더보기 하지만 사실 재고 도메인은 필요가 없었는지도 모릅니다. 일반적인 쇼핑몰과 다르게 배달

ndm-tech.tistory.com

 

이번 포스팅에서는 Redis를 어떻게 사용하여 동시성을 해결하였는지 알아보겠습니다.

 


# Redis Transaction

 

Redis 트랜잭션은 다른 DB와 달리 Rollback 개념이 없으며,

기본적으로 SpringBoot + Redis를 사용하였을 경우, Redis는 @Transactional의 영향을 받지 못합니다.

 

이유는 Spring의 트랜잭션을 관리하는 핵심 컴포넌트인 PlatformTransactionManager에서 Redis를 위한 TransactionManager를 지원하지 않기 떄문입니다.

 

수동으로 PlatformTransactionManager를 Bean으로 등록한 뒤 DataSourceTransactionManager나 JpaTransactionManager로 함께 사용하는 방법이 있으나, 저의 경우는 RedisTemplate를 사용하는 방법을 선택했습니다.

 

기본적으로 Rollback 개념이 없기에 @Transactional로 함께 뭉뚱그려 사용하고 싶지 않아 따로 직접 처리해주고 싶었기 떄문입니다.

 

RedisTemplate을 통해 해결하는 경우, execute()와 SessionCallBack()을 통해 해결이 가능합니다.

 

redisTemplate.execute(new SessionCallback<List<Object>>() {
    @Override
    public Object execute(RedisOperations operations)
        throws DataAccessException {
        try {
            operations.multi()
            operations.watch()
            
            // 로직
            
            return operations.exec()
        } catch (Exception exception) {
	    operations.discard()
        }
    }
});

 

  • multi() : Transaction 시작 커맨드. multi() 이후의 커맨드는 실행되지 않고 큐에 쌓이게 됩니다.
  • watch() : Optimistic Lock 기반으로 작동합니다. multi - exec 만으로는 트랜잭션의 고립성을 보장할 수 없기에 함께 사용합니다.
  • discard() : 큐에 쌓인 커맨드를 폐기합니다.
  • exec() : 큐에 쌓아놓은 커맨드를 실행합니다.

 

하지만 저의 경우는 Test에서 ExecutorService를 이용해 멀티스레드 환경에서 재고 감소 테스트를 진행할 때, 원하는 결과를 얻을 수 없었습니다. 이유는 watch()의 동작 원리가 다음과 같기 떄문입니다.

 

  • 특정 키를 감시(watch)한 시점 이후로 exec(), discard(), unwatch()가 나올떄 까지 감시모드를 유지한다.
  • 만약 exec() 시점에 Queue에 쌓인 커맨드를 실행하는 도중 watch()를 통해 감시하던 키에 변경사항이 있었다면, 1번의 EXEC 또는 Transaction 아닌 다른 커맨드만 허용한다.

재고를 처리하기 위해 다수의 스레드가 경합을 벌일 경우, 단순 경합이 일어났다는 이유로 모든 재고처리가 실패할 위험이 있었습니다. 때문에 Watch()를 이용한 Lock을 거는 것 보다, 직접 Lock을 간단히 만들어보기로 했습니다.

 

🙋그럼 watch()를 이용해서 낙관적 락을 걸 일이 있나 ?

낙관적 락은 롤백 및 예외가 일어났을 시에 로직을 일일히 커스텀해주어야 한다는 특징이 있습니다. 물론, 저도 스레드 경합이 벌어질 시 모든 변경사항을 취소하는 watch()에 대해서 이걸 쓸일이 있을까? 생각해보았습니다. 다음과 같은 경우라면 기획 의도에 따라 다르겠지만, 사용할 수 있을 것 같습니다.

 

  • 배달원이 [배달 대기] 상태의 배달 요청 목록들을 확인 후, 가까운 배달지를 골라 내가 배달하겠다고 신청합니다.
  • 여기서 동시성 문제가 발생한다고 가정합니다. 거의 똑같은 순간에 2명의 배달원이 같은 주문을 신청한 경우입니다.
  • watch()를 이용해 주문 건을 감시하다가, 먼저 들어온 배달원에게 주문을 할당해줍니다.
  • 동시에 요청이 들어온 경우, 첫 배달원에게 할당해주고 나머지 배달원의 요청은 취소됩니다.

이러한 경우라면 충분히 watch()를 이용해 구현해도 될 것 같습니다.


# Lettuce - setIfAbsent

 

Spring에서 기본으로 제공하는 Redis Client는 Lettuce라는 Client입니다.

Lettuce는 Lock에 관련된 추상화된 메서드를 제공하지 않아 Redis의 setIfAbsent 커맨드를 이용해 직접 락을 구현해야 합니다.

 

@Component
public class RedisLockRepository {
    private RedisTemplate<String, String> redisTemplate;

    public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    public String generateKey(Long key) {
        return key.toString();
    }
}

 

다음과 같이 간단하게 Lock 메서드를 만들 수 있습니다. 

하지만 또 문제를 만났습니다. 아까 위에서 만든 execute() + SessionCallBack()을 이용해 Lock을 걸어보겠습니다.

 

redisTemplate.execute(new SessionCallback<List<Object>>() {
    @Override
    public Object execute(RedisOperations operations)
        throws DataAccessException {
        try {
            operations.multi()
            lock(key);
            
            // 로직
            
            return operations.exec()
        } catch (Exception exception) {
	    operations.discard()
        }
    }
});

 

이런식으로 구현하면 될 것이라고 생각했습니다. 하지만 Test는 여전히 실패했고, 이유는 다음과 같았습니다.

 

https://docs.spring.io/spring-data/redis/docs/current/api/org/springframework/data/redis/core/ValueOperations.html#setIfAbsent(K,V)

 

Transaction과 함께 setifAbsent를 사용할 경우 return값이 null입니다. 원하는 결과를 얻을 수 없습니다.

 

"선착순 쿠폰 이벤트" 같이 1명당 1개의 재고만 감소시키는 경우, 해당 로직으로 해결을 할 수 있습니다. 왜냐하면 굳이 multi()를 이용해 커맨드를 모았다가 실행시킬 필요 없이 재고를 감소시키고, 롤백로직만 따로 만들어주면 되기 때문입니다.

하지만 "주문 상품 재고 감소" 같은 경우, 한 주문에 여러개의 상품이 담길 수 있으며, 하나의 상품이라도 재고 예외가 난다면 주문은 실패하고 모든 주문상품의 재고를 원복시켜야 합니다. 때문에 multi()와 함께 사용할 수 밖에 없었고, setifAbsent를 이용한 Lock은 불가능하다고 판단하였습니다.

 

* setifAbsent를 이용한 spin-lock의 경우, Redis 부하를 가중시킨다는 단점도 있습니다.

 


# Redisson

 

Redisson은 Lock에 관련된 추상화된 메서드를 제공하는 Redis Client 라이브러리입니다.

메서드를 제공해 사용도 간편하며, spin-lock 형태의 lettuce와는 달리, distributed lock 형태로 Redis 부하도 적다는 장점이 있습니다.

 

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable{

        RLock lock = redissonClient.getLock(distributedLock.lockName());
        boolean isLocked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.unit());

        try {
            if(!isLocked) {
                throw new IllegalStateException("Lock 획득 실패");
            }

            log.info("thread : {} signature : {} 락 획득 성공", Thread.currentThread(), joinPoint.getSignature());
            return joinPoint.proceed();
        } finally {
            if(lock.isLocked() && lock.isHeldByCurrentThread()) lock.unlock();
            log.info("thread : {} signature : {} 락 반납 완료", Thread.currentThread(), joinPoint.getSignature());
        }
    }
}

 

다음과 같이 @DistributedLock 어노테이션을 커스텀해 AOP 형태로 구현해 사용할 수 있습니다.

 


# 출처

 

https://wildeveloperetrain.tistory.com/137

 

Redis 동시성 처리를 위한 Transaction 사용 (MULTI, EXEC, DISCARD, WATCH)

Single-thread 기반의 Redis 동시성? Redis는 싱글 스레드(Single-thread) 기반으로 데이터를 처리합니다. 싱글 스레드를 기반으로 동작하지만 여러 명의 클라이언트 요청에 동시에 응답하는 동시성도 가지

wildeveloperetrain.tistory.com

https://velog.io/@cmsskkk/redis-transaction-spring-and-lua-pipeline

 

Redis Transaction, Lua script, Pipeline 개념 과 SpringDataRedis에서의 사용법

Spring에서 Redis를 연동하고 활용하는 연습을 진행 중 입니다.Spirng boot에서 Redis를 연동하여 사용하는 방법과 원리에 대해서 이해해보고자 하는 글입니다.Redis는 local 환경의 Docker 컨테이너를 활용

velog.io

https://github.com/jerry-ljh/time-attack-coupon-server

 

GitHub - jerry-ljh/time-attack-coupon-server: Redis기반 선착순 쿠폰 발급 서버

Redis기반 선착순 쿠폰 발급 서버. Contribute to jerry-ljh/time-attack-coupon-server development by creating an account on GitHub.

github.com

https://www.inflearn.com/course/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C

 

재고시스템으로 알아보는 동시성이슈 해결방법 - 인프런 | 강의

동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., - 강의 소개 | 인프런

www.inflearn.com