chaSunil / troubleShooting

작업을 진행하며, 문제가 생겨서 정리해놓은 트러블 슈팅을 모아놓는 공간
0 stars 0 forks source link

Trouble Shooting (0) 동시성 주문 #1

Open chaSunil opened 2 months ago

chaSunil commented 2 months ago

프로젝트 명 : final project

담당 파트 : 주문 및 결제

작성자 명 : 차선일

작성 일자 : 2024-09-11

chaSunil commented 2 months ago

현상

주문 및 결제시스템에서 한꺼번에 많은 사용자가 동시에 결제와 주문이 요청되는 경우에서, 재고에서 주문된 상품 수량을 빼는 과정에서 같은 재고를 빼는 동시성 주문에 관한 문제

(ex. 50개의 재고가 있을때 같은 시간에 동일하게 주문이 들어왔을때, a가 1개의 주문을 할 시, 49개에서 b가 결제하는 상품 수량이 빠져야 하는데, 동일하게 50개에서 재고가 차감되는 현상)


원인

아래 해당 코드에서 int res = updateProductQuantity(productId, itemQuantity); 라는 코드가 동시에 실행되면

    <update id="updateProductQuantity" parameterType="int">
        update products
        set quantity = quantity - #{ itemQuantity }
            where id = #{ productId }
    </update>

해당 코드에서 quantity가 a 이용자가 1개, b 이용자가 3개 동일한 상품을 주문했으면, a 이용자의 수량이 빠진 49개에서 b 이용자의 상품 주문갯수가 차감이 되어야 하는데,

( ex. 49 - 3 = 46) 동시 다발적으로 주문이 이뤄지기 때문에, 49개에서 주문갯수가 차감이 되는 것이 아닌, 50개에서 3개가 빠지는 현상으로 최종적으로 47개의 재고 개수가 남게됨.

(정상적인 결제 방식으로는 46개가 맞음)

@Override
@Transactional
public int updateProductInfo(List<Integer> productIds, List<Integer> itemPrices,
                           List<Integer> itemQuantities, int loginUserId) {

    int orderId = orderMapper.selectOneLast().getId();

    // 반복문을 통해서 구매한 상품 장바구니 삭제, orderItems로 추가
    for(int i = 0; i < productIds.toArray().length; i++) {

        OrderItems orderItems = new OrderItems();
        orderItems.setOrderId(orderId);

        int productId = productIds.get(i);
        int itemPrice = itemPrices.get(i);
        int itemQuantity = itemQuantities.get(i);

        // 각 값을 저장
        orderItems.setProductId(productId);
        orderItems.setItemPrice(itemPrice);
        orderItems.setItemQuantity(itemQuantity);

        // 장바구니에 입력되어 있는 정보 중 구매한 상품 전부 삭제
        cartsMapper.deleteCartProduct(productId, loginUserId);

        orderItemsMapper.insertOrderItems(orderItems);  // 장바구니 -> orderItems 추가

        // 처음에 재고 확인 후 시간차 주문공격 체크
        ProductsVo productsVo = productsMapper.selectOneCheckProduct(productId, itemQuantity);
        int itemQuantityCheck = productsVo.getQuantity();
        // 상품 정보에 재고 업데이트
        // TODO 귀여미 Exception 처리
        if(itemQuantityCheck-itemQuantity<0) {
            log.error("There is insufficient stock due to someone else's purchase.: {}", "재고수량부족");
            throw new OutofStockException("Insufficient stock: 재고가 부족합니다.");
        }

        int res = updateProductQuantity(productId, itemQuantity);
    }

    return orderId;
}




🥇 오류해결 접근 방식

Synchronized를 사용해서, 해당 메서드를 하나의 스레드에서 사용하고 있으면, 다른 스레드에서는 이 작업이 실행되지 못하고, 대기 상태에 이르게 한다.

해결책 : (Lock 방식과 synchronized 방식을 채용해서 재고 차감 메서드 동기화 처리로 메서드 진입을 한 번에 하나의 스레드만 가능하게 만들어놓음)

private final Lock lock = new ReentrantLock();

//수정 완료된 최종결과 코드
/**
 *  Lock를 이용한 동기화 처리
 */
@Override
@Transactional
public int updateProductQuantity(int productId, int itemQuantity) {
    lock.lock();
    try {
        return productsMapper.updateProductQuantity(productId, itemQuantity);
    } finally {
        lock.unlock();
    }
}

/**
 *  재고 synchronized 동기화 영역
 */
@Override
@Transactional
public synchronized int updateProductQuantity(int productId, int itemQuantity) {
    return productsMapper.updateProductQuantity(productId, itemQuantity);
}




결과

실질적으로 주문을 동시에 누르는게 오차가 너무 심해서 @Test 어노테이션을 처음으로 활용해보기로 했다.

아래에는 기본적으로 한 번 실행했을때 잘 실행이 되는지 Test를 해보았다. (처음 사용해보기에, result값이 올바르게 들어오는지 테스트 검증)

package com.githrd.figurium.order.controller;

import com.githrd.figurium.order.service.OrderServiceImpl;
import com.githrd.figurium.product.dao.ProductsMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class OrderControllerTest {

    // Mock는 실질적은 데이터값이 소모되지 않도록 Test 데이터로 사용해볼 수 있다.
    // a상품의 Product quantity - 1이 되어도 실질적인 데이터에는 차감이 없음.
    @Mock
    private ProductsMapper productsMapper;

    // @Mock 데이터를 주입받을 객체(@Autowried 하는 느낌과 매우 비슷)
    @InjectMocks
    private OrderServiceImpl orderServiceImpl;

    // Mock 데이터를 초기화 시켜주는 함수
    @BeforeEach
    public void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    void testUpdateProductQuantity() {
        int productId = 1;
        int itemQuantity = 5;

        // 결과가 올바르게 return 되면 1을 반환
        when(productsMapper.updateProductQuantity(productId, itemQuantity)).thenReturn(1);
        // 메서드 실행
        int result = orderServiceImpl.updateProductQuantity(productId, itemQuantity);

        // 결과 검증
        assertEquals(1, result);
        verify(productsMapper).updateProductQuantity(productId, itemQuantity);

    }
}

올바르게 Test가 수행이 되었다. 한 번은 들어갔다 온다는 것이니 이제 여러번의 실행을 동시에 일어나게 해보겠다.

image

데이터베이스 상품 7번의 재고는 8개이다.

image

package com.githrd.figurium.order.controller;

import com.githrd.figurium.order.service.OrderServiceImpl;
import com.githrd.figurium.product.dao.ProductsMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.junit.Assert.assertEquals;

@SpringBootTest
public class OrderControllerTest {

    @Autowired
    private OrderServiceImpl orderService;
    @Autowired
    private ProductsMapper productsMapper;

    @BeforeEach
    public void before() {
    }

    @Test
    public void testConcurrentOrders() throws InterruptedException {

        // 5개 요청
        int threadCount = 5;

        // 멀티스레드 이용
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(threadCount);
        /*
        1OO개의 요청이 모두 끝날때까지 기다려야 하므로 CountDownLatch 사용
        CountDownLatch는 다른 스레드에서 수행죽인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스 */

        for(int i = 0; i < threadCount; i++) {
            executorService.submit(()-> {
                try {
                     // 재고 8개 - 1개씩 차감
                    if (orderService.updateProductQuantity(7,1)) {
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        //assertEquals(9, successCount.get());
        assertEquals(3, productsMapper.getProductQuantity(7));

    }
}

5개의 요청이 각각 1개씩 계속 주문이 들어왔으니 결과값은 재고가 3개여야 한다.

결과는 성공

image

값이 올바르게 빠져나가서 동기화 처리가 적절하게 실행되었음을 알 수 있다.

image




사후검토

syncronized 방식의 한계점

syncronized방식은 단일 서버에서만 실행 할 수 있는 동기화방식이라서,

다중 서버에서 작업하게되는 환경에서는 lock 방식을 채용(사실 syncronized는 실무에서 거의 사용안한다)

Pessimistic Lock 비관적 잠금

그렇기에, 바로 Pessimistic Lock을 SQL문을 수정을 통해서 적용을 해보자.

기존 Mybatis Query문

    <update id="updateProductQuantity" parameterType="int">
        update products
        set quantity = quantity - #{ itemQuantity }
            where id = #{ productId }
    </update>

변경된 Pessimistic Lock을 적용한 Query문

여기서 for update를 사용하게 되면, 이 절은 선택한 행을 잠그어 다른 트랜잭션이 동시에 수정하지 못하도록 설정해버린다. 안전하게 업데이트가 가능하다.

<update id="updateProductQuantity" parameterType="int">
    UPDATE products
    SET quantity = quantity - #{itemQuantity}
    WHERE id = #{productId}
    AND quantity >= #{itemQuantity}
    FOR UPDATE
</update>




참고 사이트 및 URL

https://dev-rosiepoise.tistory.com/132