개요
프로젝트 통합테스트로 상품 구매에 대한 테스트를 진행하던 중 멀티스레드 환경에서 상품 구매 기능에 접근할 시 레이스 컨디션이 발생하는 문제를 인지할 수 있었다. 이러한 이유로 상품 구매 기능에 동시성 제어가 필요할 것이라고 생각했고 동시성 제어에 필요한 기술의 의사결정, 기술 적용, 테스트 코드 작성 및 회고를 진행하고자 한다.
동시성 처리가 필요한 이유
동시성 처리가 필요한 이유는 레이스 컨디션 발생에 있다. 레이스 컨디션이란 같은 자원(레지스터, 메서드)에 다수의 스레드가 동시에 접근해 값을 조작할때, 조작 순서가 보장되지 않아 잘못된 값이 저장되는 문제를 말한다.
레이스 컨디션의 가장 많이 알려진 예는 아래 코드라고 생각한다. 두 스레드가 동시에 count의 값을 증가시키려고 접근할때 count는 thread-safe(synchronized) 하지 않기 때문에 실제로 아래와 같이 기대한 값인 200,000이 아닌 잘못된 값이 저장되는 현상이 발생한다. 아래 예제에서 레이스 컨디션이 발생하는 이유는 count++가 row-level에서 여러 단계에 나눠 연산을 처리하기 때문인데 더 자세히 알고 싶다면 어셈블리어와 함께 레지스터가 동작하는 과정과 컨텍스트 스위칭에 대해 알아보면 이해할 수 있다.
public class Main {
public static int count = 0;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
따라서, Spring에서도 하나의 Transaction안에 여러 로직이 존재하기 때문에 다수의 사용자가 접근할 수 있는 데이터의 경우 따로 동시성 처리를 해주지 않으면 thread-safe하지 않기 때문에 사용자가 많아질수록 레이스 컨디션이 발생할 가능성 또한 높아지고 데이터의 안정성과 신뢰성을 보장할 수 없는 문제가 발생한다.
동시성 처리에 적용 가능한 기술
Java에서 제공하는 syncronized 키워드
동시성 처리가 필요한 메서드에 아래와 같이 synchronized 키워드를 추가하면 된다. synchronized는 메서드를 사용하고 있는 해당 스레드 이외의 나머지 스레드는 순차적으로 접근할 수 있도록 해준다. 서비스 레벨에서 키워드만 붙혀주면 thread-safe를 보장해주기 때문에 매우 간단한 방법이라고 생각된다. 하지만, synchronized 키워드의 경우에는 독립적인 프로그램에서 메서드 단위로 실행되기 때문에 분산서버나 스케일아웃 가능한 서버에 적용할 수 없다는 치명적인 약점이 있다.
public synchronized void purchase() {
...
}
JPA의 비관적락 (Perssimistic Lock)
두번째 방법으로는 JPA에서 제공하는 비관적락을 이용할 수 있다. 비관적락은 트랜젝션의 충돌이 발생할 것이라고 비관적으로 판단하고 실제 데이터베이스의 데이터에 Lock을 걸어 thread-safe를 보장하는 방법이다. 데이터에 비관적락을 걸게되면 다른 Transaction에서는 Lock이 해제(스레드의 Transaction 종료)되기 전에는 해당 데이터에 접근할 수 없다. 간단한 예시로 티켓 예매를 생각해보면 접속 대기 번호를 표시해주는데 비관적락을 적용한 방식이라고 할 수 있다. 비관적락은 이전 트랜젝션이 종료되지 않는다면 무한정 대기할 가능성이 있기때문에 데드락에 주의해야한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findById(@Param("id") Long id);
JPA의 낙관적락 (Optimistic Lock)
세번째 방법으로는 JPA에서 제공하는 낙관적락을 이용할 수 있다. 낙관적락은 트랜젝션의 충돌이 발생하지 않을 것이라고 낙관적으로 판단하고 최소한의 버전관리를 통해 thread-safe를 보장하는 방법이다. 아래 코드와 같이 낙관적락을 적용하고싶은 Entity에 @Version 어노테이션과 함께 필드를 추가하면 JPA에서 Entity가 생성될때 version을 0으로 초기화하고 Entity에 변화가 일어날때마다 version을 올려 관리한다. 낙관적락이 동작하는 방식은 엔티티를 조회했을때와 트렌젝션이 끝나는 시점의 version값이 일치하지 않으면 ObjectOptimisticLockingFailureException을 발생시켜 동시성을 제어한다.
public class Product {
...
@Version
private Long version;
...
}
상품 구매 서비스에 낙관적락 적용
1. 서버의 scale-out을 고려했기 때문에 synchronized 메서드는 사용할 수 없었음.
2. 일반 상품 구매의 경우 사용자들이 급격하게 몰릴 일은 크게 없을 것이라고 판단해 비관적락보다는 낙관적락을 사용
데이터 정합성을 보장하고 싶은 엔티티에 아래와 같이 @Version annotation과 필드 선언
@Version
private Long version
낙관적락을 이용한 데이터 정합성 보장에 대한 테스트코드
/**
* 사용자가 자신의 장바구니에 있는 상품의 구매 버튼을 눌렀을때 발생하는 동시성 이슈에 대한 테스트
* 상품 구매 중 상품 version이 바뀌었다면 동시성 처리를 위해 ObjectOptimisticLockingFailureException 발생
* 낙관적락을 이용해 동시성 제어가 잘 이루어지는지 확인하고 데이터 정합성 검증
* */
@Test
@DisplayName("구매 실패 - 동일 상품에 다수의 구매 요청이 들어오는 경우")
void purchaseTest_fail_concurrency() throws InterruptedException {
//given
int numberOfThreads = 10;
int beforeQuantity = product.getQuantity();
/* 테스트용 member 생성 */
Member[] members = new Member[numberOfThreads];
for (int i = 0; i < numberOfThreads; i++) {
members[i] = memberRepository.save(
MemberTestUtil.getTestMember(
TEST_MEMBER_NAME,
TEST_PASSWORD,
TEST_EMAIL + i,
MemberRole.CUSTOMER
)
);
}
/* 테스트용 장바구니 생성 및 저장 */
for (int i = 0; i < numberOfThreads; i++) {
memberProductRepository.save(
MemberProductTestUtil.getMemberProduct(members[i], product)
);
}
/* exception count를 위한 boolean flag */
boolean[] exceptionFlag = new boolean[members.length];
ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
//when
for (int i = 0; i < numberOfThreads; i++) {
final int idx = i;
service.execute(() -> {
try {
purchaseService.purchase(requestDto, members[idx]);
/* OptimisticLock과 재고부족으로 발생하는 예외처리 */
} catch(ObjectOptimisticLockingFailureException | GlobalException e) {
exceptionFlag[idx] = true;
} finally {
latch.countDown();
}
});
}
latch.await();
service.shutdown();
int exceptionCount = 0;
for (boolean flag : exceptionFlag) {
if(flag) exceptionCount++;
}
//then
/* 테스트클래스의 product와 서비스 로직의 memberProduct.getProduct()는 quantity의 더티체킹이 진행되지 않음 */
Product findProduct = productRepository.findById(product.getId()).orElse(null);
assertNotNull(findProduct);
/* 데이터 정합성 검증 */
int expectQuantity = beforeQuantity -
(numberOfThreads-exceptionCount)*MemberProductTest.TEST_QUANTITY;
int actualQuantity = findProduct.getQuantity();
assertEquals(expectQuantity, actualQuantity);
}
동시성 문제를 해결하고 느낀점
프로젝트가 끝나고 회고 단계에서 생각해보니 동일 상품에 대해 다수의 사용자가 급격히 몰릴 일이 없더라도 확실한 정합성 보장을 위해 DB자체에 락을 거는 비관적락을 사용하는 것이 더욱 안정정인 서비스를 운영할 수 있을 것 같다는 생각이 들었다.
사용자의 요청이 많지 않기때문에 비관적락을 이용한다고 해도 사용자가 불편함을 느낄만큼의 대기시간을 가지지 않을 것이라고 예상하고 낙관적락을 사용했을때는 예외처리를 해줘야하는데 상품 구매시 사용자가 에러 메세지를 받게된다면 큰 불편함을 느낄 수 있을 것이다. 데드락에 대한 방안을 잘 마련한다면 비관적락을 사용하는것이 개인적으로 더 바람직할 것 같다.
그렇다면 낙관적락은 어떤 상황에 사용하는게 좋을까. 낙관적락과 비관적락의 차이는 락을 Spring 서버에서 처리하느냐 vs DB에서 처리하느냐와 동시성 제어에 걸릴시 예외를 반환하느냐 vs 요청을 대기시켜 결국 데이터를 반환하는가 두가지 차이가 있다고 생각한다.
솔직히 잘 모르겠다. 비관적락은 처리 주체가 DB이긴 하지만, repository 코드레벨에 어노테이션을 이용해 락을 거는것이기 때문에 문제 가 발생했을때 추적이 어렵지 않을 것이고 낙관적락은 예외가 발생했을때 처리하기가 무척 애매하다. 이번 프로젝트에서도 예외가 발생하면 controller 코드에 loop를 돌려 예외가 발생해도 N번까지는 구매를 try할 수 있는 로직을 생각해봤는데 아무래도 전혀 일반적이지 않다고 느껴졌다. single server, 내부 자료구조를 이용하는 상황에서나 낙관적락의 개념을 이용해 직접 버저닝과 synchronized 키워드를 이용해 볼 수 있을 것 같은데 그럴 일이 있나 싶다.
결론적으로, 앞으로 동시성 제어에 대한 작업을 처리할 일이 있다면 비관적락 또는 메세지큐를 이용해 처리해보자!
[ 참고자료 ]