본 게시글은 인프런
최사용님의 강의 '재고시스템으로 알아보는 동시성이슈 해결방법' 을 참고하여 작성하였습니다.
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/dashboard
아래는 상품을 주문했을때 재고가 감소하는 서비스 코드이다.
@Transactional
public void decrease(Long id, Long quantity) {
//Stock조회
//재고를 감소
Stock stock = stockRepository.findById(id).orElseThrow(EntityNotFoundException::new);
stock.decrease(quantity); // this.quantity -= quantity
}
넘어온 id값으로 entity를 조회한 후
재고를 quantity만큼 줄인 후 JPA의 더티체킹에 의해 트랜잭션이 종료되면서 값이 update 된다.
만약, 요청이 동시에 여러개가 들어오면 어떻게 될까?
@BeforeEach
void before() {
Stock stock = Stock.of(1L, 100L); // 1번 상품의 수량 100개
stockRepository.saveAndFlush(stock);
}
@Test
@DisplayName("동시에 100개의 요청")
void multiThread() throws InterruptedException {
int threadCount = 100;
// 32개의 고정된 쓰레드풀을 생성
ExecutorService executorService = Executors.newFixedThreadPool(32);
// CountDownLatch : 다른 스레드에서 수행중인 작업이 완료될 때 까지
// 대기할 수 있도록 만들어주는 클래스
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(EntityNotFoundException::new);
// excepted : 100 - (1 * 100) = 0
assertThat(stock.getQuantity()).isEqualTo(0L);
/*
expected: 0L
but was: 90L
*/
}
의도와는 다르게 테스트가 실패하는 모습.
어떤 이유인고 하니
1번 Thread가 현재 재고를 조회한 후 트랜잭션이 종료되면서 값을 변경해주기 전에
2번 Thread가 기존의 재고를 조회해서 값을 변경해주면서 부적합한 데이터가 저장되는 것이 문제였다.
무슨말인고 하니,
*[스레드명] 쿼리 (남은 재고) 으로 표현하였다.
------------------------
[1번 Thread] SELECT (100)
[1번 Thread] UPDATE (99)
---
[2번 Thread] SELECT (99)
[2번 Thread] UPDATE (98)
....
이렇게 순차적으로 진행되는 것을 예상했지만,
실제 내부동작은 아래처럼
[1번 Thread] SELECT (100)
[2번 Thread] UPDATE (99)
[1번 Thread] SELECT (100)
[2번 Thread] UPDATE (99)
...
Thread1이 값을 갱신하기 이전에 Thread2가 값을 조회하여, 갱신하여 의도하지 않은 값이 저장된다.
이런 두개이상의 서로 다른 스레드가 공유데이터에 접근하여 동시에 데이터를 변경하면서 발생하는 문제를 '레이스컨디션' 이라고 한다.
문제를 해결하려면 어떻게 해줘야할까?
참고자료가 저작권이 있는 자료인만큼 간단하게 2개의 방법만 공개해보려고 한다.
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow(EntityNotFoundException::new);
stock.decrease(quantity);
}
synchronized는 메소드 선언부에 붙여주면 해당 메소드는 한개의 스레드만 접근 가능하게 해주는 자바의 기능이다.
이제 이전에 실패한 테스트코드를 다시 돌려본다.

하지만 예상대로 동작하지 않는모습..!
이유가 무엇인고 하니 문제는 스프링의 @Transactional 어노테이션에 있었다.
스프링의 @Transactional은 우리가 만든 클래스를 매핑한 클래스를 새로 만들어서 실행한다고 한다.
즉, Stockservice를 필드로 가지는 새로운 클래스를 만들어서 그 내부에서
트랜잭션을 시작 -> 메소드 실행 -> 트랜잭션종료
과정을 진행하는데
자바의 synchronized와는 무관하게
이 클래스는 트랜잭션 종료전에 다른 스레드가 메소드가 호출할 수 있게 되어있다.
이렇게 되면 다른 스레드는 갱신되기 전의 값을 가져가서 이전과 동일한 문제가 발생하게 되는 것이다.
//@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow(EntityNotFoundException::new);
stock.decrease(quantity);
}
@Transactional 어노테이션을 주석처리하니 테스트가 성공하는 모습을 볼 수 있다.

의문이 든다.
@Transcational 어노테이션을 주석처리하는게 맞나..?
java의 synchronized는 하나의 프로세스 안에서만 보장이 된다.
서버가 한대일때는 데이터의 접근을 한개만 하기 때문에 큰 문제가 없으나,
서버가 여려대일 경우에 데이터의 접근을 여러곳에서 할 수 있게 된다.
DB의 Lock에 대한 방법과 전략은 여러가지가 있겠지만,
여기서 편의상 비관적잠금(PessimisticLock)을 이용한 예제를 소개해보려고 한다.
비관적 잠금에 간단하게 알아보자면,
Thread1이 데이터에 Lock을 걸고 데이터에 접근한다.
그 후 Thread2가 데이터 접근을 시도한다.
이경우 Thread1이 데이터를 점유중이므로 Thread2는 Thread1의 작업이 종료될 때 까지 대기한다.
데이터에 대한 접근권한이 한 개 쓰레드에 대해서만 제한됨으로 데이터의 정합성이 보장되게 된다.
이를 자바코드로 구현해보자.
public interface StockRepository extends JpaRepository<Stock,Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
}
JPA에서는 @Lock이라는 어노테이션을 통해서 Pessimistic Lock을 구현할 수 있다.
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
}
* 변경된 서비스코드. repository에서 호출하는 메소드를 변경해주었다.
테스트결과를 살펴본다.

정상적으로 테스트 코드가 성공하는 모습
하지만, 별도의 Lock을 설정하기 때문에 성능적인 문제가 발생할 수 있다.
하지만 위 코드는 SpringBoot 버전별로 호환이 잘 되는버전과 그렇지 않은 버전이 있는것처럼 보인다.
나는 SpringBoot 3.1.3 버전으로 진행하다가 막혀서 무지하게 삽질 한 후, 결국 SpringBoot 2.7.0 버전으로 마이그레이션 한 후 작업을 진행하였다..
* SpirngBoot 버전에 따라 정상 진행이 되지 않는다는 질문이 제법 있는모습
*/
동시성 이슈를 다뤄야 할 땐 SpringBoot 2.7.0을 사용하자
동시성 이슈에 대해서 처음으로 한번 다뤄보았다.
여기에 소개된 방법 외에도 낙관적검증, NamedLock등의 방법이 있고,
자바코드 외에 DBMS에서 해당 이슈를 처리하는 방법도 있을것 같다는 생각이 든다.
특히나 데이터베이스를 선점에서 Locking하는경우, 다른 Thread의 데이터 변경을 막음으로써 성능문제가 발생할 가능성이 굉장히 높아보인다.
동시성이슈는 해당 문제들 해결할 수 있냐, 없냐 만큼이나
어떻게 해야 성능저하를 최소한으로 가지면서 처리할 수 있느냐,
과연 여기서 동시성이슈까지 고려해야하는가를 판단하는 능력이 매우 중요한 요소로 꼽힐 것 같다는 느낌이 든다.
아직도 배울게 많다!
배울게 많다는건 성장할 여지가 많다는 것이니
기쁜마음으로 한걸음 한걸음 성장해 가고싶다.