namalzi

← 글 목록

Spring 소스 파헤치기 24: WebFlux — DispatcherHandler, 함수형 엔드포인트, WebClient

Spring · 게시

Spring WebFlux가 요청 하나를 응답으로 바꾸는 길을 소스로 따라갑니다. HttpHandler 아래에 WebFilter와 WebExceptionHandler가 엮이는 방법, DispatcherServlet의 리액티브 짝인 DispatcherHandler, 어노테이션 컨트롤러와 함수형 엔드포인트가 같은 길을 지나는 모습, 그리고 WebClient의 필터 사슬까지 봅니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스물네 번째 글입니다. 21편, 22편, 23편에서는 서블릿 위에서 도는 Spring MVC가 요청 하나를 어떻게 처리하는지 봤습니다. 이번 글은 같은 일을 리액티브 방식으로 하는 Spring WebFlux를 읽습니다. 1편 지도에서는 Phase 5(웹)의 U22입니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 Spring Framework 7.1.0-SNAPSHOT
커밋 f447f3c310 (2026-10-01, main 브랜치)
주로 읽는 모듈 spring-webflux, spring-web (http.server.reactive, web.server, http.codec)
주로 읽는 클래스 WebHttpHandlerBuilder, HttpWebHandlerAdapter, DispatcherHandler, RequestMappingHandlerAdapter, RouterFunctions, RouterFunctionMapping, DefaultWebClient

글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.

WebFlux는 넓습니다. 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름만 적어 두었습니다.

먼저 알아 둘 말

리액티브와 Mono·Flux. 보통의 메서드는 결과가 준비될 때까지 기다렸다가 값을 돌려줍니다. 리액티브 방식의 메서드는 값을 돌려주는 대신 "나중에 값이 흘러올 통로"를 바로 돌려줍니다. Reactor라는 라이브러리의 Mono(값이 0개 또는 1개)와 Flux(값이 여러 개)가 그 통로입니다. 19편 5절에서 리액티브 트랜잭션을 볼 때 처음 만났습니다.

구독해야 실행됩니다. Mono를 돌려받았다는 것은 일이 끝났다는 뜻도, 시작됐다는 뜻도 아닙니다. Mono는 "이렇게 하겠다"는 계획서이고, 누군가 구독(subscribe)해야 그때부터 실제로 실행됩니다. 이 글의 예제에서 이것을 두 번 눈으로 확인합니다. 예제는 block()을 쓰는데, 이것은 "구독하고 끝날 때까지 이 스레드에서 기다리기"입니다.

세 가지 신호. 통로로 흘러오는 것은 값(onNext), 정상 종료(onComplete), 오류(onError) 세 가지입니다. 리액티브 코드에서 예외는 throw로 호출 스택을 거슬러 올라가는 대신 오류 신호로 통로를 따라 흘러갑니다. 그래서 try/catch 대신 onErrorResume 같은 연산자로 받습니다. Mono<Void>는 값 없이 "끝났다" 또는 "실패했다"만 알려 주는 통로입니다.

연산자. 통로에 이어 붙여 계획을 늘려 가는 메서드입니다. 이 글의 소스에 나오는 것만 적습니다.

연산자 뜻
map / flatMap 값이 오면 바꾼다 / 값이 오면 그것으로 또 다른 통로를 만들어 잇는다
concatMap Flux의 값마다 통로를 만들되 앞의 것이 끝나야 다음 것을 시작한다
next Flux에서 첫 값만 받아 Mono로 만들고 나머지는 취소한다
switchIfEmpty 값 없이 끝나면 다른 통로로 바꾼다
onErrorResume 오류 신호가 오면 다른 통로로 바꾼다 (catch에 해당)
then 앞의 것이 끝나면 다음 것을 실행한다
Mono.defer 구독될 때에야 안의 코드를 실행해 통로를 만든다

**ServerWebExchange**는 요청 하나와 그 응답, 그리고 요청 동안 쓰는 속성 맵을 한데 묶은 객체입니다(ServerWebExchange 44행, 65–75행). MVC에서 HttpServletRequest와 HttpServletResponse 두 개를 들고 다니던 것을 WebFlux는 이것 하나로 들고 다닙니다.

**핸들러, HandlerMapping, HandlerAdapter**는 MVC와 같은 말입니다. 핸들러는 요청을 실제로 처리하는 우리 코드, HandlerMapping은 "이 요청은 누가 처리하나"를 찾는 쪽, HandlerAdapter는 그 핸들러를 부를 줄 아는 쪽입니다. 21편과 22편에서 다뤘습니다. WebFlux에는 같은 이름의 인터페이스가 org.springframework.web.reactive 패키지에 따로 있습니다.

1. 바닥: 모든 것은 HttpHandler 하나로 모입니다

WebFlux의 가장 아래에는 메서드가 하나뿐인 인터페이스가 있습니다.

public interface HttpHandler {

	/**
	 * Handle the given request and write to the response.
	 * @param request current request
	 * @param response current response
	 * @return indicates completion of request handling
	 */
	Mono<Void> handle(ServerHttpRequest request, ServerHttpResponse response);

}

spring-web/src/main/java/org/springframework/http/server/reactive/HttpHandler.java 43–53행

요청과 응답을 받고, "다 끝나면 알려 주겠다"는 Mono<Void>를 돌려줍니다. 서블릿의 service(request, response)와 닮았지만 반환이 void가 아니라는 점이 전부를 바꿉니다. 메서드가 돌아온 시점에는 아무 일도 끝나 있지 않습니다.

눈여겨볼 것은 이 인터페이스가 spring-webflux가 아니라 spring-web 모듈에 있다는 점입니다. WebFilter, WebHandler, ServerWebExchange도 그렇습니다. 2편의 모듈 지도에서 spring-web이 MVC와 WebFlux 양쪽의 공통 바닥이었던 이유입니다. spring-webflux에는 그 위의 디스패처와 프로그래밍 모델이 있습니다.

서버에서 핸들러까지의 층. 맨 위의 서버(Reactor Netty, Tomcat, Jetty)가 서버 어댑터를 부르고, 어댑터는 서버의 요청과 응답을 Spring 타입으로 감싸 HttpHandler에게 넘긴다. HttpHandler의 구현인 HttpWebHandlerAdapter는 ServerWebExchange를 만들어 ExceptionHandlingWebHandler에게, 그것은 FilteringWebHandler에게, 그것은 WebFilter들을 거쳐 마지막 WebHandler인 DispatcherHandler에게 넘긴다. 아무도 처리하지 못한 오류는 HttpWebHandlerAdapter가 500으로 끝낸다. 파란 상자 둘이 우리가 끼어들 수 있는 자리(예외 처리기와 필터)입니다. 빨간 상자는 마지막 안전망입니다.

서버 어댑터 — 서버마다 하나씩

HttpHandler는 특정 서버를 모릅니다. 서버와 이어 주는 것은 서버마다 하나씩 있는 어댑터입니다. spring-web의 http.server.reactive 패키지에 ReactorHttpHandlerAdapter(Reactor Netty), TomcatHttpHandlerAdapter, JettyCoreHttpHandlerAdapter, ServletHttpHandlerAdapter가 있습니다. 하는 일은 같습니다. 서버가 준 요청·응답 객체를 Spring의 ServerHttpRequest·ServerHttpResponse로 감싸 httpHandler.handle(request, response)를 부릅니다(ReactorHttpHandlerAdapter 55–76행).

그러면 구독은 누가 할까요. 서블릿 쪽 어댑터는 직접 합니다 — this.httpHandler.handle(httpRequest, httpResponse).subscribe(subscriber)(ServletHttpHandlerAdapter 197행). Reactor Netty 쪽 어댑터는 Mono를 그대로 돌려주고(65–67행), 그 뒤의 구독은 이 저장소 밖인 Reactor Netty가 맡습니다.

WebHttpHandlerBuilder — 세 겹으로 감싸기

우리가 직접 HttpHandler를 구현하는 일은 드뭅니다. 한 단계 위의 인터페이스 셋으로 일하고, WebHttpHandlerBuilder가 그것들을 HttpHandler 하나로 엮어 줍니다.

인터페이스 메서드 역할
WebHandler Mono<Void> handle(ServerWebExchange) 요청을 처리하는 본체. 보통 DispatcherHandler (35–44행)
WebFilter Mono<Void> filter(ServerWebExchange, WebFilterChain) 본체 앞뒤에 끼어드는 필터 (32–43행)
WebExceptionHandler Mono<Void> handle(ServerWebExchange, Throwable) 오류 신호를 응답으로 바꾸는 쪽 (27–39행)

엮는 코드는 build()에 있습니다. (// ... 줄은 이 글에서 줄인 곳이고, 나머지는 소스 그대로입니다.)

	public HttpHandler build() {
		WebHandler decorated = new FilteringWebHandler(this.webHandler, this.filters);
		decorated = new ExceptionHandlingWebHandler(decorated, this.exceptionHandlers);

		HttpWebHandlerAdapter adapted = new HttpWebHandlerAdapter(decorated);
		// ...
		adapted.afterPropertiesSet();

		return (this.httpHandlerDecorator != null ? this.httpHandlerDecorator.apply(adapted) : adapted);
	}

spring-web/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java 427–459행

안쪽부터 세 겹입니다.

  1. FilteringWebHandler — 본체를 필터 사슬로 감쌉니다. 사슬은 DefaultWebFilterChain인데, 필터 목록을 뒤에서부터 훑으며 "이 필터 + 그 다음 고리"를 하나씩 만들어 앞에 붙이므로(71–78행), 실행은 등록한 순서대로입니다. 고리 하나의 filter()는 남은 필터가 있으면 그것을 부르고 없으면 본체를 부릅니다(103–108행).
  2. ExceptionHandlingWebHandler — 그 바깥을 감싸서 오류 신호를 받습니다. 바로 아래에서 봅니다.
  3. HttpWebHandlerAdapter — WebHandler를 HttpHandler로 바꿔 주는 어댑터입니다. 요청마다 ServerWebExchange를 새로 만들고(306행), 안쪽에 넘긴 뒤, 끝에서 응답을 마무리합니다(321–326행).

ExceptionHandlingWebHandler의 본문은 짧아서 그대로 읽을 만합니다.

	@Override
	public Mono<Void> handle(ServerWebExchange exchange) {
		Mono<Void> completion;
		try {
			completion = super.handle(exchange);
		}
		catch (Throwable ex) {
			completion = Mono.error(ex);
		}

		for (WebExceptionHandler handler : this.exceptionHandlers) {
			completion = completion.doOnError(error -> exchange.getAttributes().put(HANDLED_WEB_EXCEPTION, error))
					.onErrorResume(ex -> handler.handle(exchange, ex));
		}
		return completion;
	}

spring-web/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java 73–88행

두 가지를 봅니다. 첫째, try/catch가 있습니다. 안쪽 코드가 Mono를 돌려주기도 전에 그냥 throw 해 버린 경우를 오류 신호(Mono.error)로 바꿔 한 길로 모으는 것입니다. 둘째, for 루프는 예외 처리기를 실행하지 않습니다. onErrorResume을 처리기 수만큼 이어 붙일 뿐입니다. 오류가 나면 첫 처리기가 받고, 그 처리기가 다시 오류 신호를 돌려주면(= "나는 못 한다") 다음 onErrorResume이 받습니다. WebExceptionHandler의 주석도 완료 신호는 "처리 끝", 오류 신호는 "아직 처리 안 됨"이라고 적어 두었습니다(30–32행).

끝까지 아무도 처리하지 못하면 맨 바깥 HttpWebHandlerAdapter의 handleUnresolvedError가 받아 상태 코드를 500으로 정하고 끝냅니다(323행, 359–382행).

예제 1: 필터와 예외 처리기의 순서

서버를 띄우지 않고 WebHttpHandlerBuilder로 HttpHandler를 만들어, spring-test의 가짜 요청·응답(MockServerHttpRequest, MockServerHttpResponse)으로 직접 불러 봅니다. ex/u22/FilterChainExample.java입니다. 이 글의 예제 코드는 핵심만 남기고 줄여 실었습니다(임포트와 클래스·main 선언은 빼고 들여쓰기를 당겼습니다. 그 밖에 줄인 곳은 // ... 주석으로 적었고, 짧은 메서드는 한 줄로 붙였습니다).

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
/** 앞뒤로 이름을 찍는 필터. */
static WebFilter named(String name) {
    return (exchange, chain) -> {
        System.out.println("  " + name + " before");
        return chain.filter(exchange)
                .doOnSuccess(v -> System.out.println("  " + name + " after (completed)"))
                .doOnError(ex -> System.out.println("  " + name + " after (error " + ex.getClass().getSimpleName() + ")"));
    };
}

static void send(HttpHandler httpHandler) {
    MockServerHttpRequest request = MockServerHttpRequest.get("/hello").build();
    MockServerHttpResponse response = new MockServerHttpResponse();
    Mono<Void> completion = httpHandler.handle(request, response);
    System.out.println("  handle() returned a Mono. status so far = " + response.getStatusCode());
    completion.block();          // 여기서 구독한다
    System.out.println("  status = " + response.getStatusCode()
            + ", body = \"" + response.getBodyAsString().block() + "\"");
}

// ... 여기부터 main 안. hello 는 200 과 "hello" 를 쓰는 WebHandler,
//     notFound 는 Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)) 를 돌려주는 WebHandler

System.out.println("[1] two filters, then the handler");
send(WebHttpHandlerBuilder.webHandler(hello)
        .filter(named("filter A"), named("filter B"))
        .build());

System.out.println("[2] the handler fails, a WebExceptionHandler is registered");
send(WebHttpHandlerBuilder.webHandler(notFound)
        .filter(named("filter A"))
        .exceptionHandler(new ResponseStatusExceptionHandler())
        .build());

System.out.println("[3] the handler fails, no WebExceptionHandler");
send(WebHttpHandlerBuilder.webHandler(notFound)
        .filter(named("filter A"))
        .build());

이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 이 글의 나머지 예제도 같습니다.

[1] two filters, then the handler
  handle() returned a Mono. status so far = null
  filter A before
  filter B before
  handler
  filter B after (completed)
  filter A after (completed)
  status = 200 OK, body = "hello"
[2] the handler fails, a WebExceptionHandler is registered
  handle() returned a Mono. status so far = null
  filter A before
  handler (signals ResponseStatusException 404)
  filter A after (error ResponseStatusException)
  status = 404 NOT_FOUND, body = ""
[3] the handler fails, no WebExceptionHandler
  handle() returned a Mono. status so far = null
  filter A before
  handler (signals ResponseStatusException 404)
  filter A after (error ResponseStatusException)
  status = 500 INTERNAL_SERVER_ERROR, body = ""

헷갈리기 쉬운 곳

2. DispatcherHandler.handle — doDispatch의 리액티브 판

1절의 맨 안쪽 WebHandler 자리에 보통 들어가는 것이 DispatcherHandler입니다. 클래스 선언부터 WebHandler를 구현합니다(72행). MVC의 DispatcherServlet이 네 층의 상속을 가진 서블릿이었던 것과 달리 220줄짜리 클래스 하나이고, 요청을 처리하는 handle()은 열네 줄입니다.

	@Override
	public Mono<Void> handle(ServerWebExchange exchange) {
		if (this.handlerMappings == null) {
			return createNotFoundError();
		}
		if (CorsUtils.isPreFlightRequest(exchange.getRequest())) {
			return handlePreFlight(exchange);
		}
		return Flux.fromIterable(this.handlerMappings)
				.concatMap(mapping -> mapping.getHandler(exchange))
				.next()
				.switchIfEmpty(createNotFoundError())
				.onErrorResume(ex -> handleResultMono(exchange, Mono.error(ex)))
				.flatMap(handler -> handleRequestWith(exchange, handler));
	}

spring-webflux/src/main/java/org/springframework/web/reactive/DispatcherHandler.java 137–151행

마지막 여섯 줄이 전부입니다. 한 줄씩 읽습니다.

  1. Flux.fromIterable(this.handlerMappings) — HandlerMapping 목록을 통로에 올립니다.
  2. .concatMap(mapping -> mapping.getHandler(exchange)) — 하나씩 차례로 "이 요청의 핸들러를 아느냐"고 묻습니다. getHandler는 Mono<Object>를 돌려주고, 모르면 값 없이 끝납니다(HandlerMapping 107행).
  3. .next() — 처음 나온 핸들러 하나만 받고 나머지 매핑에게는 묻지 않습니다.
  4. .switchIfEmpty(createNotFoundError()) — 아무도 모르면 ResponseStatusException(HttpStatus.NOT_FOUND) 오류 신호로 바꿉니다(153–158행).
  5. .onErrorResume(...) — 여기까지 오는 동안 생긴 오류(404 포함)를 예외 처리 쪽으로 한 번 넘겨 봅니다.
  6. .flatMap(handler -> handleRequestWith(exchange, handler)) — 핸들러를 찾았으면 실행하러 갑니다.

MVC의 getHandler()가 for 루프로 하던 "차례로 물어서 처음 답하는 쪽을 쓴다"가 concatMap과 next가 됐습니다. 루프와 return이 연산자로 바뀌었을 뿐 뜻은 같습니다.

DispatcherHandler.handle의 흐름. ServerWebExchange를 받아 HandlerMapping에게 차례로 묻는다. 아무도 모르면 404 ResponseStatusException 오류 신호가 된다. 핸들러 하나가 나오면 handleRequestWith가 supports가 참인 첫 HandlerAdapter를 골라 adapter.handle로 핸들러를 실행한다. 돌아온 HandlerResult는 handleResult가 supports가 참인 첫 HandlerResultHandler에게 넘기고, 그것이 응답에 쓴 뒤 끝났다는 신호만 담은 Mono를 돌려준다. 핸들러 실행 중 오류 신호는 DispatchExceptionHandler, 곧 @ExceptionHandler 메서드로 가고, 거기서 HandlerResult가 나오면 다시 handleResult로, 맞는 메서드가 없으면 바깥의 WebExceptionHandler로 나간다. 실선이 정상 흐름, 점선이 오류 신호의 길입니다.

핸들러를 부르고, 결과를 처리합니다

	private Mono<Void> handleRequestWith(ServerWebExchange exchange, Object handler) {
		if (ObjectUtils.nullSafeEquals(exchange.getResponse().getStatusCode(), HttpStatus.FORBIDDEN)) {
			return Mono.empty();  // CORS rejection
		}
		if (this.handlerAdapters != null) {
			for (HandlerAdapter adapter : this.handlerAdapters) {
				if (adapter.supports(handler)) {
					Mono<HandlerResult> resultMono = adapter.handle(exchange, handler);
					return handleResultMono(exchange, resultMono);
				}
			}
		}
		return Mono.error(new IllegalStateException("No HandlerAdapter: " + handler));
	}

spring-webflux/src/main/java/org/springframework/web/reactive/DispatcherHandler.java 196–209행

supports(handler)가 참인 첫 HandlerAdapter가 핸들러를 실행합니다. 여기까지는 MVC와 같습니다. 다른 것은 돌아오는 것입니다. MVC의 어댑터는 ModelAndView를 돌려줬고, WebFlux의 어댑터는 Mono<HandlerResult> 를 돌려줍니다(HandlerAdapter 71행). HandlerResult는 "어떤 핸들러가, 무엇을, 어떤 선언 타입으로 돌려줬는가"를 담은 그릇입니다(HandlerResult 32–40행).

그 그릇을 응답으로 바꾸는 일은 세 번째 전략인 HandlerResultHandler 가 합니다. handleResult()가 목록을 돌며 supports(result)가 참인 첫 처리기에게 넘깁니다(181–194행). MVC에는 이 이름의 인터페이스가 없습니다. MVC에서는 반환값 처리가 어댑터 안쪽(HandlerMethodReturnValueHandler)에 들어 있었는데, WebFlux는 그것을 디스패처가 직접 고르는 전략으로 꺼내 놓았습니다. 그래서 WebFlux의 디스패처는 전략이 세 종류입니다.

핸들러 실행 중의 오류는 handleResultMono()가 다룹니다(160–179행). 어댑터가 DispatchExceptionHandler도 구현하고 있으면 그쪽에 먼저 기회를 주고(@ExceptionHandler 메서드가 여기로 불립니다, 3절), 그래도 남은 오류 신호는 DispatcherHandler 밖으로 흘러 나가 1절의 WebExceptionHandler들이 받습니다.

MVC와 나란히 놓고 보기

Spring MVC (21편) Spring WebFlux (이 글)
디스패처 DispatcherServlet (서블릿) DispatcherHandler (WebHandler)
중심 메서드 void doDispatch(request, response) Mono<Void> handle(exchange)
요청·응답 HttpServletRequest, HttpServletResponse ServerWebExchange 하나
핸들러 찾기 HandlerMapping → HandlerExecutionChain (핸들러 + 인터셉터) HandlerMapping → Mono<Object> (핸들러만)
핸들러 부르기 HandlerAdapter → ModelAndView HandlerAdapter → Mono<HandlerResult>
결과 처리 어댑터 안의 반환값 처리기, 그리고 뷰 렌더링 HandlerResultHandler
앞뒤에 끼어들기 HandlerInterceptor, 서블릿 Filter WebFilter
예외 HandlerExceptionResolver DispatchExceptionHandler, 그 바깥의 WebExceptionHandler
본문 변환 HttpMessageConverter HttpMessageReader / HttpMessageWriter
기다리는 방식 메서드가 돌아오면 끝난 것 돌려준 Mono가 완료 신호를 내야 끝난 것

표의 "핸들러만"이 눈에 띕니다. WebFlux의 HandlerMapping은 인터셉터 묶음을 돌려주지 않고, spring-webflux 모듈에는 HandlerInterceptor라는 타입이 아예 없습니다. 핸들러 앞뒤에 끼어드는 일은 1절의 WebFilter가 맡습니다.

헷갈리기 쉬운 곳

3. 전략 빈은 어디서 오나, 그리고 어노테이션 컨트롤러

@EnableWebFlux가 등록하는 것

DispatcherHandler는 세 목록을 스스로 만들지 않습니다. ApplicationContext를 받으면 initStrategies()가 HandlerMapping, HandlerAdapter, HandlerResultHandler 타입의 빈을 전부 찾아 순서대로 정렬해 둡니다(115–134행). MVC에는 빈이 없을 때 쓰는 기본값 파일(DispatcherServlet.properties)이 있었지만 DispatcherHandler에는 그런 대비책이 없습니다. 빈이 없으면 목록이 빕니다.

그 빈들을 등록하는 것이 @EnableWebFlux입니다. 이 어노테이션은 DelegatingWebFluxConfiguration을 @Import하고(EnableWebFlux 90행), 그 부모 WebFluxConfigurationSupport에 @Bean 메서드가 모여 있습니다. @Import가 설정 클래스를 끌어오는 방법은 9편에서 봤습니다. 이 글과 관계있는 것만 고르면 이렇습니다.

빈 종류 위치
webHandler → DispatcherHandler 디스패처 자신 125–128행
WebFluxResponseStatusExceptionHandler WebExceptionHandler 130–134행
RouterFunctionMapping (순서 -1) HandlerMapping 248–258행
RequestMappingHandlerMapping (순서 0) HandlerMapping 136–142행
RequestMappingHandlerAdapter, HandlerFunctionAdapter, SimpleHandlerAdapter HandlerAdapter 305행, 482–490행
ResponseEntityResultHandler, ResponseBodyResultHandler, ViewResolutionResultHandler, ServerResponseResultHandler HandlerResultHandler 521–562행

디스패처 빈의 이름이 webHandler인 것은 우연이 아닙니다. WebHttpHandlerBuilder.applicationContext(context)가 바로 그 이름으로 본체를 찾고(WEB_HANDLER_BEAN_NAME, 72행), WebFilter와 WebExceptionHandler 빈은 타입으로 모아 정렬합니다(174–189행). 1절에서 손으로 넣던 것을 컨테이너에서 꺼내 오는 것입니다.

어노테이션 컨트롤러가 지나는 길

@GetMapping이 붙은 메서드로 가는 길은 MVC와 이름까지 같습니다. 구현만 리액티브 판입니다.

  1. RequestMappingHandlerMapping — 요청에 맞는 HandlerMethod를 찾습니다. 상속도 MVC와 같은 모양입니다(RequestMappingHandlerMapping → RequestMappingInfoHandlerMapping → AbstractHandlerMethodMapping → AbstractHandlerMapping, 75행, 64행, 67행). 찾는 규칙은 22편에서 본 것과 같은 생각입니다.
  2. RequestMappingHandlerAdapter — handler instanceof HandlerMethod이면 자기 차례입니다(247–250행). 핵심은 이 다섯 줄입니다.
		Mono<HandlerResult> resultMono = this.modelInitializer
				.initModel(handlerMethod, bindingContext, exchange)
				.then(Mono.defer(() -> invocableMethod.invoke(exchange, bindingContext)))
				.doOnNext(result -> result.setExceptionHandler(exceptionHandler))
				.onErrorResume(ex -> exceptionHandler.handleError(exchange, ex));

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java 270–274행

모델을 준비하고, 그 다음에(then) 메서드를 부르고, 결과에 예외 처리기를 달아 두고, 오류가 나면 그 처리기로 넘깁니다. 여기의 exceptionHandler가 같은 클래스의 handleException()을 부르는 람다이고(267–268행), 그 안에서 맞는 @ExceptionHandler 메서드를 찾아 실행합니다(294–295행). 메서드를 실제로 부르는 것은 InvocableHandlerMethod.invoke()이고, 인자가 다 준비된 뒤에 리플렉션으로 method.invoke(getBean(), args)를 합니다(InvocableHandlerMethod 181–205행). 반환값은 무엇이든 HandlerResult에 담겨 나옵니다(239–240행).

  1. ResponseBodyResultHandler — 클래스나 메서드에 @ResponseBody가 있으면(@RestController는 그것을 품고 있습니다) 자기 차례이고(98–104행), 반환값을 writeBody()에 넘깁니다(118행).

HttpMessageWriter — 반환값이 바이트가 되는 곳

writeBody()는 부모 클래스 AbstractMessageWriterResultHandler에 있습니다(169행부터). 하는 일은 세 가지입니다.

HttpMessageWriter와 HttpMessageReader는 MVC의 HttpMessageConverter(20편)에 대응합니다. MVC에서는 읽기와 쓰기가 한 인터페이스였지만 여기서는 둘로 나뉘어 있고, 무엇보다 주고받는 것이 통로입니다. 쓰는 쪽은 객체 하나가 아니라 Publisher를 받고(HttpMessageWriter 89행), 읽는 쪽은 Flux<T>나 Mono<T>를 돌려줍니다(HttpMessageReader 86행, 96행). 본문이 다 도착할 때까지 스레드를 붙잡고 기다리지 않으려는 것입니다.

WebFlux 에서 어노테이션 컨트롤러로 가는 길. @EnableWebFlux 가 WebFluxConfigurationSupport 의 @Bean 들을 등록하고 DispatcherHandler.initStrategies() 가 그것을 타입으로 모아 정렬한다. ① RequestMappingHandlerMapping 이 요청에 맞는 HandlerMethod 를 찾는다. ② RequestMappingHandlerAdapter(270–274행)가 modelInitializer.initModel 을 한 다음(then) invocableMethod.invoke(exchange, bindingContext) 로 메서드를 부르고, 결과에 setExceptionHandler 로 예외 처리기를 달아 둔다. 오류가 나면 onErrorResume 으로 exceptionHandler.handleError 가 @ExceptionHandler 메서드를 찾는다. 결과는 HandlerResult 가 된다. ③ ResponseBodyResultHandler 의 writeBody(AbstractMessageWriterResultHandler)는 리액티브 타입이면 그대로 Publisher 로, 평범한 객체면 Mono.justOrEmpty(body) 로 감싸고, 응답 미디어 타입을 고른 뒤, HttpMessageWriter 목록에서 canWrite(타입, 미디어 타입) 이 참인 첫 번째가 쓴다 이름은 MVC와 같고, 단계마다 Mono로 이어진다는 점만 다릅니다. 이 절의 1~3번(그림의 ①②③)을 한 장에 모았습니다.

4. 함수형 엔드포인트 — 어노테이션 없이 함수로

WebFlux에는 컨트롤러를 쓰는 방법이 하나 더 있습니다. 어노테이션 대신 함수 세 개로 조립합니다.

인터페이스 메서드 한 줄 뜻
HandlerFunction<T> Mono<T> handle(ServerRequest) 요청을 받아 응답을 만드는 함수 (30–39행)
RequestPredicate boolean test(ServerRequest) "이 요청이 맞는가"를 답하는 함수 (33–40행)
RouterFunction<T> Mono<HandlerFunction<T>> route(ServerRequest) 요청을 받아 그것을 처리할 HandlerFunction을 돌려주는 함수 (36–44행)

셋 다 @FunctionalInterface라 람다로 쓸 수 있습니다. ServerRequest와 ServerResponse는 ServerWebExchange를 함수형 모델에 맞게 감싼 요청·응답 타입입니다.

가장 작은 RouterFunction은 "조건 하나 + 핸들러 하나"입니다.

		@Override
		public Mono<HandlerFunction<T>> route(ServerRequest request) {
			if (this.predicate.test(request)) {
				// ...
				return Mono.just(this.handlerFunction);
			}
			else {
				return Mono.empty();
			}
		}

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java 1295–1307행

조건이 맞으면 핸들러를, 아니면 빈 Mono를 돌려줍니다. RouterFunctions.route(predicate, handlerFunction)이 이 DefaultRouterFunction을 만듭니다(116–120행).

경로가 여럿이면 빌더를 씁니다. RouterFunctions.route()(인자 없는 쪽)는 RouterFunctionBuilder를 돌려주고(97–99행), 빌더의 GET("/path", handler)는 RequestPredicates.GET(pattern) 조건으로 위의 작은 RouterFunction을 하나 만들어 목록에 넣습니다(RouterFunctionBuilder 84–86행, 57–67행). build()는 그 목록을 BuiltRouterFunction 하나로 묶습니다(414–428행).

		@Override
		public Mono<HandlerFunction<ServerResponse>> route(ServerRequest request) {
			return Flux.fromIterable(this.routerFunctions)
					.concatMap(routerFunction -> routerFunction.route(request))
					.next();
		}

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctionBuilder.java 444–449행

2절의 DispatcherHandler.handle()과 같은 모양입니다. 목록을 차례로 물어 처음 답하는 것을 씁니다. 그래서 함수형 엔드포인트에서는 등록한 순서가 곧 우선순위입니다. 어노테이션 쪽이 여러 후보 가운데 가장 구체적인 것을 골라 주는 것과 다릅니다.

함수형 엔드포인트가 만들어지고 쓰이는 흐름. RouterFunctions.route()가 RouterFunctionBuilder를 만들고, GET(패턴, 핸들러)를 부를 때마다 RequestPredicate와 HandlerFunction을 가진 DefaultRouterFunction이 하나씩 생긴다. build()가 그것들을 등록한 순서의 목록인 BuiltRouterFunction으로 묶는다. route(request)는 목록을 앞에서부터 돌며 predicate.test(request)를 부르고, true면 Mono.just(handlerFunction)으로 멈추고, false면 Mono.empty()라서 다음 경로로 넘어간다. 고른 handlerFunction.handle(request)가 Mono를 돌려준다. 초록 상자에서 멈춥니다. 뒤의 경로는 물어보지도 않습니다.

DispatcherHandler에 끼우는 세 조각

함수형 엔드포인트는 별도의 디스패처를 쓰지 않습니다. 2절의 세 전략 자리에 하나씩 끼워져 있을 뿐입니다.

  1. RouterFunctionMapping (HandlerMapping) — 컨테이너의 RouterFunction 빈을 전부 모아 andOther로 하나로 합쳐 두고(122–134행), 요청이 오면 ServerRequest를 만들어 routerFunction.route(request)를 부릅니다. 나온 HandlerFunction이 핸들러이고, 방금 만든 ServerRequest는 exchange의 속성에 넣어 둡니다(158–172행).
  2. HandlerFunctionAdapter (HandlerAdapter) — 전부 옮겨도 열두 줄입니다.
	@Override
	public boolean supports(Object handler) {
		return handler instanceof HandlerFunction;
	}

	@Override
	public Mono<HandlerResult> handle(ServerWebExchange exchange, Object handler) {
		HandlerFunction<?> handlerFunction = (HandlerFunction<?>) handler;
		ServerRequest request = exchange.getRequiredAttribute(RouterFunctions.REQUEST_ATTRIBUTE);
		return handlerFunction.handle(request)
				.map(response -> new HandlerResult(handlerFunction, response, HANDLER_FUNCTION_RETURN_TYPE));
	}

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/HandlerFunctionAdapter.java 52–63행

매핑이 넣어 둔 ServerRequest를 속성에서 꺼내 함수를 부르고, 돌아온 ServerResponse를 HandlerResult에 담습니다. 어노테이션 쪽 어댑터가 인자 해석, 모델, 검증을 하느라 길었던 것과 견주면, 함수형 모델에서는 그 일을 우리 함수가 ServerRequest로 직접 하기 때문에 어댑터가 할 일이 없습니다. 3. ServerResponseResultHandler (HandlerResultHandler) — 반환값이 ServerResponse이면 자기 차례이고(104–107행), response.writeTo(exchange, context)를 불러 응답이 스스로 쓰게 합니다(109–123행). writeTo는 먼저 상태 코드와 헤더를 옮기고(DefaultServerResponseBuilder 327–344행) 본문은 건네받은 HttpMessageWriter 목록으로 씁니다. 3절과 같은 writer들입니다.

한 DispatcherHandler 아래의 두 프로그래밍 모델. 왼쪽은 순서 -1의 RouterFunctionMapping이 HandlerFunction을 찾고, HandlerFunctionAdapter가 그것을 불러 ServerResponse를 받고, ServerResponseResultHandler가 response.writeTo를 부른다. 오른쪽은 순서 0의 RequestMappingHandlerMapping이 HandlerMethod를 찾고, RequestMappingHandlerAdapter가 그것을 불러 @ResponseBody 반환값을 받고, ResponseBodyResultHandler가 writeBody를 부른다. 양쪽 모두 마지막에는 HttpMessageWriter가 객체를 응답 바이트로 바꾼다. 파란 쪽이 함수형, 회색 쪽이 어노테이션입니다. 맨 아래에서 다시 만납니다.

예제 2: 두 모델이 같은 디스패처를 지납니다

ex/u22/DispatcherHandlerExample.java입니다. @EnableWebFlux 설정에 컨트롤러 하나와 RouterFunction 하나를 같이 등록하고, 일부러 /both라는 같은 경로를 양쪽에 다 두었습니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@RestController
static class HelloController {
    @GetMapping("/hello")
    Mono<String> hello() { return Mono.just("hello from @GetMapping"); }

    @GetMapping("/both")
    Mono<String> both() { return Mono.just("both from @GetMapping"); }
}

@Configuration
@EnableWebFlux
static class Config {
    @Bean
    HelloController helloController() { return new HelloController(); }

    @Bean
    RouterFunction<ServerResponse> routes() {
        return RouterFunctions.route()
                .GET("/fn/hello", request -> ServerResponse.ok().bodyValue("hello from HandlerFunction"))
                .GET("/both", request -> ServerResponse.ok().bodyValue("both from HandlerFunction"))
                .build();
    }

    /** 체인이 끝난 뒤, HandlerMapping 이 고른 핸들러의 종류를 찍는 필터. */
    @Bean
    WebFilter handlerTracer() {
        return (exchange, chain) -> chain.filter(exchange).doFinally(signal -> {
            Object handler = exchange.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE);
            // ... HandlerMethod 인지 HandlerFunction 인지 출력
        });
    }
}

// ... main: 세 전략 빈을 DispatcherHandler.initStrategies 와 같은 방법으로 모아 찍은 뒤 아래를 부른다.
//     send 는 예제 1 처럼 가짜 요청을 보내고 상태와 본문을 찍는다
HttpHandler httpHandler = WebHttpHandlerBuilder.applicationContext(context).build();
send(httpHandler, "/hello");
send(httpHandler, "/fn/hello");
send(httpHandler, "/both");
send(httpHandler, "/nope");
[1] bean "webHandler" is a DispatcherHandler
  same list as getHandlerMappings()? true
  HandlerMapping:
    RouterFunctionMapping
    RequestMappingHandlerMapping
    EmptyHandlerMapping
  HandlerAdapter:
    WebSocketHandlerAdapter
    RequestMappingHandlerAdapter
    HandlerFunctionAdapter
    SimpleHandlerAdapter
  HandlerResultHandler:
    ResponseEntityResultHandler
    ServerResponseResultHandler
    ResponseBodyResultHandler
    ViewResolutionResultHandler
[2] requests through WebHttpHandlerBuilder.applicationContext(context)
GET /hello
  handler kind = HandlerMethod
  status = null, body = "hello from @GetMapping"
GET /fn/hello
  handler kind = HandlerFunction
  status = 200 OK, body = "hello from HandlerFunction"
GET /both
  handler kind = HandlerFunction
  status = 200 OK, body = "both from HandlerFunction"
GET /nope
  handler kind = null
  status = 404 NOT_FOUND, body = ""

(실행하면 맨 위에 Hibernate Validator가 버전을 알리는 로그 두 줄이 먼저 찍히는데, 이 글과 관계없어 뺐습니다.)

헷갈리기 쉬운 곳

5. WebClient — 요청을 보내는 쪽

WebClient는 spring-webflux에 들어 있는 리액티브 HTTP 클라이언트입니다. 서버 쪽과 방향만 반대이고 생각은 같습니다. 필터 사슬이 있고, 맨 끝에 실제 HTTP 라이브러리와 이어 주는 어댑터가 있고, 본문은 같은 HttpMessageReader·HttpMessageWriter가 읽고 씁니다.

서버 쪽 (1절) 클라이언트 쪽 메서드
WebHandler ExchangeFunction Mono<ClientResponse> exchange(ClientRequest) (42–55행)
WebFilter ExchangeFilterFunction Mono<ClientResponse> filter(ClientRequest, ExchangeFunction next) (35–51행)
서버 어댑터 ClientHttpConnector Mono<ClientHttpResponse> connect(method, uri, requestCallback) (34–49행)

빌더가 조립하는 것

WebClient.create()와 WebClient.builder()는 둘 다 DefaultWebClientBuilder에서 시작합니다(WebClient 147–149행, 164–166행). build()의 앞부분이 위 표의 세 가지를 준비합니다.

	public WebClient build() {
		ClientHttpConnector connectorToUse =
				(this.connector != null ? this.connector : initConnector());

		ExchangeFunction exchange = (this.exchangeFunction == null ?
				ExchangeFunctions.create(connectorToUse, initExchangeStrategies()) :
				this.exchangeFunction);

		ExchangeFilterFunction filterFunctions = (this.filters != null ? this.filters.stream()
				.reduce(ExchangeFilterFunction::andThen)
				.orElse(null) : null);
		// ...
	}

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java 304–327행

요청 한 번

client.get().uri(...)로 요청을 적어 나가는 객체는 DefaultWebClient 안의 DefaultRequestBodyUriSpec입니다(207행). 실제로 보내는 일은 그 안의 exchange()가 합니다(442–480행). 첫 줄(443행)에서 요청 빌더를 준비하는 것만 빼면 본문 전체가 Mono.deferContextual(...) 안에 들어 있어서, 구독되기 전에는 ClientRequest조차 만들어지지 않습니다(requestBuilder.build()가 456행에 있습니다). 구독되면 필터를 ExchangeFunction에 씌워 부릅니다 — finalFilterFunction.apply(exchangeFunction).exchange(request)(462–463행).

WebClient 요청 한 번의 흐름. client.get().uri(...)로 요청을 적어 두고, exchange()가 구독될 때 ClientRequest를 만든다. 요청은 ExchangeFilterFunction A, B를 차례로 지나 ExchangeFunction인 DefaultExchangeFunction에 닿고, 그것이 ClientHttpConnector.connect를 불러 HTTP 서버로 나간다. 돌아온 ClientResponse를 누가 읽는지는 두 갈래다. retrieve()를 쓰면 ResponseSpec이 읽고 상태가 400 이상이면 WebClientResponseException이 된다. exchangeToMono(함수)를 쓰면 내 함수가 상태와 본문을 직접 본다. 파란 상자가 필터입니다. 응답은 같은 길을 거꾸로 돌아옵니다.

retrieve()와 exchangeToMono()

둘 다 같은 exchange()를 부릅니다. 다른 것은 돌아온 ClientResponse를 누가 다루느냐입니다.

		private static final StatusHandler DEFAULT_STATUS_HANDLER =
				new StatusHandler(code -> code.value() >= 400, ClientResponse::createException);

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java 546–547행

bodyToMono(...)로 본문을 꺼낼 때 상태 처리기를 먼저 적용하므로(594–598행, 677–681행), 상태가 400 이상이면 본문 대신 WebClientResponseException 오류 신호가 옵니다. onStatus(...)로 처리기를 더하면 기본 처리기보다 앞에 놓입니다(572–580행).

예제 3: 서버를 잠깐 띄워 불러 보기

ex/u22/WebClientExample.java입니다. Reactor Netty 서버를 임의의 빈 포트에 띄우고(port(0)), 1절의 ReactorHttpHandlerAdapter로 함수형 엔드포인트를 물린 다음, WebClient로 부릅니다. 끝나면 서버를 닫습니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
/** 요청이 나갈 때와 응답이 돌아올 때 이름을 찍는 필터. */
static ExchangeFilterFunction named(String name) {
    return (request, next) -> {
        System.out.println("  " + name + " -> " + request.method() + " " + request.url().getPath());
        return next.exchange(request)
                .doOnNext(response -> System.out.println("  " + name + " <- " + response.statusCode().value()));
    };
}

// ... 여기부터 main 안
AtomicInteger hits = new AtomicInteger();              // 서버가 요청을 받은 횟수
RouterFunction<ServerResponse> routes = RouterFunctions.route()
        .GET("/greet/{name}", request -> {
            hits.incrementAndGet();
            return ServerResponse.ok().bodyValue("hi, " + request.pathVariable("name"));
        })
        .build();

HttpHandler httpHandler = RouterFunctions.toHttpHandler(routes);
DisposableServer server = HttpServer.create()
        .host("localhost")
        .port(0)                                              // 빈 포트를 아무거나
        .handle(new ReactorHttpHandlerAdapter(httpHandler))
        .bindNow(TIMEOUT);

// ... 아래는 try 블록 안이고, 서버를 닫는 마지막 세 줄은 finally 블록 안이다
WebClient client = WebClient.builder()
        .baseUrl("http://localhost:" + server.port())
        .filter(named("filter A"))
        .filter(named("filter B"))
        .build();

System.out.println("[1] retrieve(): nothing is sent until someone subscribes");
Mono<String> greeting = client.get().uri("/greet/{name}", "spring")
        .retrieve()
        .bodyToMono(String.class);
System.out.println("  Mono built. server hits = " + hits.get());
System.out.println("  body = \"" + greeting.block(TIMEOUT) + "\", server hits = " + hits.get());

System.out.println("[2] subscribing to the same Mono again sends a second request");
System.out.println("  body = \"" + greeting.block(TIMEOUT) + "\", server hits = " + hits.get());

System.out.println("[3] retrieve() on a 404");
try {
    client.get().uri("/nope").retrieve().bodyToMono(String.class).block(TIMEOUT);
}
catch (WebClientResponseException ex) {
    System.out.println("  " + ex.getClass().getSimpleName() + ", status = " + ex.getStatusCode().value());
}

System.out.println("[4] exchangeToMono() on a 404");
String seen = client.get().uri("/nope")
        .exchangeToMono(response -> Mono.just("my function saw status " + response.statusCode().value()))
        .block(TIMEOUT);
System.out.println("  " + seen);

server.disposeNow(TIMEOUT);
HttpResources.disposeLoopsAndConnections();
System.out.println("server disposed: " + server.isDisposed());
[1] retrieve(): nothing is sent until someone subscribes
  Mono built. server hits = 0
  filter A -> GET /greet/spring
  filter B -> GET /greet/spring
  filter B <- 200
  filter A <- 200
  body = "hi, spring", server hits = 1
[2] subscribing to the same Mono again sends a second request
  filter A -> GET /greet/spring
  filter B -> GET /greet/spring
  filter B <- 200
  filter A <- 200
  body = "hi, spring", server hits = 2
[3] retrieve() on a 404
  filter A -> GET /nope
  filter B -> GET /nope
  filter B <- 404
  filter A <- 404
  NotFound, status = 404
[4] exchangeToMono() on a 404
  filter A -> GET /nope
  filter B -> GET /nope
  filter B <- 404
  filter A <- 404
  my function saw status 404
server disposed: true

헷갈리기 쉬운 곳

이 글에서 뺀 것

큰 줄기를 위해 이름만 적고 넘어간 것들입니다. 요청 본문과 인자를 풀어 주는 리액티브 판 HandlerMethodArgumentResolver들과 ReactiveAdapterRegistry(RxJava, 코루틴 같은 다른 리액티브 타입을 Publisher로 바꿔 주는 등록소). 실제 바이트를 다루는 Encoder/Decoder와 DataBuffer, 그리고 그것들을 모아 두는 ServerCodecConfigurer(WebFluxConfigurationSupport 351–356행). 받는 쪽이 처리할 수 있는 만큼만 보내게 하는 배압(backpressure). CORS 사전 요청 처리(DispatcherHandler 211–218행, AbstractHandlerMapping 186–218행). 뷰를 그리는 ViewResolutionResultHandler와 ResponseEntity를 다루는 ResponseEntityResultHandler. 함수형 엔드포인트의 필터인 HandlerFilterFunction과 경로를 묶는 nest. WebSession, 로케일, Forwarded 헤더 처리, 관측(Observation) 연동(HttpWebHandlerAdapter 312–324행). 블로킹으로 판단된 컨트롤러 메서드를 별도 스케줄러에서 구독하게 하는 설정(RequestMappingHandlerAdapter 276–279행). 그리고 WebSocketHandlerAdapter는 다음 글의 주제와 이어집니다.

정리

개념 한 줄 요약
Mono / Flux 값이 아니라 계획서. 구독해야 실행되고, 구독할 때마다 다시 실행된다
HttpHandler WebFlux의 바닥. 요청·응답을 받아 Mono<Void>를 돌려주는 메서드 하나. spring-web에 있다
서버 어댑터 서버의 요청·응답을 Spring 타입으로 감싸 HttpHandler를 부른다
WebHttpHandlerBuilder WebHandler를 필터 사슬, 예외 처리, HttpWebHandlerAdapter 순으로 세 겹 감싼다
WebFilter 등록한 순서로 들어가고 반대로 나온다. MVC의 인터셉터와 서블릿 필터 자리를 맡는다
WebExceptionHandler 필터보다 바깥에서 오류 신호를 응답으로 바꾼다. 없으면 500
DispatcherHandler.handle 매핑에게 차례로 묻고(concatMap·next), 어댑터로 부르고, 결과 처리기로 쓴다
HandlerResultHandler MVC에 없는 세 번째 전략. HandlerResult를 응답으로 바꾼다
HttpMessageReader / Writer HttpMessageConverter의 리액티브 짝. 객체가 아니라 통로를 주고받는다
함수형 엔드포인트 RequestPredicate + HandlerFunction = RouterFunction. 등록한 순서가 우선순위
RouterFunctionMapping 순서 -1이라 어노테이션 매핑보다 먼저 묻는다
WebClient 필터(ExchangeFilterFunction) → ExchangeFunction → ClientHttpConnector
retrieve() / exchangeToMono() 400 이상을 예외로 바꿔 준다 / 응답을 내 함수가 직접 본다

다음에 볼 것

서블릿 위의 MVC(21편)와 리액티브 위의 WebFlux가 같은 뼈대(매핑 → 어댑터 → 결과)를 서로 다른 방식으로 채운다는 것을 봤습니다.

지금까지의 웹은 전부 "요청 하나에 응답 하나"였습니다. 다음 글은 연결을 열어 둔 채 양쪽이 메시지를 주고받는 세계로 갑니다. WebSocket 연결 위에서 STOMP 메시지가 어떤 채널을 타고 @MessageMapping 메서드까지 가는지를 25편: WebSocket과 메시징 — STOMP 메시지는 어떻게 흐르나에서 봅니다.

다른 글