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에 의해 쓰이기 전 응답의 body를 변경할 수 있게 해준다. 컨트롤러 메소드마다 응답을 ApiResponse<>로 감싸는 것이 귀찮았다면 이 글을 잘 찾아왔다.
JSend는 JSON 응답의 형식을 정한 규격이다. 더 궁금하다면 링크에 자세한 설명이 있다. JSend를 사용하면 API 응답을 아래와 같은 형식으로 보낸다:
{
status : "success",
data : {
id: 123,
name: "hello",
desc: "world"
}
}
JSend 성공 응답을 간단하게 구현해왔다. fail, error까지 구현하려면 data=null일 때 json 포함 여부를 처리해주어야 하므로 사용하려면 참고하자.
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class JSendRes<T> {
private String status;
private T data;
public static <T> JSendRes<T> success(T data) {
return new JSendRes<>("success", data);
}
}
간단한 DTO도 만들고 컨트롤러에 여러 API를 추가했다.
@Data
@Builder
public class SimpleDto {
private int id;
private String name;
private String desc;
}
@RestController
public class ApiController {
@GetMapping("/nothing")
public void nothing() {
}
@GetMapping("/integer")
public int integer() {
return 123456;
}
@GetMapping("/string")
public String string() {
return "Hello, world!";
}
@GetMapping("/map")
public Map<String, String> map() {
return Map.of("key1", "value1", "key2", "value2");
}
@GetMapping("/dto")
public SimpleDto dto() {
return SimpleDto.builder()
.id(369)
.name("hello")
.desc("world")
.build();
}
@GetMapping("/entity")
public ResponseEntity<SimpleDto> entity() {
return new ResponseEntity<>(
SimpleDto.builder()
.id(369)
.name("hello")
.desc("world")
.build(),
HttpStatus.OK);
}
@GetMapping("/apistring")
public JSendRes<String> apistring() {
return JSendRes.success("Hello, world in data!");
}
@GetMapping("/apidto")
public JSendRes<SimpleDto> apidto() {
return JSendRes.success(SimpleDto.builder()
.id(2468)
.name("hello")
.desc("world in data")
.build());
}
}
서버를 실행해보면 응답 형식에 따라 사용되는 HttpMessageConverter의 종류가 다르다.
void nothing(), converterType = MappingJackson2HttpMessageConverter
int integer(), converterType = MappingJackson2HttpMessageConverter
String string(), converterType = StringHttpMessageConverter
Map<String, String> map(), converterType = MappingJackson2HttpMessageConverter
SimpleDto dto(), converterType = MappingJackson2HttpMessageConverter
JSendRes<String> apistring(), converterType = MappingJackson2HttpMessageConverter
ResponseEntity<SimpleDto> entity(), converterType = MappingJackson2HttpMessageConverter
JSendRes<SimpleDto> apidto(), converterType = MappingJackson2HttpMessageConverter
나머지는 모두 MappingJackson2HttpMessageConverter를 쓰는데, String 혼자서 StringHttpMessageConverter를 쓴다. 이 녀석이 곤란하게 하니까 기억해둔다...
이제 body를 JSendRes로 바꿔주기 위해 ResponseBodyAdvice를 구현한 클래스를 만들어준다.
@RestControllerAdvice(basePackageClasses = ApiController.class)
public class JSendResAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
Class<?> type = returnType.getParameterType();
if (ResponseEntity.class.isAssignableFrom(type)) {
try {
ParameterizedType parameterizedType = (ParameterizedType) returnType.getGenericParameterType();
type = (Class<?>) parameterizedType.getActualTypeArguments()[0];
} catch (ClassCastException | ArrayIndexOutOfBoundsException ex) {
return false;
}
}
if (JSendRes.class.isAssignableFrom(type)) {
return false;
}
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
JSendRes<?> jsendRes = JSendRes.success(body);
if (MappingJackson2HttpMessageConverter.class.isAssignableFrom(selectedConverterType)) {
return jsendRes;
}
try {
response.getHeaders().set("Content-Type", "application/json");
return objectMapper.writeValueAsString(jsendRes);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
@RestControllerAdvice로 Advice를 등록할 때 공통 응답 형식을 적용할 범위를 basePackages나 basePackageClasses로 명시해주는 것이 좋다.
supports의 returnType은 컨트롤러 매핑의 반환 형식이고, beforeBodyWrite의 body는 실제로 직렬화할 객체가 넘어온다. ResponseEntity일 경우 두 메소드에 전달되는 형식이 다르니 제네릭 인자를 사용하여 검사한다.
supports로 이 Advice가 어떤 응답을 처리할지 선택할 수 있다. 이미 JSendRes<>타입인 응답을 처리하면 JSendRes<JSendRes<...>>가 되니 제외했다. ResponseEntity일 경우 실제로 변경시에는 body 내용이 인자로 넘어온다.
beforeBodyWrite에서는 body의 내용을 직접 변경한다. selectedConverterType이 StringHttpMessageConverter인 경우 반드시 문자열만을 받기에 objectMapper를 사용해서 문자열 형태로 변환하여 반환하고, 응답이 json이므로 Content-Type을 text/html에서 application/json으로 변경해준다.
'/nothing'
'/integer' 123456
'/string' "Hello, world!"
'/map' {"key1":"value1","key2":"value2"}
'/dto' {"id":369,"name":"hello","desc":"world"}
'/entity' {"id":369,"name":"hello","desc":"world"}
'/apistring'{"status":"success","data":"Hello, world in data!"}
'/apidto' {"status":"success","data":{"id":2468,"name":"hello","desc":"world in data"}}
'/nothing' {"status":"success","data":null}
'/integer' {"status":"success","data":123456}
'/string' {"status":"success","data":"Hello, world!"}
'/map' {"status":"success","data":{"key1":"value1","key2":"value2"}}
'/dto' {"status":"success","data":{"id":369,"name":"hello","desc":"world"}}
'/entity' {"status":"success","data":{"id":369,"name":"hello","desc":"world"}}
'/apistring'{"status":"success","data":"Hello, world in data!"}
'/apidto' {"status":"success","data":{"id":2468,"name":"hello","desc":"world in data"}}
정말 유익한 글입니다^^