[Spring] 예외 처리에만 국한되지 않는 @ControllerAdvice
@ControllerAdvice란?
Spring MVC에서 컨트롤러 전역 설정을 정의하기 위해 사용하는 어노테이션이다. 해당 어노테이션을 사용하면 특정 컨트롤러에 국한되지 않고, 애플리케이션의 모든 컨트롤러에 적용할 전역 설정을 정의할 수 있다.
@ControllerAdvice 어노테이션을 통해, 컨트롤러는 좀 더 컨트롤러의 역할에 집중할 수 있고, 코드의 중복을 제거하며 관심사의 분리를 이뤄낼 수 있다.
"@ControllerAdvice는 예외 처리에만 국한된 게 아니다."
주로 예외 처리를 위한 글로벌 핸들러를 제공하는데 사용되지만 이 외에도 다양한 용도로 사용할 수 있다.
@ControllerAdvice 사용 용도
1. 글로벌 데이터 바인딩 초기화
@RestControllerAdvice
public class GlobalBindingInitializer {
@InitBinder
public void initBinder(WebDataBinder binder) {
// 예: 특정 형식에 대한 커스텀 에디터 등록
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
dateFormat.setLenient(false);
binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
}
}
@InitBinder 어노테이션을 사용하여 모든 컨트롤러에 대해 데이터 바인딩 설정을 전역적으로 적용할 수 있다. 이를 통해 특정 형식의 데이터를 전처리하거나, 커스텀 에디터를 등록할 수 있다.
2. 글로벌 모델 속성 추가
@RestControllerAdvice
public class GlobalModelAttribute {
@ModelAttribute
public void addAttributes(Model model) {
model.addAttribute("globalAttribute", "This is a global attribute");
}
}
@ModelAttribute 어노테이션을 사용하여 모든 컨트롤러에 공통적으로 사용할 모델 속성을 추가할 수 있다.
3. Request/Response Body 조작
@RestControllerAdvice
public class GlobalRequestBodyAdvice implements RequestBodyAdvice {
@Override
public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
return true; // 모든 요청에 대해 적용
}
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
// 빈 요청 바디 처리
return body;
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
// 요청 바디 읽기 전에 처리
return inputMessage;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
// 요청 바디 읽은 후 처리
return body;
}
}
@RequestBody 또는 @ResponseBody와 함께 사용하여 요청이나 응답의 내용을 전역적으로 조작할 수 있다.
해당 포스트에선 방금 살펴 본 Request/Response 바디 조작에 대해서 다룰 예정이다.
Request/Response Body 조작
공통 응답 객체 만들기
@Getter
public class ApiResponse<T> {
HttpStatus httpStatus;
int statusCode;
String message;
T data;
public ApiResponse(HttpStatus httpStatus, String message, T data) {
this.httpStatus = httpStatus;
this.statusCode = httpStatus.value();
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, String message, T data) {
return new ApiResponse<>(httpStatus, message, data);
}
public static <T> ApiResponse<T> ok(T data) {
return of(HttpStatus.OK, HttpStatus.OK.name(), data);
}
public static <T> ApiResponse<T> badRequest(String message) {
return of(HttpStatus.BAD_REQUEST, message, null);
}
public static <T> ApiResponse<T> notFound(String message) {
return of(HttpStatus.NOT_FOUND, message, null);
}
}
프로젝트 API 응답을 통일성있게 관리하고 싶었다. 그렇기에 위와 같이 공통 응답 객체를 만들었다.
컨트롤러 코드
public ApiResponse<MemberResponse> login(@RequestParam String id, @RequestParam String password, HttpSession httpSession) {
// 코드
return ApiResponse.ok(response);
}
만들어준 응답 객체를 컨트롤러의 반환값으로 사용하였다. 이렇게 모든 API 컨트롤러 메소드 return 값에 ApiResponse<T>를 선언해 주었다.
공통 응답 객체 관리를 분리한 이유
컨트롤러가 조금 더 컨트롤러의 역할에만 집중할 수 있게, 즉 관심사 분리를 위해 @ControllerAdvice/@RestControllerAdvice 를 활용해보았다.
전역적으로 컨트롤러 응답 관리를 분리하기 전 의도는 아래와 같았다.
→ 컨트롤러 메서드 반환 값으로 공통 응답 객체가 명시 돼있는 것이 코드를 보는 입장에서 명확하다고 생각하여 구현했다.
public ApiResponse<MemberResponse> login(@RequestParam String id, @RequestParam String password, HttpSession httpSession) {
// 코드
return ApiResponse.ok(response);
}
public ApiResponse<List<StudyRoomResponse>> getActivatedStudyRooms() {
// 코드
return ApiResponse.ok(studyRooms.stream()
.map(StudyRoomResponse::from)
.toList());
}
코드를 보는 입장이라면 “해당 컨트롤러는 ApiResponse에 MemberResponse를 담아 성공적인 응답이라고 반환하는구나“라고 이해할 수 있다.
하지만, 만약 응답 객체와 관련하여 변경이 일어난다면?
예를 들어, API를 수행하고 성공적으로 수행이 되었을때 ok(T data) 메소드를 호출하기로 현재는 되어 있지만, 만약 해당 메소드명이 success(T data)로 바뀌는 상황이 온다면?
이러한 경우에는 API 갯수만큼 코드를 변경해줘야하는 경우가 생긴다. 또한, 컨트롤러에서 일일이 공통 응답 객체를 작성하고 응답 DTO를 감싸기 때문에 코드의 중복이 발생한다.
스프링에서는 이러한 핵심관심사와 공통적인 부가 관심사를 나누는 개념을 상당히 선호한다.
ResponseBodyAdvice + @RestControllerAdvice
ResponseBodyAdvice에 대한 공식 문서
Allows customizing the response after the execution of an @ResponseBody or a ResponseEntity controller method but before the body is written with an HttpMessageConverter.
(@ResponseBody나 ResponseEntity 컨트롤러 메서드가 실행된 후, 그러나 응답 본문이 HttpMessageConverter로 작성되기 전, 응답을 커스터마이즈할 수 있게 해줍니다.)
ResponseBodyAdvice (Spring Framework 6.1.10 API)
ResponseBodyAdvice를 구현한 ApiResponseController 클래스를 만들었다.
@RestControllerAdvice
public class ApiResponseController implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
ResponseStatus responseStatus = returnType.getMethodAnnotation(ResponseStatus.class);
return responseStatus != null &&
responseStatus.value().is2xxSuccessful() &&
MappingJackson2MessageConverter.class.isAssignableFrom(converterType);
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
return ApiResponse.ok(body);
}
}
ResponseBodyAdvice 인터페이스를 구현하기 위해선 두가지 메서드를 override 해줘야 한다.
- supports
- beforeBodyWrite
supports 메서드
/**
* Whether this component supports the given controller method return type
* and the selected {@code HttpMessageConverter} type.
* @param returnType the return type
* @param converterType the selected converter type
* @return {@code true} if {@link #beforeBodyWrite} should be invoked;
* {@code false} otherwise
*/
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
이 컴포넌트가 주어진 컨트롤러 메서드 반환 타입과 선택된 HttpMessageConverter 타입을 지원하는지 여부를 확인한다.
말이 조금 어려운데, 간단히 말하자면 특정 응답에 대해 여기서 처리를 할지 말지 결정하는 메서드이다.
public class ApiResponseController implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
ResponseStatus responseStatus = returnType.getMethodAnnotation(ResponseStatus.class);
return responseStatus != null &&
responseStatus.value().is2xxSuccessful() &&
MappingJackson2MessageConverter.class.isAssignableFrom(converterType);
}
내 프로젝트에선 전역적으로 예외처리를 해주는 부분은 따로 있으므로, 200대 응답을 반환할시 true를 return 하도록 했다.
MappingJackson2MessageConverter.class.isAssignableFrom(converterType) 코드는 다음 글에서 트러블 슈팅을 다룬 얘기를 설명하면서, 좀 더 자세히 다루도록 하겠다.
beforeBodyWrite 메서드
/**
* Invoked after an {@code HttpMessageConverter} is selected and just before
* its write method is invoked.
* @param body the body to be written
* @param returnType the return type of the controller method
* @param selectedContentType the content type selected through content negotiation
* @param selectedConverterType the converter type selected to write to the response
* @param request the current request
* @param response the current response
* @return the body that was passed in or a modified (possibly new) instance
*/
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
HttpMessageConverter 가 선택된 후, write 메서드가 호출되기 직전에 실행된다. 이를 통해 응답 본문을 커스터마이즈 할 수 있다.
응답 body에 공통적으로 커스터마이징 하고 싶은게 있으면 해당 메서드에 구현을 하면 된다.
적용 결과
public MemberResponse login(@RequestParam String id, @RequestParam String password, HttpSession httpSession,HttpServletResponse response) {
// 코드
return new MemberResponse(member.getSequenceId(), member.getId(), member.getNickname());
}
웹 레이어가 한층 깔끔해진것을 볼 수 있다.