본문 바로가기
혼자 공부하는 것들/JPA

[Spring + JPA] 동시성 문제를 해결해보자!

by applepick 2022. 9. 9.
반응형

서버에서 여러 트랜잭션에서 동시에 같은 데이터를 변경하려고 시도할 경우 데이터 요청이 일부 유실될 수 있습니다. 이것은 큰 장애로 커질 수 있는데요. 예를 들어 상품이 100개인 상품이 있다고 가정해봅니다. 서로 다른 5명이 동시에 100개를 동시에 주문할 경우 한 명의 주문만 성공해야 합니다. 5명의 주문이 성공할 경우 큰 장애로 이어집니다. 이런 이슈를 JPA의 락 기능을 통해서 동시성 문제를 해결해보려고 합니다.

 

이슈

동시에 여러 트랜잭션을 통해 주문이 들어올 경우 주문이 모두 성공하는 이슈가 있었습니다.

 

해당 상품으로 테스트를 진행해보겠습니다. 상품 테이블에 맥북의 재고가 100개가 저장되어있습니다.

아래 테스트 코드를 보면서 문제점을 확인해봅니다.

package assignment.shop.concurrency;


import assignment.shop.order.Address;
import assignment.shop.order.Order;
import assignment.shop.item.repository.ItemRepository;
import assignment.shop.order.repository.OrderRepository;
import assignment.shop.order.service.OrderService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.springframework.test.util.AssertionErrors.assertEquals;

@WebAppConfiguration
@SpringBootTest
@Transactional
public class OrderServiceConcurrencyTest {
    @Autowired
    OrderService orderService;
    @Autowired
    OrderRepository orderRepository;
    @Autowired
    ItemRepository itemRepository;

    @Test
    public void 재고가_100개_있는상품을_동시에_10개씩_80번이상주문하면_10개의주문만_성공되어야한다() throws Exception {
        //given
        DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME;
        LocalDateTime startDate =  LocalDateTime.parse("1999-08-15T00:00:00", formatter);
        LocalDateTime endDate =  LocalDateTime.parse("9999-12-31T00:00:00", formatter);
        Long memberId = 100L;
        Long itemId = 1L;
        Address address = new Address("서울시" ,"100길", "205-106");
        int orderPrice = 34000000;
        int count = 10;

        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32); //고정된 쓰레드 풀 생성
        CountDownLatch countDownLatch = new CountDownLatch(threadCount); //어떤 쓰레드가 다른 쓰레드에서 작업이 완료될 때 까지 기다릴 수 있도록 해주는 클래스

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(()->{
                try {
                    orderService.order(memberId, itemId, orderPrice, address, count);
                } finally {
                    countDownLatch.countDown(); //Latch가 1개씩 감소된다.
                }
            });
        }
        countDownLatch.await(); //Latch가 0이될때까지 기다린다.
        //when
        List<Order> orders = orderRepository.findUserOrders(100L, startDate, endDate);

        //then
        assertEquals("주문이 10개만 성립되어야한다.",orders.size(), 10);


    }


}

해당 테스트가 실패하게 됩니다. 데이터를 확인해볼까요?

 

ORDERS Table

ORDER_ITEM Table

 

ITEM Table

주문, 주문 아이템, 상품 테이블에 데이터가 하나도 맞지 않는 것을 볼 수 있습니다. 주문은 80개가 넘게 들어가고, 상품은 20개밖에 줄지 않고 주문 상품 테이블에는 맥북을 재고 수보다 많은 양을 주문 성공으로 나타나고 있습니다.

 

해결방법 

JPA Lock에 낙관적인 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)이 있습니다. 이 중에 저는 비관적 락 방식으로 해결했습니다.

 

낙관적 락(Optimistic Lock)

낙관적 락이란 데이터가 갱신할 경우 충돌이 절대 생기지 않을 거라고 낙관적으로 데이터를 보고 락을 거는 방법입니다. 이 방법은 직접적으로 디비를 잠구는 것이 아니라 충돌 방지에 좀 더 가깝습니다. JPA에서는 Optimistic Lock를 쉽게 사용하도록 제공해주고 있습니다.

바로 @version이라는 어노테이션을 설정해줘서 Entity에 변경 감지를 감지하는 형식입니다.

 

비관적 락(Pessimistic Lock)

트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법입니다. 트랜잭션 안에서 서비스 로직이 진행되어야 합니다.
비관적 잠금 메커니즘은 데이터베이스 수준에서 엔티티 잠금을 포함합니다.

 

@Lock 어노테이션의 LockModeType을 설명해드리겠습니다.

LockModeType.PESSIMISTIC_READ

dirty read가 발생하지 않을 때마다 공유 잠금(Shared Lock)을 획득하고 데이터가 UPDATE, DELETE 되는 것을 방지할 수 있습니다.

LockModeType.PESSIMISTIC_WRITE

배타적 잠금(Exclusive Lock)을 획득하고 데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는 것을 방지할 수 있습니다.

LockModeType.PESSIMISTIC_FORCE_INCREMENT

PESSIMISTIC_WRITE와 유사하게 작동 하지만 @Version이 지정된 Entity와 상호관계를 맺으며 PESSIMISTIC_FORCE_INCREMENT 잠금을 획득할 시 버전이 업데이트됩니다.

 

저는 주문 시에 상품을 조회하는 부분에 lock을 걸어줬습니다.

@Repository
@RequiredArgsConstructor
public class ItemRepository {
    private final EntityManager em;

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    public Item findOne(Long id){
        return em.find(Item.class,id);
    }
    ....
}

 

PESSIMISTIC_WRITE를 사용한 이유는 해당 상품을 조회할 시 배타적 잠금을 획득하여 상품이 변경됨을 방지하기 위해 사용했습니다. 

 

상품 Entity를 살펴보면

@Entity
@Getter @Setter
@Audited
public class Item {
    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    @Version
    private int version;
    .....

@Version을 설정해주었습니다. 해당 필드로 상품 변경을 감지할 수 있습니다.

 

배타적 잠금을 설정하고 다시 테스트 코드를 실행해보겠습니다.

해당 테스트가 성공하는 것을 볼 수 있습니다. 데이터를 확인해보겠습니다.

 

주문 테이블에 정확이 10개의 주문이 들어가는 것을 볼 수 있습니다.

 

주문 상품 테이블을 확인해보겠습니다.

 

상품 10개씩 10개의 주문에 저장됩니다.

재고가 정확히 0이 되는 것을 볼 수 있습니다.

 

 

이렇게 JPA의 lock 방식을 사용하여 동시성 문제를 해결할 수 있었습니다. 하지만 제가 사용했던 배타적 잠금(Exclusive Lock) 방식은 성능이 다소 떨어질 수 있습니다. 그 이유는 Exclusive Lock은 데이터를 변경하고자 할 때 사용되며, 트랜잭션이 완료될 때까지 유지되어 해당 Lock이 해제될 때까지 다른 트랜잭션은 해당 데이터에 읽기를 포함하여 접근을 할 수 없기 때문입니다. 

반응형

댓글