개요
프로젝트 통합테스트로 상품 구매에 대한 테스트를 진행하던 중 멀티스레드 환경에서 상품 구매 기능에 접근할 시 레이스 컨디션이 발생하는 문제를 확인할 수 있었다. 이러한 이유로 상품 구매 기능에 동시성 제어가 필요할 것이라고 판단했고 동시성 제어에 필요한 기술의 의사결정, 기술 적용, 테스트 코드 작성 및 회고를 진행하고자 한다.
동시성 처리가 필요한 이유
동시성 처리가 필요한 이유는 레이스 컨디션 발생에 있다. 레이스 컨디션이란 공유 자원에 대해 스레드가 동시에 접근해 값을 조작할때, 조작 순서가 보장되지 않아 잘못된 값이 저장되는 문제를 말한다. 레이스 컨디션의 가장 많이 알려진 예는 아래 코드라고 생각한다. 두 스레드가 동시에 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);
}
}
따라서, 코드 상에서도 하나의 Transaction안에 여러 로직이 존재하기 때문에 다수의 스레드가 접근할 수 있는 공유 자원의 경우 동시성 처리가 필수적이다. 공유 자원에 접근하는 스레드가 많아질수록 레이스 컨디션이 발생할 가능성이 높아지고, 데이터의 안정성과 신뢰성을 확보할 수 없는 문제가 발생한다.
동시성 처리에 적용 가능한 기술
✅ Java에서 제공하는 syncronized 키워드
동시성 처리가 필요한 메서드에 아래와 같이 synchronized 키워드를 추가하면 된다. synchronized는 메서드를 사용하고 있는 해당 스레드 이외의 나머지 스레드는 순차적으로 접근할 수 있도록 해준다. 메서드에 키워드만 붙혀주면 thread-safe를 보장해주기 때문에 매우 간단한 방법이라고 생각된다. 하지만, synchronized 키워드의 경우에는 독립적인 프로그램에서 메서드 단위로 실행되기 때문에 분산서버나 스케일아웃 가능한 서버에 적용할 수 없다는 단점이 있다.
public synchronized void purchase() {
...
}
✅ JPA의 비관적락 (Perssimistic Lock)
두번째 방법으로는 JPA에서 제공하는 비관적락을 이용할 수 있다. 비관적락은 트랜젝션의 충돌이 발생할 것이라고 비관적으로 판단하고 실제 데이터베이스의 데이터에 Lock을 걸어 thread-safe를 보장하는 방법이다. 데이터에 비관적락을 걸게되면 다른 Transaction에서는 Lock이 해제(스레드의 Transaction 종료)되기 전에는 해당 데이터에 접근할 수 없다. 데이터 조회시점에 락을 걸어 트랜잭션 종료 시까지 자원에 대한 접근을 제한하는 select for update를 활용하는 것으로 판단된다. 네이티브 쿼리를 사용할 시 select for update를 사용하면 된다. 간단한 예시로 티켓 예매를 생각해보면 접속 대기 번호를 표시해주는데 비관적락을 적용한 방식이라고 할 수 있다. 비관적락은 이전 트랜젝션이 종료되지 않는다면 무한정 대기할 가능성이 있기 때문에 데드락에 주의해야한다.
@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;
...
}
상품 구매 서비스에 낙관적락 적용
상품 구매 서비스에는 낙관적락을 적용했다. 서버의 스케일아웃을 고려하고 있었기 때문에 synchronized를 사용할 수 없었다. 또한, 일반 상품 구매의 경우 한가지 상품에 사용자들이 급격하게 몰릴 일이 없다고 생각하여 데드락 발생가능성이 있는 비관적락 대신 낙관적락을 사용해야 겠다고 판단했다. 아래와 같이 상품 객체에 버저닝 칼럼을 추가하고 트랜잭션 종료 시 버전 검사 실시를 판단하는 @Version 어노테이션을 명시해줬다.
@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자체에 락을 거는 비관적락을 사용하는 것이 더욱 안정정인 서비스를 제공할 수 있을 것 같다는 생각이 들었다. 사용자의 요청이 많지 않기 때문에 비관적락을 이용한다고 해도 사용자가 불편함을 느낄만큼의 대기시간을 가지지 않을 것이라고 예상하고, 낙관적락을 사용했을때는 레이스 컨디션 발생 시 예외처리를 해줘야하는데 상품 구매 시 사용자가 예외처리에 대한 메시지를 받게 된다면 불편함을 느낄 수 있을 것이다. 데드락에 대한 방안을 잘 마련한다면 비관적락을 사용하는것이 개인적으로 더 바람직할 것 같다.
참고자료