본문 바로가기
Develop/Reactor+Coroutine

Spring MVC + Coroutines 에 대한 고민

by 연로그 2024. 9. 30.
반응형

🤔 문제의 시작

 

우리 팀은 Spring MVC와 Coroutines, Kotlin을 사용하는 레포가 있다. 신규 API를 개발해서 호출하니 NoClassDefFoundError가 발생했다. MonoKt를 찾지 못한다고 한다. 모든 API에서 발생하는 건 아니고 코루틴을 적용한 API에서만 발생하였다.

Handler dispatch failed: java.lang.NoClassDefFoundError: kotlinx/coroutines/reactor/MonoKt

 

많은 곳에서 kotlinx.coroutines.reactor 의존성을 추가하라는 말만 있을 뿐, 명쾌한 해결 방법이나 원인에 대해 알기는 어려웠다. WebFlux를 사용하는 것도 아닌데 reactor와 관련된 의존성을 왜 추가해야 하는지 이해가 안 됐다

 

 

🔸 문제 원인

구글링 + GPT의 힘으로 Spring MVC를 사용하는 경우 컨트롤러에서 suspend fun을 사용하면 해당 문제가 발생할 수 있다는 사실을 알아냈다. 컨트롤러에서 suspend fun을 사용하면 스프링 내부에서 Mono/Flux로 변환하는 과정이 일어난다. 이는 에러로그 전문을 찬찬히 뜯어봐도 확인할 수 있다. HandlerMethod에서 CoroutineUtils의 메서드를 호출하고, CoroutineUtils 메서드 내부에서 MonoKt를 사용하는 것을 확인할 수 있다. 이에 대한 딥다이빙은 다음 기회에 진행해 보겠다.

 

🔻 에러로그 전문 보기

더보기

jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoClassDefFoundError: kotlinx/coroutines/reactor/MonoKt
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1104)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:205)
...


Caused by: java.lang.NoClassDefFoundError: kotlinx/coroutines/reactor/MonoKt
at org.springframework.core.CoroutinesUtils.invokeSuspendingFunction(CoroutinesUtils.java:119)
at org.springframework.core.CoroutinesUtils.invokeSuspendingFunction(CoroutinesUtils.java:96)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeSuspendingFunction(InvocableHandlerMethod.java:292)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:249)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:925)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:830)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
...


Caused by: java.lang.ClassNotFoundException: kotlinx.coroutines.reactor.MonoKt
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:525)
...

 

또한 Spring 공식 문서에도 아래와 같은 내용이 나와있다. kotlinx-coroutines-core와 kotlinx-coroutines-reactor를 모두 의존성에 추가하도록 권장한다.

Coroutines support is enabled when 'kotlinx-coroutines-core' and 'kotlinx-coroutines-reactor' dependencies are in the classpath:
dependencies {
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:${coroutinesVersion}")
}

 

 

🔸 문제 해결

위 문제를 해결하기 위해서는 두 가지 방법이 있다.

  1. kotlinx.coroutines.reactor 의존성을 추가한다.
  2. 컨트롤러의 함수에서 suspend 키워드를 제거한다.

 

1번은 단순히 의존성을 추가하면 되니 2번 방법에 대해 자세히 이야기해 보겠다. 컨트롤러의 함수에서 suspend 키워드를 제거하기 위해서는 runBlocking을 사용해야 한다. 하지만 이 경우, runBlocking이 스레드를 블록해버린다. 예제를 하나 보겠다. async { ... }를 여러 번 호출해도 모두 같은 스레드를 사용한다.

@GetMapping("/test")
fun test() = runBlocking {
    val a = async {
        println("!!! A ${Thread.currentThread().name}")
        return@async "A"
    }

    val b = async {
        println("!!! B ${Thread.currentThread().name}")
        return@async "B"
    }

    println("!!! runBlocking ${Thread.currentThread().name}")
    println("!!! runBlocking ${a.await()} ${b.await()}")
}

runBlocking 내부에서는 모든 코루틴이 블로킹한 스레드를 사용한다.

 

이렇게 하나의 스레드로만 처리하는 현상을 방지하기 위해서는 스코프를 직접 생성할 수도 있다. 디스패처를 직접 주입시킴으로써 runBlocking과의 부모-자식 관계를 깨뜨리는 것이다.

private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@GetMapping("/test")
fun scope() = runBlocking {
    val a = scope.async {
        println("!!! A ${Thread.currentThread().name}")
        return@async "A"
    }

    val b = scope.async {
        println("!!! B ${Thread.currentThread().name}")
        return@async "B"
    }

    println("!!! runBlocking ${Thread.currentThread().name}")
    println("!!! runBlocking ${a.await()} ${b.await()}")
}

코루틴이 디스패처의 스레드를 할당받아 사용한다.

 

 


Spring MVC와 Coroutines

 

2번 방법에서 컨트롤러의 함수에서 suspend 키워드를 제거하기 위해 스코프를 직접 생성하고 디스패처를 주입시켰다. 이로 인해 코루틴의 구조적 동시성이 깨져버렸다. 이슈 자체는 해결했지만 Spring MVC와 Coroutine의 조합이 과연 적절한가? 에 대한 의문이 들기 시작했다.

 

📃 가정 1. Spring MVC와 Coroutines의 패러다임은 다르다.

사실이다. 두 기술은 각기 다른 패러다임과 목적을 가지고 개발되었다. 따라서 두 기술을 동시에 사용하려고 하면 패러다임의 불일치로 인해 혼란이 올 수 있다. 아래는 ChatGPT에게 두 기술의 차이에 대해 간단히 설명해 달라고 요청한 내용이다. (GPT에게 물어본 내용을 그대로 복붙 한 거니 정확한 정보가 아닐 수 있음을 유의 바란다.)

 

Spring MVC: 동기적인 요청-응답 패턴을 사용하는 웹 프레임워크로, 요청을 처리하는 동안 스레드가 블로킹됩니다. 전통적인 웹 애플리케이션 개발에 적합합니다.
Coroutines: Kotlin에서 제공하는 비동기 프로그래밍 모델로, 작업을 비동기적으로 처리하며 스레드를 블로킹하지 않습니다. 더 많은 요청을 효율적으로 처리할 수 있습니다.

차이점을 정리해 보자면 Spring MVC는 동기/블로킹 방식, Coroutines는 비동기/논블로킹 방식으로 설계되었으며, 처리하는 방식과 목적이 다릅니다.

 

 

📃 가정 2. Spring MVC 사용 시 코루틴은 스레드 하나만 이용하게 된다.

사실이 아니다. 어떻게 사용하냐에 따르다. 톰캣에 할당되는 스레드와 디스패처가 관리하는 스레드는 다르다. 디스패처를 직접 주입시켜 실행하면 코루틴이 디스패처의 스레드를 할당받아 실행됨을 확인할 수 있다. (단, 아래와 같이 스코프를 선언해 사용하는 경우 구조적 동시성이 깨진다.)

private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@GetMapping("/test")
fun scope() = runBlocking {
    val a = scope.async {
        println("!!! A ${Thread.currentThread().name}")
        return@async "A"
    }
    println("!!! runBlocking ${Thread.currentThread().name}")
}

결과 화면

 

 

🧐 그럼에도 불구하고 Spring MVC에서 Coroutines을 사용하는 이유는?

비동기 코드가 훨씬 깔끔하다. 코틀린 사이트에도 Kotlin Coroutines와 Java 8의 CompletableFuture의 차이에 대한 논의가 있다. 간단한 코드의 경우 Coroutines나 CompletableFuture나 별 차이가 없어 보인다.

fun withCoroutines() = runBlocking {
    val deferred1 = async { taskWithCoroutines(1000L) }
    val deferred2 = async { taskWithCoroutines(2000L) }

    val result1 = deferred1.await()
    val result2 = deferred2.await()

    println("Result: ${result1 + result2}")
}

private suspend fun taskWithCoroutines(timeMillis: Long): Long {
    delay(timeMillis)
    return timeMillis
}

 

fun withCompletableFuture() {
    val future1 = CompletableFuture.supplyAsync { taskWithCompletableFuture(1000L) }
    val future2 = CompletableFuture.supplyAsync { taskWithCompletableFuture(2000L) }

    val result1 = future1.get()
    val result2 = future2.get()
    
    println("Result: ${result1 + result2}")
}

private fun taskWithCompletableFuture(timeMillis: Long): Long {
    Thread.sleep(timeMillis)
    return timeMillis
}

 

하지만 조금 더 복잡한 로직이 들어가기 시작한다면 다르다. 아래 코드는 파일을 여러 개 다운 받아서 작업을 수행하고, 파일과 관련 없는 작업에 대해서도 하나 더 수행하는 코드다. 여기서 예외 처리까지 고려해야 한다면 얼마나 더 복잡해질지도 상상해 보자.

fun withCoroutines() = runBlocking {
    val ids = listOf(1, 2, 3, 4, 5)

    // 파일을 여러개 다운받고, 수행한다.
    val results = ids.map { id ->
        async {
            val content = downloadFile(id)
            processFile(content)
        }
    }
    // 작업 하나를 실행한다.
    val otherResult = async { otherTask() }

    // 위 작업들의 결과를 가져오고 출력한다.
    results.awaitAll().forEach { println(it) }
    println(otherResult.await())
}

 

fun withCompletableFuture() {
    val ids = arrayOf(1, 2, 3, 4, 5)

    // 파일 여러개를 다운받고 수행한다.
    val futures = Array(ids.size) { i ->
        val id = ids[i]
        CompletableFuture.supplyAsync { downloadFile(id) }
            .thenApply { content -> processFile(content) }
    }
    // 작업 하나를 수행한다.
    val otherFuture = CompletableFuture.supplyAsync { otherTask()}

    // 위 작업들의 결과를 가져오고
    val allOf = CompletableFuture.allOf(*futures)
    allOf.get()

    // 화면에 출력한다.
    for (future in futures) {
        println(future.get())
    }
    println(otherFuture.get())
}

 

 

🤔 Spring MVC 대신 WebFlux를 쓰는 게 낫지 않을까?

WebFlux는 러닝커브가 높기로 악소문이 나있지만 Coroutines와 함께 사용하면 러닝커브가 훌쩍 줄어든다고 한다. 사용하는 방법도 Mono나 Flux를 직접 제어하지 않고 컨트롤러에 suspend만 붙여도 성능적으로 상당히 많은 부분이 개선된다.

 

SpringMVC + Coroutines의 자료는 비교적 적다. 아무렇게나 검색해도 WebFlux + Coroutines의 자료가 훨씬 많다. 나 역시 이러한 이유 때문에 Spring MVC를 제거하는 게 더 편하지 않나 의문을 가졌다. 팀원분께 여쭤보니 팀 내에 WebFlux에 대해 이해도가 높은 사람이 없기 때문에 장애가 났을 때 대응하기 힘들다, 그래서 모두가 익숙한 Spring MVC가 더 적합하다고 생각했다고 의견을 주셨다. 하지만 자료가 더 적으니 Spring MVC+Coroutines가 더 대응하기 어렵지 않을까라는 생각도 있었어서 여전히 고민은 아주 조금 남아있었다.

 

한데 직접 겪어보고 생각이 달라졌다. WebFlux + Coroutines를 사용하는 다른 레포가 있는데, 여기서 특이한 에러 로그가 하나 보였다. 구글링 해보니 WebFlux + Coroutines를 사용하는 환경에서 발생할 수 있는 에러였고, 설명 역시 잘 되어있었다. 한데 문제가 있었다. WebFlux에 대해서 잘 모르니 설명을 이해할 수 없었다. 해당 이슈는 베타에서 발생했고, 크리티컬 하지 않았고, 다른 분의 도움으로 잘 넘어가긴 했다. 하지만 이런 문제가 운영 환경에서 발생할 수도 있다고 생각하니 아득해졌다.

 

생각이 바뀐 계기가 하나 더 있다. 8월 초에 인프콘에서 조영호 님의 '객체지향은 여전히 유용한가?' 발표를 들었는데 이런 내용이 있다. '단 하나의 패러다임만 이용해서 개발하기는 어렵다. 다양한 패러다임을 상황에 맞게 가공해서 활용해야 한다. 코드의 목적과 변경의 방향성에 따라 언제 어떤 기술을 사용할지 결정하라.' 영호 님의 말씀을 내가 임의로 가공한 것이라 워딩은 부정확할 수 있다. 아무튼 저 내용은 꽤 인상 깊게 남았고, 기술의 패러다임이 불일치하다는 이유만으로 기술을 반대하기엔 근거가 모자라다는 생각이 들었다. (정말 좋은 발표라서 강력 추천 드립니다. 링크: https://inf.run/zAdvV)

 

[지금 무료] 인프콘 2024 다시보기 강의 | 인프런 - 인프런

인프런 | 성장하는 IT인들의 축제, 인프콘 2024에서 진행된 오프닝 및 발표 세션을 영상으로 다시 보실 수 있습니다., ✅ 확인해주세요이 콘텐츠는 2024년 8월 2일 금요일 진행된 인프콘 2024 발표 녹

www.inflearn.com

 

 

😳 앞으로도 Spring MVC + Coroutines 조합을 사용할 예정인가?

당분간은 그럴 예정이다. 하지만 장기적으로 계속 이 기술 조합을 사용할 것이냐 하면 모르겠다. 바뀔 가능성이 훨씬 크다고 생각한다. Kotlin, Coroutines에 익숙하지 않은 사람들이 조금씩 학습하며 적용한 거다 보니 숨어있는 문제도 많을 거라 생각한다. 개발하며 우리 팀에게 적절한 기술은 무엇인가에 대해 끊임없이 고민해야겠다. (이 글을 읽어주시는 분들께서 다양한 의견이 있으시다면 댓글로 달아주시면 정말 감사합니다.🙂)

 


물음표 살인마의 물음을 계속 받아준 이라마루 친구들아 고맙다

반응형