
@Test
public void requestWith100ThreadsSimultaneously() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for(int i = 0; i < threadCount; i++){
executorService.submit(() -> {
try{
this.stockService.decrease(1L , 1);
}finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = this.stockRepository.findStockByProductId(1L);
Assertions.assertThat(stock.getQuantity()).isEqualTo(0);
}
현재 Stock 이라는 Entity 가 존재하고, 이 stock 에는 id 외에 물품의 고유번호인 productId , 물품의 재고량인 quantity 필드가 존재한다. 그리고 우리는 this.stockService.decrease(Long productId , Integer quantity) 함수를 통해 해당 물품의 재고를 quantity 개 만큼 감소시키려 한다.
동시성 문제를 발생시켜야 하는 만큼, 멀티스레드 환경을 만들어야 한다. 테스트코드에서 해당 환경을 만드는 방법은 Executors 클래스 를 이용하면 된다.


즉 위 코드는 , 32개의 스레드를 갖는 스레드 풀을 생성하고 해당 스레드풀의 스레드들이 threadCount 만큼의 작업을 수행하게 한다. 그리고 현재 이 작업을 시작한 메인 스레드는 count 가 100 에서 시작하여 0 이 될때까지 대기 하다가 0 이 되면, 해당 stock 을 불러오고 남은 갯수을 확인한다.
우리가 예상하는건 100 번의 runnable 작업이 수행되고, 이 100 번의 작업에서 모두 stock 의 갯수를 1개씩 줄여서 결론적으로 0개가 되는것을 예상한다.
하지만, 결과는 0 이 아닌 다른 수가 나온다. (매번 실행할 때 마다 결과가 다르지만, 대충 80 대 정도) 왜 그럴까? 먼저, SQL 로그와 서비스에서의 로그를 한번 같이 보자!

그리고 현재 stockService 의 decrease 메서드는 다음과 같다.
@Transactional
public void decrease(Long productId , Integer quantity){
Stock stock = this.stockRepository.findStockByProductId(productId);
log.info("Current quantity = {}" , stock.getQuantity());
stock.decrease(quantity);
this.stockRepository.saveAndFlush(stock);
}
Stock 을 productId 로 찾고, 해당 stock 의 갯수를 log 로 남긴다. 이후, stock 을 1개 줄이고 이를 데이터베이스에 반영한다.
그런데 로그를 보면, 뭔가 이상하다. 우리는 순차적으로 100 , 99 , 98 ,,,,, 이렇게 한개씩 줄어들어서 결국 마지막에 0 이 되는것을 예상했지만 각 스레드에서 조회한 stock 의 갯수가 100 으로 동일하다.
==이는 race condition 때문에 발생한다.
==두 개의 이상의 스레드가 하나의 공유자원에 접근 할 수 있고, 이 자원을 동시에 변경하려 할 때 발생하는 문제이다.
위에서 우리는 "스레드 1 이 stock 갯수를 확인 -> 스레드 1이 stock 을 1 감소 -> 스레드 2가 stock 갯수를 확인 -> 스레드 2가 stock 을 2 감소" 의 순서를 예상했다. 하지만, 예상과 달리 "스레드 1이 stock 갯수 확인 -> 스레드 2의 stock 갯수 확인 -> 스레드 1의 stock 갯수 감소 반영-> 스레드 2의 stock 갯수 감소 반영" 의 순서로 진행된다.
결국 스레드 2 가 스레드 1이 감소시킨 99 개를 확인한것이 아닌, 스레드 1이 감소를 반영하기 이전 데이터인 100 개를 확인하고 이를 1개 감소시킨 99 개를 DB 에 반영시키기 때문에 해당 문제가 발생한다. 이는 위의 로그에서도 찾아볼 수 있다.

서버 인스턴스 (혹은 프로세스) 가 여러개 있으면 이 synchronized 를 적용할 수 없다. synchronized 는 하나의 인스턴스 내의 범위에서만 작동하기 때문이다.
Synchronized 와 @Transactional 메서드를 함께 사용하면 오류가 발생 할 수 있다. 이는 @Transactional 이 붙은 Service 클래스가 본래 객체가 아닌 프록시 객체가 등록되기 때문이다.
우리가 @Transactional 어노테이션을 붙이면, 스프링은 @Transactional 어노테이션을 인식해서 스프링 빈에 해당 서비스 클래스가 아닌 프록시 클래스를 등록한다. 아래는 디버깅을 이용해서 현재 테스트코드에 등록된 StockService 를 확인한 결과이다. 현재 StockService 에는 @Transactional 이 붙어있다.

아래 this.stockService 가 일반 StockService 클래스가 아닌 CGLIB 에 의해 프록시 클래스로 등록되어 있는것을 볼 수 있다. 그리고 프록시 클래스의 동작은
1. 트랜잭션 시작
2. this.stockService.decrease() 의 호출 (Synchronized 가 된 작업)
3. 트랜잭션 종료 (commit , rollback 등)
의 순서로 이뤄진다. 여기서 2와 3 사이에서 문제가 생긴다. stockService 에서 stock 1개를 줄이고 이를 saveAndFlush() 를 통해 DB 에 반영했다. 이때 commit 은 일어나지 않은 상태이다. 트랜잭션 격리 수준이 Read Committed 임을 가정하면, 다른 스레드에서는 현재 commit 되지 않은 다른 스레드 (세션)의 작업을 볼 수 없다.
그리고 이 지점에서 다른 스레드로 작업이 넘어간다 (2번 작업과 3번 작업 사이). 그러면 이제 작업을 진행하는 스레드는 아직 작업이 완료되지 않은 상태의 stock 갯수를 가져오게 된다.
Exclusive Lock
Shared Lock
Shared Lock 은 공유 락으로 불리기도 하며, 현재 세션에서 데이터를 읽을 때, 다른 세션에서 쓰기 작업을 막기 위한 lock 이다.
다른 세션에서 Shared Lock 이 걸린 데이터들에 대해서, shared lock 을 거는 것은 가능하지만, exclusive lock 을 거는것은 불가능하다.
shared lock 은 읽기 lock 이기 때문에 다른 세션에서 읽기 작업은 허용할 수 있다. 그리고 이 shared lock 을 여러개 거는 것을 허용하는 이유는 각 세션마다 해당 작업을 끝내는 시간이 다르기 때문에 세션마다 lock 을 하는것을 허용하여 세션들의 읽기 작업이 완전히 끝나면 이후에 exclusive lock 을 이용할 수 있게 만들기 위함 인 듯 하다.