Engineering Note

[SW Engineering] 이커머스 동시성 문제 해결 본문

SW Engineering

[SW Engineering] 이커머스 동시성 문제 해결

Software Engineer Kim 2025. 12. 19. 07:45

문제 상황

이커머스 도메인의 특성상 재고 관리가 중요하기 때문에 여러 사용자가 동시에 같은 상품을 주문하는 상황을 가정하고 테스트를 해보았습니다.

 

OrderService.java의 order 메서드

 public Long order(OrderDto orderDto, String email) {
        Item item = itemRepository.findById(orderDto.getItemId()).orElseThrow(EntityNotFoundException::new);
        Member member = memberRepository.findByEmail(email);

        List<OrderItem> orderItemList = new ArrayList<>();

        OrderItem orderItem = OrderItem.createOrderItem(item, orderDto.getQuantity());
        orderItemList.add(orderItem);

        Order order = Order.createOrder(member, orderItemList);
        orderRepository.save(order);

        return order.getId();
    }

 

 

동시성 제어 테스트 코드

	@Test
    @DisplayName("10명이 동시에 같은 상품 주문")
    void lostUpdateProblemTest() throws InterruptedException {
        // given: 재고가 충분한 상품
        Item item = new Item();
        item.setItemNm("인기 상품");
        item.setPrice(10000);
        item.setItemDetail("재고가 충분한 상품");
        item.setItemSellStatus(ItemSellStatus.SELL);
        item.setStockNumber(100); // 충분한 재고
        item = itemRepository.save(item);

        // 10명의 회원 생성
        int memberCount = 10;
        for (int i = 0; i < memberCount; i++) {
            Member member = new Member();
            member.setEmail("user" + i + "@test.com");
            member.setName("사용자" + i);
            memberRepository.save(member);
        }

        final Long itemId = item.getId();

        // when: 10명이 동시에 2개씩 주문 (총 20개 주문)
        int threadCount = 10;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);
        
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < threadCount; i++) {
            final int userIndex = i;
            executorService.execute(() -> {
                try {
                    OrderDto orderDto = new OrderDto();
                    orderDto.setItemId(itemId);
                    orderDto.setQuantity(2);
                    
                    orderService.order(orderDto, "user" + userIndex + "@test.com");
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    System.err.println("주문 실패: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();

        // then: Lost Update 문제 확인
        Item resultItem = itemRepository.findById(itemId).orElseThrow();
        
        int expectedStock = 100 - (successCount.get() * 2);
        int actualStock = resultItem.getStockNumber();
        
        System.out.println("=== Lost Update 문제 재현 결과 ===");
        System.out.println("성공한 주문 수: " + successCount.get() + "명");
        System.out.println("주문한 총 수량: " + (successCount.get() * 2) + "개");
        System.out.println("예상 재고: " + expectedStock + "개");
        System.out.println("실제 재고: " + actualStock + "개");
        System.out.println("재고 차이: " + (actualStock - expectedStock) + "개");
        
        // Lost Update가 발생하면 실제 재고가 예상보다 많이 남음
        // 예: 10명이 각각 차감했지만, 업데이트가 덮어씌워져서 마치 1~2명만 차감한 것처럼 보임
        assertThat(actualStock).isEqualTo(expectedStock);
    }
`

 

테스트 결과

 

 

시나리오 분석:

시간 | 트랜잭션 A (사용자1)              | 트랜잭션 B (사용자2)
-----|----------------------------------|----------------------------------
T1   | stockNumber = 100 읽음           |
T2   |                                  | stockNumber = 100 읽음
T3   | restStock = 100 - 2 = 98 계산     |
T4   |                                  | restStock = 100 - 2 = 98 계산
T5   | stockNumber = 98 로 업데이트     |
T6   |                                  | stockNumber = 98 로 업데이트 💥
T7   | COMMIT                           |
T8   |                                  | COMMIT
결과 | 최종 재고 = 98 (실제로는 96(100-4)이어야 함)

 

사용자1이 재고를 업데이트했지만 사용자2가 업데이트 이전의 재고 값으로 다시 덮어쓰는 Lost Update 문제가 발생하여 상품 재고의 일치하지 않는 문제가 발생하여 주문시 상품을 조회하는 메서드에 Lock을 걸어 문제를 해결했습니다.

 

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Override
  Optional<Item> findById(Long id);

 

 

수정후

## 🔧 해결 방법: 비관적 락 (Pessimistic Lock)

ItemRepository에 `@Lock` 어노테이션을 추가하여 비관적 락을 적용했습니다.
```java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Override
Optional findById(Long id);
```

**동작 방식:**
- `findById()` 호출 시 해당 상품에 대해 데이터베이스 레벨에서 락 획득 (`SELECT ... FOR UPDATE`)
- 다른 트랜잭션은 락이 해제될 때까지 대기
- 트랜잭션 종료(COMMIT/ROLLBACK) 시 락 자동 해제

**수정 후 흐름:**
```
시간 | 트랜잭션 A                        | 트랜잭션 B
-----|----------------------------------|----------------------------------
T1   | findById() - 락 획득 🔒          |
T2   | stockNumber = 100 읽음           |
T3   |                                  | findById() - 대기 ⏳
T4   | restStock = 100 - 2 = 98 계산    |
T5   | stockNumber = 98로 업데이트      |
T6   | COMMIT - 락 해제 🔓              |
T7   |                                  | 락 획득 → stockNumber = 98 읽음
T8   |                                  | restStock = 98 - 2 = 96 계산
T9   |                                  | stockNumber = 96로 업데이트
T10  |                                  | COMMIT
결과 | 최종 재고 = 96 ✅ (정확함)

 

Comments