작지만 꾸준한 반복

동시성 문제를 해결해보자 - synchronized 본문

카테고리 없음

동시성 문제를 해결해보자 - synchronized

iamjooon2 2023. 12. 3. 23:58

 

프로젝트를 진행하면서 꼭 한 번 만나고싶었던 문제가 바로 동시성문제였는데요

선착순으로 실행되는 기능이 없고, 좋아요 기능이 있지만 서비스 기획상 치명적이지 않아서, 동시성 문제를 만날 일이 없었습니다

 

그래서 따로 찾아 공부하고 익혀 정리해보려 합니다

 

먼저, 동시성 문제란 무엇일까용

공대생의 친구 위키피디아에 동시성 제어를 검색해보았습니다

 

정보기술과 컴퓨터 과학에서, 특히 컴퓨터 프로그래밍운영 체제멀티프로세서데이터베이스 분야에서 동시성 제어(concurrency control)는 가능한 빠른 조회와 동시에 병행되는 동작의 정확한 결과가 발생하는 것을 보증한다.

 

라고 합니다

 

제 언어로 정리해보자면, 동시에 여러 사용자가 접근할 때 발생하는 문제

라고 정리해볼 수 있겠습니다.

 

코드 예제와 함께 보겠습니다

public void decrease(Long id) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease();

        stockRepository.save(stock);
}

 

영속성 레이어에서 id를 바탕으로 재고(Stock)을 가져와 재고를 하나 감소시킨 다음(decrease()),

데이터베이스에 변경 내역을 반영(save()) 하는 메서드입니다

 

겉보기에는 문제없어보이지만, 동시에 100명의 사용자가 해당 메서드를 통해 Stock에 접근한다고 가정해봤을 때, 해당 코드는 문제가 발생합니다

테스트코드와 함께 보겠습니다

 

@SuppressWarnings("NonAsciiCharacters")
@SpringBootTest
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void before() {
        stockRepository.saveAndFlush(new Stock(1L, 100L));
    }

    @Test
    void 동시에_100개의_요청을_받는다() throws InterruptedException {
        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount); // 다른 쓰레드에서 작업이 완료될 때까지 기다릴 수 있게 해주는 클래스

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

        // then
        Long quantity = stockRepository.findById(1L).orElseThrow().getQuantity();
        assertThat(quantity).isZero();
    }

}

 

 

동시에 100개의 요청이 들어옴을 가정한 이용한 위 테스트는 보기 좋게 실패합니다.

 

이유는 동시성 문제가 발생했기 때문인데요

100명의 사용자가 같은 순간 접근한다고 가정할 때, 정합성이 보장되지 않는 것을 볼 수 있습니다.

 

이 동시성 문제는, Race Condition(경쟁 상태)에 해당되기 때문에 발생하는 문제인데요

 

Race Condition은, 두 개 이상의 프로세스나 스레드가 하나의 공유자원에 접근하여 변경하려는 경우

접근 순서에 따라 실행 결과가 달라지는 현상을 의미합니다.

 

즉, 여러 스레드가 동시에 Stock에 접근해서 일어나는 일임을 알 수 있습니다.

 

그렇다면, 이 문제를 어떻게 해결할 수 있을까요?

 

동시성 문제를 해결하기 위한 방법은 크게 세가지가 있습니다

 

1. Java의 synchronized 키워드를 이용하여 해결하는 방법

2. 데이터베이스의 Lock을 이용한 방법

- 비관적 락

- 낙관적 락

- 네임드 락

3. Redis를 이용한 방법

- Lettuce

- Redisson

 

 

위 방법들을 하나하나 정리하고, 각각의 장단점을 비교해보려 합니다

 

첫번째 방법은, synchronized를 이용한 방법입니다

synchronized를 이용한 방법은 간단합니다. 해당하는 메서드에 synchronized 키워드만 붙이면 됩니다

public synchronized void decrease(Long id) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease();

        stockRepository.save(stock);
}

 

정말 간단하죠?

 

이 synchronized 키워드를 통해, 하나의 쓰레드만 접근이 가능하도록 성장할 수 있습니다.

간단하게 해결할 수 있지만, 그만큼의 리스크역시 존재합니다.

 

1. @Transactional 어노테이션을 사용하지 못한다

2. 서버가 스케일-아웃된 환경일 시, 적용될 수 없다

 

1번의 경우, @Transaction 어노테이션은 AOP 기반으로 작동되는데요,

AOP는 해당 어노테이션이 붙은 클래스를 런타임시점에서 상속받은 객체를 만들어 사용하는데, 이 작동원리 때문에 다른 쓰레드에서 동일한 메서드를 호출할 수 있기 때문입니다.

현재는 로직이 간단하여 상관없지만 하나의 트랜잭션으로 묶어야 하는 작업이 많아지는 경우 문제가 생길 수 있습니다.

2번의 경우, synchronized 키워드는 하나의 프로세스 내에서만 보장이 되기 때문에

스케일 아웃이 적용된 경우, 여러 서버가 각각의 프로세스를 띄워서 동시성이 보장이 될 수 없습니다.

 

그렇다면, @Transactional 어노테이션도 적용하면서, 스케일 아웃된 환경에서 동시성 보장을 위해서는 어떤걸 할 수 있을까요?

 

바로 데이터베이스의 락을 이용해서 할 수 있습니다!

 

이 방법은 다음 포스트에 적어보도록 하겠습니다!

 

레퍼런스

https://www.inflearn.com/course/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C/dashboard