'개발 한 스푼' 서비스에서는 다음과 같은 형식으로 응답 Body를 보낸다. 성공과 실패 시 같은 응답형식을 취하고 code와 message를 통해 보다 자세한 실패 이유를 알려주기 위함이다. 추후 추가적인 메타정보를 넘겨줄때도 본 데이터에 섞이지 않고 확장시킬 수 있다.
// 성공
{
"code": 200,
"message": "Success",
"data" : { } // 성공시 데이터
// 추가적인 데이터가 들어갈 수도(ex. traceId 등)
}
// 실패시
{
"code": 400_001_003, // 내부적으로 유지하는 실패코드
"message": "뭔가 잘못되었습니다."
}
핸들러 메서드에서 매번 같은 형식의 응답객체로 래핑하여 응답하는 것은 반복적인 작업임이 분명하다. 이번 포스트에서 공통 형식의 응답을 효율적으로 처리하기 위한 과정을 공유해본다.
1. 핸들러 메서드에서 공통형식을 처리할 때의 문제
실패시에는 등록된 ExceptionResovler에서 공통으로 처리한다. 하지만 정상적인 응답을 처리할때는 매번 Controller 메서드에서 다음과 같이 SuccessResponse 객체로 래핑하는 방식으로 처리했다.
data class SuccessResponse<T: Any>(
val code: Int = 200,
val message: String = "Success",
val data: T
)
@RestController
@RequestMapping("/user")
class MemberController() {
@PostMapping
fun socialLogin(@RequestBody @Valid request: SocialLoginRequest) : SuccessResponse<MemberAndTokenResponse> {
return SuccessResponse<MemberAndTokenResponse>(data = authService.signIn(request))
}
}
모든 핸들러 메서드에서 공통으로 SuccessResponse을 래핑하는 과정이 들어갔다. 빼먹고 배포할 리는 거의 없겠지만 핸들러 메서드에서 매번 동일한 공수가 드는 것도 문제이다.
핸들러 메서드에서는 요청에 대한 처리에만 집중하고 공통형식을 적용하는 부분 외부에서 처리해보도록 하자
2. 공통 응답 형식, 어떻게 처리할 수 있을까?
가장 먼저 떠올랐던 것은 Filter와 Interceptor였다. Filter는 유저의 요청을 가장 먼저 맞이하고 응답 시에는 가장 마지막에 통과하는 스프링 부트의 게이트웨이이다.
Filter에서 응답을 받았을때는 이미 응답 데이터가 모두 쓰여진 상태다. 이는 응답 객체가 JSON으로 직렬화가 끝난 상태란 뜻이다. 이 데이터를 수정하기 위해서는 Response 캐싱부터 역직렬화, 수정, 다시직렬화까지 해줘야할 것들이 많다.
또한 공식설정상 Spring Container 외부에서 수행하기 때문에 단순 응답 Body 조작에서 사용하기에는 적절치 못하다 판단했다.
Interceptor의 경우에는 Spring Container 내에서 동작하지만 Controller에서 return값을 null로 넘기지 않는이상 응답 데이터를 조작할 수 없다.
또 다른 방법이 없을까하여, 요청부터 응답까지의 흐름에 사고의 템포를 맞춰 고민하다 보니 HandlerAdapter 레벨에서 처리할 수 있는 방법이 없을까? 하는 생각이 들었다. 유레카! 역시나? ResponseBodyAdvice를 통해 Controller 응답값을 조작할 수 있는 방법이 존재하였다.(GPT에게 물어봤다면 더 빨리 찾았을테지만)
3. 공통 응답을 처리하기
HandlerAdapter에서는 요청과 응답에 대한 처리를 한다. 응답을 쓰기 전(직렬화 전), 개발자들에게 응답 데이터를 가공할 수 있는 방법을 제공해준다. 바로 ResponseBodyAdvice 이다.
3.1 ResponseBodyAdvice 톺아보기
ResponseBodyAdvice는 인터페이스로 두가지 메서드를 필수로 구현해야한다. 첫번째는 해당 객체를 적용할 대상인지를 체크하는 supports이고 다른 하나는 body를 조작할 수 있는 beforeBodyWrite 메서드이다.
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
해당 인터페이스를 구현하고 @ControllerAdvice 어노테이션을 붙이면 자동으로 등록된다. 글로벌 예외처리 클래스에 사용하던 그 어노테이션과 똑같다. Controller와 AOP에서 익숙한 용어인 Advice의 합성어로 Controller에 적용되는 부가적인 기능이라고 생각하면 된다.
등록된 ResponseBodyAdvice는 HandlerAdapter에서 사용된다. Controller 메서드(핸들러메서드)에서 응답값을 리턴 받으면 해당 응답값이 ResponseBodyAdvice를 적용할 타입인지 (supports 메서드로) 확인 후 적용한다.
ResponseBodyAdvice를 사용하여 응답 데이터를 조작할 때 이점은 다음과 같다고 생각한다.
- Spring Container 내에서 응답 조작가능
- 제공 기능과 필자의 의도가 매칭된다. -> 가독성이 좋다.
3.2 커스텀 ResponseBodyAdvice로 공통 응답형식 적용하기
우선 모든 응답에 대해 SuccessResponse로 래핑할 예정이기 때문에 supports 는 true로 지정한다.(어떤 문제가 벌어질지도 모른 채.. )
beforeBodyWrite 메서드에서는 status가 성공인 경우에만 SuccessResponse로 래핑해준다. 사실 비즈니스 로직에서 실패의 경우, 예외를 던지는 케이스만 있기 때문에 status 확인 로직이 필요할까 고민했지만, 혹시나 명시적 실패를 200번 응답에 작성할 수도 있음을 고려하여 확인로직도 추가하였다.
@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? {
val servletResponse = (response as ServletServerHttpResponse).servletResponse
val status = servletResponse.status
val resolve = HttpStatus.resolve(status) ?: return body
return if (resolve.is2xxSuccessful) {
SuccessResponse(data = body)
} else body
}
}
문제없이 동작했다! 라고 생각했다.
4. 예상치 못한 문제
잘 동작하는 와중 Controller에서 객체가 아닌 문자열을 리턴하는 API를 만들었다. API를 테스트하는 도중 왠열 에러가 발생했다. 다음과 같은 에러 코드와 함께..
message: class com.*.*.*.SuccessResponse cannot be cast to class java.lang.String (com.*.*.*.SuccessResponse is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')
type: ClassCastException
문자열 리턴에서 공통응답형식을 적용하는 것으로 인해 발생하는 문제였다. 이에 대한 해결과정은 다음 포스트에 작성해보겠다.
전체 코드는 아래 레포에서 확인할 수 있다.
'Spring' 카테고리의 다른 글
예외 정보를 인터페이스와 infix함수로 확장성, 가독성 높게 관리하기 (0) | 2024.04.18 |
---|---|
API별 인증 해제, 어노테이션으로 효율적으로 처리하기 (0) | 2024.04.09 |
문자열 응답 시 공통 응답형식이 적용되지 않는 문제 해결하기 (0) | 2024.04.05 |
Query에 포함된 다수의 Legacy Enum 우아하게 관리하기 (0) | 2024.04.04 |
요청/응답 Body에 포함된 다수의 Legacy Enum 우아하게 관리하기 (0) | 2024.04.04 |