공통 응답 형식 적용기 (feat. ResponseBodyAdvice)
Pigrest 프로젝트에서 정의한 공통 응답 형식과 ResponseBodyAdvice를 적용하지 않은 이유
이번 글에서는 프로젝트에서 공통 응답 형식을 적용하고, 그 과정에서 ResponseBodyAdvice 를 도입하려다가, 결국 사용하지 않기로 결정한 이유에 대해 공유해보려고 한다.
공통 응답 형식을 정해보자
프로젝트에 들어가기 앞서, 공통 응답 형식을 정의할 때 다음과 같은 요구 사항들을 고려하였다.
- 개발 측면
- 클라이언트가 쉽게 처리할 수 있도록 일관된 JSON 형식을 유지해야 한다.
- 에러 발생 시, 디버깅이 쉽도록 정보를 제공해야 한다.
- 에러 발생 시, 클라이언트가 예외 처리를 하기 편하도록 일관된 코드로 제공해주어야 한다.
- 보안 측면
- 사용자가 해결할 수 있는 정보만 제공해준다.
- 보안상 위험한 정보(Stack Trace, DB 쿼리 결과 등)는 제공하지 않는다.
Response는 이러한 기준들을 토대로 다음과 같은 format으로 정의하였다.
Paypal API 와 Axios - Response Schema 를 참고하여 작성하였다.
1
2
3
4
5
6
7
8
9
{
"status": number, // HTTP 상태 코드
"message": string, // 요청 처리 결과 (에러일 경우, 에러 메시지)
"data": {
// 응답 데이터: 요청에 따라 상이한 결과
},
"error": string, // 에러 코드
"timestamp": "2025-02-18T12:34:56Z" // 요청 처리 시간
}
반복적인 코드가 눈에 밟히기 시작했다… 😫
이렇게 공통 응답 형식을 정하고, 실제 컨트롤러에서 구현을 하니, 응답을 매번 아래처럼 감싸야 했다.
1
2
3
4
5
6
7
8
9
@PostMapping("/register")
public ResponseEntity<ApiResponse<RegisterResponse>> register(@Valid @RequestBody RegisterRequest request) {
// 생략
return ResponseEntity.ok(
ApiResponse.success(
ApiStatusCode.CREATED,
"Member registered successfully",
RegisterResponse.from(auth)));
}
응답 데이터인 ReigsterResponse를 공통 응답 형식인 ApiResponse 로 감싸고, 이를 다시 ResponseEntity 로 반환하는 코드가 엔드포인트마다, 그리고 분기 지점마다 반복적으로 등장하다보니, “이를 공통 처리할 수 있는 방법은 없을까”라는 고민이 생겼다.
한 번에 처리할 수 있는 방법이 없을까?
Spring MVC Request LifeCycle 구조를 살펴보면서, 공통 응답을 처리할 수 있는 지점을 고민해보았다.
🤔 Filter 와 Interceptor 두 곳에서 처리할 수 있지 않을까?
결론부터 이야기하자면 불가능하다.
Filter는 요청과 응답 전반에 관여할 수 있다는 점에서 유용해보였지만, 응답 객체가 이미 JSON으로 변환되는 과정(HttpMessageConverter), 즉 직렬화를 거친 후이다.Interceptor는postHandle()에서 컨트롤러의 반환 객체를 변경할 수 있지만, 이는ModelAndView에 한정됐다. REST API에서 주로 사용하는@ResponseBody를 사용하는 방식에서는 이미 직렬화를 거친 후이다.
직렬화를 거친 후에는 response body가 수정이 불가능하기 때문에 처리가 불가능하다.
Debugger를 돌려보면서 자세히 살펴본 내용은 다음 글에 정리해놓았다.
Spring AOP로 공통 처리를 해야하나 고민하던 찰나에 발견한 것이 ResponseBodyAdvice 였다.
💡 ResponseBodyAdvice 로 응답을 커스텀하자!
Allows customizing the response after the execution of an
@ResponseBodyor aResponseEntitycontroller method but before the body is written with an HttpMessageConverter.
공식 문서에 따르면, ResponseBodyAdvice 는 @ResponseBody 또는 @ResponseEntity 컨트롤러 메서드가 실행된 후, HttpMessageConverter 가 응답을 직렬화하기 전에 응답 본문을 원하는대로 커스텀할 수 있도록 해준다.
AOP처럼 모든 메서드의 실행을 가로채는 대신, 응답 데이터에만 초점을 맞춰 불필요한 오버헤드를 줄일 수 있는 방법이다.
1
2
3
4
5
6
7
8
9
10
11
12
@RestControllerAdvice
public class ResponseWrapper implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return false;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
return null;
}
}
supports- 어떤 반환 값에
ResponseBodyAdvice가 적용될 것인가 결정 - 반환 값이 true일 때만 beforeBodyWrite 메서드가 실행됨
- 어떤 반환 값에
beforeBodyWrite- Handler가 반환하는 데이터를 가공할 수 있음
- body는 Handler가 반환한 데이터
✔️ ResponseBodyAdvice 를 사용하지 않기로 했다.
Spring AOP를 사용하지 않는 이유도 ResponseBodyAdvice를 사용하지 않는 이유와 유사하다.
1) 유연성 있는 코드를 작성하기 위해
처음에는 “ResponseBodyAdvice 에서 공통 응답 형식인 ApiResponse 로 감싸주면 되겠다.”고 생각했다. 컨트롤러에서 데이터만 반환하면 ResponseBodyAdvice 에서 자동으로 응답을 만들어줄 것이라 편리할 것 같았는데, ApiResponse 에 들어가는 status, error, message 와 같은 필드들을 자동으로 채우기 어렵다는 이슈가 있었다.
물론 추가 구현을 통해 해결할 수도 있겠지만, 오히려 코드의 복잡성을 늘릴 것이라 판단했다.
2) 부가 설명이 필요한 코드는 가급적 줄이기 위해
코드가 지나치게 숨어버리거나, 특정한 규칙을 이해해야만 동작 방식이 보이는 구조라면, 결국 유지보수와 협업에 부담이 생긴다. 처음 코드를 접하는 사람도 직관적으로 파악할 수 있어야 하고, 특별한 설명 없이도 어느 정도 자연스럽게 이해할 수 있어야 한다. 이런 관점에서 보면, ResponseBodyAdvice 처럼 Spring 내부의 특정 메커니즘을 알아야 하는 방식은 상황에 따라 오히려 복잡도를 높일 수 있다.
이러한 이유들로 각 컨트롤러 메서드에서 ResponseEntity<ApiResponse<T>> 를 직접 생성하고 반환하는 방식을 선택했다. 코드가 약간 길어진다는 단점이 있지만, 구조가 명확해짐으로써 요청 결과에 따라 다른 형태의 응답을 내려줘야 할 때나, 추후 새로운 요구사항이 추가될 때에도 유연하게 대응할 수 있다는 장점이 있다.
👏 정리하자면
ResponseBodyAdvice 를 사용하면 한 곳에서 공통 응답 형식으로 감싸, 중복 코드를 줄여 비즈니스 로직이 깔끔해질 수 있다. 하지만 세밀한 분기가 필요하거나 유연성을 고려해야 하는 경우에는 오히려 불편할 수 있다. 또한 코드 가독성과 일관성을 고려한다면, 각 컨트롤러에서 직접 ResponseEntity<ApiResponse> 를 반환하는 구조가 더 명확하다. 따라서 최종적으로 ResponseBodyAdvice 를 사용하지 않고, 모든 응답을 직접 반환하는 방식을 선택했다.
