본문 바로가기
스프링 부트

[스프링] 재고 시스템으로 알아보는 동시성 문제 해결 방법

by illlilillil 2022. 9. 2.

아래 코드를 보면 재고 100개의 요청에서 100개의 재고를 하나씩 감소시켰기 때문에 일반적으로 0이 나오는 것을 예상한다.

그러나 실제로는 race condition이 발생했기 때문에 예상한 결과를 받을 수 없다.

@Test
  public void 동시에_100개_요청() throws InterruptedException {
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
      executorService.submit(() -> {
        try {
          stockService.decrease(1L, 1L);
        } finally {
          countDownLatch.countDown();
        }
      });
    }
    countDownLatch.await();
    Stock stock = stockRepository.findById(1L).orElseThrow();
    Assertions.assertThat(stock.getQuantity()).isEqualTo(0L);
  }

이 문제 해결을 위해선 하나의 쓰레드가 완료된 후에 데이터에 접근할 수 있도록 하면 된다.

 

3가지의 분류로 나눠 해결 방법을 제시할 수 있다.

1. 애플리케이션 단에서의 해결 -> 스프링 프레임워크를 이용하지 않고 순수 자바를 이용한다.

2. 스프링에서 제공하는 데이터베이스 LOCK을 활용한 해결

3. Redis를 활용한 해결 -> 레디스는 싱글 스레드 기반이기 때문에 가능한 것으로 보여진다.

 

 

애플리케이션단에서 해결

자바에서 제공하는 synchronized를 활용해 해결하려고 했으나 테스트 케이스가 여전히 실패한다.

문제는 스프링에서 제공하는 @Transactinal의 특별한 동작 방식때문이다.

트랜잭션 종료 시점에 데이터베이스에 업데이트를 하기전에 다른 쓰레드가 decrease를 호출할 수 있다.

아직 갱신되지 않은 데이터를 다른 쓰레드가 가져갔기에 데이터 정합성이 맞지 않는 문제가 발생한다.

 

synchronized는 각 프로세스 안에서만 동작이 되기에 여러 쓰레드에서의 데이터 접근은 막지 못해 Race Condition이 발생한다.

실제 운용 서버는 한 대만 운용하지 않기 때문에 synchronized는 발생하는 문제를 해결할 수 없다.

@Transactional
public synchronized void decrease(Long id, Long quantity) {
  Stock stock = stockRepository.findById(id).orElseThrow();

  stock.decrease(quantity);

  stockRepository.saveAndFlush(stock);
}

 

+ 추가 내용

강의에는 없지만 스프링에서 제공하는 격리 수준을 활용해서도 해결해볼 수 있지 않을까 싶어 테스트를 해보았다.

현재 코드는 transactional이 먼저 우선적으로 실행되고 synchronized가 실행되기 때문에 반드시 synchronized가 있어야 하는 문제가 있다. 쓰레드 차원에서 먼저 막고 트랜잭션이 실행되는 방향으로 가야 두 개를 함께 이용할 수 있는 것으로 보인다.

변경 전 - 트랜잭션 적용 후 동기화 실행

@Transactional(isolation = Isolation.SERIALIZABLE)
public synchronized void decrease(Long id, Long quantity) {
  Stock stock = stockRepository.findById(id).orElseThrow();

  stock.decrease(quantity);

  stockRepository.saveAndFlush(stock);
}

변경 후 - 동기화 적용 후 트랜잭션 실행

public synchronized void synchronizedDecrease(Long id, Long quantity) {
  transactionalDecrease(id, quantity);
}

@Transactional
public void transactionalDecrease(Long id, Long quantity) {
  Stock stock = stockRepository.findById(id).orElseThrow();

  stock.decrease(quantity);

  stockRepository.saveAndFlush(stock);
}

 

여전한 문제점

강의에 나왔던 것처럼 synchronized를 활용하더라도 여러 대의 서버를 구동할땐 동기화가 되지 않아 해결할 수 없다.

굳이 쓸 필요는 없어보이고, 스프링에서 제공해주는 락을 사용하면 좋을거 같다.

 

 

데이터베이스에서 해결

  1. Pessimistic Lock (exclusive lock) - 타 트랜잭션이 특정 row의 lock을 얻는 것을 방지한다. 특정 row에 대한 update, delete를 할 수 없다. read는 가능하다.
  2. Optimistic Lock - 락을 걸지 않고 충돌이 일어났을때 해결한다. version 컬럼을 만들어 해결한다.
  3. named Lock 활용하기

Pessimistic Lock 특징

충돌이 빈번하게 일어난다면 낙관적 락보다 성능이 좋을 수 있다.

락을 통해 업데이트를 제어하기에 데이터 정합성이 어느정도 보장된다.

락으로 인한 성능 저하는 있을 수 있다.

 

Optimistic Lock 특징

version을 통해 정합성을 맞춘다.

충돌이 빈번하지 않다면 성능이 뛰어나다.

충돌이 일어났을 경우 프로그래머가 다시 요청하는 로직을 구성해야 한다.

 

Named Lock 특징

Lock 레포지토리를 만들어 관리해야 한다. 그렇기에 데이터소스에 연결하는 커넥션이 의도치 않게 증가하여 커넥션이 모자랄 수 있다. 따라서 실무에선 도메인 DB와는 분리된 데이터 소스를 사용하는 것을 권장한다.

강의에선 커넥션 사이즈를 40으로 두라고 했는데 이러면 커넥션이 부족해 HikariCP Dead lock이 발생하게 된다.

아래를 참고하여 hikari cp connection의 최대 연결 사이즈를 조절했다.

현재 쓰레드를 100개 생성하는데 최소 100개는 필요해보인다.

 

HIKARI CP에서 권장하는 SIZE 공식

  • Tn : 전체 Thread 갯수
  • Cm : 하나의 Task에서 동시에 필요한 Connection 수

위 공식은 최소한의 POOL SIZE를 말한다. 개 중 하나의 스레드에서 오류가 발생해 예외를 던지며 오랜 시간이 걸려 커넥션을 반환하지 않을수도 있다.

따라서 우형에서는 조금 더 기준을 높여 권고를 한다.

현재 프로젝트는 스레드가 100개이기에 넉넉하게 150을 주었더니 잘 실행이 된다.

개인적으로 최소 스레드 개수 만큼 이상은 주어야 안정적인 커넥션이 가능한 것으로 보인다.

 

 

레디스를 활용한 해결

두 가지 방법이 있다.

1. Lettuce - redis 기본 패키지에 포함

2. Redisson - 오픈 소스 라이브러리

 

Lettuce

구현이 간단하다.

mysql의 NamedLock과 유사하지만 레디스를 활용하기에 커넥션 관리를 신경쓰지 않아도 된다.

스핀 락 방식이기 때문에 계속해서 락의 획득을 시도하려고 한다. 그렇기에 레디스에 그만큼의 부하가 전해진다.

만약 300ms가 걸리는 동기화된 작업에 동시에 100개의 요청이 왔다고 가정해보겠습니다. (분산 락이므로 서버의 대수는 무관합니다.)
처음으로 락을 획득하는데 성공한 1개의 요청을 제외하고, 나머지 99개의 요청은 작업이 완료되는 300ms 동안 무려 레디스에 594회의 락 획득 요청을 하게 됩니다. 즉 1초 동안 약 2000회라는 많은 요청을 레디스에 보내게 됩니다. 출처 - 레디스를 활용한 분산 락

 

Redisson

락 획득 재시도를 기본 제공한다. 

pub-sub 방식이기 때문에 redis에 부하가 덜 간다.

 

실무에선?

재시도가 필요하지 않은 경우 - Lettuce 사용

필요한 경우 - redisson 사용

재시도가 필요하지 않은 경우는 요청이 많지 않은 경우라고 생각되어 진다. 그만큼 재시도할 가능성이 적기 때문이다.

초점을 어디에 두느냐가 중요할 것 같다. 개인적으로는 사용하게 된다면 redisson을 우선적으로 고려할거 같다.

기술 익히는 시간이 많지 들 것 같지도 않고, 생각하기 어려운 스핀 락으로 인한 부하 가능성을 줄이는게 좋지 않을까 싶다.

 

참고 자료

 

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

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

www.inflearn.com

 

HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그

{{item.name}} 안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할

techblog.woowahan.com

 

레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현

레디스를 활용한 분산 락에 대해 알아봅니다. 그리고 성능을 높이고 일관성을 보장하는 방법에 대해 알아봅니다.

hyperconnect.github.io

 

댓글