목차

들어가기 전에
이전 글에서 String 타입을 반환하는 컨트롤러 메소드는 응답 본문을 작성할 때 StringHttpMessageConverter를 사용하기 때문에 ResponseBodyAdvice를 사용해서 공통 응답 포맷을 적용하기 어렵다는 내용을 다뤘습니다. 그래서 String 타입을 반환하는 컨트롤러 메소드는 공통 응답 적용 대상에서 제외하는 식으로 처리했죠.
잠깐 언급하고 넘어간 것처럼 String 타입을 반환하는 메소드에 공통 응답 포맷을 적용하는 방법이 있긴 합니다. 다만, 이 방법은 HTTP 응답이 작성되기 전에 컨트롤러 메소드의 반환값을 조작해야 하기 때문에 과정이 조금 복잡하죠.
이번에는 ResponseBodyAdvice를 사용해서 공통 응답 포맷을 적용할 때 String 타입을 반환하는 컨트롤러 메소드도 문제 없이 적용되는 방법을 정리하겠습니다.
본문의 내용에서 다루는 코드는 Github 레포지토리에서 확인하실 수 있습니다.
HTTP 응답이 작성되는 과정
이 문제를 해결하기 위해선 컨트롤러 메소드의 응답이 HTTP 응답 본문으로 작성되는 과정을 알 필요가 있습니다.
HTTP 요청이 발생하면 RequestMappingHandlerAdapter에서 요청의 엔드포인트와 일치하는 컨트롤러 메소드를 실행합니다. 컨트롤러 메소드에서 결과를 반환하면 ServletInvocableHandlerMethod에서 반환값을 처리할 핸들러를 탐색하죠. @RestController나 @ResponseBody가 붙은 메소드가 실행된 경우라면 여기서 RequestResponseBodyMethodProcessor가 선택됩니다.
이렇게 선택된 RequestResponseBodyMethodProcessor는 응답 본문을 작성하기 위해 스프링에서 등록한 HttpMessageConverter 리스트를 순회하고, 응답 본문을 작성할 수 있는 컨버터를 발견하면 HTTP 응답 본문을 작성해서 클라이언트에게 반환하게 됩니다.
이 과정에 ResponseBodyAdvice까지 포함시킨 과정을 그림으로 나타내면 다음과 같습니다.

String에 공통 응답 포맷을 적용할 때 문제가 되는 부분
위의 그림과 같이 ResponseBodyAdvice는 HttpMessageConverter를 탐색해서 선택하고, HTTP 응답 본문을 작성하는 과정 사이에 실행됩니다.
RequestResponseBodyMethodProcessor에서 HttpMessageConverter를 탐색하는 과정을 보면 for문을 사용해서 HttpMessageConverter 리스트를 순차적으로 탐색하고 있습니다. 리스트를 순차적으로 탐색하다가 사용할 수 있는 컨버터를 발견하면 작성해둔 ResponseBodyAdvice를 실행한 후 HTTP 응답 본문을 작성해서 반환하죠.

리스트에 담긴 컨버터들을 보면 StringHttpMessageConverter가 MappingJackson2HttpMessageConverter보다 앞에 있는 사실을 알 수 있습니다. 이 때문에 String 타입을 반환하는 컨트롤러 메소드를 실행하면 StringHttpMessageConverter가 컨버터로 선택되어 버립니다.
어떻게 해결할 수 있을까?
StringHttpMessageConverter가 MappingJackson2HttpMessageConverter보다 우선 순위가 높아서 발생하는 문제니까 단순히 MappingJackson2HttpMessageConverter의 우선 순위를 StringHttpMessageConverter보다 앞에 두면 해결할 수 있긴 합니다.
다만 스프링에서 기본으로 구성되어 있는 컨버터의 순서를 바꾸게 되면 잘못된 컨버터가 선택되어 원치 않는 사이드 이펙트가 발생할 수 있다는 문제가 있습니다. 그렇기 때문에 컨버터의 순서를 바꾸는 방법이 문제를 간단히 해결할 수 있지만 이 방법은 사용하지 않겠습니다.
또한, StringHttpMessageConverter를 확장한 커스텀 컨버터를 사용하려고 해도 제네릭이 String으로 고정되어 있기 때문에 불가능하죠.
다른 방법을 생각해보자면, 컨트롤러 메소드가 String 타입을 반환하고 있으니 이 반환값을 조금 건드린다면 RequestResponseBodyMethodProcessor에서 컨버터를 선택하기 전의 어느 단계에서 문제를 해결할 수도 있을 법 합니다.
RequestResponseBodyMethodProcessor에서 응답을 처리하는 과정을 조금 더 자세히 보면 내부의 handleReturnValue()에서 컨트롤러 메소드의 반환값을 처리할 수 있는 컨버터를 선택하고 HTTP 응답으로 가공하는
writeWithMessageConverters()를 호출합니다.

그렇다면 RequestResponseBodyMethodProcessor를 상속 받고 handleReturnValue()를 오버라이딩해서 returnValue의 타입이 String일 때만 공통 응답 포맷으로 감싸서 부모 클래스의 handleReturnValue()로 넘겨주면 문제를 해결할 수 있겠네요!
RequestResponseBodyMethodProcessor를 상속받는 핸들러 작성
핸들러에서 컨버터를 선택하기 전에 String 타입의 반환값만 공통 응답 포맷으로 감싸기 위해 RequestResponseBodyMethodProcessor를 상속받는 새로운 핸들러를 작성합니다.
class ExtendedRequestResponseBodyMethodProcessor(
converters: List<HttpMessageConverter<*>>
) : RequestResponseBodyMethodProcessor(converters) {
override fun handleReturnValue(
returnValue: Any?,
returnType: MethodParameter,
mavContainer: ModelAndViewContainer,
webRequest: NativeWebRequest
) {
super.handleReturnValue(
returnValue.let { if (it is String?) CommonResponse(ResponseCode.SUCCESS, it) else it },
returnType,
mavContainer,
webRequest
)
}
}
ExtendedRequestResponseBodyMethodProcessor는 handleReturnValue()만 오버라이딩합니다. returnValue의 타입이 String일 때만 공통 응답 포맷으로 감싸서 부모 클래스의 handleReturnValue()를 그대로 호출하는 것이죠.
생성자의 파라미터로는 RequestResponseBodyMethodProcessor의 생성자를 호출하기 위한 컨버터의 리스트를 선언해줍니다.
새로 작성한 핸들러 적용하기
새로 작성한 핸들러를 적용하려면 ServletInvocableHandlerMethod에서 반환값을 처리할 핸들러를 탐색해서 새로운 핸들러를 선택할 수 있어야 합니다. 이를 위해 핸들러가 어떻게 선택되는지를 먼저 봅시다.

ServletInvocableHandlerMethod는 자신의 returnValueHandlers라는 객체의 handleReturnValue()를 호출합니다. 이 메소드가 실제로 핸들러를 선택해서 컨트롤러 메소드의 반환값을 처리하는 역할이죠.

returnValueHandlers의 handleReturnValue()를 타고 들어가면 selectHandler()를 통해 반환값을 처리할 핸들러를 선택하고, 선택된 핸들러를 통해 컨트롤러 메소드의 반환값을 처리합니다. @RestController나 @ResponseBody의 경우라면 여기서 RequestResponseBodyMethodProcessor가 선택되는 것이죠.

selectHandler를 보면 반환값을 처리할 핸들러를 선택할 때 RequestResponseBodyMethodProcessor에서 컨버터를 선택하는 것과 동일하게 단순히 리스트를 순회한다는 것을 알 수 있습니다. 즉, 새로 작성한 ExtendedRequestResponseBodyMethodProcessor를 저 핸들러 리스트에 추가하되, RequestResponseBodyMethodProcessor보다 앞에 추가해야 한다는 것이죠.
핸들러 리스트에 새로 작성한 핸들러를 추가하자!
ExtendedRequestResponseBodyMethodProcessor를 핸들러 리스트에 추가하면서, RequestResponseBodyMethodProcessor보다 앞에 추가해야 합니다.
일반적으로 커스텀 스프링 설정을 할 때는 WebMvcConfigurer의 구현체를 사용하죠? 하지만 이 방법으로 커스텀 핸들러를 추가하면, returnValueHandlers가 초기화된 후 그 뒤에 커스텀 핸들러들이 추가됩니다. 다시 말해, 스프링 기본 핸들러들보다 우선 순위가 밀리게 되죠.
그렇기 때문에 BeanPostProcessor를 사용해서 RequestMappingHandlerAdapter 빈이 생성되어 핸들러 리스트가 초기화된 이후에 핸들러 리스트의 중간에 ExtendedRequestResponseBodyMethodProcessor를 집어 넣을 것입니다.
@Component
class HandlerAdapterPostProcessor : BeanPostProcessor {
override fun postProcessAfterInitialization(bean: Any, beanName: String): Any? {
if (bean is RequestMappingHandlerAdapter) {
val handlers = bean.returnValueHandlers ?: return bean
val index = handlers.indexOfFirst { it is RequestResponseBodyMethodProcessor }
bean.returnValueHandlers = buildList {
addAll(handlers.take(index))
add(ExtendedRequestResponseBodyMethodProcessor(bean.messageConverters))
addAll(handlers.drop(index))
}
}
return bean
}
}
BeanPostProcessor를 구현해서 postProcessAfterInitialization()을 오버라이딩합니다. 이 메소드는 각 스프링 빈이 생성된 후에 실행되는 메소드입니다.
생성된 빈이 RequestMappingHandlerAdapter라면 핸들러 리스트에서 RequestResponseBodyMethodProcessor의 위치를 찾고, 그 바로 앞에 ExtendedRequestResponseBodyMethodProcessor를 집어 넣은 새로운 리스트를 만들어서 핸들러 리스트를 교체합니다.
굳이 핸들러 리스트를 교체하는 방법을 사용한 이유는 returnValueHandlers의 Getter가 자바의 UnmodifiableList를 반환해서 직접적인 조작이 불가능하기 때문입니다. 다행히, RequestMappingHandlerAdapter에서는 returnValueHandlers의 Setter를 제공하기 때문에 위와 같이 핸들러 리스트를 교체할 수 있습니다.
테스트 코드 수정 후 테스트
기존 StringControllerTest의 내용을 조금 수정합니다.
@Import(HandlerAdapterPostProcessor::class) // 추가
@WebMvcTest
class StringControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@Test
fun `문자열 반환 테스트`() {
mockMvc.get("/string")
.andExpect {
status { isOk() }
// content { string("Hello, World!") } -> 이건 이제 필요없으니 삭제
// JSON 형태로 응답이 오는지 확인하도록 수정
jsonPath("$.code") { value("SUCCESS") }
jsonPath("$.message") { value("성공") }
jsonPath("$.data") { value("Hello, World!") }
}
}
}
Mocking 테스트를 하기 때문에 @Import로 커스텀한 스프링 빈을 추가합니다. 또는 @SpringBootTest를 사용해도 무방합니다.
그리고 기존에 문자열의 내용만 체크하던 코드를 지우고 JSON 형태로 응답이 오는지 확인하도록 검증 부분을 수정합니다.
이제 테스트 코드를 실행해보면...

테스트에 성공하는 것을 볼 수 있습니다!

내용을 출력했을 때도 원하는 대로 공통 응답 포맷이 적용된 것을 확인할 수 있습니다.
마치며
이 문제를 해결하면서 스프링에서 컨트롤러의 반환값을 HTTP 응답으로 전달하는 과정을 비롯해서 많은 내용을 알 수 있었습니다. 단순한 문제에서 시작했지만 생각보다 훨씬 깊게 파고들어야 해서 스프링의 작동 방식을 좀 더 자세히 알게되었죠. 특히, 컨트롤러의 반환값을 처리할 때 이를 위한 객체를 선택하는 방법이 단순 리스트 순회라는 사실이라든지...
사실 뷰를 반환하는 컨트롤러가 아니라면 String 타입을 반환할 일이 거의 없겠지만, 모든 @RestController나 @ResponseBody의 반환값에 공통 응답 포맷을 예외 없이 적용하려다 보니 먼 길을 돌아온 느낌입니다.
참조 링크
SpringBoot의 ResponseBodyAdvice를 이용한 공통 응답 처리
1. 기존 응답 형식의 한계 티켓팅 관련 프로젝트를 하면서 하나의 공연 정보를 조회할 때 아래와 같이 응답 데이터를 만들었다. 만약 하나의 공연 정보가 아니라,여러 개의 공연 정보를 조회한다
velog.io
문자열 응답 시 공통응답형식이 적용되지 않는 문제 개선하기
MessageConverter가 결정되어 문자열을 DTO객체로 바꾸지 못하는 문제! 어떻게 해결할 수 있을까
rokwonk.github.io
'Development > Spring & Spring Boot' 카테고리의 다른 글
| [Spring] ResponseBodyAdvice를 사용해서 API에 공통 응답 포맷 적용하기 (2) | 2025.02.25 |
|---|---|
| [Spring Scheduler] 서버 이중화 또는 증설 시 ShedLock을 사용하여 스케쥴러 중복 실행 방지하기 (2) | 2024.09.09 |
| [Spring/Spring Boot] 파일 다운로드와 multipart/form-data 업로드 컨트롤러 메소드 테스트 코드를 작성해보자 (0) | 2024.08.14 |
| [Spring] Spring REST Docs를 사용해서 API 명세서를 작성해보자 (0) | 2024.07.08 |
| [Spring Boot] 스프링 인터셉터(Spring Interceptor)를 활용해서 API 로그를 DB에 기록해보자 (0) | 2024.03.01 |
댓글