언어/JAVA

동시성을 검증하는 테스트 코드 작성하기!

SeongOnion 2024. 2. 25. 17:42
728x90

종종 필요하지만 그때마다 매번 까먹어서 검색하게되는 동시성 검증을 위한 테스트 코드 작성법을 정리해보자.

 

동시성 검증 테스트 코드의 개념은 간단하다.

 

1. 검증하고자 하는 메서드를 별개의 스레드에서 동시에 실행시킨다.

2. 실행시킨 스레드의 모든 작업이 끝날 때까지 기다린다.

3. 결과를 확인하다.

 

java.util.concurrent 패키지에 존재하는 클래스들을 적절히 사용하여 위 작업들을 수행할 수 있다.

 

책의 개수를 카운트하는 간단한 예제 코드를 만들어보자.

@Service
public class BookCountService {

    private int count;

    public void setCount(int count) {
        this.count = count;
    }

    public int getCount() {
        return this.count;
    }

    public synchronized void decreaseCount() {
        this.count -= 1;
    }
}

위에 명시한 절차에 따라 책 개수를 감소시키는 decreaseCount()에 동시성 이슈는 없을지 테스트해보자.

 

먼저, 메서드를 별개의 스레드에서 병렬적으로 수행시켜주기 위해 아래와 같이 원하는 개수만큼의 스레드 풀을 만들 수 있다.

@Test
void concurrentTest() {
    // 스레드 풀 생성
    ExecutorService executorService = Executors.newFixedThreadPool(32);
}

 

풀에 담을 스레드의 개수는 테스트하고자 하는 메서드를 실행시킬 횟수를 고려해 적절히 설정하면 된다.

 

동시성을 높이고 싶다면 스레드 풀의 개수를 크게 잡으면 될 것이다.

 

 

다음은, 테스트하고자 하는 메서드를 executorService.submit() 을 통해 원하는 횟수만큼 실행시켜주면된다.

 

submit()Runnable 구현체를 파라미터로 받아 스레드풀에 대기 중인 하나의 스레드에 해당 작업을 할당해 비동기로 실행시켜준다.

Runnable 객체를 직접 변수화해서 실행시켜줄수도 있고, 아예 submit() 내부에 실행 메서드를 담아줄수도 있다.

 

BookCountService.decreaseCount()submit()을 통해 실행시켜주면된다.

 

아래와 같이 초기 값을 100개로 잡고, 해당 메서드를 100번 수행시켜 최종적으로 카운트가 0이 되는지 확인해보자.

@Test
void concurrentTest() {
    // 스레드 풀 생성
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    
    // 기존 카운트 100개로 설정
    bookCountService.setCount(100);
    int executeCount = 100;
    
    for (int i = 0; i < executeCount; i++) {
    	executorService.submit(() -> {
        	bookCountService.decreaseCount();
        });
    }
    
    assertThat(bookCountService.getCount()).isEqualTo(0);
}

테스트에 실패한다.

 

코드가 잘못짜여졌기 때문일까? 그렇지 않다.

 

앞서 언급했듯, ExecutorService로 호출한 메서드는 비동기로 실행된다.

 

따라서 for문은 각 메서드를 100번 실행시키도록 요청한 후 바로 종료되고, 해당 메서드들이 모두 완료되지 않은 시점에 검증 메서드(assertThat())가 실행된 것이다.

 

이제 2번 작업, 실행시킨 비동기 메서드가 모두 종료되길 기다려주는 것이 필요하며 이를 위해 CountDownLatch를 사용할 수 있다.

 

메서드 실행횟수를 인자로 CountDownLatch 객체를 만들고 각 메서드 실행마다 카운트를 1씩 차감해주고, 해당 카운트가 0이 되고 난 후에 검증을 수행하도록 할 수 있다.

 

예제 코드를 작성해보자.

@Test
void concurrentTest() throws InterruptedException {
    // 스레드 풀 생성
    ExecutorService executorService = Executors.newFixedThreadPool(32);

    // 기존 카운트 100개로 설정
    bookCountService.setCount(100);
    int executeCount = 100;
    CountDownLatch countDownLatch = new CountDownLatch(executeCount);

    for (int i = 0; i < executeCount; i++) {
        executorService.submit(() -> {
            bookCountService.decreaseCount();
            countDownLatch.countDown();
        });
    }

    countDownLatch.await();
    assertThat(bookCountService.getCount()).isEqualTo(0);
}

executorService()가 실행될때마다 CountDownLatch의 카운트를 1씩 감소시켜주고, await()을 통해 해당 카운트가 0이 될때까지 기다려주도록 할 수 있다.

 

이제 해당 테스트를 실행시켜보면 예상한대로 테스트가 동작해 성공함을 확인할 수 있다.

해당 부분을 별도 메서드로 분리해 테스트하고자 하는 메서드를 Runnable로 만들어 넘길 수 있도록 하면 다양한 메서드들에 대해 더욱 깔끔한 테스트 코드를 만들 수 있다.

@Test
void decreaseTest() throws InterruptedException {
	bookCountService.setCount(100);
    Runnable decreaseCount = () -> bookCountService.decreaseCount();
    concurrentTest(100, decreaseCount);
    assertThat(bookCountService.getCount()).isEqualTo(0);
}

@Test
void increaseTest() throws InterruptedException {
    Runnable increaseCount = () -> bookCountService.increaseCount();
    concurrentTest(100, increaseCount);
    assertThat(bookCountService.getCount()).isEqualTo(100);
}

void concurrentTest(int executeCount, Runnable methodToTest) throws InterruptedException {
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(executeCount);

    for (int i = 0; i < executeCount; i++) {
        executorService.submit(() -> {
            methodToTest.run();
            countDownLatch.countDown();
        });
    }

    countDownLatch.await();
}