해결된 질문
작성
·
1.1K
·
수정됨
1
안녕하세요.
Spring MVC 환경에서 코루틴을 사용할 때 질문이 있습니다. (WebFlux 사용 X)
다음과 같은 코드가 있다고 가정하겠습니다.
컨트롤러
@GetMapping("/test")
suspend fun test(): ApiResponse<Void> {
testService.run()
return ApiResponse.success(statusCode = HttpStatus.OK)
}
서비스
class TestService(
private val userRepository: UserRepository,
) {
suspend fun run(): Triple<String, String, Int> {
// 외부 API 호출 1
val nicknameDeferred = CoroutineScope(Dispatchers.IO).async {
callApiForNickname()
}
// 외부 API 호출 2
val addressDeferred = CoroutineScope(Dispatchers.IO).async {
callApiForAddress()
}
// Do something business logic
// DB 호출
val duplicateCount = userRepository.findDuplicateCountById(1L)
return Triple(first = nicknameDeferred.await(), second = addressDeferred.await(), third = duplicateCount)
}
}
(callApiFor~() 메소드는 외부 API 호출이라고 가정하며 suspend function입니다)
서비스 레이어에서는 2개의 외부 API 호출을 수행하고 DB호출을 한 후 리턴해주는 간단한 구조입니다.
질문
초기에는 내부적으로 코루틴을 사용할 지 말지 결정할 수 없는 상황이 있기에 모든 컨트롤러 메소드를 suspend로 선언하는건 어떨까 합니다. (2번 질문의 성능 차이가 크지 않다면요^^) 이 부분에 대해 강사님의 생각과 실무에서는 보통 어떻게 하는지 궁금합니다.
위 질문에 이어서, 코루틴이 전혀 사용되지 않는 상황에서 컨트롤러 메소드를 suspend로 선언하면 일반 메소드로 선언하는것과 성능차이가 어느정도 있을까요? (물론 트래픽 등 기타 상황별로 다르겠지만요)
서비스 레이어를 보면 CoroutineScope을 통해 각 외부 API호출별로 루트 코루틴을 따로 만들고 있습니다. 각 외부 API들은 단순 조회 요청으로써 구조적 동시성이 필요하지 않다고 생각하기 때문인데요. 실무에서도 위와 같이 여러개의 외부 API호출을 병렬로 수행해야하는 경우 각 호출마다 CoroutineScope을 사용하여 개별로 루트 코루틴을 만들어서 사용하는지 궁금합니다.
일반적인 웹 API 서버 개발을 수행할 때 구조적 동시성이 필요한 케이스가 있을까요? 어차피 추후 로직을 통해 반환값에 대한 검증을 수행한다고 생각해서요. 예를 들어 A, B, C를 코루틴으로 호출할 때 A가 실패해서 B, C를 취소하려해도 각 메소드의 레이턴시에 따라 취소될수도, 이미 완료됐을수도 있을 것 같습니다. 실무에서 웹 API 비즈니스 로직 구성 시, 개별 루트 코루틴이 아닌 하나의 루트 코루틴으로 묶어서 구조적 동시성을 보장해야만 하는 상황이 있을지, 있다면 무엇일지 궁금합니다.
감사합니다.
답변 1
1
안녕하세요! teamhide님! 🙂 정말 좋은 질문 감사합니다.
적어주신 것처럼 MVC에서도 Controller 부터 suspend 키워드를 쓰는게 가능하죠! 제 기억에 Spring Framework 5.3 부터 가능했던 것 같아요! 제가 알고 있는 부분 하나씩 답변 드려보겠습니다.
[1. 모든 Controller method를 suspend로 선언하는건 어떨지]
저는 개인적으로 살짝 애매해다고 생각합니다! 그 이유는 다음과 같습니다.
잘 아시겠지만 Spring MVC는 thread-per-request 구조로 동작합니다. Client로부터 요청이 들어오면 thread에 해당 요청이 매핑되어 처리되는 방식이죠. 그리고 이 과정에서 DB 호출 혹은 RestTemplate을 쓰게 되면 thread에 대한 blocking이 일어나게 됩니다. Network I/O를 타는 응답이 도달할 때까지 스레드가 아예 멈추게 되는 것이죠.
코루틴은 강의에서 설명드린 것처럼, 한 스레드에서 실행이 되다가 코루틴을 멈출 때가 되면, 그 코루틴을 멈춰 두고 그 스레드가 다른 코루틴을 실행하여 처리량을 극대화 하는 방식입니다. 그런데 문제는 Java21에서 등장한 virtual thread와는 다르게 코루틴은 blocking I/O를 만났다고 해서 자동으로 다른 코루틴으로 교체되지 않는다는 것입니다. blocking 대신 non-blocking을 사용해야만 스레드를 blocking 하지 않고 스레드를 계속 활용할 수 있습니다.
여기서 mismatch가 발생합니다. 보통 Spring MVC와 함께 사용하는 JPA는 blocking call 입니다. 따라서 coroutine과 JPA를 함께 사용하면 그 궁합이 별로 좋을 수가 없죠. 물론, 어차피 coroutine을 Spring MVC와 함께 사용해봤자 thread-per-request 구조이고, suspend 함수 안에서 DB 호출을 해도, 어차피 수백개의 (default : 200) 스레드 중 한 개의 스레드가 blocking 되는 것이기 때문에 suspend를 사용하지 않는 것과 큰 차이가 발생하게 됩니다. 하지만 그럼에도 불구하고 코루틴 안에서 blocking call을 하는 것이 어색해보이긴 하죠.
하지만 suspend 함수를 모든 Controller 코드에 사용하게 되면
JPA와 coroutine의 궁합이 좋지 않다 보니 코드 자체가 어색하게 느껴질 수 있고
어쨌건 suspend 함수를 쓰면 Spring MVC 내부적으로 해당 함수를 Mono로 매팽하는 등의 overhead가 약간은 있다 보니
저라면 모든 Controller method를 suspend 함수로 선언하지는 않고, 꼭 필요한 부분에서 사용할 것 같습니다. 🙂
[2. suspend로 선언하면 일반 메소드로 선언하는것과 성능차이가 어느정도 있을까요?]
저도 솔직히 말씀드리면 정확한 benchmark를 찾을 수는 없어서 잘 모르겠습니다.
다만 suspend 함수를 사용하게 되면 약간의 overhead가 있다는 점
그리고 Dispatchers.IO
를 사용해 외부 API를 호출하거나, DB를 사용해 병렬성을 활용하더라도, 해당 I/O가 blocking I/O 라면 Dispatchers.IO
(= 스레드 풀) 자체가 소진될 수 있다는 점
을 고려해보면, 일반 메소드보다 약간의 손해가 있지 않을까 싶습니다. 🙂
즉 바꿔 말하면 Dispatchers.IO
+ non blocking API call을 사용하면 병렬성을 극대화 할 수 있기 때문에 훨씬 나은 성능을 보장할 수 있겠죠! 2개의 API가 각각 1초 소요된다면, 단순 Spring MVC -> 2초, coroutine -> 1.05초가 소요될겁니다
그렇다면 이런 생각도 할 수 있습니다
어 그러면... JPA 자체를 쓰지 않고 non-blocking DB call로 바꾸고 RestTemplate
대신 WebClient
를 사용하면 엄청 좋겠네?
네 맞습니다!!! 다만, 이런 경우는 보통 webflux + coroutine을 사용하게 되죠! 😊
[3. 위와 같이 여러개의 외부 API호출을 병렬로 수행해야하는 경우 각 호출마다 CoroutineScope을 사용하여 개별로 루트 코루틴을 만들어서 사용하는지 궁금합니다]
상황에 따라 다를 것 같습니다. 다만 몇 개의 외부 API를 호출해야 하는데 각 호출이 모두 독립적이라면, 각 호출마다 CoroutineScope을 사용해 개별 루트 코루틴을 만드는 방법도 충분히 쓸 수 있을 것 같아요! 🙂 당연히 하나의 코루틴으로 묶을 수도 있지만 성능이나 코드 퀄리티에 드라마틱한 차이는 없을 것 같습니다.
반대로 아래에서 설명드리는 것처럼 1번 API에 2번, 3번이 의존하고 있다면 하나의 루트 코루틴 안에서 다시 2개의 서브 코루틴을 만드는 식으로도 구성할 수 있겠네요
[4. 실무에서 웹 API 비즈니스 로직 구성 시, 개별 루트 코루틴이 아닌 하나의 루트 코루틴으로 묶어서 구조적 동시성을 보장해야만 하는 상황이 있을지, 있다면 무엇일지 궁금합니다.]
만약 첫 번째 API를 호출하고, 그 API의 결과를 토대로 2번, 3번 API를 호출해야 한다면, 루트 코루틴 안에 서브 코루틴을 2개를 만드는 방식으로 구성할 수 있을 것 같습니다.
첫 번째가 실패하면 당연히 전체 (root) 코루틴이 실패하니 2번, 3번은 애당초 호출되지 않을 것이고, 2번에서 실패하면 3번을 취소하게 되겠죠
물론 다음과 같은 생각을 하실 수도 있습니다!
3번을 취소한다고 하더라도 어차피 API 호출이 나갔을텐데 무슨 의미가 있는가~
이런 생각이 맞을 수도 있는데요! 아닐 수도 있습니다. 만약 초당 수십, 수백개의 요청을 동일한 로직으로 처리하고 있고, 소켓 소진을 막기 위해 Connection Pool을 사용하고 있다면, 외부 API를 동시에 호출하는 것은 Connection Pool 개수만큼만 가능하기 때문에 다른 요청에 밀려, 2번 요청은 실행됐지만, 3번 요청은 실행되지 않았을 수 있습니다.
물론 확률적인 이야기이지만, 충분히 일어날 수 있는 상황이라고 생각해요!
긴 글 읽어주셔서 감사합니다. 또 궁금한 점 있으시면 편하게 질문 남겨주세요! 😊
답변이 도움이 되었으면 좋겠네요! 감사합니다!! 🙏
물론, 어차피 coroutine을 Spring MVC와 함께 사용해봤자 thread-per-request 구조이고, suspend 함수 안에서 DB 호출을 해도, 어차피 수백개의 (default : 200) 스레드 중 한 개의 스레드가 blocking 되는 것이기 때문에 suspend를 사용하지 않는 것과 큰 차이가 발생하게 됩니다.
-> 맞습니다. 사실 제가 궁극적으로 궁금한 것은 전체 코드에는 blocking이 존재하지만 non-blocking으로 줄일 수 있는 코드가 있는 경우 해당 부분에만 코루틴을 적용할 지 말지에 대한 것이었습니다ㅎㅎ
코루틴을 사용하지 않았을 때와 사용했을 때의 흐름/사용되는 스레드는 아마 다음과 같을 것입니다. (Spring MVC 기본 스레드풀을 MVC 스레드로, 코루틴의 스레드를 코루틴 스레드로 표시하겠습니다. 또한 callApiFor~() 외부 API호출들은 말씀하신 Webclient를 사용중이기에 non-blocking입니다.)
외부 호출 코루틴 사용 X
[MVC 스레드] 컨트롤러에서 Request를 받아 서비스 호출
[MVC 스레드] callApiForNickname() 외부 API 호출
[MVC 스레드] callAPiForAddress() 외부 API 호출
[MVC 스레드] findDuplicateCountById() DB 호출
[MVC 스레드] 리턴
[MVC 스레드] 컨트롤러에서 응답 리턴
외부 호출 코루틴 사용 O
[MVC 스레드] 컨트롤러에서 Request를 받아 서비스 호출
[코루틴 스레드] callApiForNickname() 외부 API 호출
[코루틴 스레드] callAPiForAddress() 외부 API 호출
[MVC 스레드] findDuplicateCountById() DB 호출
[MVC 스레드] 리턴
[MVC 스레드] 컨트롤러에서 응답 리턴
위 흐름에서 callApiForNickname(), callApiForAddress() 가 각각 1초씩 걸릴 때 코루틴을 적용하면 2초 -> 1.X초로 줄일 수 있습니다. 마치 코루틴 이전 별도의 스레드를 통해 실행하는것과 비슷한 느낌이네요 :)
MVC에서 Request를 다루는 스레드풀과 코루틴이 실행되는 Dispatchers.IO의 스레드풀은 별개라고 알고있습니다. 위 2개의 예시 상황은 모두 어차피 MVC 스레드가 블락킹 되는 상황입니다. 이러한 상황에서 코루틴을 적용할 지 고민이 됩니다.
실시간 대화가 아니라 최대한 풀어서 쓰려고 했는데 질문이 잘 이해가 가실지 모르겠네요 ^^; 감사합니다!
non-blocking으로 줄일 수 있는 코드가 있는 경우 해당 부분만 코루틴을 적용할지 말지
-> 저라면 무조건 적용할 것 같아요! 🙂 위에서 "즉 바꿔 말하면 Dispatchers.IO
+ non blocking API call을 사용하면 병렬성을 극대화 할 수 있기 때문에 훨씬 나은 성능을 보장할 수 있겠죠! 2개의 API가 각각 1초 소요된다면, 단순 Spring MVC -> 2초, coroutine -> 1.05초가 소요될겁니다" 라고 말씀드렸던 것처럼 훨씬 나은 성능을 보장할 수 있을 뿐만 아니라
-> MVC thread는 어떻게 하건 어차피 blocking이 되지만, 말씀해 주신 것처럼 코루틴을 실행하는 Dispatchers.IO 스레드는 non-blocking 코드만 호출하니 매우 큰 처리량을 가질 수 있어 해당 부분이 병목이 되지도 않습니다.
감사합니다!! 🙏
코루틴과 가상스레드에 대해 더 알고 싶으시면,
https://inf.run/SjyDc
위 강의의 가상 스레드 편을 참고해주셔도 좋고요!
위 강의를 꼭 결재하지 않으시더라도, https://www.youtube.com/watch?v=bOLChQ3fFQo 영상을 보셔도 좋습니다! 🙂
감사합니다! 🙏