Kotlin WebFlux에서 요청 처리 흐름과 코루틴 어댑터 역할 완벽 정리
개요
Spring WebFlux는 Reactor 기반의 논블로킹 웹 프레임워크다. 그러나 Reactor의 Mono/Flux 체인은 복잡한 비즈니스 로직을 표현할 때 가독성이 급격히 떨어진다. Kotlin 코루틴은 이 문제를 해결한다. suspend 함수로 비동기 코드를 명령형 스타일로 작성하면서도 내부적으로는 Reactor 파이프라인 위에서 완전한 논블로킹 처리가 이루어진다. 이 포스트에서는 HTTP 요청이 들어온 순간부터 응답이 반환되기까지 WebFlux + 코루틴 스택 전체 흐름과, 그 중심에 있는 코루틴 어댑터의 역할을 정리한다.
요청 처리 전체 흐름
HTTP 요청 하나가 들어왔을 때 내부에서 벌어지는 일을 순서대로 나열하면 다음과 같다.
[HTTP 요청 수신]
↓
[Reactor Netty (논블로킹 I/O)]
↓
[HttpHandler → WebHttpHandlerBuilder]
↓
[DispatcherHandler.handle()]
↓
[HandlerMapping → HandlerAdapter 선택]
↓
[suspend function 감지]
↓
[CoroutinesUtils.invokeSuspendingFunction()]
↓
[mono { } 빌더 → MonoCoroutine 생성]
↓
[코루틴 실행: 일시중단 / 재개]
↓
[Mono.success() 또는 Mono.error() 발행]
↓
[Reactor 파이프라인 → 직렬화 → HTTP 응답]
HandlerAdapter가 핸들러 메서드를 호출하기 직전, 해당 메서드가 suspend 함수인지 리플렉션으로 확인한다. suspend 함수라면 일반 invoke 대신 CoroutinesUtils.invokeSuspendingFunction()을 통해 실행한다. 이 유틸리티가 바로 Reactive 세계와 Coroutine 세계 사이의 공식 어댑터 지점이다.
Reactive ↔ Coroutine 변환 테이블
| Reactive (Reactor) | Coroutine (Kotlin) | 방향 |
|---|---|---|
Mono<T> |
suspend fun(): T |
Reactive → Coroutine |
Mono<Void> |
suspend fun() |
Reactive → Coroutine |
Flux<T> |
fun(): Flow<T> |
Reactive → Coroutine |
suspend fun(): T |
Mono<T> |
Coroutine → Reactive |
fun(): Flow<T> |
Flux<T> |
Coroutine → Reactive |
Mono<T>.awaitSingle() |
T |
Mono → 값 추출 |
mono { ... } |
Mono<T> |
블록 → Mono 래핑 |
Flow<T>.asFlux() |
Flux<T> |
Flow → Flux |
코루틴 어댑터 핵심 역할
CoroutinesUtils.invokeSuspendingFunction()
Spring Framework 내부에 위치한 이 유틸리티는 suspend 함수를 Mono로 변환하는 핵심 진입점이다. 동작 방식을 단순화하면 다음과 같다.
- Kotlin 리플렉션으로 대상 메서드가
suspend함수인지 확인한다. kotlinx-coroutines-reactor의mono { }빌더를 이용해 코루틴 블록을Mono로 감싼다.- Reactor
Subscriber가 구독을 시작하면 코루틴이 실행된다. suspend지점에서 코루틴이 일시 중단되고, I/O 완료 시 Reactor 이벤트 루프 스레드에서 재개된다.- 코루틴이 값을 반환하면
Mono.success(value)로, 예외를 던지면Mono.error(ex)로 신호를 발행한다.
mono { } 빌더와 핵심 API
kotlinx-coroutines-reactor 라이브러리가 제공하는 핵심 브릿지 API는 다음과 같다.
// suspend → Mono: 코루틴 블록을 Reactor Mono로 래핑
fun <T> mono(block: suspend CoroutineScope.() -> T?): Mono<T>
// Mono → suspend: Mono 값을 코루틴에서 꺼내기
suspend fun <T> Mono<T>.awaitSingle(): T
suspend fun <T> Mono<T>.awaitSingleOrNull(): T?
suspend fun <T> Mono<T>.awaitFirstOrNull(): T?
suspend fun <T> Mono<T>.awaitFirstOrDefault(default: T): T
// Flow ↔ Flux 변환
fun <T> Flow<T>.asFlux(): Flux<T>
fun <T> Flux<T>.asFlow(): Flow<T>
mono { } 빌더는 내부적으로 MonoCoroutine이라는 Coroutine + Publisher 하이브리드 객체를 생성한다. 이 객체는 Reactor의 Mono 인터페이스를 구현하는 동시에 코루틴의 실행 컨텍스트(Job, CoroutineContext)를 보유한다.
코드 예제
@RestController
@RestController 방식에서는 Spring이 자동으로 코루틴 어댑터를 적용한다. 개발자는 suspend와 Flow 반환 타입만 신경 쓰면 된다.
@RestController
@RequestMapping("/users")
class UserController(private val service: UserService) {
// suspend fun → Mono<User> 자동 변환
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): User =
service.findById(id)
// Flow<User> → Flux<User> 자동 변환
@GetMapping
fun getUsers(): Flow<User> =
service.findAll()
// 병렬 처리: coroutineScope + async
@GetMapping("/summary")
suspend fun getSummary(): Summary = coroutineScope {
val users = async { service.findAll().toList() }
val count = async { service.count() }
Summary(users.await(), count.await())
}
}
coRouter DSL + Handler
함수형 라우팅 방식인 coRouter를 사용하면 라우팅 선언과 핸들러 로직을 깔끔하게 분리할 수 있다.
// Router
@Configuration
class RouterConfig(private val handler: ProductHandler) {
@Bean
fun routes() = coRouter {
"/products".nest {
GET("", handler::findAll)
GET("/{id}", handler::findById)
POST("", handler::create)
DELETE("/{id}", handler::delete)
}
}
}
// Handler
@Component
class ProductHandler(private val repository: ProductRepository) {
suspend fun findAll(request: ServerRequest): ServerResponse =
ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyAndAwait(repository.findAll())
suspend fun findById(request: ServerRequest): ServerResponse {
val id = request.pathVariable("id").toLong()
return repository.findById(id)
?.let { ServerResponse.ok().bodyValueAndAwait(it) }
?: ServerResponse.notFound().buildAndAwait()
}
suspend fun create(request: ServerRequest): ServerResponse {
val product = request.awaitBody<Product>()
return ServerResponse.status(HttpStatus.CREATED)
.bodyValueAndAwait(repository.save(product))
}
suspend fun delete(request: ServerRequest): ServerResponse {
val id = request.pathVariable("id").toLong()
repository.deleteById(id)
return ServerResponse.noContent().buildAndAwait()
}
}
bodyValueAndAwait()가 내부적으로 하는 일은 간단하다.
// 확장 함수 내부 원리 (개념적 표현)
suspend fun ServerResponse.BodyBuilder.bodyValueAndAwait(body: Any): ServerResponse =
this.bodyValue(body).awaitSingle()
suspend fun ServerResponse.HeadersBuilder<*>.buildAndAwait(): ServerResponse =
this.build().awaitSingle()
Reactor Mono를 반환하는 빌더 메서드 뒤에 awaitSingle()을 체이닝하는 것이 AndAwait 접미사 패턴의 전부다.
CoroutineCrudRepository
Repository 계층도 CoroutineCrudRepository를 사용하면 Reactor 타입이 완전히 사라진다.
interface ProductRepository : CoroutineCrudRepository<Product, Long> {
fun findByCategory(category: String): Flow<Product>
suspend fun findByName(name: String): Product?
}
findAll()→Flow<Product>반환findById(id)→Product?반환 (nullable, Optional 불필요)save(entity)→Product반환deleteById(id)→Unit반환
Reactive Streams vs 코루틴 실무 비교
복잡한 비즈니스 로직에서 두 방식의 차이가 가장 명확하게 드러난다.
Before: 중첩된 Reactive 체인
// 오븐 예열 완료 여부에 따라 분기하는 로직
fun bakeHoney(): Mono<Honey> {
return ovenService.heatOven(temperature)
.flatMap { ovenReady ->
honeyService.getHoney()
.zipWith(butterService.getButter())
.flatMap { (honey, butter) ->
heaterService.heatButterWithHoney(honey, butter)
.flatMap { heated ->
if (ovenReady) ovenService.bake(heated)
else Mono.just(heated)
}
}
}
}
After: 코루틴으로 절차형 표현
fun bakeHoney(): Mono<Honey> = mono {
val ovenReady = ovenService.heatOven(temperature).awaitFirstOrDefault(false)
val honey = honeyService.getHoney().awaitFirst()
val butter = butterService.getButter().awaitFirst()
val heated = heaterService.heatButterWithHoney(honey, butter).awaitFirst()
if (ovenReady) ovenService.bake(heated).awaitFirst() else heated
}
같은 로직이지만 코루틴 버전에서는 각 변수의 의미와 흐름이 한눈에 들어온다. null 처리도 Kotlin의 nullable 타입이 그대로 작동한다.
// Reactive: Optional 래핑 강요
fun checkAccess(ip: String): Mono<Boolean> {
return Mono.zip(
userRepo.findById(ip).map { Optional.of(it) }.defaultIfEmpty(Optional.empty()),
blockRepo.findById(ip).map { Optional.of(it) }.defaultIfEmpty(Optional.empty())
).map { (profile, block) -> isAllowed(profile.orElse(null), block.orElse(null)) }
}
// 코루틴: nullable 타입으로 자연스럽게
suspend fun checkAccess(ip: String): Boolean {
val userProfile = userRepo.findById(ip).awaitFirstOrNull()
val block = blockRepo.findById(ip).awaitFirstOrNull()
return isAllowed(userProfile, block)
}
성능: 리액티브와 코루틴은 동등하다
코루틴이 Reactor 위에서 동작하기 때문에 처리량과 지연 시간 모두 WebFlux 리액티브 방식과 통계적으로 동등하다. Allegro Tech의 Vegeta 부하 테스트 결과도 이를 뒷받침한다.
| 방식 | 처리량 | 응답 지연 |
|---|---|---|
| 블로킹 RestTemplate | 낮음 | 높음 |
| WebFlux + Reactor | 매우 높음 | 낮음 |
| WebFlux + 코루틴 | 매우 높음 | 낮음 (동등) |
코루틴 어댑터 레이어의 오버헤드는 무시할 수 있는 수준이다. 성능을 포기하지 않고 가독성을 얻는 셈이다.
컨텍스트 전파
분산 추적(Distributed Tracing)에서 traceId가 코루틴 경계를 넘어 올바르게 전파되려면 별도 설정이 필요하다.
Hooks.enableAutomaticContextPropagation()
애플리케이션 시작 시 한 번만 호출하면 Reactor Context와 ThreadLocal 기반 컨텍스트(Micrometer, MDC 등)가 코루틴 실행 중에 자동으로 전파된다.
@SpringBootApplication
class Application {
companion object {
@JvmStatic
fun main(args: Array<String>) {
Hooks.enableAutomaticContextPropagation() // 반드시 애플리케이션 시작 시 호출
runApplication<Application>(*args)
}
}
}
PropagationContextElement 수동 설정
자동 전파 대신 특정 코루틴에만 적용하고 싶다면 PropagationContextElement를 명시적으로 주입한다.
suspend fun tracedOperation() {
withContext(PropagationContextElement()) {
// 이 블록 안에서 traceId가 MDC에 전파됨
delay(100)
logger.info("traceId 포함된 로그")
}
}
의존성은 다음과 같다.
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
implementation("io.micrometer:context-propagation") // 컨텍스트 전파 (선택)
}
정리
CoroutinesUtils.invokeSuspendingFunction()이suspend함수를Mono로 변환하는 공식 어댑터 진입점이다.mono { }빌더와awaitSingle()계열 확장 함수가 Reactive ↔ Coroutine 세계를 양방향으로 연결한다.coRouterDSL과CoroutineCrudRepository를 사용하면 비즈니스 코드에서Mono/Flux타입이 완전히 사라진다.- 복잡한
flatMap체인 대신 절차형 코루틴 코드로 가독성을 높이면서 성능은 동등하게 유지된다. Hooks.enableAutomaticContextPropagation()으로 분산 추적 컨텍스트를 코루틴 경계 너머로 자동 전파할 수 있다.