[spring] 동시성 이슈에 대해서

김용범·2024년 12월 26일
0

동시성 이슈

동시성 이슈는 정말 많은 곳에서 발생한다. 보통 한정된 자원에 대해서 여러 유저들이 동시에 접근하는 상황에서 반드시 발생하는 이슈라고 생각한다. 그 예는 다음과 같다.

  1. 영화, 뮤지컬, 콘서트 좌석 예약을 하는 경우
  2. 음식집 예약을 하는 경우
  3. 쇼핑몰에서 재고가 남아있는 의류를 주문하는 경우

이 외에도 많은 상황들이 존재한다. 이런 상황에서 백엔드 개발자들은 데이터의 정합성을 유지하면서 유저의 요청을 문제없이 해결해야한다. 그럼 그 방법에는 어떤 것들이 있을까 ?

동시성 해결 방안

카운트 감소 방법

가장 일반적으로 간단하게 생각하는 방법은 아마 일정 개수를 설정한 다음에, 요청이 들어올 때마다 개수를 요청한 개수만큼 줄이는 방법이 있을 것이다. 다음 코드를 보자.

코드

@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 사용

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 는 단일 서버 내에서만 적용되고, 다른 서버의 스레드에는 관여를 하지 못한다. 즉, 각기 다른 서버에서 스레드가 동시에 접근하는 상황에서는 처음과 같이 동시성 문제를 해결할 수 없게 된다. 그럼 분산 서버까지 확장해서 해결책을 생각해보자.

DB 단 Lock

Pessimistic Lock

Optimistic Lock

Named Lock

Redis 분산 락

profile
꾸준함을 기록하며 성장하는 개발자입니다!

0개의 댓글

관련 채용 정보