Spring ResponseBodyAdvice로 API 응답 형식 지정하기

Lechros·2024년 3월 7일

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.

@ResponseBodyResponseEntity 동작 이후, HttpMessageConverter에 의해 쓰이기 전 응답의 body를 변경할 수 있게 해준다. 컨트롤러 메소드마다 응답을 ApiResponse<>로 감싸는 것이 귀찮았다면 이 글을 잘 찾아왔다.

JSend

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로 명시해주는 것이 좋다.
supportsreturnType은 컨트롤러 매핑의 반환 형식이고, beforeBodyWritebody는 실제로 직렬화할 객체가 넘어온다. ResponseEntity일 경우 두 메소드에 전달되는 형식이 다르니 제네릭 인자를 사용하여 검사한다.
supports로 이 Advice가 어떤 응답을 처리할지 선택할 수 있다. 이미 JSendRes<>타입인 응답을 처리하면 JSendRes<JSendRes<...>>가 되니 제외했다. ResponseEntity일 경우 실제로 변경시에는 body 내용이 인자로 넘어온다.
beforeBodyWrite에서는 body의 내용을 직접 변경한다. selectedConverterTypeStringHttpMessageConverter인 경우 반드시 문자열만을 받기에 objectMapper를 사용해서 문자열 형태로 변환하여 반환하고, 응답이 json이므로 Content-Typetext/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"}}

참고

1개의 댓글

comment-user-thumbnail
2024년 5월 13일

정말 유익한 글입니다^^

답글 달기