동시성 이슈는 정말 많은 곳에서 발생한다. 보통 한정된 자원에 대해서 여러 유저들이 동시에 접근하는 상황에서 반드시 발생하는 이슈라고 생각한다. 그 예는 다음과 같다.
이 외에도 많은 상황들이 존재한다. 이런 상황에서 백엔드 개발자들은 데이터의 정합성을 유지하면서 유저의 요청을 문제없이 해결해야한다. 그럼 그 방법에는 어떤 것들이 있을까 ?
가장 일반적으로 간단하게 생각하는 방법은 아마 일정 개수를 설정한 다음에, 요청이 들어올 때마다 개수를 요청한 개수만큼 줄이는 방법이 있을 것이다. 다음 코드를 보자.
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고 감소
stock.decrease(quantity);
// 재고 저장
stockRepository.save(stock);
}
}
이 코드는 매개로 받은 id에 해당하는 Entity를 조회하고, 해당 Stock Entity에 매개로 받은 quantity 개수만큼 감소를 시킨 후에 다시 DB에 저장하는 로직이다. 정말 간단한 로직이다. 이 코드가 정합성을 유지할 수 있는지 테스트를 해보자.
@Test
public void 재고감소() {
stockService.decrease(1L, 1L);
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
이 테스트 코드는 당연하게도 통과한다. 그렇다면, 이 코드로 동시성 문제를 완벽하게 해결할 수 있을까?
이 코드 가지고는 동시성 문제를 해결할 수 없다. 이 코드의 문제점은 멀티 스레드 환경에서 발생한다. java ExecutorService
라이브러리를 활용하여 100개의 재고에 대해서 동시에 100개의 스레드가 재고 1개씩 감소시키는 코드를 작성해보자. 이론적으로는 각 스레드가 1씩 감소하므로 최종 결과는 0이 될 것이다.
@Test
@DisplayName("동시에 100개의 요청 - 멀티스레드 버전")
public void multiThread() 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(0, stock.getQuantity());
}
이 코드에 대한 테스트 결과는 다음과 같다.
예상한 결과는 0개인데, 실제로는 88개가 남아있다. 왜 이런 현상이 나타날까? 그것은 바로 멀티스레드 환경에서 발생하는 레이스 컨디션
현상 때문이다. 레이스 컨디션이란 멀티스레드 환경에서 공유 자원에 동시에 여러 스레드가 접근하여 실행 순서를 예측할 수 없는 상황을 말한다.
위 사진과 같이 Lock을 걸지 않는 한, 멀티스레드 환경에서는 동시에 남아있는 quantity값이 5인 상황이 여러 스레드 환경에 적용이 될 수 있다. 즉, 그렇다면 여러 스레드가 동시에 접근 하더라도 재고를 감소하는 메소드에 하나의 스레드만 접근할 수 있도록 전략을 바꿔보자.
java에서 synchronized
키워드를 메소드에 붙여주면 해당 메소드가 어떤 스레드에서 사용중이면 다른 스레드에서는 접근할 수 없게 만들어준다. 따로 구현할 것 없이 해당 키워드를 메서드 반환형 타입 왼쪽에 작성해주자.
// @Transactional
public synchronized void decrease(Long id, Long quantity) {
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고 감소
stock.decrease(quantity);
// 재고 저장
stockRepository.save(stock);
}
이렇게 수정한 다음에 Test 코드를 다시 실행해보니, 결과는 다음과 같다. (단, @Transactional 어노테이션은 주석처리해주자. synchorized 키워드를 붙인다고 해도, Transactional 어노테이션의 동작 방식으로 인하여 정상적으로 작동하지 않을 수 있다.)
테스트를 통과하는 것을 알 수 있다. 그러면, 정말 이것으로 동시성 이슈를 모두 해결할 수 있을까?
이 방법 역시 허점이 많다. 현재까지는 단일 서버의 멀티스레드 환경을 가정하고, 해결책을 찾아본 것이다. 요즘엔 대부분 분산 서버를 사용한다. synchronized
는 단일 서버 내에서만 적용되고, 다른 서버의 스레드에는 관여를 하지 못한다. 즉, 각기 다른 서버에서 스레드가 동시에 접근하는 상황에서는 처음과 같이 동시성 문제를 해결할 수 없게 된다. 그럼 분산 서버까지 확장해서 해결책을 생각해보자.