동시성 문제는 다양한 상황에서 발생할 수 있는데, 해당 문제를 인지하지 않고 개발하게되면 서비스의 규모가 커질수록 감당할 수 없는 문제가 생긴다.
가장 흔한 예시가 계좌이체에 관련된 이슈이다.
현재 10,000원을 가지고있는 A의 계좌에 B와 C가 동시에 10,000원, 20,000원을 입금한다고 가정해보자.
비즈니스 로직 상, 기존 A의 계좌에 있는 데이터를 확인 후에 입금 된 돈을 더하는 방식일 확률이 굉장히 높다.
웹은 대개 멀티 쓰레드 환경이다. 각 요청은 독립적으로 작용하기때문에 각 요청별로 쓰레드가 할당되어서, 로직을 수행한다.
예를 들어보자.
B와 C가 동시에 접근하여, A의 계좌를 조회했다. 이 때, A의 계좌에는 B나 C가 입금한 금액이 추가되지 않았다고 가정한다.
A의 계좌에 금액이 추가되기 전에 B, C 트랜잭션이 DB에서 A의 현재 금액(10,000원)을 조회해왔을 것이다.
여기서 B 트랜잭션이 먼저 A의 계좌에 10,000원을 추가했을 때, A의 계좌에는 20,000원이 있을 것이다.
그런데, C 트랜잭션이 조회했을 시점의 A의 계좌의 금액은 10,000원이기 때문에, C 트랜잭션이 입금했을때는 40,000원이 되는 것이 아니라 30,000원이 되는 것이다.
즉, B가 입금한 금액 10,000원이 영구적으로 분실되는 상황이 발생한다.
이러한 상황을 바로 두 번의 갱신 분실 문제 (second lost updates problem) 라고 한다.
트랜잭션의 격리 수준으로는 '마지막 커밋만 인정하기' 외의 정책을 구현할 수가 없다.
현재 진행중인 토이 프로젝트에서도 같은 상황이 발생한다.
내가 만든 서비스는 상품 구매 서비스이다.
동시에 여러 요청이 들어와서 해당 상품을 구매하려고 할 때 재고가 조회되는 시점과 최종적으로 구매에 성공하여 재고가 감소하는 시점에서 충분히 해당 문제가 발생할 수 있다.
@Test
void 주문_생성_동시성_테스트_성공() throws InterruptedException {
long beforeStock = itemRepository.findById(item.getId()).orElseThrow().getQuantity();
int threadCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
orderService.purchase(item, member.getId(), 1);
latch.countDown();
}
);
}
latch.await();
assertThat(beforeStock - threadCount).isEqualTo(item.getQuantity());
assertThat(orderRepository.findAll().size()).isEqualTo(threadCount);
}
당장 해당 사항에 대해서 테스트를 해봤다. ExecutorService는 멀티 쓰레드 환경을 만들어주고 테스트하기에 편리한 구성을 해준다고 생각하면 된다.
기존 상품의 재고는 1000개, 쓰레드를 총 1000개를 만들어서 테스트를 돌리기때문에, 동시성 문제가 없다면 해당 상품은 0개의 재고를 가지고 있게 되는것이 맞을 것이다.
그러나 결과는 이렇다.
내가 여기서 동시성 문제를 해결하기 위해서 사용한 방법은 비관적 락 이라는 DB에서 지원하는 기능인데,
동시에 접근하는 트랜잭션들이 데이터를 변경하지 못하도록 제어하는 락 기법이다. 비관적 락은 데이터의 무결성을 보장하고, 동시성 제어를 위해 사용된다.
이 비관적 락은 다음과 같은 특징을 가진다.
- 락 획득: 비관적 락은 트랜잭션이 데이터를 접근하기 전에 락을 획득한다. 이를 통해 다른 트랜잭션이 해당 데이터를 변경하는 것을 방지한다.
- 잠금 유지: 비관적 락은 트랜잭션이 데이터를 사용하는 동안 락을 유지한다. 이는 다른 트랜잭션이 해당 데이터에 대한 변경을 시도할 때 대기하게 만든다.
- 동시성 제어: 비관적 락은 동시에 여러 트랜잭션이 데이터에 접근하는 것을 제어한다. 데이터에 대한 락을 획득한 트랜잭션 외에는 다른 트랜잭션이 해당 데이터를 변경할 수 없다.
이 비관적 락은 해당 접근에 대해서 항상 동시성 문제가 생길것을 가정한다.
항상 문제가 생기지는 않겠지만, 상품 구매와 같은 중요한 서비스에서는 다른 서비스들에 비해 문제가 생길 가능성이 높기 때문에 비관적 락(Exclusive Lock)을 사용해 동시성 문제를 해결하기로 결정했다.
소스코드는 다음과 같다.
@Transactional
public OrderResponse paymentValidate(PaymentRequest request) throws IamportResponseException, IOException {
Payment payment = iamportClient.paymentByImpUid(request.getImpUid()).getResponse();
Optional<Item> item = itemRepository.findByWithPessimisticLock(request.getItemName());
validation(payment, item, request.getQuantity());
OrderResponse response = orderService.purchase(item.get(), request.getMemberId(), request.getQuantity());
return response;
}
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select i from Item i where i.name = :name")
Optional<Item> findByWithPessimisticLock(@Param("name") String name);
}
1. 구매 요청이 들어왔을 때 해당 테이블에 대해 쓰기 잠금에 대한 락을 획득한다.
2. 상품과 구매 요청에 대한 검증 처리 (해당 검증에서 실패하면 rollback이 이뤄짐)
3. 상품 구매
이렇게 요청이 들어오는 시점에 다른 트랜잭션이 해당 데이터에 대한 쓰기 접근 권한을 막아버려 동시성 문제를 제어할 수 있다.
낙관적 락, 비관적 락, MySql의 네임드 락 등 다양한 방법을 지원하지만 해당 시점에 가장 맞는 동시성 제어 방식은 비관적 락이라고 결정했고, 로직을 구성했다.
테스트는 정상적으로 성공하게됐다.
이 과정에서 발생한 문제가 있는데,
해당 테스트에서는 @Transactional을 사용하면 안된다!
테스트에서 트랜잭셔널 어노테이션을 사용하게되면 테스트 실행 후 해당 데이터를 롤백시켜준다는 사실은 다들 알고있을것이고, 아무런 의심없이 사용하게 될 확률이 높다.
해당 테스트가 실행중인 하나의 쓰레드에서만 돌아가는 테스트라면 문제가될 부분이 전혀 없지만, 여기서는 인위적으로 쓰레드를 1000개를 만들어서 해당 테스트를 돌린다.
@Transactional은 단일 스레드에서만 동작한다. DataSource로부터 현재 스레드에 커넥션을 연결하는데, 쓰레드로컬에서 해당 커넥션을 관리하기때문에 멀티 스레드를 사용하는 경우에는 의도한 것 처럼 실행되지 않는다.
이런 상황만 조심하면 될 것 같다.
'ToyProject' 카테고리의 다른 글
[ToyProject] 상품 구매 서비스 개선 - 2 - (0) | 2023.07.01 |
---|---|
[ToyProject] 상품 구매 서비스 개선 - 1 - (0) | 2023.07.01 |
[ToyProject] 동시성 문제 해결에 대한 고민 (0) | 2023.06.30 |
[ToyProject] 상품 구매 서비스 리팩토링 (0) | 2023.06.25 |
[ToyProject] PortOne 결제모듈 연동 (1) | 2023.06.12 |