[SpringBoot] ResponseBodyAdvice를 이용한 공통 응답 처리와, 관련 트러블 슈팅

kyle·2023년 10월 14일
5

1. 기존 응답 형식의 한계

티켓팅 관련 프로젝트를 하면서 하나의 공연 정보를 조회할 때 아래와 같이 응답 데이터를 만들었다.

GET /api/performances/1

{
	"performanceId": 1,
	"title": "제4회 더 싱어즈 정기연주회",
	"duration": "2시간",
	"ageRating": 7,
	...(생략)
}

만약 하나의 공연 정보가 아니라,여러 개의 공연 정보를 조회한다면 어떻게 응답 데이터가 만들어질까?

아래와 같이 리스트 형태로 감싸져서 응답이 나가게 된다.

GET /api/performances

[
	{
		"performanceId": 1,
		"title": "제4회 더 싱어즈 정기연주회",
		"duration": "2시간",
		"ageRating": 7,
		...(생략)
	},
	{
		"performanceId": 2,
		"title": "테너 박성원과 함께하는 일콰트로의 네번째 행복한 여정",
		"duration": "1시간 40분",
		"ageRating": 7,
		...(생략)
	}
	...
]

프로젝트를 진행하다보니 이런 형태에는 몇가지 아쉬운 점이 존재했다.
  • 클라이언트 단에서 응답을 받을 때, response 객체의 타입이 일관되지 못한다.
  • 현재 서버 시간, 요청 URL 경로, 응답 메시지 등의 부가적인 정보를 응답하기가 어렵다.

따라서, 봉투 패턴(Envelope Pattern)을 도입하여 응답을 한번 감싸서 형식을 통일하고자 하였다.


2. 봉투 패턴의 도입

봉투 패턴(Envelope Pattern)이란, 응답 DTO를 한번 감싸서 일관된 형식으로 응답하는 패턴을 말한다.

보통 ApiResponse.java 라는 별도의 Wrapper DTO를 만들고, 필드에 제네릭으로 응답 DTO 타입을 받을 수 있도록 선언한다.

public class ApiResponse<T> {

    private final LocalDateTime timestamp = LocalDateTime.now();
    private String path;
    private T data;
    private String message;

	@Builder
    private ApiResponse(String path, T data, String message) {
        this.path = path;
        this.data = data;
        this.message = message;
    }
}
  • 여기서 data 필드에 해당하는 부분이 응답 DTO 타입에 해당한다.

이에 따라 클라이언트에게 전달되는 응답 데이터 형식은 아래와 같이 변한다.
GET /api/performances/1

{
  "timestamp": "2023-09-14T10:36:12.42831",
  "path": "/api/performances/1",
  "data": {
		"performanceId": 1,
		"title": "제4회 더 싱어즈 정기연주회",
		"duration": "2시간",
		"ageRating": 7,
		...(생략)
	},
  "message": null
}
  • 기존에 응답 DTO 그대로 반환되던 부분이, 마치 봉투에 들어가듯 data라는 필드에 속한다.
  • 응답 데이터에 관한 부분은 모두 data 부분에 들어가므로, 공통적인 정보(서버 시간, 요청 URL 경로 등)를 자유롭게 추가 및 제거할 수 있게 되었다.

하지만 이렇게 했을 때의 단점이 존재하는데, 바로 각 컨트롤러 단에서 일일히 응답 DTO를 감싸야 한다는 것이다.

봉투 패턴을 적용하지 않았을 때의 컨트롤러는 아래와 같았다.

@GetMapping("/api/performances/{performanceId}")
public PerformanceResponse findPerformanceById(@PathVariable Long performanceId) {
    return performanceService.findPerformanceById(performanceId);
}

@GetMapping("/api/performances")
public List<PerformanceResponse> findAllPerformances() {
    return performanceService.findAllPerformances();
}
  • 응답 DTO인 PerformanceResponse 객체를 반환하므로 코드가 간결하고 단순하다.

봉투 패턴을 적용한 후에는 아래와 같이 중복된 부분이 발생하게 되어 코드가 지저분해진다.

@GetMapping("/api/performances/{performanceId}")
public ApiResponse<PerformanceResponse> findPerformanceById(
		@PathVariable Long performanceId,
		HttpServletRequest httpServletRequest
) {
    PerformanceResponse data = performanceService.findPerformanceById(performanceId);

		return ApiResponse.builder()
				.path(httpServletRequest.getRequestURI())
				.data(data)
				.build();
}

@GetMapping("/api/performances")
public ApiResponse<List<PerformanceResponse>> findAllPerformances(HttpServletRequest httpServletRequest) {
		List<PerformanceResponse> data = performanceService.findAllPerformances();

		return ApiResponse.builder()
				.path(httpServletRequest.getRequestURI())
				.data(data)
				.build();
}
  • 공통 정보인 “요청 URL 경로(path)”를 넣기 위해, HttpServletRequest를 매 컨트롤러 메서드마다 매개변수로 받는다.
  • 메서드 반환타입을 ApiResponse로 한번 감싸줘야 하므로 가독성이 떨어진다.
  • ApiResponse 객체를 매번 생성해야하므로 중복 코드가 늘어난다.

봉투 패턴의 적용을 유지하면서, 코드는 예전처럼 간결하게 가져갈 순 없을까?

SpringBoot의 ResponseBodyAdvice를 사용하면, 각 컨트롤러에서 중복으로 했던 작업을 한 곳에서 처리할 수 있다.


3. ResponseBodyAdvice

ResponseBodyAdvice는 각 컨트롤러의 응답 데이터에 공통적으로 가공하고 싶은 요소가 있을 때 사용한다.

공식문서에서는 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로 반환할 때의 응답을 커스터마이징 할 수 있다.
  • 이는 메시지 컨버터가 해당 응답 객체를 변환하기 전에 일어난다.

ResponseBodyAdvice 기능을 사용하기 위해 ResponseWrapper라는 클래스를 만들었다.

@RestControllerAdvice
public class ResponseWrapper 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
    ) {
        String path = request.getURI().getPath();

        if (body instanceof ErrorData errorData) {
            ExceptionRule exceptionRule = errorData.getExceptionRule();
            response.setStatusCode(exceptionRule.getStatus());

            return ApiResponse.builder()
                .path(path)
                .data(errorData.getRejectedValues())
                .message(exceptionRule.getMessage())
                .build();
        }

        return ApiResponse.builder()
            .path(path)
            .data(body)
            .build();
    }
}
  • ResponseBodyAdvice는 각 컨트롤러의 응답을 일괄적으로 처리하기 때문에 @ControllerAdvice를 붙여야한다.
  • ResponseBodyAdvice 인터페이스를 구현할 때, 두 가지 메서드를 재정의 해야한다.
    1. supports
      • 특정 응답에 대해 공통 처리를 할지 말지 결정하는 메서드이다.
      • 반환 타입과 메시지 컨버터 타입을 매개변수로 받아, 사용자가 원하는대로 사용할 수 있다.
      • 위에서는 반드시 ResponseBodyAdvice를 거치도록 하기 위해 true를 반환하였다.
    2. beforeBodyWrite
      • 컨트롤러의 반환 데이터에 공통 처리하는 로직을 담당하는 메서드이다.
      • 위에서는 정상 응답과 예외 응답으로 나누어 ApiResponse 객체를 따로 만들어주고 있다.

ResponseBodyAdvice에서 공통 응답 처리를 함으로써, 컨트롤러 단의 코드는 예전처럼 응답 DTO만을 반환하도록 작성하면 된다. 컨트롤러 메서드의 반환값은 ResponseBodyAdvice로 전달되어, 공통 정보가 삽입되고 ApiResponse 객체로 감싸지게 될 것이다. 결과적으로 코드의 중복이 많이 줄었다!

@GetMapping("/api/performances/{performanceId}")
public PerformanceResponse findPerformanceById(@PathVariable Long performanceId) {
    return performanceService.findPerformanceById(performanceId);
}

@GetMapping("/api/performances")
public List<PerformanceResponse> findAllPerformances() {
    return performanceService.findAllPerformances();
}

4. 컨트롤러 반환 타입에 따른 ClassCastException 트러블 슈팅

4.1. 개요 및 원인

현재까지 프로젝트에 ResponseBodyAdvice를 적용하여 응답을 감싸고, 코드의 중복을 줄였다.

이후 PR을 올렸는데, 아래와 같은 리뷰가 달렸다.

리뷰 내용을 요약하면 아래와 같다.

  • 컨트롤러 메서드에서 객체가 아니라 문자열을 반환하면, StringHttpMessageConverter가 선택된다.
  • ResponseBodyAdvice는 메시지 컨버터가 변환하기 전에 응답을 가로채어 공통 처리를 한다.
  • 공통 처리된 ApiResponse 객체를 메시지 컨버터가 변환하려고 할 때, 문자열이 아니라서 에러가 발생한다.

정말 그런지 확인하기 위해 컨트롤러에 문자열을 반환하는 api를 임시로 만들어 테스트를 해보았다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/performances/{performanceId}")
public class ScheduleController {

		...

    @GetMapping("/schedules/test")
    public String testResponse() {
        return "test"; // String 반환
    }
}

Postman 이용해서 요청을 보냈더니 서버 내부에 에러가 발생했다는 응답이 왔다.

스택 트레이스를 살펴보니 ApiResponse 객체를 문자열로 변환할 수 없다는 ClassCastException 예외가 발생함을 알 수 있다.

스스로 코드를 따라가 보면서 되짚어 본 결과, 아래와 같은 과정으로 에러가 나는 것으로 보인다.

해당 내용에 틀린 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.

  1. 먼저 Controller에서 String 타입으로 반환한다.
  2. @ResponseBody로 반환하기 때문에 ReturnValueHandler를 거칠 때 MessageConverter를 호출한다.
  3. 여기서 StringHttpMessageConverter가 선택된다.
  4. ResponseBodyAdvice가 Controller의 반환값(문자열)을 가로채서 ApiResponse로 감싼다.
  5. HandlerAdapter로 ModelAndView를 반환하는 과정에서 타입 캐스팅 에러가 발생한다.
    • HTTP Body에 문자열 데이터를 write() 하는 과정에서 addDefaultHeaders() 를 호출한다.
    • StringHttpMessageConverter의 addDefaultHeaders() 의 인자에는 String 타입의 데이터가 들어가는데, 현재 ResponseBodyAdvice 때문에 데이터가 ApiResponse 타입이다.
    • 따라서 ApiResponse 타입이 String으로 변환될 수 없으므로 ClassCastException이 발생한다.

4.2. 해결

해당 에러는 ResponseBodyAdvice의 supports() 메서드에서 무조건 true를 반환하기 때문에 발생한다.

@RestControllerAdvice
public class ResponseWrapper implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

		...
}

공식문서에서 말하는 supports() 메서드의 정의는 다음과 같다.

Whether this component supports the given controller method return type and the selected HttpMessageConverter type.”

  • 특정 응답에 대해 공통 처리를 할지 말지 결정하는 메서드이다.
  • 따라서 supports() 메서드를 잘 작성하면 특정 컨트롤러의 응답 혹은 특정 메시지 컨버터가 사용될 때에만 ResponseBodyAdvice를 거쳐 ApiResponse로 감싸도록 할 수 있다.

방법은 의외로 간단하다.

@RestControllerAdvice
public class ResponseWrapper implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType);
    }

		...
}
  • 컨트롤러의 반환타입이 객체일 때는 직렬화를 위해 MappingJackson2HttpMessageConverter를 사용하게 되므로, 해당 컨버터가 사용될 때만 ResponseBodyAdvice를 거치도록 변경한다.
  • 이제 문자열일 때는 문자열 그대로 Body에 반환될 것이고, 응답 DTO와 같은 객체일 때는 ApiResponse로 감싸져서 반환된다.

결과를 확인해보자.

  1. String을 반환하는 경우에는 문자열 그대로 Body에 작성된다.
  2. DTO를 반환하는 경우에는 원래 의도했던 대로 작성된다.

4.3. 추가사항

사실 위의 해결책도 완벽하지는 않다. 컨트롤러 메서드의 반환 타입에 따라 응답 결과가 다르기 때문이다.

  • DTO가 반환되는 경우 → ApiResponse로 감싸서 반환한다.
  • DTO 이외가 반환되는 경우 → 감싸지 않고 그대로 반환한다.

따라서 컨트롤러에서 반환하는 타입이 String이든, DTO든, 그 외의 것들이든, 모두 ApiResponse로 감싸서 반환하고 싶은 경우에는 해당 해결책이 효과가 없다.
이럴 때는 MappingJackson2HttpMessageConverter를 상속받아 Custom MessageConverter를 만들어서
해당 커스텀 컨버터를 Spring의 메시지 컨버터 우선순위 중 가장 높은 우선순위로 밀어넣어보니 원하는 대로 동작했다.

  1. 커스텀 메시지 컨버터 작성

    public class MyHttpMessageConverter extends MappingJackson2HttpMessageConverter {
    
        public MyHttpMessageConverter(ObjectMapper objectMapper) {
            super(objectMapper);
            objectMapper.registerModule(new JavaTimeModule());
            setObjectMapper(objectMapper);
        }
    
        @Override
        public boolean canWrite(Class<?> clazz, MediaType mediaType) {
            return canWrite(mediaType);
        }
    }
  2. 해당 컨버터를 Spring의 컨버터 우선순위 중 가장 높은 우선순위로 밀어 넣기

    @Configuration
    @RequiredArgsConstructor
    public class WebConfig implements WebMvcConfigurer {
    
        private final ObjectMapper objectMapper;
    
        @Override
        public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
            converters.add(0, new MyHttpMessageConverter(objectMapper));
        }
    }

최종적으로 어떤 타입을 컨트롤러 메서드에서 반환해도 모두 ApiResponse로 잘 감싸지긴 한다.
하지만, 프레임워크에서 기본적으로 제공하는 기능을 변경하는 것이기 때문에, 추후 어떤 예외가 발생할지 알 수가 없어서 좋은 방법은 아닌 것 같다.

이에 대한 다른 해결법은 추후 더 찾아볼 예정이다.


5. ResponseBodyAdvice의 대상 선택하기

현재 ResponseBodyAdvice의 경우에는 모든 api에 대해 공통 응답처리를 하고 있다.

따라서 Spring Actuator나 Swagger와 같이,
내가 추가한 api가 아닌 라이브러리가 제공하는 api를 호출하는 경우에도 ResponseBodyAdvice에 의해 응답이 ApiResponse로 감싸질 수 있다.


내가 직접 컨트롤러에 작성한 api에 대해서만 ApiResponse로 감싸야 하므로, 아래와 같은 처리가 필요하다.
@RestControllerAdvice(
    basePackages = "com.programmers.ticketparis.controller",
    basePackageClasses = GlobalExceptionHandler.class
)
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
    ...
}
  • ResponseBodyAdvice에 basePackages를 설정해 주어, 내가 만든 컨트롤러가 있는 패키지에만 Advice를 적용하도록 하면 된다.
  • 추가적으로 예외처리에 대해, GlobalExceptionHandler는 basePackages에 의해 인식되지 않으므로 basePackageClasses를 통해 추가적으로 처리를 하였다.

긴 글 읽어주셔서 감사합니다.

profile
공유를 기반으로 선한 영향력을 주는 개발자가 되고 싶습니다.

3개의 댓글

comment-user-thumbnail
2024년 4월 24일

좋은 글 감사합니다

답글 달기
comment-user-thumbnail
2024년 10월 16일

ResponseBodyAdvice를 사용해보려고 찾아보고 있었는데 참고하기에 너무 좋은 내용이네요.
감사합니다 :)

답글 달기
comment-user-thumbnail
2025년 1월 13일

컨버터 우선순위 중 가장 높은 우선순위로 밀어 넣으면 추후 스웨거랑 같이 사용할 때 인식을 못해서 문제가 발생하더군요..

답글 달기

관련 채용 정보