요즘 비동기 로직을 정말 많이 작성합니다. 외부 API 호출, 대용량 데이터 처리, 이메일 발송과 같은 시간이 걸리는 작업들을 기다리지 않고 처리할 수 있게 해 주기 때문입니다. 이를 통하여 불필요한 작업을 비동기로 넘겨서 response time을 크게 줄일 수 있죠.
그러나 비동기 로직은 테스트하기가 까다롭습니다. 실행 시점을 정확히 예측하기 어렵고, 메인 스레드가 아닌 다른 스레드에 동시에 실행되면서 발생하는 복잡성도 고려해야 합니다. 이로 인하여 어떤 함수를 호출하고, 이의 반환 값을 검증하거나, 반환 이후의 상태를 확인하는 방식으로는 테스트를 할 수 없게 됩니다.
아래와 같은 예시로 좀 더 이야기해볼게요.
class EmailService(
private val emailRepository: EmailRepository,
private val emailPublisher: EmailMessagePublisher,
private val executor: Executor,
) {
fun sendEmailAsync(to: String, content: String) {
CoroutineScope(executor.asCoroutineDispatcher()).launch {
val email = EmailEntity(to = to, content = content)
emailRepository.save(email)
emailPublisher.publish(email)
}
}
}
위에 작성한 코드는 이메일 정보를 저장하고 이메일 메세지를 발급하는 두 가지 작업을 비동기적으로 수행합니다. 이러한 코드를 테스트할 때 가장 쉽게 선택할 수 있는 방법은 Thread.sleep() 입니다.
@Test
fun `이메일 발송 테스트`() {
emailService.sendEmailAsync("test@test.com", "hello")
// executor에 들어간 작업을 처리하는 것을 기다리기 위하여 1000ms의 sleep을 사용
Thread.sleep(1000)
assertThat(emailRepository.findAll()).hasSize(1)
verify(emailPublisher).publish(any())
}
하지만, 위와 같이 sleep을 사용하는 방식은 여러 문제가 존재합니다.
- 테스트 실행 시간 증가 : sleep은 여유롭게 부여하는 편이므로, 각 테스트마다 불필요한 대기 시간이 추가되어 전체 테스트 실행 시간이 늘어납니다. 수백 개의 테스트가 있다면 CI 실행 시간도 크게 증가할 수 있습니다.
- 테스트 불안정성 : 시스템 부하나 실행 환경에 따라 1초가 충분하지 않을 수 있습니다. 이는 간헐적인 테스트 실패로 이어져 개발팀의 생산성을 저하시킵니다.
쓰기 싫은 sleep을 써서 기분이 나빠집니다.
고정적으로 1000ms를 사용하는 작업이라면 괜찮은 선택일 수 있겠지만, 그렇지 않은 환경에서는 비동기 로직의 유동성을 고려하여 고정적으로 리소스를 낭비하고 있는 상황이라고 할 수 있습니다.
이런 상황에서 sleep보다 조금 더 나은 해결법 두 가지를 이야기해 보겠습니다.
ExecutorService 활용하기
첫 번째 아이디어는 executor에 들어간 job들을 추적하여, 이것들이 모두 완료되었을 때 핑을 받는 방법을 생각했습니다. 이를 Latch를 이용하여 Executor를 간단히 구현해 보면 아래와 같을 것 같은데요.
val latch = CountDownLatch(1)
val wrappedExecutor = Executor { command ->
command.runCatching {
command.run()
}.also {
latch.countDown()
}
}
// 작업 완료 대기
latch.await()
근데, 이런 것을 저희가 직접 짜서 관리할 필요는 없겠죠? 이 정도의 기능은 ExecutorService의 shutdown과 awaitTermination을 활용하여 처리할 수 있을 것 같습니다. executor를 확장하는 ExecutorService는 아래와 같은 기능을 가졌다고 설명을 하고 있습니다.
종료를 관리하는 방법과 하나 이상의 비동기 작업의 진행 상황을 추적할 수 있는 방법을 제공하는 실행자입니다. (주석 번역)
이를 이용하여 아래와 같이 테스트 코드를 작성해 볼 수 있습니다.
@Test
fun `이메일 발송 테스트`() {
val executorService = Executors.newSingleThreadExecutor()
val emailService = EmailService(emailRepository, emailPublisher, executorService)
emailService.sendEmailAsync("test@test.com", "hello")
executorService.shutdown() // 새로운 작업 수락을 중단
executorService.awaitTermination(5, TimeUnit.SECONDS) // 실행 중인 작업 완료 5초간 대기
.also { it shouldBe true } // 작업이 성공적으로 완료되면 true / 5초 이내에 완료되지 않으면 false
assertThat(emailRepository.findAll()).hasSize(1)
verify(emailPublisher).publish(any())
}
위 방법은 명시적으로 작업완료를 기다릴 수 있어서, timeout을 정말 worst 상황에 수행되기를 기대하는 시간을 지정할 수 있습니다. 그러면서도 불필요한 시간을 기다리지 않아도 되어서 안정적이고, 테스트 시간의 개선을 기대를 해볼 수 있습니다.
하지만, 테스트에 여러 가지 비동기 로직이 존재하는 경우 ExecutorService 관리가 복잡해질 수 있고, 계속 shutdown을 통하여 작업 종료를 감지하므로 ExecutorService의 생명주기를 잘못 관리하면 테스트가 괴상하게 깨질 수 있습니다.
Polling을 통하여 검증하기
두 번째 아이디어는 완료되기를 기대하는 시간을 지정하고, 이 기간 동안 지정한 interval 마다 polling을 통하여 완료되었는지 확인하는 방법을 생각해 봤습니다. 직접 구현하면 아래처럼 나올 것 같습니다.
@Test
fun `직접 구현한 polling 방식의 비동기 테스트`() {
val timeout = 5_000L
val interval = 100L
emailService.sendEmailAsync("test@test.com", "hello")
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeout) {
if (emailRepository.findAll().size == 1) {
return // 성공 처리
}
Thread.sleep(interval)
}
fail("비동기 로직 polling timeout으로 테스트 실패")
}
이러한 polling 방식은 이미 잘 만들어진 테스트 라이브러리들에서 제공하고 있습니다. 대표적으로 Kotest의 Eventually와 Awaitility를 살펴보겠습니다.
물론 이러한 polling 방식은 또한 테스트 라이브러리에서 잘 제공하고 있습니다. Kotest의 Eventually와 Awaitility가 존재하는데요. 각각 아래와 같이 구현할 수 있습니다.
@Test
fun `이메일 발송 테스트 with Kotest`() {
// given
val emailService = EmailService(executorService, emailRepository)
// when
emailService.sendEmailAsync("test@test.com", "hello")
// then
eventually(5.seconds) {
emailRepository.findAll().size shouldBe 1
}
}
@Test
fun `이메일 발송 테스트 with Awaitility`() {
// given
val emailService = EmailService(executorService, emailRepository)
// when
emailService.sendEmailAsync("test@test.com", "hello")
// then
await()
.atMost(Duration.ofSeconds(5))
.until { emailRepository.findAll().size == 1 }
}
위와 같은 방법은 polling을 여러 차례 수행하므로 이에 대한 리소스를 소모하게 될 수 있습니다. 또한 완료를 직접 감지하는 것이 아니므로 interval 정도의 시간적 손해가 발생할 수 있고, polling을 종료할 성공 여부를 잘 정의해야 합니다.
대신 executor에 종속적이지 않으므로 보다 덜 침투적이고, ExecutorService의 생명 주기를 관리하지 않아도 된다는 장점이 있습니다. 또한 이를 제외하고 n초 동안 성공해야 하는 등 결정적이지 않은 테스트를 검증하기 좋은 함수들을 제공해주고 있습니다.
개인적으로는 테스트 맥락이 침투적이지 않은 것이 중요한 가치라고 생각하고, 다양한 관심사를 만족시킬 수 있는 이 방법이 조금 더 괜찮은 해결책이지 않나 생각하고 있습니다.
물론 ‘테스트를 수행한다’라는 가치가 제일 중요하므로 어떠한 방법이든 선택하여 테스트를 진행한다면 그것이 좋은 해결책이라고 생각합니다. 😄
'Java & Kolin' 카테고리의 다른 글
Sentry에서 Cron 모니터링이 가능하다는 사실 (1) | 2024.10.14 |
---|---|
M1 사용기 - JVM환경에서 ARM / Rosetta 번역 알아내기 (0) | 2021.10.10 |
Garbage Collection (0) | 2021.04.04 |
JVM은 무엇이며 자바 코드는 어떻게 실행하는 것인가. (0) | 2021.03.30 |
Kotlin의 Generic - 기본문법 (0) | 2021.03.03 |
댓글