[BootakHae] 동시성 문제 해결(1)

Kim Hyen Su·2024년 5월 16일

BooTakHae

목록 보기
1/22
post-thumbnail

동시성 문제 해결


💡 읽기 전 꼭 확인해주세요!
해당 포스팅은 시리즈로 이어지며, 목록은 다음과 같습니다! :)

개요

동시성 문제란 여러 프로세스 또는 스레드가 하나의 공통된 자원에 접근하려 할 때 발생하는 문제를 말합니다.

Ecommerce 프로젝트를 진행하면서, 재고 관리 시스템 내 다음과 같은 동시성 문제가 발생했습니다.

  • 하나의 상품을 동시에 여러 회원이 주문했을 때, 상품 재고가 정상적으로 감소되지 않는 문제 발생.

위의 문제 상황에 대한 테스트 코드를 작성하여 직접 테스트를 해보겠습니다.

상황은 다음과 같이 가정하겠습니다.

  • 상품의 재고 5,000개
  • 전체 요청 수 5,000명
  • 동시에 발생하는 스레드 갯수 30개

Test code

// InventoryTest.java
    @Test
    @DisplayName("멀티스레드를 활용해서 동시에 5000명이 1개씩 주문을 넣는 상황")
    public void 동시에5000명이주문을하는상황() throws InterruptedException{
        //given
        final int threadCount = 5000; // 총 요청 갯수
        ExecutorService executorService = Executors.newFixedThreadPool(30);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount); // count-down

        for(int i=0; i< 5000 ;i++){
            executorService.submit(() -> { // 개별 쓰레드 마다 호출할 요청을 람다식으로 정의
                try{
                    productService.decreaseStock(uuid, 1L); // 재고 감소
                }finally{
                    countDownLatch.countDown(); // 요청이 들어간 쓰레드는 대기 상태로 전환
                }
            });
        }
        countDownLatch.await(); // 모든 쓰레드의 호출이 끝나면 쓰레드 풀 자체 종료

        ProductEntity product = productRepository.findByProductId(uuid).orElseThrow();

        assertEquals(0, product.getStock());
    }

// ProductService.java
    @Transactional
    @Override
    public ProductDto decreaseStock(String productId, Long qty) {
       ProductEntity product = productRepository.findByProductId(productId).orElseThrow(
                () -> new CustomException(ErrorCode.NOT_REGISTERED_PRODUCT)
        );

        product.decreaseStock(qty);

        return productRepository.saveAndFlush(product).entityToDto();
    }

Result

Expected :0
Actual   :3815

org.opentest4j.AssertionFailedError: expected: <0> but was: <3815>
	...

문제 원인

예상 재고량은 "0"이어야 하지만, 실제로는 "3815"개의 재고가 남게된 상황입니다.

테스트를 통해서 스레드 간의 레이스 컨디션으로 인해 동시성 문제가 발생하게 됐음을 확인했습니다.

이를 조금 더 자세하게 설명하면 다음과 같습니다.

스레드1과 스레드2가 동시에 재고 자원에 접근하고 있다고 가정하겠습니다.

  • 스레드1 → 재고 5,000개 조회
  • 스레드2 → 재고 5,000개 조회
  • 스레드1 → 재고 처리 → 재고 4,999개
  • 스레드2 → 재고 처리 → 재고 4,999개(예상 : 4,998개)

즉, 순서가 보장되지 않고 처리되어 스레드1이 처리하기 전에 스레드2의 조회가 우선적으로 발생하는 것입니다.

그렇다면, 레이스 컨디션으로 인한 동시성 문제를 해결하기 위해서는 어떤 방법들이 있을까요?

해결 방안

이를 해결하기 위한 방식으로 크게 4가지가 있습니다.

  • Java Synchronized
  • DB Pessimistic Lock
  • Optimistic Lock
  • Distributed Lock

Synchronized


하나의 자원에 동시에 접근하는 스레드를 동기적으로 처리하기 위한 예약어 입니다.

사용 방법은 사용하고자 하는 부분에 synchronized 블록을 만들거나 메서드 자체를 synchronized화 해주는 방법이 있습니다.

즉, 아래와 같이 메서드 레벨에서 synchronized 예약어만 추가해주면 됩니다.

 @Transactional
    @Override
    public synchronized ProductDto decreaseStock(String productId, Long qty) {
	  ...
    }

실행한 결과 다음과 같은 결과가 나타납니다.

Expected :0
Actual   :6

org.opentest4j.AssertionFailedError: expected: <0> but was: <6>

동기적으로 처리됐지만, 이전에 비해서 갯수만 줄어들 뿐 여전히 동시성 문제가 발생했습니다.

이러한 결과가 발생한 이유는 메서드 상단에 트랜잭션을 처리하기 위해 추가된 @Transactional 어노테이션 때문입니다. 조금 더 자세하게 설명하면 Spring AOP가 원인입니다.

@Transactional을 사용하게 되면 Spring AOP에 의해 프록시 객체가 생성되는데, 이 때 원래의 메서드를 호출하게 되는데 호출하는 레벨에서는 동시성 제어가 불가능하게 됩니다.

이를 해결하기 위해서는 메서드 레벨보다 더 위에서 동시성을 제어해줘야 하는데, 이는 서버 성능 자체에 큰 영향을 미칩니다.

  • 실제 동시성 문제가 발생하는 레벨은 데이터베이스 레벨에서 발생하는데, 애플리케이션 레벨에서 동시성을 미리 제어하게 되면, 애플리케이션 성능에도 영향을 미치게 됩니다.

Synchronized는 동시성 제어의 완벽한 해답이 아닙니다.

위처럼 synchronized를 통해 완전한 동시성 제어가 가능하더라도 위에서 언급한 성능 저하의 문제가 발생할 수 있습니다.

또한, synchronized는 하나의 서버 내에서 요청에 대한 동시성을 제어하는 것입니다. 만약, 동일한 서버의 스케일 아웃한 다른 인스턴스가 존재할 경우 해당 인스턴스 간의 레벨에서 동시성 제어가 불가능해집니다.

따라서, Java에서 제공하는 synchronized는 제가 구현한 MSA 환경의 프로젝트에서는 사용이 어렵다고 판단했습니다.


Pessimistic Lock vs. Optimistic Lock

Pessimistic Lock(비관적 락)

충돌이 무조건 발생할 것이라는 비관적인 가정 하에 락을 걸어주어 동시성을 제어하는 방법입니다.

항상 충돌상황을 가정하고 락을 걸기 때문에 비용이 많이 들지만, 충돌이 빈번하게 발생하는 개소에서 확실한 동시성 제어가 가능한 방법입니다.

Pessimistic Lock은 Repeatable Read 또는 Serializable 정도의 격리성 수준을 제공합니다.

  • Repeatable Read : 트랜잭션 시작 시 데이터 상태를 유지하여 동일 쿼리를 반복 실행해도 동일한 데이터를 보장하는 상태.
  • Serializable : 완전한 격리 보장, 트랜잭션이 순차적으로 실행되는 상태.

비관적 락의 대표적인 예로, 공연장 티케팅 시 먼저 예약한 사람이 좌석 선택 시에 결제를 하지 않아도 해당 좌석을 점유하고 있는 것과 같습니다.

적용 방법

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from ProductEntity  p where p.productId = :productId")
    Optional<ProductEntity> findByProductIdPessimistic(String productId);

repository 레벨에서 해당 재고를 감소할 상품을 조회 시 lock을 걸어두어 동시성을 제어하도록 해줍니다.

위처럼 lock을 걸어주면, Write Lock(배타락) 이 걸리게 된느데, 이는 트랜잭션이 완료될 때까지 다른 트랜잭션에서 read/write 하려는 접근을 전부 막아줍니다.

Optimistic Lock(낙관적 락)

동시성 문제가 거의 발생하지 않을 것이라고 낙관적인 가정 하에 락을 걸어주는 방법입니다.

애플리케이션 레벨에서 특정 컬럼 정보를 통해 동시성을 제어하며, 해당 컬럼의 값이 변경(ex, version 1 -> 2)된 경우에만 접근이 가능하도록 제어하는 방법입니다.

적용 방법

// ProductRepository
    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select p from ProductEntity  p where p.productId = :productId")
    Optional<ProductEntity> findByProductIdOptimistic(String productId);
    
// ProductEntity
	@Entity
    public class ProductEntity {
	  private int version; // 버전 정보를 위한 컬럼
      ...
	}

// OptimisticInventoryFacade
	@Component
	@RequiredArgsConstructor
	public class OptimisticInventoryFacade {
    	/**
    	 * 퍼사드 클래스의 역할은 낙관적 락 서비스의 decrease 를 반영될 떄까지 지속적으로 재시도하는 로직을 Service 객체에 wrapping 하는 것
    	 */
    	private final ProductService productService;

    	public void decrease(String productId, Long qty) throws InterruptedException {
        	while(true){ // 성공 할 때까지 무한 반복
            	try{
                	productService.decreaseStockOptimistic(productId, qty); // 서비스의 decrease 호출
                	break; // 위 서비스 구문의 감소가 버전 정보가 맞아서 잘 처리될 경우, 반복문을 탈출
            	}catch(Exception e){
                	Thread.sleep(100);// 낙관적 락에 의해서 버전 정합성이 맞지 않아서 예외가 발생하게될 경우, 0.1초 대기 후 재시도.
            	}
        	}
    	}
	}

낙관적 락을 사용하게 될 경우, 위처럼 버전 정보가 update될 때까지 UPDATE를 실패하더라도 자동으로 예외를 던지는 것이 아니라, 직접 예외 및 롤백 처리를 구현해줘야 합니다.

Test Code

비관적 락 테스트 코드

    @Test
    @DisplayName("Pessimistic Lock 을 통한 동시성 제어")
    public void 동시에5000명이주문하는상황비관적() throws InterruptedException{
        //given
        final int threadCount = 5000; // 동시 요청 갯수
        ExecutorService executorService = Executors.newFixedThreadPool(30); // 30개의 쓰레드
        CountDownLatch countDownLatch = new CountDownLatch(threadCount); // 요청 마친 쓰레드는 대기하도록 처리

        for(int i=0; i< 5000 ;i++){
            executorService.submit(() -> {
                try{
                    ProductDto dto = productService.decreaseStockPessimistic(uuid, 1L);
                }finally{
                    countDownLatch.countDown(); // 요청이 들어간 쓰레드는 대기 상태로 전환
                }
            });
        }
        countDownLatch.await(); // 모든 쓰레드의 호출이 끝나면 쓰레드 풀 자체 종료

        ProductEntity product = productRepository.findByProductId(uuid).orElseThrow();

        assertEquals(0, product.getStock());
    }

실행한 결과는 다음과 같습니다.

  • 테스트 연산 속도 : 8437 ms
  • 결과 : 성공

낙관적 락 테스트 코드

@Test
    @DisplayName("Optimistic Lock 을 통한 동시성 제어")
    public void 동시에5000명이주문하는상황낙관적() throws InterruptedException{
        //given
        final int threadCount = 5000; // 동시 요청 갯수
        ExecutorService executorService = Executors.newFixedThreadPool(30); // 30개의 쓰레드
        CountDownLatch countDownLatch = new CountDownLatch(threadCount); // 요청 마친 쓰레드는 대기하도록 처리

        for(int i=0; i< 5000 ;i++){
            executorService.submit(() -> {
                try{
                    inventoryFacade.decrease(uuid, 1L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally{
                    countDownLatch.countDown(); // 요청이 들어간 쓰레드는 대기 상태로 전환
                }
            });
        }
        countDownLatch.await(); // 모든 쓰레드의 호출이 끝나면 쓰레드 풀 자체 종료

        ProductEntity product = productRepository.findByProductId(uuid).orElseThrow();

        assertEquals(0, product.getStock());
    }

실행한 결과는 다음과 같습니다.

  • 테스트 연산 속도 : 30078ms
  • 결과 : 성공

결론

비관적 락은 동시성 문제가 발생할만한 개소에서 완전한 동시성 제어를 보장하지만, 트랜잭션 단위로 매번 락을 걸기 때문에 처리 비용이 올라가게 됩니다.

낙관적 락은 매번 락을 걸지 않기 때문에 레이스 컨디션이 없을 경우, 비관적 락보다 빠른 성능을 보여주지만, 충돌이 자주 일어나게 되면 비관적 락보다 성능이 떨어지게 됩니다.

따라서, 재고 처리중 발생한 레이스 컨디션을 해결하기 위해서는 비관적 락을 사용해야 좋은 성능 및 동시성 제어가 가능할 것으로 판단됩니다.


추가

위에서 언급한 분산락 관련 설명 및 테스트는 다음 포스팅에서 이어서 진행하겠습니다.

profile
백엔드 서버 엔지니어

0개의 댓글