Java (spring) 동시성 이슈

이광훈·2024년 9월 19일
post-thumbnail

동시성 문제

  • 멀티 스레드 환경에서 여러개의 스레드가 동일한 자원에 접근하여 값을 수정할 때 발생하는 문제이다.

동시성 문제가 발생하는 환경을 만들기 위한 셋팅

@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 클래스 를 이용하면 된다.

Executors 란?

  • Executor , ExecutorService, ThreadFactory 등등을 만들어서 반환해주는 메서드들을 갖는다. 이 중 우리는 ==Executors.newFixedThreadPool(int nThread)== 라는 메서드를 사용하고 있다.

  • 위의 메서드는 고정된 수의 스레드 (최대 nThread 개) 를 갖는 스레드 풀을 생성한다. 그리고 만약 모든 스레드들이 일을 하고 있는데 새로운 task 가 들어오면, 해당 task 는 queue 에서 대기한다.

CountDownLatch 란?

  • CountDownLatch 는 다른 스레드(들) 에서 작업이 완료될때까지 대기하여 동기화를 가능하게한다.
  • CountDownLatch 에는 count 가 주어지는데, 다른 스레드에서 countDown() 메서드의 호출로 인해 이 count 가 0 이 될때까지 대기하고 0 이 되면, 대기중인 스레드가 풀리면서 다시 스레드 스케줄링에 들어간다.

정리

  • 즉 위 코드는 , 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 때문에 발생한다.

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 에도 문제점이 있다.
  1. 서버 인스턴스 (혹은 프로세스) 가 여러개 있으면 이 synchronized 를 적용할 수 없다. synchronized 는 하나의 인스턴스 내의 범위에서만 작동하기 때문이다.

  2. Synchronized 와 @Transactional 메서드를 함께 사용하면 오류가 발생 할 수 있다. 이는 @Transactional 이 붙은 Service 클래스가 본래 객체가 아닌 프록시 객체가 등록되기 때문이다.

  • 2에 대한 추가 설명

우리가 @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 갯수를 가져오게 된다.

Pessimistic Lock

  • 비관적 락을 활용한 방법이다. 이 방법은 어플리케이션 레벨이 아니라 DB 레벨에서의 해결이다. 이 pessimistic Lock 은 2가지로 나뉜다.
  1. Exclusive Lock

    • Exclusive Lock 은 배타 락으로 불리기도 하며, ==현재 세션에서 데이터를 쓸 때, 다른 세션에서 읽기 / 쓰기 작업을 모두 막기 위한 lock 이다. ==
    • 다른 세션에서 Exclusive Lock 이 걸린 데이터들에 대해서는 exclusive lock , shared lock 을 걸 수 없다
  2. Shared Lock

    • Shared Lock 은 공유 락으로 불리기도 하며, 현재 세션에서 데이터를 읽을 때, 다른 세션에서 쓰기 작업을 막기 위한 lock 이다.

    • 다른 세션에서 Shared Lock 이 걸린 데이터들에 대해서, shared lock 을 거는 것은 가능하지만, exclusive lock 을 거는것은 불가능하다.

    • shared lock 은 읽기 lock 이기 때문에 다른 세션에서 읽기 작업은 허용할 수 있다. 그리고 이 shared lock 을 여러개 거는 것을 허용하는 이유는 각 세션마다 해당 작업을 끝내는 시간이 다르기 때문에 세션마다 lock 을 하는것을 허용하여 세션들의 읽기 작업이 완전히 끝나면 이후에 exclusive lock 을 이용할 수 있게 만들기 위함 인 듯 하다.

  • Pessimistic Lock 중 Exclusive Lock 을 이용해 위 동시성 문제를 해결 할 수 있다. Pessimistic Lock 은 현재 데이터를 쓸 때, 다른 세션의 접근을 막는다. 그리고 다른 세션은 해당 Exclusive lock 을 획득하기 전 까지 데이터에 접근할 수 없기 때문에 데이터의 일관성이 보장된다. **
profile
허허,,,

0개의 댓글