서론
이커머스 프로젝트에서 주문 시 재고를 감소시키는 메서드를 구현했다.
재고는 들어오는 요청들에 따라 정확하게 값을 변경시켜야하기 때문에 동시성 문제에 주의해야한다.
만약 동시성 문제가 발생하면, 클라이언트가 실제로 존재하지 않는 재고에 대해 결제를 하게 되는 문제가 발생할 수 있다.
밑에는 재고를 감소 시키는 메서드다.
1
2
3
4
5
6
7
8
public void reduceQuantity(Integer quantity){
if(this.quantity >= quantity){
this.quantity -= quantity;
this.lastUpdate = LocalDateTime.now();
}else{
throw BusinessException.create(ErrorCode.INSUFFICIENT_INVENTORY);
}
}
이 메서드는 요청에서 받은 quantity
와 현재 재고를 비교하여, 재고가 충분하면 수량을 감소시키고, 그렇지 않으면 BusinessException
을 발생시키도록 했다.
그리고 테스트하기 위해 단위 테스트를 작성했다.
테스트는 여러개의 쓰레드가 한번에 reduceQuantity
에 접근할수있도록 테스트 환경을 구축했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@BeforeEach
void setUp() {
inventory = Inventory.builder()
.quantity(100)
.build();
}
@RepeatedTest(20)
void testReduceQuantityConcurrency() throws InterruptedException {
// given
int reduceCount = 2;
int threadCount = 100;
CountDownLatch readyLatch = new CountDownLatch(threadCount); // 100개의 스레드가 준비되었음을 알림
CountDownLatch startLatch = new CountDownLatch(1); // 모든 스레드가 동시에 시작되도록 대기
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
AtomicInteger success = new AtomicInteger();
AtomicInteger failure = new AtomicInteger();
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try{
readyLatch.countDown(); // 100개의 스레드가 준비됨을 알림(메인 스레드는 제외)
startLatch.await(); // 100개의 스레드 대기
inventory.reduceQuantity(reduceCount);
success.incrementAndGet();
}catch (BusinessException e){
failure.incrementAndGet();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
readyLatch.await(); // 메인 스레드는 모든 스레드가 준비될 때까지 대기
startLatch.countDown(); // 100개의 스레드가 준비되면 startLatch를 통해 reduceQuantity 동시에 시작
executorService.shutdown(); // 제출된 작업들은 모두 완료하고 스레드 풀 종료
executorService.awaitTermination(1, TimeUnit.MINUTES); // 모든 스레드가 작업을 완료할 때까지 대기
// then
assertAll(
()->assertThat(success.get()).isEqualTo(50),
()->assertThat(failure.get()).isEqualTo(50)
);
}
테스트는 100개의 재고를 가진 상태에서 시작한다.
그리고 100개의 쓰레드를 생성해 CountDownLatch
를 통해 reduceQuantity()
앞까지 모든 쓰레드를 대기 시켰다가 준비가 되면 한번에 reduceQuantity()
에 접근하여 재고를 2개씩 감소 시키도록 구현했다.
결과는 50개는 성공하고 50개는 재고가 부족해 실패하는것을 예상했다.
그렇게 테스트를 실행해 보면 성공할때도 있고 실패할 때도 있다.
어쩃든 실패한 결과가 있으니 동시성 문제가 발생한것을 알 수 있다.
그래서 해결하기 위해 reduceQuantity()
에 synchronized
키워드를 붙여 하나의 쓰레드만 접근할 수 있도록 임계영역을 설정해 주었다.
1
2
3
4
5
6
7
8
public synchronized void reduceQuantity(Integer quantity){
if(this.quantity >= quantity){
this.quantity -= quantity;
this.lastUpdate = LocalDateTime.now();
}else{
throw BusinessException.create(ErrorCode.INSUFFICIENT_INVENTORY);
}
}
그리고 다시 테스트를 돌려본 결과는 성공이다.
그렇다면 이 테스트 코드는 동시성 문제에 대한 테스트 코드를 작성한것인가?
한번 생각해보자
결과를 봐도 그럴듯이 동시성 문제는 해결이 됐을 것이다.
하지만 단위 테스트를 중심으로 본다면 어떨까?
본론
단위 테스트란 의도된 대로 정확히 작동하는지 검증하는것이다. 다시 말해, 단위 테스트는 성공하거나 실패할 상황을 100% 예측하여 무조건적으로 결과가 나오는 테스트여야 하는 것이다. 내가 작성한 테스트 코드는 과연 의도한 대로 작동했나 생각해보자
처음 synchronized
가 붙어있지 않은 reduceQuantity()
의 테스트 결과는 성공할 수도 실패할 수도 있던걸 생각해보자. 나는 CountDownLatch
와 ExecutorService
를 사용해 동시성 문제가 발생할 수 있는 환경을 만들었지만 무조건 동시성 문제가 발생하는것은 아니었다.
그 이유는 JVM의 쓰레드 스케줄링이 비결정적이기 때문이다. JVM이 쓰레드의 실행 순서를 정해주는데 실행 환경이나 상황에 따라 실행 순서가 달라진다. 따라서 어떤 실행에서는 동시성 문제가 발생하지 않다가, 다른 실행에서는 같은 코드에서 문제가 발생할 수 있는것 이다.
이렇게 운좋으면 성공하고 안좋으면 실패하는 테스트가 맞는 테스트일까?
동시성 테스트란 동시성 문제가 반드시 발생하는 코드를 테스트하는 것이다. 나는 동시성 문제가 발생할 수도 아닐수도 있는 코드에 테스트를 작성했기 때문에 테스트가 성공할수도 실패할수도 있는 것이었다.
그렇다면 어떻게 해야하는 걸까? 동시성 문제를 테스트할 순 없는 걸까? 무조건 동시성 문제를 발생시킬수 있는 방법이 없을까?
나는 비즈니스 코드에 인위적인 코드를 삽입했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void reduceQuantity(Integer quantity){
if(this.quantity >= quantity){
try {
Thread.sleep(300); // 인위적으로 멈춤
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
this.quantity -= quantity;
this.lastUpdate = LocalDateTime.now();
}else{
log.error(ErrorCode.INSUFFICIENT_INVENTORY.getDetail(),this.quantity,quantity);
throw BusinessException.create(ErrorCode.INSUFFICIENT_INVENTORY);
}
}
이렇게 reduceQuantity
안에 Thread.sleep()
을 주면 거의 100% 실패한다. (거의를 붙이는 이유는 만 번 실행했을때 성공한다면 내가 만 번 실행 시킬순 없기 때문이다.)
JVM이 우선 순위를 정해준 뒤 sleep
으로 잠재우면 다른 쓰레드가 실행되기 때문에 reduceQuantity()
에 두 쓰레드를 집어 넣을수 있게 된다.
이렇게 비즈니스 코드에 인위적인 코드를 넣으면 동시성 문제를 발생시켜 내가 의도한 대로 작동하는 테스트 코드가 되는 것이다.
이 후 synchronized
를 붙여 테스를 해본 결과는 모두 성공했다. (블러킹+sleep(300)+스레드 100개 = 5분ㅋㅋ..)
하지만 테스트 코드를 작성하자고 비즈니스 코드에 hread.sleep()
을 둘순 없는 노릇이다.
결국에는 테스트를 하고 Thread.sleep()
을 걷어 내야만 하는데 Thread.sleep()
을 걷어 내면 테스트코드는 다시 의도한대로 작동하지 않는 테스트 코드로 돌아가게 된다.
그래서 Thread.sleep()
을 걷어 내면서 테스트 코드도 같이 삭제 해야한다.
결론
동시성 문제가 발생할지 안할지 모르는 코드에 테스트 하는것은 단위 테스트에 맞지 않는다고 생각했기 때문에 비즈니스 로직에 인위적인 코드를 넣어 동시성 문제가 반드시 발생하도록 만들어 테스트를 진행했다.
결론은 동시성 테스트란 동시성 문제가 반드시 발생하는 코드를 테스트하는 것이다.
이 포스트는 동시성 문제를 해결하는 방법에 대해 고민한 글이 아니다. 동시성 문제를 테스트 하는 방법에 대해 고민한 글이다.
그렇다면 인위적인 코드를 넣지 않고 테스트 코드만으로 무조건 동시성 문제를 발생시키는 방법이 있을까? 이 궁금증에 대한 답은 천천히 생각해볼것이다.