본문 바로가기

개발/SpringBoot

멀티 프로세스 환경 DB 동시성 제어

서론


현재 진행중인 StudyHub 프로젝트를 2월말 출시한 뒤 사용자 유치를 위해 이벤트를 계획하고 있었다.

 

이벤트 순서는 다음과 같다.

 

1. 이벤트 시간이되면 회원이 홈 화면에 있는 이벤트 참여 버튼을 누른다.

2. GiftStock 테이블의 StarBucks를 GiftName으로 가지는 튜플을 조회해 재고가 남아있는지 확인한다.

3. 재고가 남아있지 않을 경우 요청을 예외처리하고, 재고가 남아있을 경우 재고의 개수를 (남아있는 재고 - 1) 로 update 한다.

4. Gift 테이블에 회원과 선물 정보를 insert 한다.

 

@Transactional
public GiftStock register() {
    GiftStock giftStock = giftStockRepository.findByGiftName("StarBucks");
    minusStock(giftStock);
    giftRepository.save(Gift.builder()
            .giftName("StarBucks")
            .build());

    return giftStock;
}

private void minusStock(GiftStock giftStock) {
    if(giftStock.getStock() > 0) {
        giftStock.minusStock();
        return;
    }
    throw new IllegalArgumentException("Stock under zero");
}

 

 

하지만 이러한 과정은 동시성 문제가 발생할 수 있다. 아래 두개의 트랜잭션이 동시에 실행되는 흐름도를 살펴보자.

 

 

각각의 트랜잭션은 Stock을 조회한 뒤, Stock이 1 이상이면 Stock - 1 로 업데이트 하는 작업을 수행한다.

 

현재 프로젝트에서는 MySQL 서버에서 InnoDB 스토리지 엔진을 사용하고 있기 때문에 Update 쿼리를 실행하면 곧바로 InnoDB 버퍼 풀에 반영되고 언두 로그에 Update 이전 정보가 기록된다.

 

또한 트랜잭션 격리 수준은 MySQL InnoDB 기본 격리 수준인 Repeatable read 격리 수준을 사용하기 때문에 트랜잭션 2번의 Select 쿼리는 언두 영역의 데이터를 반환한다. 

 

이렇게 되면 트랜잭션 2번의 Select 쿼리는 1을 반환하게 되고 Update 쿼리는 Stock 값에 다시 0을 넣게 된다. 

 

회원이 가져간 선물 재고는 두개인데 반해 재고의 개수는 1만 줄어들게 된 것이다.

 

 

아래는 위의 상황을 테스트해본 테스트 코드이다. 멀티 프로세스 환경과 비슷하게 처리하기 위해 스레드를 여러개 만든뒤 비동기 처리했다.

 

@Test
void Synchronized_사용() throws InterruptedException {
    giftStockRepository.save(GiftStock.builder()
            .giftName("StarBucks")
            .stock(10L)
            .build());

    ExecutorService executorService = Executors.newFixedThreadPool(50);
    CountDownLatch countDownLatch = new CountDownLatch(50);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    for (int i = 1; i <= 50; i++) {
        executorService.execute(() -> {
            try {
                giftService.callRegister();
                successCount.incrementAndGet();
            } catch (Exception e) {
                failCount.incrementAndGet();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();

    System.out.println("successCount = " + successCount);
    System.out.println("failCount = " + failCount);
}

 

 

 

 

선물 수량이 10개일 때, 50명이 동시에 요청을 보내는 테스트를 실행한 결과 35명이 선물 요청에 성공했다.

 

로그 결과 같은 개수의 Stock이 여러개 있는것을 보아 처음에 예상했던 흐름과 같이 트랜잭션 여러개가 동시에 처리되면서 Stock 값이 순차적으로 줄어들지 않는 문제가 발생한 것이다.

 

 

 

 

 

 

본론


실제 이벤트 진행 중 이런 상황이 벌어진다고 생각해보자.

 

스터디허브의 백엔드 파트는 나 포함 두명인데 둘이서 스타벅스 기프티콘 25개를 구입할 생각을하면.. 상상만해도 무서운일이다.

 

이를 방지하기 위해 세가지 방법들을 생각했다

 

  • synchronized를 붙여 동기화한다.
  • JPA 낙관적 락을 사용해 동기화한다.
  • JPA 비관적 락을 사용해 동기화한다. 

 

 

 

 

synchronized


register() 메서드에 synchronized를 붙여 임계영역에 한개의 스레드만 접근 가능하게 만들었다.

 

 

@Transactional
public synchronized GiftStock register() {
    GiftStock giftStock = giftStockRepository.findByGiftName("StarBucks");
    minusStock(giftStock);
    giftRepository.save(Gift.builder()
            .giftName("StarBucks")
            .build());
    return giftStock;
}

 

 

 

하지만 synchronized를 붙인 뒤 동일한 테스트를 다시 돌렸을 때 10개가 아닌 16개가 성공했다.

 

문제는 synchronized에 Transactional을 함께 사용한 것이다.

 

Spring AOP는 메서드 프록시를 생성할 때 CGLIB 방식으로 생성한다. 이 때, Transactional 어노테이션이 붙은 메서드의 프록시가 생성될 때 트랜잭션 시작과 종료는 동기화가 적용되지 않는 것이다.

 

아래 코드는 프록시를 생성해 메서드가 실행되는 대략적인 코드이다.

 

TransactionStatus status = transactionManager.getTransaction(..);

try {
	target.logic(); // public synchronized void register 메서드 수행 
    
    transactionManager.commit(status);
} catch (Exception e) {
	transactionManager.rollback(status);
    throw new IllegalStateException(e); 
}

 

1번 스레드가 target.logic 메서드를 빠져나왔을 때 2번 스레드가 트랜잭션에 진입할 수 있다.

 

이 때 1번 스레드의 transactionManager.commit 이 실행되지 않았을 때, 커밋되지 않은 상태로 2번 스레드가 메서드를 실행하게되면 언두 영역의 데이터를 읽기 때문에 1번 스레드의 변경 내역이 반영되지 않은 데이터를 읽게되는 것 이다.

 

 

 위에서 발생한 문제의 흐름도는 아래와 같다. 

 

 

 

이를 해결하기 위해 synchronized를 붙인 메서드가 Transactional 어노테이션이 붙은 메서드를 호출하는 방법이 있다.

 

@Service
@RequiredArgsConstructor
public class ScService {

    private final GiftService giftService;

    public synchronized GiftStock register() {
        return giftService.callRegister();
    }
}

 

 

외부에서 @Transactional 이 붙은 메서드를 호출하게되면 트랜잭션을 커밋하고 완전히 종료 돼야지만 다음 스레드가 register 메서드에 진입할 수 있기 때문에 문제를 해결할 수 있다.

 

하지만 이런 방식은 단일 서버를 운영할때만 사용 가능한 방식이다.

 

멀티 프로세스 환경에서는 synchronized를 붙여도 서버 개수만큼 트랜잭션이 겹치기 때문에 다른 방법이 필요하다.

 

 

 

 

JPA 낙관적 락


JPA는 제공하는 낙관적 락을 사용하려면 엔티티에 버전 관리용 필드를 추가해야한다.

 

아래와 같이 버전 관리용 필드를 추가해주고 테스트 코드를 재 실행 해보겠다.

 

 

 

 

 

10개의 재고에 회원 50명이 요청했는데 성공한 회원이 9명 밖에 되지 않았다.

 

Version 어노테이션을 사용하면 JPA 낙관적 락의 NONE 옵션이 적용되어 수정할 때 버전을 체크하면서 버전을 증가한다.

 

이때 데이터베이스의 버전 값이 현재 버전이 아니면 예외가 발생한다.

 

아래는 예외가 발생하는 흐름도이다.

 

 

 

 

스레드 50개중 41개가 버전이 겹쳤기 때문에 9개만 커밋된 것이다.

 

JPA가 제공하는 다른 낙관적 락 옵션인 OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT 를 사용하더라도 조회 시점이 겹치면 필연적으로 예외가 발생할 수 밖에 없다.

 

또한 이 방법은 큰 문제가 존재한다.

 

이벤트 참여 버튼을 2등으로 누른 회원이 1등으로 누른 회원의 트랜잭션과 겹쳐 버전이 맞지 않게 될 경우 2등 회원의 요청은 예외처리 되기 때문에 선착순으로 이벤트를 진행할 수 없다.

 

이를 해결하기 위해서 비관적 락을 사용해보자.

 

 

 

JPA 비관적 락


 

JPA 비관적 락은 데이터베이스 트랜잭션 락 메커니즘과 같다.

 

아래 코드는 같이 PESSIMISTIC_WRITE를 적용해 트랜잭션 시작과 동시에 select for update 쿼리를 날려 식별자가 1인 데이터에 락을 걸어주고 있다.

 

@Transactional
public GiftStock callRegister() {
    GiftStock giftStock = entityManager.find(GiftStock.class, 1L, LockModeType.PESSIMISTIC_WRITE);
    minusStock(giftStock);
    giftRepository.save(Gift.builder()
            .giftName("StarBucks")
            .build());
    return giftStock;
}

 

 

테스트 코드를 다시 실행해보겠다.

 

 

 

GOOD! 재고 10개에 대해 50명의 회원이 요청했을 때 10명만 성공하는 결과가 정확히 실행된 모습이다.

 

 

트랜잭션이 시작된 뒤 Lock을 획득하고 커밋되기 전까지 반환하지 않기 때문에 다른 트랜잭션이 동시에 실행될 수 없다.

 

 

 

 

정리


synchronized, 낙관적 락, 비관적 락을 이용해 멀티스레드, 멀티프로세스 환경에서의 동기화를 적용해보았다.

 

지금은 프로젝트에서 서버 한대를 돌리고 있지만 추후 트래픽이 몰리게되면 서버 두대를 돌려야 하는 상황이 올 수 있다.

 

이 때 synchronized 를 사용하게 되면 AOP의 동작방식에 의해 트랜잭션이 겹쳐 Stock이 줄어들지 않는 문제가 생길 수 있다.

 

낙관적 락은 Stock이 줄어드는 문제는 해결했지만 선착순으로 이벤트를 진행할 수 없는 문제가 생긴다.

 

그래서 현재 진행하려는 이벤트의 경우 배타락을 이용해 트랜잭션의 격리 수준을 Serializable 로 올려 진행해야한다.

 

Serializable 은 트랜잭션 격리 수준중 성능이 가장 떨어지기 때문에 선착순 이벤트와 같은 상황이 아니라면 이렇게 구현할 필요는 없다.

 

요구사항을 충족하며 성능을 최대로 올릴 수 있는 방법을 잘 고민해야겠다.

 

 

스터디 허브에서도 synchronized 로 해결되지 않는 상황이 왔으면 좋겠다