[Java/Spring] 재고 시스템으로 알아보는 동시성 이슈와 해결 방법

이하영·2023년 8월 12일
1

자바(JAVA)

목록 보기
1/1
post-thumbnail

인프런 재고시스템으로 알아보는 동시성이슈 해결방법 강의를 듣고 작성한 글입니다.

머뭄 프로젝트를 진행하면서 재고 관리 기능을 구현했고, 프로젝트가 끝나고 다시 확인하다가 실시간으로 동시에 재고를 수정할 경우 재고가 일치하지 않는 오류를 발견했습니다. 이 오류를 해결하려고 방법을 찾다가 인프런에서 제공하는 동시성 이슈에 관한 강의를 발견해 학습하게 되었습니다.

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

  • 간단한 재고 시스템을 만들어 보면서 동시성 이슈가 무엇이고, 동시성 이슈를 처리하는 방법들을 배울 수 있습니다.
  • 작업 환경 : Java, Spring, Mysql, Redis
  • 학습하면서 작성한 코드는 깃허브에 올렸습니다.
  • 학습 기간 : 23.08.10 ~ 23.09.15

동시성 이슈란?


동시성 이슈 해결 방법

간단한 재고 시스템을 만들어 보면서 해결 방법에 대해 알아보겠습니다.

간단한 재고 시스템 작성

재고 감소 로직을 작성한 자세한 내용은 깃허브에 있습니다.

테스트 케이스 작성

@Test
public void 재고감소(){
    stockService.decrease(1L, 1L);

    //100-1 = 99
    Stock stock = stockRepository.findById(1L).orElseThrow();

    assertEquals(99, stock.getQuantity());
}

테스트 케이스 요청이 1개씩 들어오는 상황에서는 문제 없이 구현됩니다.
그러면 요청이 동시에 여러 개 들어오면 어떻게 될까요...??

테스트 케이스 작성(동시에 100개 요청)

@Test
public void 동시에_100개의_요청() throws InterruptedException {
    int threadCount = 100;  //100개의 요청을 보냄
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for(int i=0; i<threadCount; i++){
        executorService.submit(() -> {
            try {
                stockService.decrease(1L, 1L);
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Stock stock = stockRepository.findById(1L).orElseThrow();
    //100-(1*100) = 0
    assertEquals(0, stock.getQuantity());
}
  • 동시에 여러 개의 요청을 보내야 하기 때문에 멀티 스레드를 사용해야 한다.
  • 멀티 스레드를 이용해야 하기 때문에 ExecutorService를 사용한다.
    • ExecutorService는 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API이다.
  • 100개의 요청이 끝날 때까지 기다려야 되므로 CountDownLatch를 활용한다.
    • CountDownLatch는 다른 스레드에서 수행중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스이다.

재고가 0개일 것이라고 예상했지만 실제로는 96개여서 오류가 발생합니다.

Why? 레이스 컨디션이 일어났기 때문

레이스 컨디션(Race Condition)이란?

  • 둘 이상의 스레드가 공유 데이터에 액세스할 수 있고 동시에 변경하려고 발생하는 문제를 말합니다.

Thread1이 데이터를 가져가서 갱신한 값을 Thread2가 가져가 갱신한다고 예상했지만,
실제로는 Thread1이 데이터를 가져가서 갱신하기 이전에 Thread2가 갱신하기 이전의 값을 가져가게 됩니다.
그리고 Thread1이 갱신을 하고 Thread2도 갱신을 하지만 둘다 같은 재고인 상태에서 1을 줄인 값을 갱신하기 때문에 갱신이 누락되게 됩니다.

이런 문제를 해결하기 위해서는 하나의 스레드가 작업을 완료하면 다른 스레드가 데이터에 접근할 수 있도록 하면 됩니다.

이제 문제를 해결할 수 있는 방법에 대해 알아봅시다.

1. Application Level

먼저 Java에서 지원하는 방법으로 문제를 해결해 볼 수 있습니다.
Java에서는 Synchronized 키워드 사용하여 손쉽게 한 개의 스레드만 접근이 가능하도록 할 수 있습니다.

synchronized를 메서드 선언부에 넣어주면 해당 메서드는 한 개의 스레드만 접근이 가능하게 됩니다.

@Transactional
//재고 감소 메서드
public synchronized void decrease(Long id, Long quantity){
    // Stock 조회
    Stock stock = stockRepository.findById(id).orElseThrow();

    // 재고를 감소
    stock.decrease(quantity);

    // 갱신된 값을 저장
    stockRepository.saveAndFlush(stock);
}

오류 발생

Spring의 Transactional 어노테이션의 동작 방식 때문에 오류가 납니다.

  • 우리가 만든 클래스를 래핑한 클래스를 새로 만들어서 실행하게 된다.
    1. 트랜잭션 시작
    2. 메서드 호출
    3. 메서드 실행이 종료됨
    4. 트랜잭션 종료
  • 트랜잭션 종료 시점에 데이터베이스 업데이트를 진행하게 되고 이 시점에서 문제가 발생하게 됩니다.
    실제 데이터베이스에 업데이트 되기 전에 다른 스레드가 실행되면서 갱신되기 전의 값을 가져가기 때문입니다.

오류 해결

  • @Transactional 어노테이션을 주석 처리하면 테스트를 통과하는 것을 확인할 수 있습니다.

문제점

  • synchronized는 하나의 프로세스 안에서만 보장이 됩니다.
  • 서버가 한 대일 때는 데이터의 접근을 서버 하나가 해서 괜찮겠지만, 서버가 여러 대 일 경우에는 데이터의 접근을 여러 대에서 할 수 있게 됩니다.

synchronized는 각 프로세스 안에서만 보장되기 때문에 결국은 여러 스레드에서 동시에 데이터에 접근이 가능하게 되고, 레이스 컨디션이 발생하게 됩니다.
실제 운영 중인 서비스는 대부분 두 대 이상의 서버를 사용하기 때문에 synchronized는 거의 사용하지 않습니다.


2. Database Lock

데이터베이스를 활용하여(데이터베이스가 제공하는 Lock을 이용) 데이터 정합성을 맞추는 여러 가지 방법이 있습니다.

Pessimistic Lock

  • 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
  • exclusive lock을 걸게 되고, 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없다.
  • 주의 : 데드락이 걸릴 수 있다.
  • 장점
    • 충돌이 빈번하게 일어날 경우 Optimistic Lock보다 성능이 좋을 수 있다.
    • Lock을 통해 update를 제어하기 때문에 데이터의 정합성이 보장된다.
  • 단점
    • 별도의 Lock을 잡기 때문에 성능 감소가 있을 수 있다.

🔎 Pessimistic Lock 적용 예제

  1. lock을 걸고 데이터를 가져오는 메서드 작성
public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimistic(Long id);
}
  • Spring Data JPA에서는 @Lock이라는 어노테이션을 통해 Pessimistic Lock을 구현할 수 있습니다.
  1. 재고 감소 로직 작성
@Transactional
public void decrease(Long id, Long quantity){
    //Pessimistic Lock을 활용해서 데이터를 가져옴
    Stock stock = stockRepository.findByIdWithPessimisticLock(id);

    //재고를 감소시킴
    stock.decrease(quantity);

    //데이터를 저장
    stockRepository.save(stock);
}
  • PessimisticLockStockService 클래스를 생성하고 재고 감소 로직을 작성합니다.
  1. 테스트 실행
  • 테스크가 정상적으로 완료되었습니다.
  • 쿼리를 보면 for update 문구가 있는데, 이 부분이 락을 걸고 데이터를 가져오는 것입니다.

Optimistic Lock

  • 버전을 이용함으로써 정합성을 맞추는 방법
  • 먼저 데이터를 읽고 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하여 업데이트 한다.
  • 내가 읽은 버전에서 수정사항이 생겼을 때, application에서 다시 읽은 후 작업을 수행해야 한다.
  • 장점
    • 별도의 Lock을 잡지 않아 Pessimistic Lock보다 성능상으로 이점이 있습니다.
  • 단점
    • update가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 하는 번거로움이 있습니다.

🔎 Optimistic Lock 적용 예제

  1. 버전 컬럼 추가
@Version
private Long version;
  • Optimistic Lock을 사용하기 위해서는 @Version 어노테이션을 이용하여 버전 컬럼을 추가해야 합니다.
  • 주의! javax.persistence 패키지에 있는 @Version 어노테이션을 사용해야 합니다!
  1. 쿼리 작성
public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithOptimisticLock(Long id);
}
  1. 재고 감소 로직 작성
@Transactional
public void decrease(Long id, Long quantity){
    //Optimistic Lock을 이용해 데이터를 가져옴
    Stock stock = stockRepository.findByIdWithOptimisticLock(id);

    //수량을 감소시킴
    stock.decrease(quantity);

    //데이터를 저장
    stockRepository.save(stock);
}
  • OptimisticLockStockService 클래스를 생성하고 재고 감소 로직을 작성합니다.
  1. 퍼사드 생성
@Component
public class OptimisticLockStockFacade {

    private final OptimisticLockStockService optimisticLockStockService;

    public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService){
        this.optimisticLockStockService = optimisticLockStockService;
    }

    public void decrease(Long id, Long quantity) throws InterruptedException{
        while (true){  //업데이트를 실패했을 때 재시도를 해야함
            try {
                optimisticLockStockService.decrease(id, quantity);

                //정상적으로 업데이트 되면 빠져나감
                break;
            } catch (Exception e) {
                //수량 감소 실패 시, 재시도
                Thread.sleep(50);
            }
        }
    }
}
  • Optimistic Lock은 실패했을 경우 재시도를 해야 하기 때문에 퍼사드를 생성해주어야 합니다.
  1. 테스트 실행
  • 테스트가 정상적으로 완료되었습니다.

Named Lock

  • 이름을 가진 metadata locking이다.
  • 이름을 가진 lock을 획득하고 해제할 때까지 다른 세션은 이 lock을 획득할 수 없도록 한다.
  • 주의 : 트랜잭션이 종료될 때 lock이 자동으로 해제되지 않는다. 별도의 명령어로 해제를 수행해 주거나 선점 시간이 끝나야 해제된다.
  • Mysql에서는 get_lock 명령어를 통해 획득할 수 있고 release_lock 명령어를 통해 해제할 수 있다.
  • 주로 분산 락을 구현할 때 사용한다.
  • Time out을 손쉽게 구현할 수 있다.
  • 데이터 삽입 시 정합성을 맞춰야 할 경우 사용할 수 있다.
  • 트랜잭션 종료시 락 해제 세션 관리를 잘 해주어야 하기 때문에 주의해서 사용해야 하고 실제 사용 시 구현 방법이 복잡할 수 있다.

🔎 Named Lock 적용 예제

  1. lock 관련 기능 작성할 레포지토리 생성, 명령어 작성
public interface LockRepository extends JpaRepository<Stock, Long> {
    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    void getLock(String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(String key);
}
  • 편의성을 위해 JPA의 native query 기능을 활용하여 구현하고 동일한 데이터 소스를 사용할 것입니다.
    (실제로 사용할 때는 커넥션 풀이 부족해지는 현상으로 다른 서비스에도 영향을 끼칠 수 있기 때문에 데이터 소스를 분리해서 사용하는 것을 추천함)
  1. 퍼사드 생성
@Component
public class NamedLockStockFacade {

    private final LockRepository lockRepository;
    private final StockService stockService;

    public NamedLockStockFacade(LockRepository lockRepository, StockService stockService){
        this.lockRepository = lockRepository;
        this.stockService = stockService;
    }

    //decrease 메서드
    @Transactional
    public void decrease(Long id, Long quantity){
        try {
            //lock 획득
            lockRepository.getLock(id.toString());
            //재고 감소
            stockService.decrease(id, quantity);
        }finally {
            //모든 로직이 종료되었을 때, lock 해제
            lockRepository.releaseLock(id.toString());
        }
    }
}
  • 실제 로직 전후로 Lock 획득 해제를 해주어야 하기 때문에 퍼사드를 생성합니다.
  1. StockService propagation 수정
@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void decrease(Long id, Long quantity){
    // Stock 조회
    Stock stock = stockRepository.findById(id).orElseThrow();

    // 재고를 감소
    stock.decrease(quantity);

    // 갱신된 값을 저장
    stockRepository.saveAndFlush(stock);
}
  • 부모의 트랜잭션과 별도로 실행되어야 하기 때문에 propagation을 변경해주어야 합니다.
  1. connection pool size 수정
  • 같은 데이터 소스를 사용하기 위해 connection pool size를 변경합니다.
    maximum-pool-size: 40
  1. 테스트 실행
  • 테스트가 완료된 것을 확인할 수 있습니다.

3. Redis Distributed Lock

동시성 이슈를 해결하는 대표적인 라이브러리 Lettuce Redisson
작업 환경을 세팅하고 Lettuce와 Redisson을 활용하여 동시성 이슈를 해결해 보겠습니다.

Lettuce

  • setnx 명령어를 활용하여 분산락 구현 가능

    • setnx : SET if Not eXist의 줄임말로, 특정 key에 value 값이 존재하지 않을 경우에 값을 설정(set)하는 명령어
  • spin lock 방식

    • lock을 획득하려는 스레드가 lock을 사용할 수 있는지 반복적으로 확인하면서 lock 획득을 시도하는 방식
    • retry 로직을 개발자가 작성해주어야 합니다.
  • Spring data redis를 이용하면 lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 됩니다.

  • Lettuce를 활용해 재고감소 로직 작성하기

    • MySQL Named Lock과 비슷함
    • 세션 관리에 신경을 쓰지 않아도 됨
@Component
public class RedisLockRepository {

    private RedisTemplate<String, String> redisTemplate;

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

    //로직 실행 전 키와 setnx 명령어를 활용해 lock
    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    //로직 실행 후 unlock 메소드를 통해 lock 해제
    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}
@Component
public class LettuceLockStockFacade {

    private RedisLockRepository redisLockRepository;

    private StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
        this.redisLockRepository = redisLockRepository;
        this.stockService = stockService;
    }

    public void decrease(Long key, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(key)) {
            Thread.sleep(100);  //lock 획득 시도 실패 시
        }

        try {
            stockService.decrease(key, quantity);  //lock 획득 성공 시
        } finally {
            redisLockRepository.unlock(key);
        }
    }
}
  • 테스트 케이스 작성하고 테스트 실행

  • 구현이 간단하다는 장점이 있지만, spin lock 방식이므로 redis에 부하를 줄 수 있습니다.
    그렇기 때문에 Thread.sleep(100);으로 lock 획득 재시도 간 텀을 두어야 합니다.

Redisson

  • pub-sub 기반으로 Lock 구현 제공

    • 채널 하나를 만들고 lock을 점유중인 스레드가 lock 획득하려고 대기하는 스레드에게 해제를 알려주면, 안내를 받은 스레드가 lock 획득을 시도하는 방식
  • lock 획득 재시도를 기본으로 제공

  • Redisson를 활용해 재고감소 로직 작성하기

    • Redisson 라이브러리 추가하기
    • Redisson은 lock 관련 class를 라이브러리에서 제공해주기 때문에 별도의 repository를 작성하지 않아도 됩니다.
public void decrease(Long id, Long quantity){
    //lock 객체 가져오기
    RLock lock = redissonClient.getLock(id.toString());

    try {
        boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

        //lock 획득 실패 시
        if(!available){
            System.out.println("lock 획득 실패");
            return;
        }

        //lock 획득 성공 시
        stockService.decrease(id,quantity);
    }catch (InterruptedException e){
        throw new RuntimeException(e);
    }finally {
        lock.unlock();
    }
}
  • 테스트 케이스 작성하고 테스트 실행

  • redis 부하를 줄여준다는 장점이 있지만 구현이 복잡하고 별도의 라이브러리를 이용해야하는 단점이 있습니다.


실무에서는?

  • 재시도가 필요한 경우에는 redisson 활용
  • 재시도가 필요하지 않은 경우에는 lettuce 활용

Mysql과 Redis 비교

  • Mysql

    • 이미 Mysql을 사용 중이면 별도의 비용 없이 사용 가능함
    • 어느 정도의 트래픽까지는 문제없이 활용 가능
    • Redis 보다는 성능이 좋지 않음
  • Redis

    • 사용 중인 Redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 발생함
    • Mysql 보다 성능이 좋음
profile
안녕하세요, 웹 개발자 이하영입니다!

0개의 댓글