0. 들어가기 전에
앞선 글에서 TaskScheduler
사용한 기능을 만들었다. 그중 WAS가 많아지면 생길 Race Condition 때문에 비관적 락을 적용했다. 이 부분을 테스트하기 위해 고민한 글입니다.
1. 어떻게 테스트 할까?
동시 요청을 테스트하려면 어떻게 할 수 있을까? 이를 위한 클래스가 자바에 있다. 바로 ExecutorService
다. ExecutorService
는 쓰레드 풀을 만들어 비동기적으로 동작하는 것을 돕는 클래스이다. 자세한 방법은 코드로 알아보자.
@DisplayName("다수의 스케줄이 동시에 실행되도 ReviewCount는 1개만 증가한다.")
@Test
void finishReview_when_multiple_request() throws InterruptedException {
// given
final Runner runner = persistRunner(MemberFixture.createHyena());
final RunnerPost runnerPost = persistRunnerPost(runner);
final Supporter supporter = persistSupporter(MemberFixture.createEthan());
persistAssignSupporter(supporter, runnerPost);
deadlineOutboxCommandRepository.save(DeadlineOutboxFixture.deadlineOutbox(runnerPost.getId(), Instant.now()));
final ReviewCount originReviewCount = ReviewCountFixture.reviewCount(supporter.getReviewCount().getValue());
final int requests = 10;
final ExecutorService executorService = Executors.newFixedThreadPool(requests);
final CountDownLatch latch = new CountDownLatch(requests);
// when
for (int i = 0; i < requests; i++) {
executorService.submit(() ->
{
try {
runnerPostDeadlineCheckScheduler.finishReview(runnerPost.getId());
} catch (final Exception e) {
} finally {
latch.countDown();
}
return null;
}
);
}
latch.await();
executorService.shutdown();
// then
final RunnerPost actual = runnerPostQueryRepository.joinSupporterByRunnerPostId(runnerPost.getId()).get();
assertAll(
() -> assertThat(actual.getSupporter().getReviewCount()).isEqualTo(ReviewCountFixture.reviewCount(originReviewCount.getValue() + 1)),
() -> assertThat(actual.isReviewStatusDone()).isTrue(),
() -> assertThat(deadlineOutboxCommandRepository.findAll()).isEmpty()
);
}
- given 절에서 데이터를 세팅하고,
ExecutorService
를 통해 쓰레드를 10개를 생성한다. 그 후 비동기 상황에서 실행 개수를 확인하기 위해CountDownLatch
를 통해 10개의 요청을 셀 수 있게 세팅한다. - when 절에서는 비즈니스 로직을 실행시키고 예외가 발생하더라도 latch의 개수를 감소시켜 개수가 몇 번 실행되었는지 확인한다.
- then 절에서는 원하는 결괏값이 나왔는지 확인하는 코드이다.
2. 잘 동작할까?
그러면 좋겠지만, 문제가 발생했다.
2.1 내부 트랜잭션 문제
ExecutorService안에서는 트랜잭션이 동작하지 않는다. finishReview()
안에 TransactionSynchronizationManager.isActualTransactionActive()
를 이용해서 로그를 찍어보면 ExecutorService 안에서 트랜잭션이 동작하지 않는다. 정확한 이유는 모르겠지만, @DataJpaTest
에 @Transactional
메서드가 달려있어서 service에서는 동작하지만, 새로운 쓰레드여서 Transaction이 적용 안 되는 것 같다.
이 문제를 해결하기 위해서 아래와 같이 TransactionTemplate
를 사용해서 직접 트랜잭션을 실행시켜 줬다.
transactionTemplate.execute((none) -> {
try {
runnerPostDeadlineCheckScheduler.finishReview(runnerPost.getId());
} catch (final Exception e) {
} finally {
latch.countDown();
}
return null;
})
2.2 given 절의 데이터 초기 세팅 문제
그 다음 테스트를 실행시켜보자.
오류가 또 발생했다. 왜 그런걸까?
원인은 트랜잭션 격리 수준 때문이다.
Tx1에서 save 메서드로 미리 RunnerPost를 저장한다. 그 후 ExcutorService로 finishReview()
를 호출할 때 RunnerPost를 찾는다. 정상적으로 저장한 뒤 찾는 로직인 것 같지만, 쓰레드가 분리되어, 트랜잭션이 나뉜 게 문제가 된다. 보통 데이터베이스들은 트랜잭션 격리 수준이 Read Commited 이상(h2 : Read Commited, MySQL : Repeatable Read)이다. 이 때문에 트랜잭션에서의 변경 사항은 commit이 되어 있지 않으면 다른 트랜잭션에서 읽을 수 없다.
문제를 해결하기 위해서 데이터베이스에 강제로 커밋을 해줘야 하는데, 나는 TestTransaction
클래스를 사용해 트랜잭션을 수동으로 관리해 줬다.
private void transactionCommit() {
TestTransaction.flagForCommit();
TestTransaction.end();
}
이 메서드를 통해 트랜잭션 커밋을 강제로 발생하게 했다.
최종적인 코드는 다음과 같고, 성공하는 테스트를 볼 수 있었다.
@Test
void finishReview_when_multiple_request() throws InterruptedException {
// given
final Runner runner = persistRunner(MemberFixture.createHyena());
final RunnerPost runnerPost = persistRunnerPost(runner);
final Supporter supporter = persistSupporter(MemberFixture.createEthan());
persistAssignSupporter(supporter, runnerPost);
deadlineOutboxCommandRepository.save(DeadlineOutboxFixture.deadlineOutbox(runnerPost.getId(), Instant.now()));
transactionCommit();
final ReviewCount originReviewCount = ReviewCountFixture.reviewCount(supporter.getReviewCount().getValue());
final int requests = 10;
final ExecutorService executorService = Executors.newFixedThreadPool(requests);
final CountDownLatch latch = new CountDownLatch(requests);
// when
for (int i = 0; i < requests; i++) {
executorService.submit(() ->
transactionTemplate.execute((none) -> {
try {
runnerPostDeadlineCheckScheduler.finishReview(runnerPost.getId());
} catch (final Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
return null;
})
);
}
latch.await();
executorService.shutdown();
// then
final RunnerPost actual = runnerPostQueryRepository.joinSupporterByRunnerPostId(runnerPost.getId()).get();
assertAll(
() -> assertThat(actual.getSupporter().getReviewCount()).isEqualTo(ReviewCountFixture.reviewCount(originReviewCount.getValue() + 1)),
() -> assertThat(actual.isReviewStatusDone()).isTrue(),
() -> assertThat(deadlineOutboxCommandRepository.findAll()).isEmpty()
);
}
private void transactionCommit() {
TestTransaction.flagForCommit();
TestTransaction.end();
}
※ 참고
'개발 > [우테코]' 카테고리의 다른 글
확장을 고려해서 TaskScheduler로 자동 리뷰 완료 기능 구현하기 (0) | 2024.02.20 |
---|---|
Spring에서 Redis Test 하기 (0) | 2024.02.12 |
Redis를 활용해서 RefreshToken 최적화하기 (0) | 2024.02.08 |
바톤의 DB Replication (0) | 2023.10.14 |
build 할 때 Rest Docs 파일이 생성이 안되는 문제 트러블 슈팅 (0) | 2023.08.20 |