개발/Java&Kotlin

[Spring] 동시성 이슈 해결 방법 (2)

devhooney 2022. 9. 21. 08:20
728x90

지난번 포스팅과 코드를 이어간다.

 

https://devhooney.tistory.com/108

 

[Spring] 동시성 이슈 해결 방법 (1)

간단한 재고 시스템으로 알아보는 동시성 이슈 Stock @Entity public class Stock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long productId; private Long quantity;..

devhooney.tistory.com

 

지난번에 race condition을 방지하기 위해서 데이터에 하나의 쓰레드만 접근하도록 하는것이 방법이었다.

이외에도 다른 방법들이 있는데, 하나씩 정리해보려고 한다.

 

자바에서 지원하는 방법으로 문제를 해결해보려고 한다.

 

synchronized를 이용하면 데이터에 하나의 쓰레드만 접근하게 된다.

 

StockService

@Service
public class StockService {
    private StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    @Transactional
    public synchronized void decrease(Long id, Long quantity) {
        // get stock
        // 재고 감소
        // 저장

        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}

- void 앞에 synchronized를 추가했다.

- 이후 테스트코드를 실행하면 문제가 발생하는데, 이러한 이유는 @Transactional 동작방식 때문인데,

스프링에서는 @Transactional는 만든 클래스를 새롭게 만들어서 실행한다.

 

이해하기 쉽게 코드로 보면

 

public void decrease(Long id, Long quantity) {
    startTransaction();
    
    stockService.decrease(id, quantity); 
    
    endTransaction();
}

 

이런식으로 진행되는데

endTransaction()에서 DB에 커밋을 한다.

하지만 이때 endTransaction()전에 다른 쓰레드에서 DB 커밋 전에 decrease()를 하면, 최신 상태가 아닌 데이터를 조작하기 때문에 결과가 꼬이게 된다.

이러한 문제를 해결하기 위해서는 @Transactional를 삭제하고 진행하면 된다.

 

Test코드


@SpringBootTest
class StockServiceTest {
    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    public void before() {
        Stock stock = new Stock(1L, 100L);

        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void after() {
        stockRepository.deleteAll();
    }

    @Test
    public void stock_decrease() {
        stockService.decrease(1L, 1L);

        // 100 - 1 = 99
        Stock stock = stockRepository.findById(1L).orElseThrow();

        assertEquals(99, stock.getQuantity());

    }

    @Test
    public void 동시에_100개의_요청() throws InterruptedException {
        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();

        Stock stock = stockRepository.findById(1L).orElseThrow();

        // 100 - (1 * 100) = 0
        assertEquals(0L, stock.getQuantity());

    }
}

 

하지만 이러한 방법에서도 문제가 있다.

서버가 1개가 아닌 여러 개 일 경우

Time Server1 Stock Server2
10:00 select * from stock where id =1 id: 1, quantity: 5  
    id: 1, quantity: 5 select * from stock where id =1
10:05 update set quantity = 4 fromstock where id = 1 id: 1, quantity: 4  
    id: 1, quantity: 4 update set quantity = 4 fromstock where id = 1

 

이렇게 서버 1에서 10시에 서버 재고 감소 로직을 시작하고 10시 5분에 종료하게 되면,

서버2에서 10시와 10시 5분 사이에 데이터에 접근이 가능하게 된다. -> race condition 발생

synchronized는 각 프로세스 안에서만 보장이 되기 때문에 서버가 여러 개일 경우 여러 쓰레드가 데이터에 접근할 수 있게 된다.

보통 실무에선 서버가 2대 이상이기 때문에 실무에서는 synchronized는 잘 사용되지 않는다.

 

 

728x90