해결된 질문
작성
·
109
1
안녕하세요. 저는 오랜기간 동안 Java, Spring을 기반으로 웹 프로그래밍 해왔고 이번에 일부 프로젝트를 코틀린 + Spring을 기반으로 구현을 검토하게 되어 해당 강의를 듣게 되었습니다.
사실 Java, Spring 기반이다 보니 동기방식의 프로그램에 익숙해져 있고 러닝커브나 디버깅의 어려움, DB등의 관련 라이브러리들이 아직 안정화 수준이 아니라 판단하여 WebFlux 도입을 꺼려왔고,
일부 RestTemplate 호출등의 병렬 처리가 필요할 경우 CompletableFuture에 별도 ThreadExecutor를 사용해 처리하는 방법을 주로 사용해 왔습니다.
이번 신규 프로젝트도 우선은 Spring 프레임워크를 사용하기 때문에 Webflux가 아닌 Spring MVC 기반의 동작이 될 예정인데요,
그러다 보니 강의를 다 들었지만 coroutine을 어떻게 활용할 수 있을지 감을 잘 못잡은 상태입니다.
가령 RestTemplate이나 RestClient 등의 동기식 block 기반의 통신 시 호출 부를 CoroutineScope(Dispatchers.Default).async { } 으로 감싸서 호출을 하게 되면 두개의 API가 각각 1초가 걸린다면 1.05 정도로 가능하겠지만 이런 패턴은 기존 자바에서 CompletableFuture로 충분히 가능했던더라 coroutine을 잘 활용했다고 보는게 맞는지 궁금한데요,
coroutine의 장점을 활용하려면 결국 API 통신 같은 경우 가능하면 webclient나 ktorClient 등을 통해 non-block으로 변경해서 처리를 해야 하는건지 궁금하고, 코틀린으로 웹애플리케이션을 구현한다고 했을때 spring을 사용하면 webflux 도입은 약간 필수? 같은 개념으로 봐야 하는건지 궁금합니다.
(강의 마지막 Continuation 예제에서 repository call을 두번 하는 부분도 non-block으로 DB처리가 가능해야 의미있는 coroutine 동작이 가능한거겠죠??)
질문을 요약하자면,
Spring MVC 구조에서 block 기반의 로직 처리 시 CoroutineScope(Dispatchers.Default).async { } 으로 감싸서 호출하는 구조가 coroutine의 장점을 활용한 방식이 맞을까요?
1번이 장점이 아니라면 non-blocking으로 처리 가능한 webclient나 ktorClient을 사용해야 해야 할까요?
보통 Spring에서 coroutine을 활용하려면 webflux를 사용하는게 기본일지? 혹시 다른 활용 방안은 없을지?
입니다.
감사합니다.
답변 1
1
안녕하세요~ yki1204님! 정말 좋은 질문 감사드립니다. 저도 Learning curve가 높은 webflux 보다는 MVC를 선호해서 오랫동안 고민하던 부분이네요 🙂
본격적으로 질문에 대한 답변을 드리기에 앞서, 제가 생각하는 코루틴의 최강 장점은 비동기 프로그래밍을 마치 동기 프로그래밍 처럼 하게 해준다 라는 것입니다.
말씀해주신 것처럼 CompletableFuture
를 적절히 활용하면 Java 에서도 충분히 비동기 프로그래밍을 할 수 있습니다. 하지만, 비동기 프로그래밍의 특성상
깊어지는 callback depth
직관적인지 않은 중첩 비동기 호출
CompletableFuture
에 대한 약간의 코드 의존성과 불편함
runAsync, supplyAsync 와 그에 맞춰 들어가는 함수형 프로그래밍 등등
이라는 단점이 일부 있다고 (개인적으로) 생각하는데요
코루틴은 이를 중단 함수라는 개념을 활용해 비동기 프로그래밍 인데도, 마치 동기 프로그래밍을 작성하는 것과 같은 코드 가독성을 제공한다고 생각합니다. 🙂
이런 관점에서 하나씩 답변 드려 보겠습니다.
Spring MVC 구조에서 block 기반의 로직 처리 시 CoroutineScope(Dispatchers.Default).async { } 으로 감싸서 호출하는 구조가 coroutine의 장점을 활용한 방식이 맞을까요?
네 저는 장점이라고 생각합니다. 더 정확히는 Spring MVC는 Spring 5.3.x 부터 코루틴과의 통합을 지원하기에 Controller Level 부터 suspend 함수를 사용하실 수 있습니다.
@GetMapping("/api/v1/user")
suspend fun getUser() {
// 즉, 여기에 자유롭게 코루틴을 만들어 낼 수 있죠
}
runBlocking을 사용하서도 되고 endpoint 부터 suspend 함수를 쓰셔도 된다고 생각합니다.
@GetMapping("/api/v1/user")
fun getUser() = runBlocking {
}
그리고 제가 생각하기에 "코루틴이 비동기 프로그래밍 코드를 직관적이고 쉽게 만들어 준다" 라는 측면에서 외부 I/O를 여럿 호출해야 하는 비동기 프로그래밍에 코루틴을 사용한다면 이는 코루틴을 충분히 활용하는 예시라고 생각합니다.
단, 예시로 적어주신 것처럼 RestTemplate 혹은 spring JPA의 기본 Repository은 모두 blocking I/O 입니다. 따라서 코루틴이 특정 스레드에 실행되어 I/O를 태우게 되면 해당 스레드는 blocking이 걸리죠.
그래서 저는 스프링 MVC + 코틀린 조합에선 "외부 API를 다수 사용해야 할 때 webClient만 얹어 쓰는" 편입니다. DB 같은 경우는 기존 MVC 패턴을 그대로 쓰고 있습니다. 🙂
1번이 장점이 아니라면 non-blocking으로 처리 가능한 webclient나 ktorClient을 사용해야 해야 할까요?
말씀 드렸던 것처럼 저는 충분히 장점이라고 생각합니다. 비록 RestTemplate을 사용해 blocking이 일어난다고 하더라도 병렬 비동기 프로그래밍을 쉽게 할 수 있다는 측면이 긍정적으로 느껴져요.
다만, RestTemplate은 한 때 deprecated 될 뻔 하기도 했고,
스프링 부트 3부터는 declarative HTTP Client + WebClient 조합도 무척 강력하기 때문에
저는 WebClient
사용을 추천(?) 드리긴 합니다 ☺
보통 Spring에서 coroutine을 활용하려면 webflux를 사용하는게 기본일지? 혹시 다른 활용 방안은 없을지?
마지막으로 MVC와 함께 라면 위에 말씀드린 것처럼 활용이 제한적인 측면이 있습니다. 결국 외부 I/O 프로그래밍 중 DB는 R2DBC를 쓰지 않는 이상 비동기 호출이 어려우니 API 호출 정도로 제한되죠.
하지만 서비스가 커지고 독립적인 서버가 여럿 뜨게 되면 생각보다 이 장점이 크게 다가올 수 있고, 서버 개발자 입장에서는 API만 개발하는게 아니라 pub-sub 패턴, 배치, 스케줄링 같은 다양한 로직을 개발하게 되는데, 이럴 때 코루틴이 적용 가능한 부분도 꽤 있었습니다. 🙂
예를 들어 kafka event를 받아, 독립적인 또 다른 서버의 API를 수십개 호출한 결과를 조립해 어딘가에 적재해야 한다거나.. (전형적인 zero-payload CQRS 패턴이죠) 스케줄링의 성능을 당장 높이기 위해 모든 로직을 그대로 두고 Dispatcher 스레드 풀만 설정을 추가해 cpu core 활용을 끌어 올려 성능을 N배 개선한다거나.. 하는 식으로 활용이 가능했던 것 같습니다.
답변이 도움이 되었으면 좋겠습니다.
감사합니다. 🙇
네 그럼요~ 🙂 결론부터 말씀드리면 runBlocking
+ async
+ WebClient를 이용한 nonbocking I/O가 가장 일반적인 패턴인데요~
이 경우 스프링 MVC에서 한 스레드가 한 요청에 고정되어 지는 것은 맞지만, RestTemplate을 2회 연속 호출하면 Network I/O 를 탈 때마다 해당 스레드가 blocking 되어 각각 3초 씩 총 6초가 걸린다고 하면,
코루틴과 WebClient를 함께 사용하게 되면 한 스레드에서 병렬로 처리가 되기 때문에 총 3.1초 정도만 걸립니다.
예를 들어 보겠습니다.
@GetMapping("/test")
fun test(): String = runBlocking {
val user1 = async { getUser(1L) }
val user2 = async { getUser(2L) }
return user1.await() + user2.await()
}
suspend fun getUser(id: String): UserResponse {
return webClient.get() // runBlocking이 없습니다!
.uri("/users/$id")
.retrieve()
.awaitBody<UserResponse>()
}
대략 이런 코드가 있다고 합시다. 이때 getUser()
에서는 webClient
+ awaitBody()
를 사용하고 있는데요,
Spring MVC는 1 Thread / 1 Request 구조를 갖고 있기 때문에, 하나의 요청이 들어오면 하나의 스레드가 해당 요청을 처리하게 됩니다. 그리고 우리는 runBlocking()
을 사용했기 때문에 우리가 작성한 함수 Body
val user1 = async { getUser(1L) }
val user2 = async { getUser(2L) }
return user1.await() + user2.await()
가 완전히 종료될 때까지 할당 받은 1개의 스레드가 멈추게 되죠.
이는 어차피 runBlocking을 사용할 때나 사용하지 않을 때나 동일한 동작입니다. MVC에서는 Controller에 작성한 로직이 모두 호출될 때까지 요청을 처리하는 스레드가 스레드풀에 반납되지 않는 것이 일반적이죠.
그리고 내부 동작은 이렇게 됩니다.
요청이 들어오면 MVC는 스레드를 할당, Hander를 정하고 해당 Controller의 함수가 실행된다.
runBlocking을 타고 들어가 첫 번째 API 호출 지점을 만난다.
우리가 작성한 getUser(1L)
을 실행하게 되는데, 비동기 처리가 되어 있기 때문에 getUser(2L)
도 연이어 호출한다.
그리고 user1.await() 과 user2.await()에 걸려 거의 동시에 호출한 API의 응답을 기다린후
대략 3초 정도 지나면 최종 결과를 연산해 반환한다.
보시면 스레드를 2개 사용해 API를 동시에 2개 호출한게 아니라, 1개의 스레드 내에서 API를 2번 동시에 호출하는 것처럼 동작하게 됩니다. 그리고 코드를 읽을 때 역시 suspend
라거나 async
라거나 코루틴과 관련된 코드는 일부 있어도 동기 코드 처럼 함수 호출과 연산이라는 직관적인 코드로 구성되죠 🙂
여기서 만약 Blocking I/O를 사용할 수 밖에 없다고 한다면,
val r1 = async(Dispatchers.IO) { tempClient.call() }
처럼 async 옆에 Dispatcher만 적절히 지정해 주어 여러 blocking I/O를 쉽게 멀티 스레드로 돌릴 수도 있게 됩니다.
몇 가지 테스트와 추론을 통해 Best Pracitces을 빠르게 찾으시네요~ 👍 감사합니다. 🙇
상세한 답변 너무 감사드립니다. 그래서 저도 코틀린을 조금 더 효과적으로 사용해 보기 위해 스프링 MVC에 webClient를 적용해 보는 방안을 테스트 해보고 있는데요,
suspend fun getUser(id: String):UserResponse { return webClient.get() .uri("/users/$id") .retrieve() .awaitBody<UserResponse>() }
예를들어 위와 같은 webClient 기반의 method가 있다고 할때 suspend가 붙었으니 이를 호출하는 서비스 레이어도 마찬가지고 suspend가 붙어야 할꺼 같고,
그럼 말씀 주신것 처럼 Controller Level 까지 suspend를 붙여 사용하는게 가능하겠지만 개인적으론 결국 스레드가 요청에 고정된 방식에서 suspend 키워드를 통해 요청이 중단되었을때 다른 요청을 해당 스레드가 사용하여 스레드의 효율을 올리는 처리는 안될것 같고
(suspend 함수만 만들었을 경우 내부에선 결국 하나의 코루틴이라 동기처리와 다르지 않고, 서블릿 엔진? 입장에서도 요청에 스레드가 고정이라 별다른 의미가 없는)
그렇다고 서비스 레이어에서 getUser()를 호출할때 해당 작업 자체는 awaitBody()에 의해 결과를 기다리고 있기에 비동기(결과를 기다리지 않고 다른 작업을 수행) 처리도 딱히 아닌것 같은 생각이 있습니다.
때문에, getUser() 요청의 결과를 기다리지 않고 다른 작업(blocking 기반의 DB 작업?)을 하려면 async { getUser() } 로 감싸고 실제 결과가 필요한 부분에 job.await()를 해야 하나 싶은데요,
테스트해본 패턴을 정리 해보자면,
fun getUser(id: String):UserResponse { return runBlocking { webClient.get() .uri("/users/$id") .retrieve() .awaitBody<UserResponse>() } }
과 같이 webClient 처리지만 호출부에서 결과를 바로 사용해야 해서 딱히 비동기처리가 필요 없을 경우 내부에 runBlocking를 붙여 호출부에서 조금 편하게 사용하는 방법이 있을거 같고
suspend fun getUser(id: String):UserResponse { return webClient.get() .uri("/users/$id") .retrieve() .awaitBody<UserResponse>() }
으로 suspend 함수를 호출하려면 호출부에서 async { } 를 사용해 단일 스레드로 병렬처리가 가능한 효과를 내는 방법이 필요하지 않나? 생각해 보았습니다.
혹시 이런 방식이 실무에서 Spring MVC + WebClient 기반으로 사용하는 방식이 맞을까요??
일반적으로 사용하는 다른 방식이 있을지 궁금합니다.
추가로 아래와 같이 runBlocking 으로 처리된 메소드를 두번 호출을 해도 async {}로 감싸면 비동기로 동시 처리가 되던데 동작 방식이 잘 이해가 가지 않습니다ㅜ
@GetMapping("/test") fun test(): String = runBlocking { val user1 = async { getUser() } val user2 = async { getUser() } user1.await() user2.await() "OK" } fun getUser(id: String):UserResponse { return runBlocking { webClient.get() .uri("/users/$id") .retrieve() .awaitBody<UserResponse>() } }
살짝만 설명 부탁드려도 괜찮을까요??