Spring Boot에서 API의 응답을 표준화하거나, 공통 데이터를 추가하고 싶을 때는 @ControllerAdvice를 사용하여 응답을 가로채 변형할 수 있다.
이때 핵심적인 역할을 하는 것이 바로 ResponseBodyAdvice 인터페이스이다.
1. ResponseBodyAdvice란?
Spring 공식 문서를 보면 ResponseBodyAdvice는 다음과 같이 설명되어 있다.
@ResponseBody 또는 ResponseEntity를 사용하는 컨트롤러 메서드가 실행된 후, 응답 본문이 HttpMessageConverter를 통해 작성되기 전에 이를 수정할 수 있도록 해줍니다.
해당 인터페이스의 구현체는 RequestMappingHandlerAdapter 및 ExceptionHandlerExceptionResolver에 직접 등록할 수도 있으며,
더 일반적으로 @ControllerAdvice를 사용하여 등록하면 두 곳에서 자동으로 감지됩니다.
요약하자면, ResponseBodyAdvice는 컨트롤러의 응답 데이터를 가로채서 공통 로직을 적용할 수 있도록 도와주는 인터페이스이다.
이를 통해 API 응답을 표준화하거나, 추가적인 로직(로깅, 암호화, 필터링 등)을 쉽게 적용할 수 있다.
2. ResponseBodyAdvice의 동작 과정
Spring Boot에서 @RestController가 동작할 때는 ViewResolver 대신 MessageConverter가 실행된다.
이 과정에서 ResponseBodyAdvice가 개입하여 응답을 가공할 수 있다.
전체적인 흐름
1. 컨트롤러 메서드 실행
2. RequestResponseBodyMethodProcessor가 MessageConverter를 선택
3. ResponseBodyAdvice가 응답을 가로채 변환 (공통 로직 적용)
4. MessageConverter가 데이터를 변환 (writeInternal() 실행)
5. 클라이언트에게 최종 HTTP 응답 반환
즉, ResponseBodyAdvice는 MessageConverter가 실행되기 직전에 응답 값을 가로채어 수정하는 역할을 한다.
3. ResponseBodyAdvice 구현
ResponseBodyAdvice를 사용하기 위해서는 두 가지 메서드를 반드시 구현해야 한다.
1. supports()
- ResponseBodyAdvice가 어떤 응답을 가로챌지 결정하는 메서드
- return true;를 하면 모든 API 응답에 적용
- 특정 컨트롤러나 특정 데이터 타입에만 적용하고 싶다면 조건을 추가 가능
2. beforeBodyWrite()
- HttpMessageConverter가 실행되기 전에 응답 데이터를 수정하는 메서드
- JSON 응답을 감싸거나, 특정 필드를 추가하거나, 데이터를 변환할 때 활용
@RestControllerAdvice
public class ResponseHandler implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 모든 응답에 대한 처리
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 예상하지 못한 실패에 대한 응답
if (body instanceof ErrorResponse errorResponse) {
body = errorResponse.getBody(); // ProblemDetails
}
// ProblemDetail -> ExceptionResponse 로 | 제어 가능한 객체로 변환
if (body instanceof ProblemDetail problemDetail) {
// 예상하지 못한 실패에 대한 응답 2
final int status = problemDetail.getStatus();
response.setStatusCode(HttpStatus.valueOf(status));
return ApiResponse.fail(String.valueOf(status), problemDetail.getTitle(), problemDetail);
}
response.setStatusCode(HttpStatus.OK);
// 공통 응답 객체로 감싸기
return ApiResponse.ok(body);
}
}
위 코드의 주요 구현 사항은 아래와 같다.
에러 응답 처리: ErrorResponse 및 ProblemDetail을 ApiResponse 형태로 변환
정상 응답 처리: ApiResponse.ok()을 통해 공통 응답 객체로 감싸서 반환
만약 특정 패키지에 속한 컨트롤러의 응답만 가로채고 싶다면
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
return returnType.getDeclaringClass().getPackageName().startsWith("com.example.api");
}
위와 같이 작성해 특정 패키지만 적용할 수도 있다.
AOP적용 전과 후를 비교해 보면
AOP 적용 전
// AOP 적용 전
@GetMapping("/api/v1/example")
public ResponseEntity<ApiResponse<Map<String, String>>> example() {
return ResponseEntity.ok(ApiResponse.ok(Map.of("example", "example")));
}
AOP 적용 후
// AOP 적용 후
@GetMapping("/api/v1/example")
public Map<String, String> example() {
return Map.of("example", "example");
}
ResponseBodyAdvice를 사용하면, 자세한 응답 객체를 신경 쓰지 않고 반환할 데이터만 집중하여 개발 가능하다.
정리
ResponseBodyAdvice는 컨트롤러의 응답 데이터를 가로채어 공통 로직을 적용할 수 있도록 도와주는 인터페이스
MessageConverter가 실행되기 직전에 응답 값을 변환할 수 있음
beforeBodyWrite()를 활용하면 API 응답을 공통 객체(ApiResponse<T>)로 감싸거나, 추가 데이터를 포함할 수 있음
특정 패키지에만 적용하거나, 특정 응답 타입만 선택적으로 변환할 수도 있음
컨트롤러 코드가 더욱 간결해지고, 일관된 응답 포맷을 유지할 수 있음
원시 타입(int, double 등) 맟 String 처리 주의 사항
Spring Boot에서 ResponseBodyAdvice를 활용하면 컨트롤러 메서드의 반환값을 가로채어 공통 응답 형식으로 감쌀 수 있다. 하지만, ResponseBodyAdvice는 객체(Object) 기반으로 동작하기 때문에 원시 타입(int, double 등)을 반환할 경우 Type Casting 오류가 발생할 수 있다.
또한, 컨트롤러가 String을 반환할 경우 Spring이 StringHttpMessageConverter를 사용하여 처리하는데,
이때 ResponseBodyAdvice가 개입하여 String을 ApiResponse<T>로 감싸려 하면 변환 과정에서 오류가 발생하게 됩니다.
해결 방법
이 문제를 방지하기 위해 Spring의 HttpMessageConverter를 커스텀하여 원시 타입 및 String을 ApiResponse<T> 형태로 변환하는 방식이 필요하다.
커스텀 HttpMessageConverter 구현
아래와 같이 CommonHttpMessageConverter를 만들어 모든 응답을 ApiResponse<T> 형식으로 변환할 수 있도록 설정한다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 가장 먼저 실행되도록 설정
public class CommonHttpMessageConverter extends AbstractHttpMessageConverter<ApiResponse<Object>> {
private final ObjectMapper objectMapper;
public CommonHttpMessageConverter(ObjectMapper objectMapper) {
super(MediaType.APPLICATION_JSON); // JSON 응답만 처리
this.objectMapper = objectMapper;
}
@Override
protected boolean supports(Class<?> clazz) {
// ApiResponse<T> 타입, 원시 타입(int, double, boolean), 그리고 String을 처리
return clazz.equals(ApiResponse.class) || clazz.isPrimitive() || clazz.equals(String.class);
}
@Override
protected ApiResponse<Object> readInternal(Class<? extends ApiResponse<Object>> clazz, HttpInputMessage inputMessage) throws IOException {
throw new UnsupportedOperationException("읽기 전용 컨버터가 아님");
}
@Override
protected void writeInternal(ApiResponse<Object> apiResponse, HttpOutputMessage outputMessage) throws IOException {
// JSON 형태로 변환하여 응답
final String responseMessage = this.objectMapper.writeValueAsString(apiResponse);
StreamUtils.copy(responseMessage.getBytes(StandardCharsets.UTF_8), outputMessage.getBody());
}
}
커스텀 컨버터 동작 방식
1. supports(Class<?> clazz)
- supports() 메서드는 이 컨버터가 적용될 클래스 타입을 결정
- 여기서는 ApiResponse<T> 타입뿐만 아니라, 원시 타입(int, boolean)과 String도 감싸도록 설정
2. writeInternal()
- writeInternal() 메서드는 API 응답을 ApiResponse<T> 형식으로 감싼 후, JSON 변환을 수행
- ObjectMapper를 사용해 JSON 문자열로 변환한 뒤, HTTP 응답 바디에 작성
3. @Order(Ordered.HIGHEST_PRECEDENCE)
- 이 컨버터가 Spring이 제공하는 기본 HttpMessageConverter보다 먼저 실행되도록 설정
- 이를 통해 모든 응답을 일관된 형식으로 변환할 수 있게 됨
정리
원시 타입(int, double, boolean) 및 String 응답 처리 시 주의할 점
ResponseBodyAdvice는 객체 기반으로 동작하기 때문에 원시 타입을 변환하려 하면 Type Casting 오류가 발생할 수 있음
String 응답의 경우 StringHttpMessageConverter가 선택되지만, ResponseBodyAdvice가 개입하면서 응답이 JSON이 아닌 단순 문자열로 변환될 가능성이 있음
해결책: HttpMessageConverter를 커스텀하여 처리
CommonHttpMessageConverter를 만들어 모든 응답을 ApiResponse<T>로 변환
supports(Class<?> clazz)에서 원시 타입(int, double, boolean)과 String도 포함하여 변환 대상 확장
writeInternal()에서 JSON 직렬화 후 응답 바디에 기록
@Order(Ordered.HIGHEST_PRECEDENCE)를 적용하여 기본 컨버터보다 우선 실행되도록 설정
이제 모든 응답이 일관된 ApiResponse<T> 형식으로 변환된다.
'Back-End > Spring Boot' 카테고리의 다른 글
| [Spring Boot] ApplicationEventPublisher를 활용한 도메인 간 역할 분리 (0) | 2026.01.30 |
|---|---|
| [Spring Boot] refreshVersions로 gradle 설정하기 (0) | 2025.03.26 |
| Spring Boot - @RestController의 동작 과정과 MessageConverter (0) | 2025.02.14 |
| Spring Boot - 요청 프로세스와 검증 (0) | 2025.02.10 |
| Spring Boot - RestTemplate를 사용한 Server To Server 연결(3) (0) | 2024.08.18 |