문자열 응답 시 공통응답형식이 적용되지 않는 문제 개선하기


이전 포스팅(ResponseBodyAdvice로 공통응답형식 적용기)에서 만든 전역 공통응답형식 적용 덕분에 수월하게 API를 개발할 수 있었다. 하지만 얼마가지 않아 한 가지 문제를 인지할 수 있었는데, 바로 Controller에서 문자열 응답시 공통응답형식의 적용이 불가능하다는 점이었다.

의도한대로라면 적어도 다음과 같은 응답을 받아야 하지만 예외 발생 응답이 돌아왔다.

{
	code: 200,
	message: "Success",
	data: "문자열 응답"
}

이번 포스트에서는 해당 문제를 해결하기 위해 고군분투한 이야기를 담아봤다.

처음 문제부터 해결방법을 찾기, 해결한 줄 알았으나 추가적인 이슈들과 그에 대한 해결방안까지 한 포스트에 담아 내용이 꽤 방대하다. 때문에 어떤 순서로 진행되는지 미리 알면 좋을 것 같아 먼저 목차별 의미를 정리해 둔다.

  1. (문제찾기) - 문자열에 공통응답형식 적용이 불가능한 이유
  2. (솔루션) - HandlerAdapter 탐색하며 방법 찾고 구현하기
  3. (이슈1)
  4. (이슈2)
  5. (마무리) - 정리


문제 - 문자열에 공통응답형식 적용이 불가능한 이유

우선 예외가 발생한 이유부터 알아보자. 기존에 작성한 ResposneBodyAdvice코드를 보면 Controller에서 어떤값을 리턴해도 advice 코드가 실행되도록 만들었으며, 이를 SuccessResponse로 래핑하였다.

@RestControllerAdvice
class ResponseAdviceHandler: ResponseBodyAdvice<Any> {  
    override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {  
        return true
    }  
  
    override fun beforeBodyWrite(body: Any?, returnType: MethodParameter, selectedContentType: MediaType, selectedConverterType: Class<out HttpMessageConverter<*>>, request: ServerHttpRequest, response: ServerHttpResponse  
    ): Any? {  
	    // ...
        SuccessResponse(data = body)  
    }  
}

supports 메서드를 보면 converterType으로 HttpMessageConverter가 들어오는 것을 볼 수 있다. 다시말해, ResponseBodyAdvice가 실행되기 전에 HttpMessageConverter가 정해지는 것이다. 때문에 beforeBodyWrite 메서드에서 응답데이터를 수정하더라도 정해진 컨버터는 바뀌지 않는다.

여기서 핵심은 문자열과 객체를 처리하는 컨버터가 다르다는 것이다. 문자열은 StringHttpMessageConverter, 객체는 Jackson 기준 MappingJackson2HttpMessageConverter가 사용되어진다.

때문에 핸들러 메서드(Controller 메서드)에서 문자열을 리턴해 StringHttpMessageConverter로 정해진 상태에서 응답데이터를 객체로 변환시키다보니 컨버터가 제 기능을 하지 못해 예외가 발생하는 것이다.

예외를 막고자 한다면 supports 메서드에서 객체 처리용 컨버터에만 적용되도록 바꾸면 된다. 하지만 핸들러 메서드에서 문자열 리턴시 공통응답형식이 적용되지 않고 문자열 그대로 클라이언트에게 응답될 것이다.

override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {  
    return MappingJackson2HttpMessageConverter::class.java.isAssignableFrom(converterType)  
}

그렇다면 문자열 리턴시에도 공통응답형식을 적용할려면 어떻게 해야할까?



HandlerAdapter 탐색하며 방법 찾고 구현하기

쉽게 생각해보면 MessageConverter가 정해지기 전에 응답을 조작하면 될 것이다. 문제는 그 사이에 응답 코드를 제어할 수 있는 기능이 어떤 것이 있냐는 것이다. 방법을 찾아내기 위해 핸들러 메서드가 실행되는 코드를 디버깅해봤다.


핸들러 메서드 실행 전 설정

DispatcherServlet에서 HandlerAdapter를 통해 요청과 응답 처리 작업을 진행한다. 이 작업들은 invokeHandlerMethod 메서드에서 이뤄진다. 먼저, 핸들러 메서드 실행 전에 요청, 응답 처리시 필요한 정보들을 셋팅한다.

눈여겨 볼 부분은 핸들러 어댑터가 가지는 argumentResolversreturnValueHandlers를 실행할 메서드에 그대로 셋팅한다는 것이다. 이 둘은 각각 요청에 작업 처리, 응답 작업 처리를 전담하는 역할을 맡는다.

invocableMethod.invocableMethod 메서드에서 그 모습을 볼 수 있다.

@Nullable  
protected ModelAndView invokeHandlerMethod(HttpServletRequest request,  
      HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {  
	// ...
	// 핸들러메서드를 실행시킬 객체로 래핑
	ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);  

	// ArgumentResolver 등록(요청 처리 시 사용)
	if (this.argumentResolvers != null) {  
	    invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);  
	}  
	// ReturnValueHandler 등록(응답 처리 시 사용)
	if (this.returnValueHandlers != null) {  
		invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);  
	}  
	// ...

	// 실제 요청 및 응답 작업 실행
	invocableMethod.invokeAndHandle(webRequest, mavContainer);  
	// ... 
}


핸들러 메서드 실행과 응답처리 로직

invokeAndHandle메서드에서는 두 가지 핵심 메서드로 요청과 응답값을 처리한다.

  • invokeForRequest 메서드
    실행 전 설정에서 셋팅된 argumentResolvers들을 통해, 핸들러 메서드를 실행하는데 필요한 정보를 받아오고 실행시킨다. 필자와 같은 개발자가 작성한 로직들이 수행되고 응답값을 받아온다. 요청처리에 대해 다루는 글은 아니니 자세한 설명은 생략하겠다. returnValue 변수에는 Controller 메서드가 리턴한 응답값 그 자체가 들어가 있다.
  • handleReturnValue 메서드
    설정 시 넘겨준 returnValueHandlers를 이용해 응답 데이터를 처리한다. 자세하게 말자하면 응답데이터를 HttpMessage 형식에 맞춰 바꾸고 부가적인 작업들을 처리한다는 의미이다. 가장 처음 문제가 되었던 MessageConverter 선택과 ResponseBodyAdvice가 실행되는 것도 바로 이 부분이다.
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {    
   Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);  
   setResponseStatus(webRequest);  
	// ...
   try {  
      this.returnValueHandlers.handleReturnValue(  
            returnValue, getReturnValueType(returnValue), mavContainer, webRequest);  
   // ...
   }
}

여기가 가장 중요한 부분이다. returnValueHandlers는 HandlerMethodReturnValueHandler의 인터페이스를 구현한 구현체들의 모음집이다. 해당 인터페이스의 구현체들은 응답 처리 전략을 제공한다. 각각이 서로 다른 응답속성(ResponseBody, ModelAndView 등)을 처리하는 전략을 가지고 있다.

핸들러 메서드에서 어떤 응답속성을 리턴하느냐 따라 이러한 전략들 중 하나가 선택된다. 예를 들어, @ResponseBody라면 RequestResponseBodyMethodProcessor가, ModelAndView라면 ModelAndViewMethodReturnValueHandler가 선택된다.

필자의 서버는 ResponseBody를 리턴하는 REST API 서버이기 때문에 RequestResponseBodyMethodProcessor가 선택된다.(@RestController의 메타어노테이션으로 @ResponseBody가 적용되어있다)

RequestResponseBodyMethodProcessor에서는 모든 ResponseBody 응답데이터 처리가 이뤄진다. 해당 구현체 내부에서 MessageConverter를 순회하며 returnValueType으로 가능한 Converter를 선택하고 ResponseBodyAdvice를 적용, Converter로 JSON 직렬화하는 로직을 찾을 수 있었다.

protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,  
      ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)  
      throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {  
      
	// ...
	// 등록된 MessageConverter를 순회
	for (HttpMessageConverter<?> converter : this.messageConverters) {  
		GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);  
		
		// 해당 Converter로 처리 가능한지 확인
		if (genericConverter != null ? ((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) : converter.canWrite(valueType, selectedMediaType)) {  
		
			// 처리가능하다면 ResponseBodyAdvice 적용 후 결정된 Converter로 write
			body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType, (Class<? extends HttpMessageConverter<?>>) converter.getClass(), inputMessage, outputMessage);  

			// ... converter로 write하는 로직
			return;  
		}  
	}  
}


어디서 응답값을 조작할 수 있을까?

응답처리 흐름을 도식화하면 다음과 같다.

HandlerAdapter응답처리
HandlerAdapter 응답 처리 흐름

아쉽게도 응답 처리 과정 중 MessageConverter결정과 ResponseBodyAdvice 로직실행은 완전히 결합되어 있었고 Converter가 결정되기 전 개발자가 개입하여 응답을 제어할 부분은 보이지 않았다.

때문에 응답 처리 과정을 전담하는 ReturnValueHandler 자체를 재구성해보기로 한다.(WebMvc에서 이를 추가할 수 있도록 지원한다)


응답 처리 덮어쓰기 - ReturnValueHandler 구현

ReturnValueHandler 구현을 위한 인터페이스를 살펴보자. support 메서드는 응답속성이 ResponseBody이며, returnValue의 Type이 String일때 처리하는 걸로 하면 문제없을 듯하다. 난감한 부분은 응답처리를 전담하는 로직을 어떻게 작성하냐는 것이다.

public interface HandlerMethodReturnValueHandler {  
	// returnValue의 타입을 보고 해당 ReturnValueHandler를 사용할 것인지
	boolean supportsReturnType(MethodParameter returnType);  
	
	// 실제 응답처리를 전담하는 로직이 들어감
    void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,  
         ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception; 
}

기존에 ResponseBody 처리를 담당하던 RequestResponseBodyMethodProcessors의 구현체는 어떻게 처리하는지 살펴보자. 아래코드를 보면 눈에 띄는 점은 writeWithMessageConverters 호출이다. 위에서 봤듯 응답처리 로직은 전부 해당 메서드에서 진행한다.

@Override  
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,  
      ModelAndViewContainer mavContainer, NativeWebRequest webRequest)  
      throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {  
    
	mavContainer.setRequestHandled(true);  
	ServletServerHttpRequest inputMessage = createInputMessage(webRequest);  
	ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);  
	
	if (returnValue instanceof ProblemDetail detail) {  
		outputMessage.setStatusCode(HttpStatusCode.valueOf(detail.getStatus()));  
		if (detail.getInstance() == null) {  
			URI path = URI.create(inputMessage.getServletRequest().getRequestURI());  
			detail.setInstance(path);  
		}  
	}  
	
	writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);  
}

여기까지 봤을때, returnValue와 returnType만 String에서 SuccessResponse로 수정하면 다른 것들은 RequestResponseBodyMethodProcessors에게 그대로 위임해도 될 것 같다는 생각이 들었다. 바로 구현해보자.

상당히 간단하다. delegate에는 기존의 RequestResponseBodyMethodProcessors을 주입받아서 returnValue만 수정해서 delegate에 넘겨주면 된다!(returnType은 수정하지 않아도 내부적으로 returnValue에서 타입을 추출해 사용한다.)

class StringResponseBodyReturnValueHandler(  
    private val delegate: HandlerMethodReturnValueHandler  
): HandlerMethodReturnValueHandler {  
	// ...suports 메서드
  
    override fun handleReturnValue(returnValue: Any?, returnType: MethodParameter, mavContainer: ModelAndViewContainer,webRequest: NativeWebRequest) {
		val realReturnValue = SuccessResponse(message = returnValue as String, data = null)
        delegate.handleReturnValue(realReturnValue, returnType, mavContainer, webRequest)  
    }  
}


의존성 주입하고 ReturnValueHandler 등록하기

WebMvc의 기능을 확장하고자 할때 WebMvcConfigurer 인테페이스를 사용한다. 해당 인터페이스에서 returnValueHandler도 등록할 수 있게 메서드를 지원해준다.

returnValueHandler들은 빈으로 등록되는 것이 아니라 빈 주입을 하지 못한다. 따라서 등록시에 기존에 등록되어 있는 RequestResponseBodyMethodProcessor를 가져와 주입하기로 하자.

@Configuration  
class WebMvcConfig: WebMvcConfigurer {
    override fun addReturnValueHandlers(handlers: MutableList<HandlerMethodReturnValueHandler>) {  
        handlers.firstOrNull { it is RequestResponseBodyMethodProcessor }  
            ?.let { it as RequestResponseBodyMethodProcessor }  
            ?.also {  
                handlers.add(StringResponseBodyReturnValueHandler(delegate = it))  
            }  
    }  
}

여기서 새로운 문제가 발생했다.. 아래 사진에서처럼 메서드 인자로 들어오는 handlers가 빈 리스트라는 것이다. 이래서는 직접 만든 Handler에 의존성을 주입할 수가 없다.

handlers비어있는 handlers



커스텀 ReturnValueHandler 적용 문제

addReturnValueHandlers 메서드의 handlers에 정보들이 들어가 있지 않는 이유는 간단하다. returnValueHandlers 들은 RequestMappingHandlerAdapter의 초기화 과정에서 생성되는데 그 이전에 WebMvcConfigurer가 수행되기 때문이다.

아래 초기화 과정을 코드를 보자. InitializingBean를 구현하였기에 afterPropertiesSet 메서드가 실행 되는데(빈 생성, 의존성 주입이 완료되고 실행) 정직하게 returnValueHandler들을 생성, 추가하는 현장을 목격할 수 있다.

public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter implements BeanFactoryAware, InitializingBean {
	@Override  
	public void afterPropertiesSet() {  
		if (this.returnValueHandlers == null) {  
			// handler들 가져오기
			List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers();  
			this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers);  
		}  
		// ... 
	}
	
	private List<HandlerMethodReturnValueHandler> getDefaultReturnValueHandlers() {  
		List<HandlerMethodReturnValueHandler> handlers = new ArrayList<>(20);  
		
		handlers.add(new ModelAndViewMethodReturnValueHandler());  
		handlers.add(new ModelMethodProcessor());
		handlers.add(new RequestResponseBodyMethodProcessor(getMessageConverters(),  
      this.contentNegotiationManager, this.requestResponseBodyAdvice));
		// ... 등등 add 
		return handlers;  
	}
}

WebMvcConfigurer에서 handlers가 왜 비어있는지 알았다. 이들이 초기화 되는것이 HandlerAdapter가 생성될 때인데, WebMvcConfigurer는 이보다 먼저 실행되기 때문이다.

그렇다면, returnValueHandlers들을 초기화하는 HandlerAdapter가 생성되고 난 후에 HandlerAdapter를 직접 이용하면 되지 않을까 생각이 들었다.


HandlerAdapter내 returnValueHandlers 직접 커스텀하기

RequestMappingHandlerAdapter에서 returnValueHandlers를 직접 추가 설정할 수 있는 getter와 setter를 메서드가 존재한다. 정말 다행이다.

public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter implements BeanFactoryAware, InitializingBean {
	public void setReturnValueHandlers(@Nullable List<HandlerMethodReturnValueHandler> returnValueHandlers) {  
	   // .. 추가하는 로직
	}  
	
	public List<HandlerMethodReturnValueHandler> getReturnValueHandlers() {  
	   return (this.returnValueHandlers != null ? this.returnValueHandlers.getHandlers() : null);  
	}
}

본격적으로 HandlerAdapter를 커스텀해보자. 설정 클래스를 만들고 RequestMappingHandlerAdapter를 주입받자. 자연스럽게 주입될 해당 빈이 생성된 이후에 설정 클래스가 빈 객체화된다.

커스텀 ReturnValueHandler 을 추가하는 로직은 다음의 순서로 동작한다.

  1. HandlerAdapter에서 초기화한 returnValueHandlers 꺼내오기
  2. 커스텀 객체에 주입할 의존성 객체 뽑기
  3. 커스텀 객체 생성 및 추가
  4. HandlerAdapter에 새롭게 구성된 returnValueHandlers 셋팅
@Configuration  
class HandlerAdapterCustomConfig (  
    private val handlerAdapter: RequestMappingHandlerAdapter,  
) {  
    fun addStringResponseBodyReturnValueHandler() {  
        val newReturnValueHandlers = mutableListOf<HandlerMethodReturnValueHandler>()
	        .also{  
			    it.addAll(handlerAdapter.returnValueHandlers ?: emptyList())  
			}  
		val responseBodyReturnHandler = handlerAdapter.returnValueHandlers  
			    ?.firstOrNull { it is RequestResponseBodyMethodProcessor }  
			    ?: return 
		newReturnValueHandlers.add(StringResponseBodyReturnValueHandler(responseBodyReturnHandler))  
		handlerAdapter.returnValueHandlers = newReturnValueHandlers
    }  
}

마지막으로 필자가 작성한 로직은 빈 생성 후(의존성 주입 완료 후)에 자동으로 적용되어야한다. 따라서, 빈 라이프사이클 콜백을 통해 적용되도록 만들었다.

@Configuration  
class HandlerAdapterCustomConfig (  
    private val handlerAdapter: RequestMappingHandlerAdapter,  
) {  
    @PostConstruct
    fun postConstruct() {  
	    addStringResponseBodyReturnValueHandler()
	}
}

RequestMappingHandlerAdapter에서는 클라이언트의 요청이 들어와 핸들러메서드가 실행될때, 가지고있는 returnValueHandlers를 사용하는 구조이기 때문에 큰 문제없이 커스텀 객체를 추가할 수 있었다.


실제로 사용되지 않는 문제

빠짐없이 코드를 작성했음에도 커스텀 ReturnValueHandler가 동작하지 않았다. 흠, 산 너머 산이다.



ReturnValueHandler 적용순서 문제

왜 적용되지 않는가? HandlerAdapter의 초기화 과정에서 보았듯이 List에 returnValueHandlers들을 만든다. 그리고 이들 중 하나가 선택되어 핸들러메서드의 응답을 처리한다.

returnValueHandlers 중 하나를 선택하는 로직을 찾아봤다. 단순히 List에서 순회하면서 처리가능한 것을 발견하면 곧바로 적용하더라.

보통 Ordered 인터페이스를 구현해 우선순위를 정해주기도 하는데 단순한 List 순회여서 순간 당황스러웠다. 아무튼 기존 RequestResponseBodyMethodProcessor로 처리가 가능하기 때문에 커스텀 객체에게는 기회가 가지 않는 것이 문제였다.

ReturnValueHandler 순서 ReturnValueHandler 순회


적용 순서 바꾸기

해당 문제의 해결법은 아주 단순하다. 커스텀 객체를 가장 앞에 추가하면 된다.

@Configuration  
class HandlerAdapterCustomConfig (  
    private val handlerAdapter: RequestMappingHandlerAdapter,  
) {  
	// ...
  
    // 커스텀 ReturnValueHandler 의존성 해결, 최우선순위로 등록  
    private fun addStringResponseBodyReturnValueHandler() {  
        val newReturnValueHandlers = mutableListOf<HandlerMethodReturnValueHandler>()  
        val responseBodyReturnHandler = (handlerAdapter.returnValueHandlers  
            ?.firstOrNull { it is RequestResponseBodyMethodProcessor }  
            ?: return)  
        // 우선 추가
        newReturnValueHandlers.add(StringResponseBodyReturnValueHandler(responseBodyReturnHandler))  
        newReturnValueHandlers.addAll(handlerAdapter.returnValueHandlers ?: emptyList())  
        handlerAdapter.returnValueHandlers = newReturnValueHandlers  
    }  
}


기존 ResponseBodyAdvice 수정 & 마무리

마지막으로 기존에 작성했던 ResponseBodyAdvice의 supports 메서드를 수정했다. 커스텀 ReturnValueHandler 작성 당시 returnValue는 객체로 수정했지만 returnType은 따로 수정하지 않았다. 때문에 returnTyped은 String으로 유지된다. 이를 이용해서 ResponseBodyAdvice를 타지 않도록 만들어줬다.

@RestControllerAdvice(basePackages = ["com.adevspoon"])  
class SuccessResponseBodyAdvisor: ResponseBodyAdvice<Any> {  
    override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {  
        return returnType.parameterType != String::class.java &&
        MappingJackson2HttpMessageConverter::class.java.isAssignableFrom(converterType)  
    }
}

다행히 더 이상의 이슈없이 잘 동작했다!(String 반환 값이 data 필드가 아닌 message 필드에 넣는 것으로 ReturnValueHandler를 수정했다)

String 요청String 공통응답형식으로 처리



마무리

정리

문제

  • Controller에서 String 반환시 MessageConvter가 String 처리용으로 정해져 공통응답객체로 변환하지 못함.

해결방법

  • 응답 데이터 처리를 전담하는 ReturnValueHandler를 직접 구현하여 응답 처리전 String을 공통응답객체로 변환하기
  • 기존 ReturnValueHandler를 활용하여 이후 과정은 위임하기

이슈 처리

  1. 기존 ReturnValueHandler를 가져와 커스텀 객체 주입하기 위해서는 HandlerAdapter에 직접 접근해 커스텀해야한다.
  2. 등록된 ReturnValueHandler들은 단순 순회하며 선택하기하기 때문에 커스텀 객체를 리스트 앞단에 추가해야한다.


마치며

기존에 등록된 빈을 커스텀 마이징까지 해야할 줄은 몰랐다. 다행히 결국 풀어냈고 순수하게 디버깅과 필자의 생각만으로 풀어냈다는 것에서 의미가 깊다.

HandlerAdapter를 여행하면서 WebMvc 기능들에 대해서도 여러모로 배울 수도 있었고 흥미로운 트러블 슈팅 과정이었다.ㅎ


카테고리:

업데이트:

댓글남기기