✏️/Spring

[Spring Boot] 동시성 제어 (1) - 동시성 문제, 재고 감소 시스템 구현 및 테스트

sssbin 2024. 11. 9. 16:43

** 본 글은 인프런 <재고시스템으로 알아보는 동시성이슈 해결방법> 을 수강한 후 작성한 글입니다.

동시성 문제를 해결하지 않으면?

Race Condition!

→ 두 개 이상의 프로세스가 공통 자원을 병행적으로(concurrently) 읽거나 쓰는 동작을 할 때, 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 같지 않고 달라지는 상황

재고 감소 시스템 구현 및 테스트

재고 감소 로직 작성

  1. 엔티티 생성

     package com.example.stock.domain;
    
     import jakarta.persistence.Entity;
     import jakarta.persistence.GeneratedValue;
     import jakarta.persistence.GenerationType;
     import jakarta.persistence.Id;
     import lombok.AccessLevel;
     import lombok.Builder;
     import lombok.Getter;
     import lombok.NoArgsConstructor;
    
     @Entity
     @Getter
     @NoArgsConstructor(access = AccessLevel.PROTECTED)
     public class Stock {
    
         @Id
         @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         private Long productId;
    
         private Long quantity;
    
         @Builder
         private Stock(Long productId, Long quantity) {
             this.productId = productId;
             this.quantity = quantity;
         }
    
         public static Stock of(Long productId, Long quantity) {
             return builder()
                     .productId(productId)
                     .quantity(quantity)
                     .build();
         }
    
         public void decrease(Long quantity) {
             if (this.quantity - quantity < 0) {
                 throw new RuntimeException("재고는 0개 이상이어야 합니다.");
             }
    
             this.quantity -= quantity;
         }
     }
  2. repository 생성

     package com.example.stock.repository;
    
     import com.example.stock.domain.Stock;
     import org.springframework.data.jpa.repository.JpaRepository;
    
     public interface StockRepository extends JpaRepository<Stock, Long> {
     }
  3. 서비스 로직 작성

     package com.example.stock.service;
    
     import com.example.stock.domain.Stock;
     import com.example.stock.repository.StockRepository;
     import lombok.RequiredArgsConstructor;
     import org.springframework.stereotype.Service;
     import org.springframework.transaction.annotation.Transactional;
    
     @Service
     @RequiredArgsConstructor
     public class StockService {
    
         private final StockRepository stockRepository;
    
             @Transactional
         public void decrease(Long id, Long quantity) {
             // Stock 조회
             Stock stock = stockRepository.findById(id)
                     .orElseThrow(() -> new RuntimeException("Stock을 조회할 수 없습니다."));
    
             // 재고 감소
             stock.decrease(quantity);
    
             // 갱신된 값 저장
             stockRepository.saveAndFlush(stock);
         }
     }
  4. 테스트

     package com.example.stock.service;
    
     import com.example.stock.domain.Stock;
     import com.example.stock.repository.StockRepository;
     import org.junit.jupiter.api.AfterEach;
     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.jupiter.api.Assertions.*;
    
     @SpringBootTest
     class StockServiceTest {
    
         @Autowired
         private StockService stockService;
    
         @Autowired
         private StockRepository stockRepository;
    
         @BeforeEach
         void before() {
             stockRepository.saveAndFlush(Stock.of(1L, 100L));
         }
    
         @AfterEach
         void after() {
             stockRepository.deleteAll();
         }
    
         @Test
         void 재고_감소() {
             // when
             stockService.decrease(1L, 1L);
    
             // then
             Stock stock = stockRepository.findById(1L).get();
             assertEquals(99, stock.getQuantity());
         }
     }

    1개의 재고를 감소시켰고, 예상 값과 결과 값이 일치했다.

재고 감소 시스템에서의 동시성 문제

그렇다면 동시에 100개의 요청을 넣으면 어떻게 될까? 과연 예상 값과 결과 값이 일치할까?

@Test
void 동시에_100개의_요청() throws InterruptedException {
    // when
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i=0; i<threadCount; i++) {
        executorService.submit(() -> {
            try {
                stockService.decrease(1L, 1L);
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    // then
    Stock stock = stockRepository.findById(1L).get();
    assertEquals(0, stock.getQuantity());
}

100개의 재고에 대해 100개의 요청을 넣으면 0개가 남을 것 같지만 테스트 코드는 실패했다.

이유가 뭘까? 바로 Race Condition이 발생했기 때문!

하나의 자원에 여러 프로세스가 접근하면서 값이 올바르게 갱신되지 않는 문제가 생겼다.

이러한 문제를 해결하려면 하나의 스레드가 작업이 완료된 이후에 다른 스레드가 데이터에 접근할 수 있도록 하면 된다.