ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 내가 선택한 DB 동시성 해결방법
    Java 2021. 12. 28. 23:19

    내가 선택한 DB 동시성 해결방법

    팬텀문제에 어울리는 대표 이미지가 없었다


    서론

    오랜만에 블로그 포스팅이다. 포스팅 아이템들은 넘처나는데, 시간이 없어서(핑계) 작성할 시간은 없었던 것 같다. 마침 진행하던 프로젝트에서 동시성 이슈가 터졌고, 이를 해결하는 여정을 공유해보려고 한다. 대외비가 걱정되어 작성을 고민했지만, 알고보니 아주 흔하디 흔한 동시성 문제이고 생각보다 내가 생각한 방식으로 많이들 해결하고 있다기에 작성을 망설이지 않았다. 시작해보자


    배경 지식

    이커머스 프로젝트를 진행할 때, 이벤트 상품 또는 물량이 제한되어있는 상품이 있을 것이다. 간단한 예를 하나 들어보도록 하자.

     

    커머스에서 맥북 pro m1 max 를 100개 10만원에 한정 판매(가지고싶다) 한다고 하자.

     

    그리고 이 이벤트는 다가올 2021년 1월 1일 07시 00분에 시작한다. 그리고 이 물건들은 1명의 유저당 여러개를 주문(보통은 1개 제한 일텐데 예시를 위한 시적 허용 이라고 하자)할 수 있다.

     

    그럼 이 물건을 판매하는 커머스의 DB 구조는 어떤 식일까 ? 아마도 이런느낌일 거다.

    • Customers : 소비자의 정보
    • Orders : 주문 정보 (소비자 정보와 상품 정보, 그리고 중요한 몇개 주문했는지를 알 수 있다.)
    • Product : 상품에 대한 정보

    문제 발생

    서버에서는 100개의 주문만 제한하는 로직을 어떻게 처리하는게 좋을까? (Java Spring 으로 예시를 들어보자)

    Repository

    @Repository
    public interface OrderRepository extends JpaRepository<Orders, Long> {
        @Query("SELECT sum(o.orderCount) from Orders o where o.productName = ?1")
        Optional<Integer> findSumOrderCount(String productName);
    }

    Service

    @Service
    public class OrderCreater {
    ​
        OrderRepository orderRepository;
    ​
        @Autowired
        public OrderCreater(OrderRepository orderRepository){
            this.orderRepository = orderRepository;
        }
    ​
        /**
         * @param nowOrderCnt 현재 주문 건
         */
        @Trasactional
        public void creater(int nowOrderCnt){
            // 이전 까지의 주문 건수
            int currentOrderCnt = 
              orderRepository.findSumOrderCount("MAC PRO M1 MAX").orElse(0);
          
            if(currentOrderCnt + nowOrderCnt > 100){
                System.out.println("주문이 실패 (사유 : 상품 주문 갯수 초과)");
                return;
            }
          
            Orders order = new Orders("MAC PRO M1 MAX", nowOrderCnt);
            orderRepository.save(order);
          
            System.out.println("주문이 성공했습니다.");
        }
    }

    코드가 좀 난해할 수 있긴 하지만, creater 란 메서드를 통해서 주문을 생성하는 메서드를 통해 DB 에 주문을 넣는 방식을 진행할 것이다. 여기서 의문점을 하나 가져보자

     

    이 코드는 문제없이 잘 작동할 것이며, MAX PRO M1 MAX 는 절대로 주문 갯수가 100개를 넘지 않을까?

     

    간단한 테스트로 이 로직이 왜 문제인지 알아보자.

    @Test
    @Transactional
    public void testSummationWithConcurrency2() throws InterruptedException {
    ​
        int numberOfThreads = 1000;
        ExecutorService service = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        for (int i = 0; i < numberOfThreads; i++) {
            service.submit(() -> {
                orderCreater.creater(10);
                latch.countDown();
            });
        }
        latch.await();
    ​
        Integer orderCount = orderRepository.findSumOrderCount("MAC PRO M1 MAX").orElse(0);
        
        Assertions.assertThat(orderCount).isEqualTo(100);
    }

    결과는 다음과 같다.

    expected: 100
     but was: 110
    org.opentest4j.AssertionFailedError: 
    expected: 100
     but was: 110

    물론 멀티 스레딩의 힘을 빌려서 상황을 극한으로 물고간건 있지만 일단 땡이다.  주문량의 100개가 넘으면 안된다는 일관성이 깨져버렸다.

     

    그리고 저렇게 동시 다발적으로 주문이 들어오지 않으란 법이 없다. 그런상황이 애초에 없었으면 난 이 글을 쓰지도 않고 고생을 하지도 않았을 것이다. 실제로 많은 커머스에서도 다음과 같은 문제를 겪을 수도 있다.


    문제점 발견

    PHANTOM READ (유령 읽기)

    유령읽기 라는 현상 때문에 발생하는 것이다. Wiki 의 내용을 발췌하자면 데이터를 읽은 후 로직을 진행하는데 있어서, 다른 트렌적션의 Update, Insert 의 개입으로 다시 읽었을 때 데이터의 무결성이 깨진다는 이야기이다.

    Phantom reads

    A phantom read occurs when, in the course of a transaction, new rows are added or removed by another transaction to the records being read.

    This can occur when range locks are not acquired on performing a SELECT ... WHERE operation. The phantom reads anomaly is a special case of Non-repeatable reads when Transaction 1 repeats a ranged SELECT ... WHERE query and, between both operations, Transaction 2 creates (i.e. INSERT) new rows (in the target table) which fulfill that WHERE clause.

    사실 완벽한 유령읽기 라고 하기에는 애매하지만, 결론은 여러명의 클라이언트가 동시 다발적으로 주문정보의 합을 계산을 하고 이를 100개가 넘지 않을 때 로직을 실행을 하는데 둘다 똑같은 SUM(OrderCount) 을 읽어 버리기 때문에 발생하는 문제이다. 그림을 그려보자


    문제 해결방안 생각 (실패 와 이유)

    문제점은 확인했으니, 이제 문제를 해결할 방안만 생각하면 된다. 문제를 해결하는게 제일 힘들었다. 대부분 현실성이 떨어지는 방안 이었고, 만약 운용하는 서비스에 적용하려면 빠르고, 정확하게 대처해야 한다.

    1. Message Queue 방식 도입

    Kafka 와 같은 message queue 를 사용한다는 점이다. 이부분은 나 스스로가 kafka 를 사용한 경험이 없으며, 한두달 공부한다고 사용할 수 있는 수준이 아니라고 생각한다. 그리고 message queue 를 도입하면, 지금까지 즉답을 받는 request response 방식이 아닌 event driven 방식의 application 을 고려해야 한다.

     

    그렇다고 kafka만 있지는 않다. 요즘 여럿 기업에서 클라이언트(front) 단에 message queue를 도입해서 request 를 큐로 정렬시키는 방식이 있다고 한다. 이 부분은 잘 모르겠다. 일단 가격적인 측면에서 물음표가 생길수 밖에 없다.

     

    1. Table lock 도입
    @Repository
    public interface OrderRepository extends JpaRepository<Orders, Long> {
        @Query("SELECT sum(o.orderCount) from Orders o where o.productName = ?1")
        Optional<Integer> findSumOrderCount(String productName);
    }

    이런식의 쿼리는 row Lock 을 걸기가 상당히 까다롭다. 사실 불가능에 가깝다고 생각한다. 해당하는 모든 열을 select 하고 이를 연산하는데 row Lock 은 불가능하다. 그렇다고 Table Lock 을 사용하기에는 DB에 무리를 주기 때문에 부담스러운 건 사실이다.

     

    이 모든 고민은 사실 row lock을 어떻게 걸 수 있을까에 대한 고민이었을 것이다.


    문제 해결법

    충돌 구체화

    자세한 내용은 링크를 참고하도록 하자

     

    https://academey.github.io/database/2020/06/24/transaction.html#%EC%B6%A9%EB%8F%8C-%EA%B5%AC%EC%B2%B4%ED%99%94

     

    데이터 중심 애플리케이션 7장. 트랜잭션

    0. 들어가며데이터 중심 애플리케이션 책의 7장 부분을 요약한 내용입니다. 학습을 위해 정리한 내용을 공유합니다. 정말 좋은 책이니 꼭 읽어보시길 강추드립니다.

    academey.github.io

     

    일단 연산을 통해 발생하는 충돌이고 이 연산은 모든 row 를 읽어야 하는 연산이기 때문에 row lock 이 불가능 했지만 만약 모든 연산을 이미 알고있는 객체가 있고, 이 객체를 읽을 때 row lock 을 건다면 ? 얘기가 달라진다. 다시 간단하게 그림으로 알아보자면

     

    그럼 어떻게 구현하면 될까? 만약 mybatis 를 사용하고 있다면 문법 그대로 비관적 락인 for update 를 사용하여 문제를 해결 할 수 있다. 일단 DB 구조부터 변경하자 연산이 되어있는 테이블을 Proxy_Orders 라고 생성했다.

    먼저 연산을 이미 한 객체 Table 이름은 Proxy 테이블 이라고 지정해 두었는데,

     

    지금 생각해보니 이름이 너무 구리다. 프록시는 아닌데 말이다.

    <select id="findOrderCnt" resultType="map">
        select po.order_cnt
        from proxy_orders po
        where po.product_name = 'MAC PRO M1 MAX' for update;
    </select>

    만약 JPA 를 사용한다면 다음 코드를 참고하도록 하자.

    @Repository
    public interface ProxyOrdersRepository extends JpaRepository<ProxyOrders, String> {
    ​
        // PESSIMISTIC_WRITE Exclusive Lock을 획득하고 데이터를 읽거나, 업데이트하거나, 삭제하는 것을 방지
        // PESSIMISTIC_READ Shared Lock을 얻고 데이터가 업데이트되거나 삭제되지 않도록
        // PESSIMISTIC_FORCE_INCREMENT  PESSIMISTIC_WRITE와 유사하게 작동하며 엔티티의 버전 속성을 추가로 증가
    ​
      @Lock(LockModeType.PESSIMISTIC_WRITE) // LockModeType으로 Option 변경 가능
      Optional<ProxyOrders> findByProductName(String productName);
      
    }

    PESSIMISTIC_WRITE 를 통행 @Lock 을 걸어 두었다, 이를 통해 다른 트랜잭션이 해당 프록시 DB 로우를 읽거나 쓸 수 없으니, Row Lock 완성이다.!

    이에 맞게 로직에서 사용하는 쿼리도 수정해서 반영하고, 테스트 코드도 반영해보자

    @Transactional
    public void creater(int nowOrderCnt){
        // 이전 까지의 주문 건수
        ProxyOrders proxyOrder = proxyOrdersRepository.findByProductName("MAC PRO M1 MAX").orElseThrow(NullPointerException::new);
        int expectedOrderCount = proxyOrder.getOrderCount() +  nowOrderCnt;
        if(expectedOrderCount > 100){
            System.out.println("주문이 실패 (사유 : 상품 주문 갯수 초과)");
            return;
        }
    ​
        proxyOrdersRepository.updateOrderCount(expectedOrderCount, proxyOrder.getProductName());
    ​
        System.out.println("주문이 성공했습니다.");
    }
    @Test
    @Transactional
    public void testSummationWithConcurrency2() throws InterruptedException {
    
        int numberOfThreads = 1000;
        ExecutorService service = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        for (int i = 0; i < numberOfThreads; i++) {
            service.submit(() -> {
                orderCreater.creater(10);
                latch.countDown();
            });
        }
        latch.await();
    
        ProxyOrders proxyOrder = proxyOrdersRepository.findByProductName("MAC PRO M1 MAX").get();
    
        Assertions.assertThat(proxyOrder.getOrderCount()).isEqualTo(100);
    }

    테스트 결과를 보면

    너무도 깔끔하고 완벽하게 작동한다. 여기 까지 팬텀문제 해결의 포스팅을 끝내도록 하겠다.


    후기

    F-lab 에서 멘토링을 진행하면서 멘토님(사부님 이라고 생각한다)께서 어떤 개발자가 되고싶냐는 꽤나 철학적인 질문을 받은적이 있다. 나는 "문제해결을 잘하는 개발자." 라고 말했던 것 같다.

     

    멘토님 께서는 "주어진 환경에서 최고의 문제해결을 제시하는 개발자" 라고 말씀하셨다. 사실 이번 문제는 message queue 를 도입하거나, 외부 의뢰를 통해서도, 또는 테이블락을 통해서도 해결할 수 있는 문제였다.

     

    하지만  지금 현재 내가 있는 조직에 적합한 해결방안인가?에는 생각이 많아지는 해결이긴 하다.

     

    나 또한 이번 문제를 해결하면서 현재 조직에 제일 적합한 해결 방안을 제시하는 것이 개발자의 역할 이고 본분이라고 생각하는 바뀌는 계기가 되었다. 물론 내가 틀렸을 수도 있다 :) ㅎㅎ

     

    그래도 문제를 해결했으니 행복하다면 OK 입니다.!

     

     

    댓글

Designed by Tistory.