동시성 문제 해결하기

JeongYong Park·2023년 9월 26일
1

문제점

이번 프로젝트를 진행하던 중 상품의 상세 페이지를 조회하면 조회수가 1 증가하는 로직이 있었습니다.
매번 각각의 요청이 들어오게 되면 조회수가 정상적으로 증가하겠지만 동시에 100개의 요청이 들어오면 어떻게 될까요?

테스트코드를 통해 한 번 알아보겠습니다.

테스트

	@DisplayName("구매자가 상품의 상세화면을 조회하면 해당 상품의 조회수가 증가한다.")
    @Test
    void given_whenBuyerReadItemDetails_thenIncreaseViewCount() throws InterruptedException {
        // given
        int threadCount = 8;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch countDownLatch = new CountDownLatch(100);

        // when
        for (int i = 0; i < 100; i++) {
            executorService.submit(() -> {
                try {
                    itemService.read(buyer.getId(), item.getId());
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();

        // then
        Item foundItem = supportRepository.findById(Item.class, item.getId()).get();

        assertThat(foundItem.getViewCount()).isEqualTo(100);
    }

다음 테스트를 수행했을 때 상품의 조회수가 100임을 기대했지만 결과는 그렇지 않았습니다.

이를 해결하기 위해서는 어떻게 해야할까요? 생각한 방법으로는 크게 3가지가 있습니다.

  1. synchronized 사용하기
  2. database lock 사용하기
  3. redis 사용하기

synchronized 이용하기

처음 생각한 것은 synchronized 키워드를 이용하여 동시성을 제어하는 것이었습니다.

간략하게 조회수를 증가시키는 로직을 synchronized를 사용해 작성해보았습니다.

	@Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    public synchronized void increaseViewCount(ItemViewEvent event) {
        Item item = itemRepository.findById(event.getItemId()).orElseThrow();
        item.incrementViewCount();
    }

이후 동일한 테스트를 돌렸을 때 각각의 스레드가 이전 스레드의 작업이 완료될 때까지 대기하기 때문에 성공할 것이라 예상했지만 실패했습니다.

이유는 무엇일까요?
바로 스프링이 @Transactional을 처리하는 방식 때문인데요. 스프링은 @Transactional 애노테이션을 AOP 방식으로 처리하게 됩니다. 즉 @Transactional 애노테이션이 선언되어있는 클래스를 상속받는 proxy 클래스를 만들어 트랜잭션을 처리하게 됩니다.

그림으로 간략하게 살펴보면 t1 스레드가 조회수를 증가시키는 로직을 완료한 후 트랜잭션을 종료시키려는 시점에 t2 스레드가 접근해버려 문제가 발생했던 것이죠.

그렇다면 트랜잭션 시작 이전에 synchronized 키워드를 사용해 문제를 해결하면 될 것 같습니다.

	@TransactionalEventListener
    public synchronized void increaseViewCount(ItemViewEvent event) {
        synchronizedService.increaseViewCount(event.getItemId());
    }
@RequiredArgsConstructor
@Component
public class SynchronizedService {

    private final ItemRepository itemRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void increaseViewCount(Long itemId) {
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.incrementViewCount();
    }
}

여전한 문제점

하지만 synchronized는 서버가 여러 대인 경우 문제가 생길 수 있습니다. synchronized는 하나의 프로세서 안에서만 보장을 받을 수 있기 때문에 서버가 두 대 이상일 경우 데이터의 접근을 여러 곳에서 하게 되면 race condition 문제를 야기할 수 있습니다.

Locking

서버가 여러 대일 경우 synchronized의 사용은 어려우니 DB에서 락을 활용해 동시성 문제를 해결할 수 있습니다.

현재 프로젝트에서는 MySQL 8.0 을 사용하고 있음을 미리 알립니다.

Locking 기법에는 여러 가지가 존재하는데요. 그 중 다음 두 가지를 소개하겠습니다.

  1. Pessimistic lock (비관적 락)
  2. Optimistic lock (낙관적 락)

Pessmisitc lock

비관적 락은 DB의 실제 데이터에 락을 걸어 데이터의 정합성을 맞추는 방법입니다. 비관적 락은 트랜잭션이 시작할 때 X-lock 혹은 S-lock을 걸게 됩니다.

그럼 코드를 통해 비관적 락을 적용하는 과정을 살펴보겠습니다.

public interface ItemRepository extends JpaRepository<Item, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT item FROM Item item WHERE item.id = :itemId")
    Optional<Item> findByIdWithPessmisticLock(@Param("itemId") Long itemId);
}
	@Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    public void increaseViewCount(ItemViewEvent event) {
        Item item = itemRepository.findByIdWithPessmisticLock(event.getItemId())
                .orElseThrow();
        item.incrementViewCount();
    }

JPA에서는 쉽게 락을 설정할 수 있는 @Lock 애노테이션을 제공해줍니다. 해당 애노테이션의 PESSIMISTIC_WRITE는 X-lock을 건다는 의미와 동일합니다. 조회수를 증가시켜야하기 때문에 이를 사용했습니다.

역시나 여전한 문제점

하지만 비관적 락을 사용하는 것이 항상 좋지만은 않습니다. 왜냐하면 하나의 트랜잭션의 작업이 완료될 때까지 lock을 걸고 있기 때문에 다른 트랜잭션은 대기해야되기 때문입니다.
또한 단일 DB가 아닌 환경에서는 문제가 발생할 수 있습니다.

Optimisitic lock

낙관적 락은 실제로 락을 이용하지는 않고 버전 정보를 이용해 데이터의 정합성을 맞추는 방법입니다.

위 그림처럼 t1이 먼저 DB의 데이터를 변경하고 버전 정보를 수정한 후 t2가 version=1을 가지고 데이터를 수정한 후 DB에 반영하려 했을 때 내가 가지고 있는 버전 정보와 맞지 않아 업데이트에 실패하게 됩니다. 이것처럼 내가 읽은 버전 정보에서 수정사항이 생겼을 경우 애플리케이션에서 다시 데이터를 읽은 후 업데이트를 시도해야 합니다.

코드로 살펴보겠습니다. 먼저 상품의 조회수를 이벤트를 받는 리스너를 등록합니다.

@RequiredArgsConstructor
@Component
public class ItemEventListener {

    private final OptimisticLockFacade optimisticLockFacade;

    @TransactionalEventListener
    public void increaseViewCount(ItemViewEvent event) throws InterruptedException {
        optimisticLockFacade.increaseViewCount(event.getItemId());
    }
}

낙관적 락의 경우 버전 정보가 일치하지 않을 때 재시도해야하기 때문에 다음과 같이 while문을 통해 데이터를 수정하는 로직을 작성해 보았습니다.

@RequiredArgsConstructor
@Component
public class OptimisticLockFacade {

    private final OptimisticLockViewCountService optimisticLockViewCountService;

    public void increaseViewCount(Long itemId) throws InterruptedException {
        while (true) {
            try {
                optimisticLockViewCountService.increaseViewCount(new ItemViewEvent(itemId));
                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}
@RequiredArgsConstructor
@Component
public class OptimisticLockViewCountService {

    private final ItemRepository itemRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void increaseViewCount(ItemViewEvent event) {
        Item item = itemRepository.findByIdWithOptimisiticLock(event.getItemId())
                .orElseThrow();
        item.incrementViewCount();
    }
}

이처럼 낙관적 락의 경우 비관적 락과 달리 데이터에 락을 걸지 않고 버전 정보를 이용하기 때문에 상황에 따라 비관적 락보다 성능이 더 좋을 수 있습니다. 하지만 위처럼 업데이트가 실패했을 경우 개발자가 직접 재시도 로직을 작성해줘야 한다는 단점이 있습니다.

결론

여기까지 synchronized, 비관적 락, 낙관적 락에 대해 알아보았습니다.
현재 우리의 프로젝트는 다중 서버, 단일 DB 환경을 고려하고 있기 때문에 synchronized 보다는 락을 활용한 동시성 제어를 사용하게 되었습니다.
또한 조회수 증가의 경우 데이터의 변경이 빈번하게 일어나고 race condition이 종종 발생할 것이라 판단해 현재 가장 간단하게 적용할 수 있는 비관적 락을 선택하게 되었습니다.

만약 분산 DB 환경이 되거나 다른 문제가 생긴다면 그때 다른 방법을 고려해보도록 하겠습니다.

profile
다음 단계를 고민하려고 노력하는 사람입니다

0개의 댓글