스프링으로 알아보는 동시성 이슈

김상운(개발둥이)·2022년 8월 26일
1
post-thumbnail

들어가기

해당 포스팅은 인프런 최창용님의 '재고시스템으로 알아보는 동시성이슈 해결방법'을 학습 후 정리한 내용입니다.


시나리오

로직은 재고를 감소하는 로직 하나이며 여러 사용자가 동시에 재고를 감소시키는 시나리오이다. 이때 발생하는 동시성 문제를 살펴보도록 하자.

예제 작성

프로젝트 구조

엔티티

Stock

@Entity
@NoArgsConstructor
@Getter
public class Stock {

    @Id @GeneratedValue
    @Column(name = "stock_id")
    private Long id;

    private Long productId;

    private Long quantity;

    public Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public void decrease(Long quantity) {
        if (this.quantity - quantity < 0) {
            throw new RuntimeException("foo");
        }

        this.quantity = this.quantity - quantity;
    }

}

repository

StockRepository

public interface StockRepository extends JpaRepository<Stock, Long> {
}

service

StockService

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

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

        stock.decrease(quantity);
    }

}

테스크 코드 작성-1

@SpringBootTest
class StockServiceTest {

    @Autowired
    StockService stockService;
    @Autowired
    StockRepository stockRepository;

    @BeforeEach
    public void before() {
        Stock stock = new Stock(1L, 100L);
        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void after() {
        stockRepository.deleteAll();
    }

    @Test
    void stock_decrease() {
        stockService.decrease(1L, 1L);

        Stock stock = stockRepository.findById(1L).orElseThrow();

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

    }

    @Test
    void 동시에_100개의_요청() 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 {
                stockService.decrease(1L, 1L);
                } finally {
                    latch.countDown();;
                }
            });
        }

        latch.await();//다른 쓰레드에서 수행중인 작업이 완료될때까지 기다려줌

        Stock stock = stockRepository.findById(1L).orElseThrow();

        //race condition 이 발생함 동시에 변경하려고 할때 발생하는 문제
        //하나의 쓰레드의 작업이 완료되기 이전에 쓰레드가 공유 자원에 접근하였기 떄문에 값이 공유 자원의 값이 다르다.
        assertEquals(0L, stock.getQuantity());

    }

}

테스트 시나리오

마지막 테스트의 경우 100개의 쓰레드가 decrease() 메서드를 병행적으로 실행할 경우이다.

executorService 는 병렬 작업 시 여러 개의 작업을 효율적으로 처리하기 위해 제공되는 JAVA 라이브러리이다.
CountDownLatch는 어떤 쓰레드가 다른 쓰레드에서 작업이 완료될 때 까지 기다릴 수 있도록 해주는 클래스입니다.
countDownLatch.countDown() 을 호출하면 Latch 의 숫자가 1개씩 감소합니다.
latch.await() 을 호출하면 Latch의 값이 0이 될때까지 수행중인 작업이 완료될때까지 기다린다는 의미입니다.

테스트 결과

quantity의 값이 0 이기를 기대했는데.. race condition이 발생하여 당연히 실패한다. stock 엔티티라는 공유 자원에 쓰레드가 동시에 비동기적으로 접근하기 때문에 실패한다. 앞의 쓰레드가 데이터베이스 커밋 이전에 다음 쓰레드가 공유 자원에 접근하기 때문에 stock 엔티티의 quantity 는 여전히 100이다.

하지만 테스트 결과가 100인것은 아니다. 100 개의 쓰레드가 실행하는 중에 데이터베이스에 커밋을 하여 공유자원에 값이 바뀐 상태에서 쓰레드가 접근할 수 있으므로 대략 quantiy 의 값은 95~96 개이다.

synchronized 사용하기!

자바의 synchronized 를 사용해서 decrease() 메서드에 하나의 쓰레드만 접근하기를 허용해보자!

StockService

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    //자바의 synchronized 는 하나의 프로세스에서만 보장이 된다.
    //서버가 한 대 일때는 괜찮지만, 그 이상일 경우 데이터의 접근이 여러 프로세스에서 일어남
    //따라서 서버가 두대 이상일때는 synchronized 를 사용하지 않음
    @Transactional
    public synchronized void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
    }

    private void startTransaction() {
    }

    private void endTransaction() {
    }


}

테스트 결과

여전히 실패한다..

그 이유가 무엇일까? 그 이유는 스프링의 트랜잭션 메커니즘 때문이다. 스프링은 아래의 그림과 같이 트랜잭션이 동작한다.

스프링의 aop 동작 방식 때문인데.. stockService 를 직접 호출하여 decrease() 를 호출하는 것이 아닌 프록시를 통해서 실제 사용할 stockService 인 target 의 decrease() 를 호출한다.

스프링 aop에 대해 자세히 알아보기

따라서 트랜잭션의 커밋 시점은 target.decrease() 호출 후 이다. 위처럼 synchronized 키워드를 사용하더라도 데이터베이스 커밋 이전에 다음 쓰레드가 접근이 가능하기 때문에 테스트가 실패한다.

데이터베이스 lock 사용해보기

같이보면 좋은 자료

트랜잭션 격리수준 알아보기

Pessimistic Lock (비관적 락)

  • 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법
  • 트랜잭션안에서 서비스로직이 진행되어야함.

JPA 에서 Pessimistic Lock 사용해보기

public interface StockRepository extends JpaRepository<Stock, Long> {
    //Exclusive Lock을 획득하고 데이터를 읽거나, 업데이트하거나, 삭제하는 것을 방지합니다.
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByWithPessimisticLock(@Param("id") Long id);

}

옵션

PESSIMISTIC_WRITE

비관적 락이라 하면 일반적으로 이 옵션을 뜻함 데이터베이스 쓰기에 락을 건다.

Exclusive Lock 으로 데이터를 변경하고자 할 때 사용되며, 트랜잭션이 완료될 때까지 유지되어 해당 Lock이 해제될 때까지 다른 트랜잭션은 해당 데이터에 읽기를 포함하여 접근을 할 수 없다.

PESSIMISTIC_READ

데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용 일반적으로는 잘 사용하지 않음

Shared Lock은 다른 사용자가 동시에 데이터를 읽을 수는 있지만 Write는 할 수 없다. Shared Lock을 얻고 데이터가 업데이트되거나 삭제되지 않도록합니다.

Pessimistic 테스트

@Test
    void PessimisticLock_동시에_100개의_요청() 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 {
                    pessimisticLockStockService.decrease(1L, 1L);
                } finally {
                    latch.countDown();;
                }
            });
        }

        latch.await();//다른 쓰레드에서 수행중인 작업이 완료될때까지 기다려줌
        Stock stock = stockRepository.findById(1L).orElseThrow();
        
        assertEquals(0L, stock.getQuantity());

    }

테스트 결과

성.. 성공이다!

하지만 shared lock 인 PESSIMISTIC_READ 옵션을 사용할 경우 테스트 실패한다.

Optimistic Lock (낙관적 락)

  • 낙관적 락은 트랜잭션을 커밋하는 시점에서 충돌을 알 수 있다는 특징이 있음

  • DB 가 제공하는 락 기능을 사용하지 않고 JPA 가 제공하는 버전 관리 기능을 사용합니다

낙관적 락은 version 정보를 사용한다.

위의 그림처럼 동시에 트랜잭션이 접근하여 수정을 하게될 경우 첫번 트랜잭션이 해당 row 의 version 의 값을 1 증가 시켜준다. 두번째 트랜잭션은 동시에 접근했기 때문에 version 이 1인 row를 수정하고자 하는데 version 의 값은 첫번째 트랜잭션에서 2로 값이 수정되었기 때무에 첫번째 트랜잭션에서의 수정만 반영되고 두번째 트랜잭션은 실패가 된다.

JPA 에서 낙관적 락을 구현하기 위해서는 entity 클래스에 필드를 추가후 @version 어노테이션을 달아줘야 한다.

Optimistic Lock 추가하기

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByWithPessimisticLock(@Param("id") Long id);

    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByWithOptimisticLock(@Param("id") Long id);

}
@Entity
@NoArgsConstructor
@Getter
public class Stock {

    @Id @GeneratedValue
    @Column(name = "stock_id")
    private Long id;

    private Long productId;

    private Long quantity;
    
    //optimistic lock 에서 사용
    @Version
    private Long version;

    public Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public void decrease(Long quantity) {
        if (this.quantity - quantity < 0) {
            throw new RuntimeException("foo");
        }

        this.quantity = this.quantity - quantity;
    }

}

@Lock 어노테이션에 LockModeType.OPTIMISTIC 옵션을 추가

@Service
@RequiredArgsConstructor
public class OptimisticLockStockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        //다른 트랜잭션인 특정 row 의 lock 을 얻는 것을 방지합니다.
        //하나의 트랜잭션이 stock 에 해당하는 row 에 lock 을 건다.
        //일반 select 는 가능
        //99개의 다른 쓰레드는 첫번째 쓰레드에 pessimistic lock 걸려있기 때문에 대기 -> 성능 저하
        Stock stock = stockRepository.findByWithOptimisticLock(id);

        stock.decrease(quantity);
    }

}

OptimisticLockStockService 는 낙관적 락을 사용하기 위한 서비스 클래스이다.

@Service
@RequiredArgsConstructor
public class OptimisticLockStockFacade {

    private final 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);
            }
        }
    }
}

위 서비스 클래스를 사용하는 이유는 여러 쓰레드가 동시에 접근 시 version 정보가 맞지 않아 exception 이 터질 경우 다시 요청을 하기 위함이다. lock 을 어플리케이션 에서 해결한다는 의미이다.

중요

낙관적 락 구현은 데이터 베이스에서 DB 가 제공하는 기능을 이용하는 것이 아닌. JPA 가 제공하는 버전 관리 기능을 사용한다.


깃헙 주소 https://github.com/issiscv/concurrency-Issue-wtih-spring

profile
공부한 것을 잊지 않기 위해, 고민했던 흔적을 남겨 성장하기 위해 글을 씁니다.

0개의 댓글