티켓팅 관련 프로젝트를 하면서 하나
의 공연 정보를 조회할 때 아래와 같이 응답 데이터를 만들었다.
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,
...(생략)
}
...
]
따라서, 봉투 패턴(Envelope Pattern)을 도입하여 응답을 한번 감싸서 형식을 통일하고자 하였다.
봉투 패턴(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를 감싸야 한다는 것이다.
봉투 패턴을 적용하지 않았을 때
의 컨트롤러는 아래와 같았다.
@GetMapping("/api/performances/{performanceId}")
public PerformanceResponse findPerformanceById(@PathVariable Long performanceId) {
return performanceService.findPerformanceById(performanceId);
}
@GetMapping("/api/performances")
public List<PerformanceResponse> findAllPerformances() {
return performanceService.findAllPerformances();
}
봉투 패턴을 적용한 후
에는 아래와 같이 중복된 부분이 발생하게 되어 코드가 지저분해진다.
@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();
}
SpringBoot의 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();
}
}
@ControllerAdvice
를 붙여야한다.supports
beforeBodyWrite
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();
}
현재까지 프로젝트에 ResponseBodyAdvice를 적용하여 응답을 감싸고, 코드의 중복을 줄였다.
이후 PR을 올렸는데, 아래와 같은 리뷰가 달렸다.
리뷰 내용을 요약하면 아래와 같다.
정말 그런지 확인하기 위해 컨트롤러에 문자열을 반환하는 api를 임시로 만들어 테스트를 해보았다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/performances/{performanceId}")
public class ScheduleController {
...
@GetMapping("/schedules/test")
public String testResponse() {
return "test"; // String 반환
}
}
Postman 이용해서 요청을 보냈더니 서버 내부에 에러가 발생했다는 응답이 왔다.
스택 트레이스를 살펴보니 ApiResponse 객체를 문자열로 변환할 수 없다는 ClassCastException
예외가 발생함을 알 수 있다.
스스로 코드를 따라가 보면서 되짚어 본 결과, 아래와 같은 과정으로 에러가 나는 것으로 보인다.
해당 내용에 틀린 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.
@ResponseBody
로 반환하기 때문에 ReturnValueHandler를 거칠 때 MessageConverter를 호출한다.addDefaultHeaders()
를 호출한다.addDefaultHeaders()
의 인자에는 String 타입의 데이터가 들어가는데, 현재 ResponseBodyAdvice 때문에 데이터가 ApiResponse 타입이다.해당 에러는 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);
}
...
}
결과를 확인해보자.
사실 위의 해결책도 완벽하지는 않다. 컨트롤러 메서드의 반환 타입에 따라 응답 결과가 다르기 때문이다.
DTO가 반환되는 경우
→ ApiResponse로 감싸서 반환한다.DTO 이외가 반환되는 경우
→ 감싸지 않고 그대로 반환한다.따라서 컨트롤러에서 반환하는 타입이 String이든, DTO든, 그 외의 것들이든, 모두 ApiResponse로 감싸서 반환하고 싶은 경우에는 해당 해결책이 효과가 없다.
이럴 때는 MappingJackson2HttpMessageConverter를 상속받아 Custom MessageConverter를 만들어서
해당 커스텀 컨버터를 Spring의 메시지 컨버터 우선순위 중 가장 높은 우선순위로 밀어넣어보니 원하는 대로 동작했다.
커스텀 메시지 컨버터 작성
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);
}
}
해당 컨버터를 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로 잘 감싸지긴 한다.
하지만, 프레임워크에서 기본적으로 제공하는 기능을 변경하는 것이기 때문에, 추후 어떤 예외가 발생할지 알 수가 없어서 좋은 방법은 아닌 것 같다.
이에 대한 다른 해결법은 추후 더 찾아볼 예정이다.
현재 ResponseBodyAdvice의 경우에는 모든 api에 대해 공통 응답처리를 하고 있다.
따라서 Spring Actuator나 Swagger와 같이,
내가 추가한 api가 아닌 라이브러리가 제공하는 api를 호출하는 경우에도 ResponseBodyAdvice에 의해 응답이 ApiResponse로 감싸질 수 있다.
@RestControllerAdvice(
basePackages = "com.programmers.ticketparis.controller",
basePackageClasses = GlobalExceptionHandler.class
)
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
...
}
긴 글 읽어주셔서 감사합니다.
좋은 글 감사합니다