티스토리 뷰
현상
Spring Web 기반의 API 응답에 Future 타입은 제네릭으로 선언한 반환 타입을 반환하지 않는다.
@GetMapping("/async/future")
public Future<ResponseEntity<String>> greetAsyncFuture(@RequestParam String locale) {
Locale mappedLocale = getLocale(locale);
Callable<ResponseEntity<String>> callable = () -> {
String greetMessage = greetUseCase.greet(mappedLocale);
return ResponseEntity.ok(greetMessage);
};
return executorService.submit(callable);
}
위와 같이 Future를 반환하는 API를 호출하면

위와 같은 결과를 볼 수 있다.
분석
ReturnValueHandler 선정
톰캣의 서블릿에서는 invoke(수행 로직을 실행)와 handle(실행 결과를 응답에 매핑)에 대한 역할이 분리되어있다.
ReturnValueHandler가 수행한 결과를 응답에 매핑하는 역할을 수행한다. 이를 좀 살펴보았다.
RequestMappingHandlerAdapter.invokeHandlerMethod(...)에는 아래와 같은 코드가 포함되어있다.
// 요청에 대한 로직을 실행하고 응답에 매핑
invocableMethod.invokeAndHandle(webRequest, mavContainer);
// 만약 비동기 요청이면 스레드 할당 해제
if (asyncManager.isConcurrentHandlingStarted()) {
return null;
}
// 아니면 mav 반환
return getModelAndView(mavContainer, modelFactory, webRequest);
그리고 invocableMethod.invokeAndHandle(...)을 타고 들어가보면 아래의 코드를 만날 수 있다.
...
// invoke
Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
...
// response handle
try {
this.returnValueHandlers.handleReturnValue(
returnValue, getReturnValueType(returnType), mavContainer, webRequest);
}
...

위와 같은 핸들러 목록에서 적절한 핸들러를 찾는다.
여기서 눈여겨봐야할 핸들러는 CallableMethodReturnValueHandler, RequestResponseBodyMethodProcessor 이다.
디버그해보면 컨트롤러 반환 타입에 따라 핸들러를 결정하고, Future는 RequestResponseBodyMethodProcessor로 결정된다.
저 많은 핸들러 중 하나의 핸들러를 선정하기 위한 로직을 좀 더 분석해보았다.
HandlerMethodReturnValueHandlerComposite클래스의 아래 코드에서 핸들러 선정 로직을 수행한다.
@Nullable
private HandlerMethodReturnValueHandler selectHandler(@Nullable Object value, MethodParameter returnType) {
boolean isAsyncValue = isAsyncReturnValue(value, returnType);
for (HandlerMethodReturnValueHandler handler : this.returnValueHandlers) {
if (isAsyncValue && !(handler instanceof AsyncHandlerMethodReturnValueHandler)) {
continue;
}
if (handler.supportsReturnType(returnType)) {
return handler;
}
}
return null;
}
Future의 경우에는 isAsyncValue: false이고 따라서 for문에서 handler.supportsReturnType(Future): true`를 반환하는 RequestResponseBodyMethodProcessor를 반환한다.
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
위와 같이 RequestResponseBodyMethodProcessor에서는 supportsReturnType(...) 메서드가 선언되어있다.
@RestContoller에는 @ResponseBody가 포함되어있기 때문에 해당 핸들러를 타게된다.
이 이후에는 반환 결과(FutureTask 타입)를 JSON 형태로 직렬화하게 된다.
JSON 직렬화
RequestResponseBodyMethodProcessor에서는 반환 결과를 직렬화한다.
DispatcherSevlet에서 적절한 서블릿으로 dispatch하고, 결과 매핑을 위해 ServletInvocableHandlerMethod를 호출한다.
여기에서 내부적으로 AbstactJackson2HttpMessageConvertor를 사용해서 JSON 데이터를 만든다.
Jackson 라이브러리는 기본적으로 public property 기준으로 직렬화를 수행한다.
FutureTask는 기본적으로 isDone(), isCancelled() 메서드로 프로퍼티를 열고 있어서 이 프로퍼티를 기준으로 직렬화해서,
클라이언트에서 응답으로 확인할 수 있다.
올바른 방식
결과적으로 Future를 반환 타입으로 사용하는 방식은 어찌보면 프레임워크 레벨에서 지원하지 않는 것이다.
지원하는 방식에 대해 ReturnValueHandler 중 CallbaleMethodReturnValueHandler를 보면 좀 더 명확하게 알 수 있다.
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return Callable.class.isAssignableFrom(returnType.getParameterType());
}
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue == null) {
mavContainer.setRequestHandled(true);
return;
}
Callable<?> callable = (Callable<?>) returnValue;
WebAsyncUtils
.getAsyncManager(webRequest)
.startCallableProcessing(callable, mavContainer);
}
CallbaleMethodReturnValueHandler에서는 위와 같이 구현되어있다.
반환 타입이 Callable이면 해당 핸들러를 선정하게 된다.
그렇다면 응답 결과를 handle 하는 로직을 한 번 살펴보면 WebAsyncManager.startCallableProcessing(...)으로 구현된다.
...
try {
Future<?> future = this.taskExecutor.submit(() -> {
Object result = null;
try {
interceptorChain.applyPreProcess(this.asyncWebRequest, callable);
result = callable.call();
}
...
});
...
이 경우 내부적으로 Future를 사용해서 응답을 만든다.
@GetMapping("/async/future")
public Callable<ResponseEntity<String>> greetAsyncFuture(@RequestParam String locale) {
Locale mappedLocale = getLocale(locale);
return () -> {
String greetMessage = greetUseCase.greet(mappedLocale);
return ResponseEntity.ok(greetMessage);
};
}
Future를 반환하는 코드에서 Callable을 반환하는 코드로 수정해보았다. (더 간략해졌다)

이번에는 정상적으로 응답을 처리하는 것으로 보인다.
'공부 > Spring' 카테고리의 다른 글
| Spring Retry로 재시도 로직 구현 시 고려할 점 (0) | 2026.02.22 |
|---|---|
| Spring 기반 DeferredResult 비동기 응답 방식 (0) | 2026.01.11 |
| Spring 기반 어플리케이션 기동 시 발생하는 이벤트 (0) | 2025.04.14 |