팀이나 프로젝트마다 각각의 공통된 Response(이하 응답이라 칭하겠다) 형식을 정하곤 한다. 그렇지 않으면 클라이언트 입장에서는 API 마다 응답 형식이 다 달라 혼란을 야기할 수 있다. 그래서 이번엔 나의 첫 번째 프로젝트인 쪼잉에 공통 응답을 정해서 처리해 보도록 하겠다.
현재 코드의 상황
현재 나의 코드는 아래와 같다.
HttpStatus를 넘기기 위해 ResponseEntity를 감싸서 응답을 반환하고 있고, 공통된 응답 형식도 없이 그냥 DTO만 반환하고 있는 상황이다. HttpsStatus만 넘기려면 ResponseEntity보다 아래와 같이 @ResponseStatus를 사용하는 것이 훨씬 간편하고 코드도 보기 편하게 바뀐다.
코드만 간략해질 뿐 아까와 결과는 같다. 그래서 이제 공통 Response를 만들어 적용해 보겠다.
공통 Response 적용
먼저 내가 응답 부분에 포함되어야 할 것들을 정하면, http 상태를 처리할 state 그리고 원래 반환 값을 받을 body, 현재 서버의 시간을 저장할 timeStamp 마지막으로 에러가 발생했을 때 에러 메시지를 담을 message가 있다. 이를 코드로 구현하면 아래와 같다.
public record CommonResponse<T> (
HttpStatus state,
T body,
LocalDateTime timeStamp,
String message
) {
}
이제 이 Response를 Controller에서 사용해주면 된다.
@GetMapping
@Operation(summary = "게시글 전체 조회")
public CommonResponse<PostResponseList> getAll() {
return new CommonResponse<>(HttpStatus.OK, queryPostService.execute(), LocalDateTime.now(), "");
}
하지만 이렇게 제네릭 타입이라 아까 ResponseEntity를 쓸 때와 똑같은 방식으로 작성을 해야 한다. 그 말은 가독성이 떨어지고 다른 API에도 똑같이 작성해줘야 하기 때문에 코드에 중복이 발생한다. 또 새로운 팀원이 오면 우리는 응답을 반환할 때 CommonResponse로 감싸야 한다는 것을 다 말해주어야 하고, 실수로 빼고 응답을 반환하는 경우도 생길 수 있다. 이를 해결하는 방법이 ResponseBodyAdvice를 사용하면 된다. ResponseBodyAdvice는 컨트롤러에서 @ResponseBody나 ResponseEntity를 사용하는 응답 데이터를 AOP를 통해 Hanlder가 중간에서 가로채 우리가 원하는 형식으로 바꿀 수 있게 도와준다. 그러면 이제 ResponsBodyAdvice를 사용해서 코드를 구현해 보겠다.
@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
) {
if (body instanceof ErrorResponse errorResponse) {
response.setStatusCode(errorResponse.status());
return CommonResponse.error(errorResponse.status(), errorResponse.message());
}
return CommonResponse.success(HttpStatus.OK, body);
}
}
이렇게 ResponseBodyAdvice의 구현체를 만들 때 두 가지의 메소드를 재정의해야 하는 데 supports, beforeBodyWriter를 구현해야 한다. supports는 어떤 응답에서 ResponseBodyAdvice가 작동할지 결정하는 메소드고 나는 모든 응답을 다 공통 응답으로 감싸기 위해서 true를 사용하였다. beforeBodyWrite는 응답에 대해 처리하는 로직을 써야 하는 메소드이다. 나는 에러와 성공으로 Response를 나누었다. 참고로 ResponseBodyAdvice의 구현체를 바꾸면서 CommonResponse에 정적 팩토리 메소드를 추가했다.
public record CommonResponse<T> (
HttpStatus state,
T body,
LocalDateTime timeStamp,
String message
) {
public static <T> CommonResponse<T> error(HttpStatus state, String message) {
return new CommonResponse<>(state, null, LocalDateTime.now(), message);
}
public static <T> CommonResponse<T> success(HttpStatus state, T body) {
return new CommonResponse<>(state, body, LocalDateTime.now(), "");
}
}
이렇게 하면 이제 Controller에서 DTO를 반환할 때 CommonResponse에 감싸져서 반환이 된다. 그리고 아래 그림처럼 CommonResponse로 직접 감싸주지 않아도 아까와 똑같이 작동한다.
{
"state": "OK",
"body": {
"postResponses": [
{
"id": 1,
"title": "밥 먹으러 갈 사람",
"content": "밥 먹으러 갈 사람 구합니다.",
"viewCount": 0,
"commentCount": 0,
"postImg": null,
"createTime": "2024-11-15T21:58:38.669319",
}
]
},
"timeStamp": "2024-11-15T22:05:32.6608585",
"message": ""
}
결과도 우리가 원하는 대로 잘 나오는 것을 알 수 있다.
ResponseBodyAdvice의 문제점
ResponseBodyAdvice를 설계하고 난 뒤 응답이 CommonResponse에 잘 감싸져서 반환되는 것을 알 수 있다. 하지만 문제는 다른 API들 중 원시형 타입(Stirng, int, long 등)을 반환할 때 발생한다. 원인은 아래와 같다.
java.lang.ClassCastException: class com.woongeya.zoing.global.wraper.response.CommonResponse
cannot be cast to class java.lang.String (com.woongeya.zoing.global.wraper.response.CommonResponse
is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')
- String을 반환하게 되면 HttpMessageConverter#canRead가 실행되면서 리턴 타입에 맞는 Converter를 찾아서 실행해 준다. ( String은 StringHttpMessageConverter를 선택해 준다)
- StringHttpMessageConverter 실행 도중 RseponseBodyAdvice가 리턴 값을 가로채서 CommonResponse로 감싸서 반환한다.
- 다시 StringHttpMessageConverter로 돌아와 String을 write 하는 과정에서 타입이 String이 아니라 CommonResponse<String> 이기 때문에 에러가 발생한다.
해결법
해결 방법이 두 가지가 있다. 첫 번째는 원시형 타입이나 String이 반환될 때는 ResponseBodyAdvice에 supports에서 객체 형태의 반환 타입만 작동하게 설정하여 해결할 수 있다. 두 번째는 Converter를 상속받아 구현하고 우선순위를 가장 빠르게 설정하면 된다. 하지만 첫 번째 방법은 원시형 타입이나 String이 반환되면 우리가 설정한 CommonResponse를 감싸서 반환할 수가 없기 때문에 두 번째 방법을 선택했다. 두 번째 방법을 구현하는 방법은 두 가지가 있는데 MappingJackson2HttpMessageConverter를 상속받아 구현하는 것과 AbstractHttpMessageConverter를 상속받아 구현하는 방법이 있다. 나는 둘 다 구현해 보고 결정하기로 했다. 먼저 MappingJackson2HttpMessageConverter부터 구현해 보았다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CommonHttpMessageConverter extends MappingJackson2HttpMessageConverter {
public CommonHttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper);
objectMapper.registerModule(new JavaTimeModule());
setObjectMapper(objectMapper);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return canWrite(mediaType);
}
}
여기서 @Order(Ordered.HIGHEST_PRECEDENCE)가 중요한데 이는 해당 컨버터의 우선순위를 가장 최상단으로 위치하게 도와주는 어노테이션이다. 이를 통해 해당 컨버터가 먼저 실행되어 모든 타입에 CommonResponse를 감쌀 수 있게 된다. 아래는 AbstractHttpMessageConverter를 상속받아 구현한 코드이다.
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CommonHttpMessageConverter extends AbstractHttpMessageConverter<CommonResponse<Object>> {
private final ObjectMapper objectMapper;
@Override
public List<MediaType> getSupportedMediaTypes() {
return Collections.singletonList(MediaType.APPLICATION_JSON);
}
@Override
protected boolean supports(Class<?> clazz) {
return clazz.equals(String.class);
}
@Override
protected CommonResponse<Object> readInternal(Class<? extends CommonResponse<Object>> clazz,
HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
throw new NotSupportedException();
}
@Override
protected void writeInternal(CommonResponse<Object> resultMessage, HttpOutputMessage outputMessage) throws
IOException,
HttpMessageNotWritableException {
String responseMessage = this.objectMapper.writeValueAsString(resultMessage);
StreamUtils.copy(responseMessage.getBytes(StandardCharsets.UTF_8), outputMessage.getBody());
}
}
이렇게 Converter를 커스텀하면 아래 사진처럼 두 개의 코드에서 모두 원시형 타입과 String을 처리할 수 있게 된다.
첫 번째 코드보다 다소 양이 많아 보이는데 그 이유는 MappingJackson2HttpMessageConverter는 이미 AbstractHttpMessageConverter를 상속받아 확장한 것으로 이미 우리가 두 번째에서 작성한 코드가 들어가 있다. 그럼에도 왜 두 번째 코드를 사용해 본 이유는 예외처리를 우리가 원하는 대로 할 수 있고, 또 원하는 타입만 선택해서 CommonResponse로 감쌀 수 있다는 장점이 있기 때문이다. 반면에 첫 번째 코드는 모든 타입들을 다 CommonResponse로 감싸고 예외처리도 우리가 구현할 수 없다. 추후에 확장성을 고려해 봐도 나는 두 번째 코드가 더 좋다고 생각한다. 물론 편리함 면에서는 첫 번째 코드가 훨씬 좋다고 생각한다. 왜냐? 이미 다 구현이 되어 있고 조금만 바꿔주면 되기 때문이다. 하지만 나는 자유롭게 커스텀할 수 있고 확장성에 유리한 두 번째 코드를 사용하기로 했다.
이번 공통 응답을 처리하면서 많은 것을 배우고 느꼈다. 옛날에 다른 코드에서 공통 응답을 처리하는 것을 보았을 때는 대부분 Controller에서 공통 응답을 감싸서 사용하는 코드였기 때문에 나는 굳이 저렇게까지 해야 할까?라는 생각이 있었고, 하지만 이번에 좀 더 공부하게 되면서 왜 써야 하는지와 직접 감싸지 않고 구현할 수 있는 방법도 있다는 것을 알게 되었다. 이 과정에서도 다양한 방법이 존재했는데 내가 선택한 방법이 꼭 옳은 방법은 아니다. 그러니 이 글을 읽고 의견이 있으면 댓글에 꼭 써주길 바란다.