요약

동시성 문제를 해결하기 위해 1️⃣ synchronized 2️⃣ DB Lock 3️⃣ 메시지 큐, Redis를 이용한 글로벌 락 를 고려해보았을 때, 현재 프로젝트에선 Pessimistic Lock을 사용하여 공유되는 데이터 DB 데이터 row에 직접 Exclusive Lock을 거는 방안이 적합하다고 생각하여 Pessimistic Lock을 적용하였다.

 

 

동시성

여러 작업이 겹치는 기간에 실행될 수 있음을 의미한다.

동시에 실행하는 것이 아니라 CPU가 작업마다 시간을 분할해 적절하게 context switching을 해서 동시에 실행되는 것처럼 보이게 한다.

동시성의 핵심 목표 : 유휴 시간을 최소화하는 것이다.

유휴 시간은 컴퓨터가 작동 가능한데도 작업을 하지 않는 시간으로 아무것도 안하고 놀고 있는 시간이라고 생각하면 된다.

현재 프로세스 또는 스레드가 I/O 작업, DB 트랜잭션 등등 외부 프로그램 실행을 기다리는 동안에 다른 프로세스 또는 스레드가 CPU 할당을 받는다. 그림으로 표현하면 아래와 같다.

 

이 여러개의 task들은 하나 이상의 코어에서 실행된다. 같은 시간에 같은 자원에 접근하는 상황이 생길 수 있는데 해당 자원에 write 권한으로 접근하는 경우 '데이터의 무결성 유지!'를 꼭 염두해둬야 한다.

 

 

고려사항

사용자의 주문하기와 관련된 서비스이다 보니 “동시성” 문제 또한 고려하지 않을 수 없다.

동시성 문제가 발생할 수 있는 부분은 사용자가 주문하기 위해 가게 정보와 체리박스 수량을 조회하는 부분과 체리박스 수량을 감소하는 부분이다.

주문하기가 끝나기전 체리박스 수량이 감소되지 않은 시점에서 체리박스 주문하기를 하여 주문이 되는 문제가 발생하면 안된다

또한 체리박스 수량이 마지막 1개 남아 있는 시점에서 동시에 두 명의 유저가 요청을 하게 되었을 때 둘 모두 성공하는 것이 아니라 한 명만이 성공해야 한다

이러한 동시성 문제를 해결하기 위해 1️⃣ synchronized 2️⃣ DB Lock 3️⃣ 메시지 큐, Redis를 이용한 글로벌 락 를 고려할 수 있다.

일반적으로 synchronized를 붙이는 코드는 @Transactional 로 감싸진 메소드이다.

즉 AOP를 통해 해당 메소드 앞 뒤로 트랜잭션 시작과 트랜잭션 커밋 또는 롤백을 수행해준다

 

 

 

우선 트랜잭션 시작은 동시에 요청이 온 두 트랜잭션 모두가 시작할 수 있다. 트랜잭션 프록시를 호출하게 되면 트랜잭션 프록시는 데이터 소스를 찾아서 사용하게 되면서 이때 커넥션 풀에서 커넥션을 획득하게 된다. 따라서 동시에 온 두 요청 모두가 각각의 커넥션을 소유할 수 있게 된다. 이렇게 커넥션을 맺은 이후에 실제 Target Method에 대해서는 synchronized 를 걸었기에 순차적으로 진행된다.

 

하지만 커밋하는 시점, 즉 하나의 스레드가 Target Method를 처리하고 나오는 순간 아직 첫 번째 트랜잭션 커밋이 되지 않은 시점에서 다른 트랜잭션에서 해당 메소드에 진입이 가능하게 된다. 앞선 트랜잭션에서는 커밋이 완료되지 않았고, 두 번째 실행된 트랜잭션에서 쿠폰을 발급받게되면 둘 모두 성공하게 된다.

즉, 처음 트랜잭션이 아직 커밋되지 않은 시점에 두 번째 트랜잭션이 DB로부터 데이터를 조회할 수 있게 되고 해당 데이터를 통해서 검증을 수행하게 되면 여전히 동시성 문제가 존재한다.

 

이러한 방식은 근본적으로 하나의 서버를 고려하였을 때 생각할 수 있는 방법이며, 여러 대의 서바가 존재한다고 하면 진입점이 여러 개가 되므로 결국 동일한 문제가 발생한다.

 

예를 들어, 각각의 서버에서 수량을 조회할 수 있고, A,B 서버 모두 수량 =1 을 조회해온다면 둘 다 감소 시키는 문제가 발생할 수 있다.

 

이를 해결하기 위한 방법으로 메시지 큐, Redis를 이용한 글로벌 락과 같은 방법도 있겠지만,

실무라 가정하고, 실무라면 현재 사용하는 리소스이외의 Redis를 사용할 경우에는 운영비, 구축비용등 비용적인 측면이 발생하기 때문에 사용하고 있던 MySQL을 활용해서 Optimistic Lock, Pessimistic Lock 2가지 방법을 고려했다.

 

 

 

결론

결론적으로 Pessimistic Lock을 사용하여 공유되는 데이터 DB 데이터 row에 직접 Exclusive Lock을 거는 방안을 선택했다. 따라서 여러 요청이 왔을 때 한 번에 하나의 요청만이 해당 row에 접근할 수 있게 되고 순차적으로 처리되는 효과를 얻을 수 있다. 이러한 비관적 락은 DB row에 Exclusive Lock을 걸기에 속도가 많이 저하되는 문제가 있지만, Optimistic Lock을 사용하지 않은 이유는 다음과 같다.

 

 

Optimistic Lock은 “선착순 1명”과 같이 여러 요청 중 하나의 요청만을 성공시켜야 할 때 적절한 방법이라고 생각한다 Optimistic Lock은 실패 시에 개발자가 직접 재시도를 해주어야 하는데 특정 예외가 발생했을 때 해당 메소드를 재시도하는 Spring에서 제공해주는 @Retryable 어노테이션을 함께 사용하는 것을 고려해 볼 수 있다. 하지만 이 경우 몇 번 재시도를 하는 것이 적절한지 명확한 답을 생각하는 게 어렵다 판단하였다.

 

 

Optimistic Lock 은 일반적으로 처리 요청을 받은 순간부터 처리가 종료될 때까지 레코드를 잠그는 Pessimistic Lock보다 성능이 좋다.

데이터 성향에따라, Pessimistic Lock이 좋은 경우도 있는데 이런 경우이다.

  • 재고가 1개인 상품이 있다.
  • 100만 사용자가 동시적으로 주문을 요청한다.

Pessimistic Lock의 경우 1명의 사용자 말고는 대기를 하다가 미리 트랜잭션 충돌 여부를 파악하게 된다. 즉, 재고가 없음을 미리 알고 복잡한 처리를 하지 않아도 된다.

Optimistic Lock 의 경우 동시 요청을 보낸 사용자가 처리를 순차적으로 하다가 Commit을 하는 시점에 비로소 재고가 없음을 파악하게 된다. 그리고 처리한 만큼 롤백도 해야하기 때문에, 자원 소모도 크게 발생하게 된다.

 

 

적용하기

Pessimistic Lock 전 수정전 코드

  1. feign을 통해 store-service에서 가게 정보, 체리박스 정보 반환받는다.
  2. order 데이터 생성
  3. feign 을 통해 체리박스 수량 감소 로직 실행 후 반환
    1. 반환값이 필요 없음에도 feign을 사용한 이유 성공, 실패 로직 없기 때문에 비동기로 구현할 경우, 체리박스 수량 감소가 실패할때 판단할 수 없기때문에 성공, 실패 로직을 따로 만들지 않고, 순차적으로 로직을 수행하기 위해 feign 방안 선택했다.

Order-service

OrderService

@Transactional
    public void registerOrder(RegisterOrderDto orderDto){
        log.info("orderDto.getStoreId() = {}", orderDto.getStoreId());

        SingleResult<StoreDetailforOrderResponseDto> result = storeServiceClient.storeDetailforOrder(orderDto.getStoreId());
        StoreDetailforOrderResponseDto storeDetail = result.getData();

        if(!storeDetail.isOpen()){
            throw new StoreNotOpenException();
        }

        if(orderDto.getOrderQuantity()>storeDetail.getCherryBox().getQuantity()){
            throw new CherryBoxQuantityInsufficientException();
        }

        int totalSalesAmount = orderDto.getOrderQuantity() * storeDetail.getCherryBox().getPricePerCherryBox();

        // TODO : 하나의 회원이 해당 가게에 대해 여러 번 주문할 경우 컬럼이 새로 생성된다.
        // TODO : 손님 a, 손님 b가 동시에 가게 c에 주문을 한다면 ? 동시성 이슈 !
        orderRepository.save(Order.builder()
                .memberId(orderDto.getMemberId())
                .storeId(orderDto.getStoreId())
                .state(State.ORDER_COMPLETE)
                .quantity(orderDto.getOrderQuantity())
                .totalSalesAmount(totalSalesAmount)
                .build());

        Result decreaseCherrybox = storeServiceClient.decreaseCherryBox(storeDetail.getStoreId(), orderDto.getOrderQuantity());

    }

 

 

Test code

동시성 테스트는 CountDownLatch를 이용했다.

📌 ExecutorService

  • 병렬 작업시 여러 개의 작업을 효율적으로 처리하기 위해 제공된 JAVA 라이브러리
  • 손쉽게 ThreadPool을 구성하고 Task를 실행하고 관리할 수 있는 역할
  • Executors를 사용하여 ExecutorService 객체를 생성하며, ThreadPool의 개수 및 종류를 지정할 수 있는 메소드를 제공한다.

📌 CountDownLatch

  • 어떤 스레드가 다른 스레드에서 작업이 완료될 때까지 기다릴 수 있도록 해주는 클래스
  • CountDownLatch 를 이용하여, 멀티스레드가 100번 작업이 모두 완료한 후, 테스트를 하도록 기다리게 한다.
  • 작동원리
    1. new CountDownLatch(100); 를 이용해 Latch할 갯수를 지정한다
    2. CountDown()을 호출하면 Latch의 숫자가 1개씩 감소한다.
    3. await()은 Latch의 숫자가 0이 될 때까지 기다리는 코드다.
@Test
    void registerOrder_동시요청() 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{
                    RegisterOrderDto build = RegisterOrderDto.builder()
                            .storeId(1L)
                            .memberId(3L)
                            .orderQuantity(1)
                            .build();
                    orderService.registerOrder(build);
                }finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        List<Order> byMemberId = orderRepository.findByMemberId(3L);

        assertEquals(100,byMemberId.size());
    }

이와 같은 경우 테스트 실패

CountDownLatch를 이용하여, 멀티스레드 작업이 100번의 주문증가 로직을 호출한 뒤에 주문량 100, 재고가 0이 되는지 확인했지만, 실제로는 의외의 값이 나오는 것을 확인할 수 있다.

그 이유는 레이스 컨디션(Race Condition) 이 일어나기 때문이다.

  • 🔥 레이스 컨디션이란, 2 이상의 스레드가 공유 데이터에 액세스 할 수 있고, 동시에 변경하려할 때 발생할 수 있는 문제

해결방안

수정후 코드

Store-service

StoreRepository

		/**[주문하기용] 가게 상세 조회 */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Store s JOIN FETCH s.cherryBox WHERE s.id = :id")
    Optional<Store> findByIdLockWithCherryBox(Long id);

실패

이와 같은 경우 테스트 실패

CountDownLatch를 이용하여, 멀티스레드 작업이 100번의 주문증가 로직을 호출한 뒤에 주문량 100, 재고가 0이 되는지 확인했지만, 실제로는 의외의 값이 나온다.

트랜잭션 관리가 되지 않아 체리박스 감소 메소드에서 동시성 이슈 발생한다 판단하여 체리박스 감소 메소드에도 Pessimistic Lock을 걸어 동시성 이슈를 방지하였다.

Store-service

StoreRepository

		/**[주문하기용] 가게 상세 조회 */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Store s JOIN FETCH s.cherryBox WHERE s.id = :id")
    Optional<Store> findByIdLockWithCherryBox(Long id);

    /**
     * [주문하기용] 체리박스 감소
     */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Store> findById(Long storeId);

이와 같은 경우 테스트 실패

하지만 이 역시도 주문량 100, 재고가 0이 되는지 확인했지만, 실제로는 의외의 값이 나온다.

 

 

 

성공

현재 진행했던 로직에서 2번의 feign 통해 데이터를 주고 받는 부분에서 트랜잭션 관리가 되지 않아 의외의 값이 나온다고 판단하여, 1번의 feign만으로 데이터를 주고 받는 로직을 수정하였다.

수정 로직

  1. order-service에서 받은 ResigerOrderDto를 feign을 통해 store-service에 보낸다 [Pessimistic Lock 적용]
    1. store-service에서 체리박스 수정과 같은 로직을 수행한 후, order 컬럼을 만들 때 필요한 데이터 StoreDetailforOrderResponseDto 를 order-service에 준다.
  2. order-service에서 order 데이터 생성

Order-service

orderService

@Transactional
    public void registerOrder(RegisterOrderDto orderDto){
        log.info("[OrderService] registerOrder");
        log.info("orderDto.getStoreId() = {}", orderDto.getStoreId());

        SingleResult<StoreDetailforOrderResponseDto> result = storeServiceClient.storeDetailforOrder(orderDto);
        StoreDetailforOrderResponseDto storeDetail = result.getData();

        // TODO : 하나의 회원이 해당 가게에 대해 여러 번 주문할 경우 컬럼이 새로 생성된다.
        orderRepository.save(Order.builder()
                .memberId(orderDto.getMemberId())
                .storeId(orderDto.getStoreId())
                .state(State.ORDER_COMPLETE)
                .quantity(orderDto.getOrderQuantity())
                .totalSalesAmount(storeDetail.getTotalSalesAmount())
                .build());


    }

 

 

store-service

storeService

/**[주문하기용] 가게 상세 조회 */
    @Transactional
    public StoreDetailforOrderResponseDto storeDetailforOrder(RegisterOrderDto orderDto){
        log.info("[StoreService] storeDetailforOrder");
        Store store = storeRepository.findByIdLockWithCherryBox(orderDto.getStoreId()).orElseThrow(StoreNotFoundException::new);

        if(!store.isOpen()){
            throw new StoreNotOpenException();
        }

        if(orderDto.getOrderQuantity()> store.getCherryBox().getQuantity()){
            throw new CherryBoxQuantityInsufficientException();
        }

        int totalSalesAmount = orderDto.getOrderQuantity() * store.getCherryBox().getPricePerCherryBox();

        cherryBoxService.decreaseCherryBox(store.getId(), orderDto.getOrderQuantity());

        return StoreDetailforOrderResponseDto.create(store,totalSalesAmount);

    }

 

storeRepository

		/**[주문하기용] 가게 상세 조회 */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Store s JOIN FETCH s.cherryBox WHERE s.id = :id")
    Optional<Store> findByIdLockWithCherryBox(Long id);

 

 

 

 

 

그외 고민 사항

에러 처리

에러처리에 대해서도 많은 고민을 했다.

수정 전 로직에서 체리박스 수량 부족, 가게 오픈 여부에 대한 Exception이 한 메소드안에서 발생하는데, 둘다 404로 Exception을 터뜨리니, order-service에서 에러처리 로직에서 어떻게 구현해야할지 고민이 많았다. 실제로 인프런 강의에서도 exception이 한 번만 터지는 로직만 예시로 봐서 고민 끝에 유사한 Exception코드를 찾아 사용하였다.

store-service

ExceptionAdvice

@ExceptionHandler(CherryBoxQuantityInsufficientException.class)
    @ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
    public Result CherryBoxQuantityInsufficientException() {
        return responseService.getFailureResult(-201,"해당 가게에 대한 체리박스 수량이 부족합니다.");
    }

@ExceptionHandler(StoreNotOpenException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Result StoreNotOpenException() {
        return responseService.getFailureResult(-300,"해당 가게 오픈 시간이 아닙니다.");
    }

 

order-servicce

FeignErrorDecoder

@Slf4j
public class FeignErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        switch (response.status()){
            case 404:
                if(methodKey.contains("storeDetailforOrder")){
                    return new StoreNotOpenException();
                }
                break;
            case 406:
                if(methodKey.contains("storeDetailforOrder")){
                    return new CherryBoxQuantityInsufficientException();
                }
            default:
                return new Exception(response.reason());
        }
        return null;
    }
}

 

 

동시성 이슈를 해결하면서 로직 설계의 중요성을 깨닫게 되었다. 

수정 전 코드를 보면 알 수 있다싶이 feignclient를 통해 데이터를 가져온 부분이 2번이나 사용이 되는데, DB Lock을 거는데에도 어떻게 걸어야할지에 대한 고민을 하게끔하는 로직이였다면,수정 후 코드에선 feignclient를 통해 데이터를 가져오는 부분을 1개로 줄이면서 DB Lock도 보다 쉽게 걸 수 있는 것을 확인할 수 있다.

 

📮개인 공부를 위한 공간으로 틀린 부분이 있을 수도 있습니다.📮

문제점 및 수정 사항이 있을 시, 언제든지 댓글로 피드백 주시기 바랍니다.