티스토리 뷰
이전에는 API 요청에 대한 응답으로 단순히 데이터만을 전달해주었다. 그러나 한 프로젝트에서 모든 API 요청에 대해 공통의 응답 형식을 가지도록 개발해달라는 요청을 받았다. 예를 들어 이전에는 아래와 같이 응답 데이터를 전달한 반면
{
"categoryList": [
{
"categoryId": 1,
"type": "FOOD"
},
{
"categoryId": 2,
"type": "TRAFFIC"
},
{
"categoryId": 3,
"type": "RESIDENCE"
}
]
}
이제는 실질적인 데이터는 data
필드로 감싸고, timestamp
, status
, code
, message
등의 필드를 추가한 공통 응답 형식을 가지도록 개발하는 것이다.
{
"timestamp": "2024-01-14 13:10:18",
"status": 200,
"code": "OK",
"message": "요청이 정상 처리되었습니다.",
"data": {
"categoryList": [
{
"categoryId": 1,
"type": "FOOD"
},
{
"categoryId": 2,
"type": "TRAFFIC"
},
{
"categoryId": 3,
"type": "RESIDENCE"
},
{
"categoryId": 4,
"type": "CLOTHING"
},
{
"categoryId": 5,
"type": "LEISURE"
},
{
"categoryId": 6,
"type": "ETC"
}
]
}
}
공통 응답 형식을 처리하는 방법
이렇게 공통 응답 형식을 가지도록 개발하는 방법에는 여러가지가 있다. Controller 계층에서 직접 공통 응답 형식 객체를 생성하여 반환해도 되고, 스프링 AOP를 통해 특정 Controller의 반환값을 가지고 공통 응답 형식 객체를 생성하여 반환해도 된다. 추가로 찾아보니 Filter를 통해 처리하는 방법도 있지만 AOP나 Filter나 구현 방식은 비슷한 것 같아 이번 글에서는 AOP를 통해 이를 해결한 방법을 위주로 알아보려고 한다.
CommonResponse 공통 응답 클래스 정의
먼저 위에서 예시로 제시한 JSON 형태의 공통 응답 형식을 반환하기 위한 CommonResponse
클래스는 다음과 같다. 어떤 타입의 data
값이든 가질 수 있도록 제네릭을 사용하였으며, timestamp
필드는 CommonResponse
객체 생성 시 자동적으로 현재 시각으로 설정된다. 또한 @JsonInclude(JsonInclude.Include.NON_NULL)
어노테이션을 통해 만약 CommonResponse
클래스 내 필드 중 null인 값이 있다면 응답 JSON에서 제외되도록 설정하였다.
@Getter
@RequiredArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CommonResponse<T> {
private final String timestamp = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
private final int status;
private final String code;
private final String message;
private final T data;
}
예를 들어 data
필드가 null이라면 응답 JSON은 data
값이 제외된 다음과 같은 형태가 될 것이다. 이를 통해 응답 데이터가 없는 API 요청에 대해서도 응답 상태, 메시지, 타임 스탬프 등의 메타 데이터를 전달할 수 있다.
{
"timestamp": "2024-01-14 13:10:18",
"status": 200,
"code": "OK",
"message": "요청이 정상 처리되었습니다."
}
Controller에서 직접 생성하여 반환
다음과 같이 모든 Controller 내 메소드에서 직접 CommonResponse
객체를 생성하여 반환할 수 있다. 한눈에 보기에도 중복이 많다. 물론 현재 상태에서 리팩토링을 통해 중복을 조금 제거할 순 있겠지만, 이외에도 여러 문제가 있다. 먼저 만약 구현한 API가 100개가 넘어가는 경우 각 Controller 메소드 마다 CommonRespone
객체를 생성하여 반환하는 코드를 추가해줘야 하며, 반대로 만약 이러한 공통 응답 형식을 더 이상 사용하지 않게 되었을 때에는 100개를 또 다시 직접 수정해줘야 한다. 즉 변경에 따른 코드 변경 범위가 매우 넓어진다.
또한 생각해보면 공통 응답 형식으로의 변환은 모든 API에 대해 수행되는 부가 로직이다. 즉 핵심 로직이 아니다. 따라서 Controller에서 직접 처리해주는 것이 아닌 별도의 클래스, 다시 말해 한 곳에서 공통적으로 처리해주는 것이 좋다. (핵심 로직과 부가 로직을 분리하자!) 이러한 문제로 Controller에서 직접 CommonResponse
를 생성하여 반환하는 것은 권장되지 않으며 다음에 소개할 AOP를 통해 공통 응답 형식을 반환하는 것이 더 적절하다.
@RequiredArgsConstructor
@RequestMapping("/api/categories")
@RestController
public class CategoryController {
private final CategoryService categoryService;
@GetMapping
public CommonResponse<CategoryListResponse> getCategoryList() {
List<CategoryResponse> categoryList = categoryService.getCategoryList();
reutrn new CommonReponse<>(HttpStatus.OK.value(), HttpStatus.OK.name(),
"요청이 정상 처리되었습니다.", categoryList); //공통 응답 형식 객체 생성
}
@GetMapping("/{categoryId}")
public CommonResponse<CategoryListResponse> getCategoryList(@PathVariable Long categoryId) {
CategoryResponse category = categoryService.getCategory(categoryId);
reutrn new CommonReponse<>(HttpStatus.OK.value(), HttpStatus.OK.name(),
"요청이 정상 처리되었습니다.", category); //공통 응답 형식 객체 생성
}
}
ResponseBodyAdvice를 정의하여 AOP 처리
물론 @Aspect
어노테이션을 통해 직접 어드바이저를 생성하고 빈 등록하여 사용해도 된다. 그러나 직접 구현해본 결과 공통 응답 형식을 write 하기 위해 현재 요청에 대한 HttpServletResponse
객체를 어드바이저에서 주입 받기가 어려우며, 주입 받았다 하더라도 실행 순서?로 인해 response
객체에 변환된 공통 응답을 write 했음에도 불구하고 실제 응답 데이터에는 적용되지 않았던 문제가 있었다. (정확히 디버깅 해보진 않았지만 @Aspect
로 직접 구현한 어드바이저에서 write한 응답값에 대해 HttpMessageConveter
가 다시 overwrite하지 않았을까 예상해본다.)
아무튼 좀더 구글링해 본결과 ResponseBodyAdvice
라는 것을 알게 되었다. 해당 인터페이스를 구현한 클래스는 @ResponseBody
어노테이션이 선언되었거나, ResponseEntity
를 반환하는 Controller 메소드 호출 후 HttpMessageConverter
가 response
객체에 응답값을 쓰기 직전에 호출되며, 따라서 ResponseBodyAdvice
인터페이스 메소드를 구현함으로써 응답 바디 내용을 조작할 수 있도록 해준다.
해당 인터페이스에는 supports
, beforeBodyWrite
메소드가 정의되어 있는데, supports
는 해당 Controller 메소드에 대해 beforeBodyWrite
메소드의 호출 여부를 결정한다. 즉 supports
메소드가 true를 반환하면 beforeBodyWrite
메소드가 이어 호출된다. (즉 일종의 포인트컷) 그리고 beforeBodyWrite
메소드의 반환값이 HttpMessageConverter
에 의해 response
객체의 응답 바디에 write 된다. (즉 일종의 어드바이스)
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
정리하자면 공통 응답 형식을 적용하고자 하는 Controller 메소드에 대해서는 supports
메소드가 true를 반환하도록 구현하고, beforeBodyWrite
메소드에서는 CommonReponse
객체를 생성하여 반환하면 우리가 원하는 대로 공통 응답 형식으로의 변환을 AOP를 통해 수행할 수 있는 것이다.
AOP로 공통 응답 형식 처리
요구사항 정의
요구사항이 하나 더 생겼다. 공통의 응답 형식을 반환하는 것 뿐만 아니라 API에 따라 응답 상태나 코드, 메시지 등을 Controller 단에서 설정하면 이를 응답 데이터에도 반영하였으면 좋겠다. 예를 들어 POST 요청을 통해 새로운 리소스가 생성된 경우에는 200(OK) 상태 코드보다는 201(CREATED)가 더 적절하다. 이를 기존의 @ResponseStatus
어노테이션 처럼 Controller 단에서 선언하면 상태 코드나 응답 바디에도 반영되었으면 좋겠다. 즉 CommonResponse
객체의 status
, code
필드 등이 항상 200이나 "OK"로 고정된 것이 아닌 API 별로 지정할 수 있기를 원하는 것이다.
{
"timestamp": "2024-01-14 13:10:18",
"status": 200,
"code": "OK",
"message": "요청이 정상 처리되었습니다."
"data": {}
}
{
"timestamp": "2024-01-14 13:10:18",
"status": 201,
"code": "CREATED",
"message": "새로운 리소스가 생성되었습니다."
"data": {}
}
이를 위해 CommonReponseContent
라는 커스텀 어노테이션을 정의할 것이다. 이 어노테이션은 Controller 클래스나 메소드 레벨에서 선언할 수 있으며, 해당 어노테이션이 선언된 경우 어노테이션의 설정값을 참고해서 Controller 메소드의 반환값이 공통 응답 형식으로 변환될 것이다. 이제 하나씩 구현해보자.
CommonResponseContent 어노테이션 정의
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CommonResponseContent {
HttpStatus status() default HttpStatus.OK;
String message() default "요청이 정상 처리되었습니다.";
}
CommonReponseContent
어노테이션은 클래스와 메소드 레벨에서 선언할 수 있다. 따라서 @Target
값을 TYPE
, METHOD
로 설정해주었으며, status
, message
필드를 가져 API 별로 상태 코드와 메시지를 커스텀하게 설정할 수 있도록 하였다. 각 필드의 기본값은 HttpStatus.OK
와 "요청이 정상 처리되었습니다."
이다.
CommonResponseAdvice 클래스 정의
@RestControllerAdvice
public class CommonResponseAdvice implements ResponseBodyAdvice<Object> {
//HttpMessageConverter가 응답값을 쓰기 직전에 호출
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
CommonResponseContent methodContent = returnType.getExecutable() //현재 호출된 메소드의 메타 데이터 반환
.getDeclaredAnnotation(CommonResponseContent.class);
CommonResponseContent classContent = returnType.getDeclaringClass() //현재 호출된 클래스의 메타 데이터 반환
.getDeclaredAnnotation(CommonResponseContent.class);
return methodContent != null || classContent != null;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
CommonResponseContent methodContent = returnType.getExecutable()
.getDeclaredAnnotation(CommonResponseContent.class);
CommonResponseContent classContent = returnType.getDeclaringClass()
.getDeclaredAnnotation(CommonResponseContent.class);
CommonResponseContent content = methodContent == null ? classContent : methodContent;
response.setStatusCode(content.status());
return new CommonResponse<>(content.status().value(), content.status().name(),
content.message(), body);
}
}
ResponseBodyAdvice
인터페이스를 구현한 CommonResponseAdvice
클래스에 @RestControllerAdvice
어노테이션을 선언해주었다. 해당 어노테이션을 사용하면 스프링 컨테이너가 관리하는 모든 Controller의 메소드에 대해 동작하는 어드바이스를 빈으로 등록해준다. (내부에 @Component
어노테이션 포함) 한 프로젝트에서 ControllerAdvice 클래스는 여러 개 만들수 있지만 많을 경우 성능에 악영향을 끼칠 수 있다고 한다.
supports
메소드에서는 returnType.getExecutable()
, returnType.getDeclaringClass()
메소드를 통해 현재 요청을 처리하고 있는 Controller에서 클래스 레벨 또는 메소드 레벨로 CommonResponseContent
어노테이션이 선언되어 있는지 확인한다. 만약 하나라도 선언되어 있다면 현재 Controller 메소드의 반환값을 CommonReponse
객체로 변환해야 하므로 true를 리턴한다.
beforeBodyWrite
메소드에서는 선언된 CommonResponseContent
어노테이션 정보를 가지고 response.setStatusCode(content.status())
로 응답 상태 코드를 지정하며, 현재 Controller 메소드의 반환값인 body
를 CommonResponse
객체로 변환하여 리턴하고 있다. 참고로 메소드 레벨에 선언한 CommonResponseContent
어노테이션이, 클래스 레벨에 선언한 CommonResponseContent
어노테이션 설정보다 우선한다.
공통 응답 형식이 적용된 Controller
@CommonResponseContent //추가
@RequiredArgsConstructor
@RequestMapping("/api/users")
@RestController
public class UserController {
private final UserService userService;
@GetMapping("/{username}")
public UsernameCheckResponse checkForUsername(@PathVariable String username) {
return userService.checkForUsername(username);
}
@CommonResponseContent(status = HttpStatus.CREATED) //추가
@PostMapping
public void joinUser(@RequestBody @Valid UserJoinRequest request) {
userService.joinUser(request);
}
}
기존 Controller에는 위와 같이 @CommonResponseContent
어노테이션만 추가해주면 된다. 클래스 레벨에 선언하면 UserController
내 모든 메소드에 대해 적용되며, joinUser
메소드와 같이 메소드 레벨에 선언한 경우에는 해당 설정이 우선한다. 즉 유저 회원가입 API의 상태코드는 기본값인 200(OK)이 아니라 201(CREATED)이 될 것이다.
checkForUsername 메소드 응답 데이터
{
"timestamp": "2024-01-14 14:43:20",
"status": 200,
"code": "OK",
"message": "요청이 정상 처리되었습니다.",
"data": {
"isDuplicatedUsername": true
}
}
joinUser 메소드 응답 데이터
{
"timestamp": "2024-01-14 14:43:52",
"status": 201,
"code": "CREATED",
"message": "요청이 정상 처리되었습니다."
}
정리
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler
public ResponseEntity<CommonResponse<Void>> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity.status(errorCode.getHttpStatus())
.body(new CommonResponse<>(errorCode.getHttpStatus().value(), errorCode.name(),
errorCode.getMessage(), null));
}
}
위와 같이 @RestControllerAdvice
, @ExceptionHandler
를 통해 예외 처리 어드바이스를 정의하고 공통 응답 형식인 CommonResponse
를 반환하도록 하여, 예외 상황에서의 응답 형식과 정상 상황에서의 응답 형식이 통일될 수 있다. 이렇게 되면 프론트 쪽에서 데이터를 받아오는 것이 훨씬 수월하지 않을까 생각한다. (참고로 BusinessException
은 현재 프로젝트의 전역적인 커스텀 예외이다.)
그러나 현재 마음에 걸리는 것은 추가적인 ControllerAdvice로 프로젝트 성능이 떨어질 수 있다는 것과, CommonResponse
객체에 포함된 필드들이 정말 필요한 데이터일까? 즉 상태 코드의 경우 중복되는 데이터가 아닌가? 하는 의문이 든다는 것이다. 특히 사용자 예외 처리(Exception Handling)가 아닌 공통 응답(Common Response)은 필수적으로 구현해야 하는 부분이 아니기 때문에 프론트에서 특별히 요구하지 않는다면 앞서 설명한 이유들로 굳이 도입하진 않을 것 같다. 그럼에도 불구하고 Controller 메소드 별 공통적인 부가 로직을 처리할 수 있는 ResponseBodyAdvice
에 대해 알 수 있게 되어 좋았다.
참고
https://devlog-wjdrbs96.tistory.com/182
https://blog.naver.com/chgy2131/222890294173
'Spring > Spring' 카테고리의 다른 글
[Spring] 스프링에서의 SSE 구현 (with SseEmitter) (1) | 2024.09.09 |
---|---|
[Spring] Link 헤더를 이용한 REST Pagination 구현 (0) | 2024.01.14 |
[Spring Boot] Controller - Service 계층 리팩토링 with Facade 패턴 (0) | 2023.12.21 |
[Spring Boot] 날짜 데이터를 요청 및 응답으로 주고 받는 여러 가지 방법 (0) | 2023.11.23 |
[Querydsl] Spring Boot 3.x with Gradle 설정 (1) | 2023.10.20 |
- Total
- Today
- Yesterday
- 전략 패턴
- C++
- 서블릿 컨테이너
- SSE
- Transaction
- facade 패턴
- junit5
- Spring Security
- mockito
- spring
- servlet filter
- Gitflow
- Java
- Front Controller
- rest api
- Git
- 디자인 패턴
- spring boot
- 단위 테스트
- ParameterizedTest
- spring aop
- 템플릿 콜백 패턴
- QueryDSL
- Assertions
- vscode
- 모두의 리눅스
- Linux
- JPA
- FrontController
- github
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |