개발 계기
SSAFY 2번째 프로젝트 초기에, 어떤 방식으로 응답을 내보낼 지 회의한 적은 없지만, 다른 백앤드 팀원들이 아래와 같은 방식으로 응답을 내보내는 것을 선호했다. 왜인지는 모르겠지만, 그 전 팀에서는 관습적으로 그렇게 하자고 하여, 의심 없이 수용했을 수도 있고, 다른 이유가 있을 수 있다. 정확히 왜 그런지 아시는 분들은 설명 부탁드립니다..!
정확히 아래와 같은 방식이었다.
{
status : "",
msg : "",
data : {
}
}
이런 방식의 큰 단점 한 가지가 명확해 보였다. 매 Controller Method 에서, 위의 공통 응답 코드를 만들어 주기 위한 작업을 해야 한다는 점이다. 예를 들어, 백앤드 개발을 할 때 50 개의 API Endpoint 를 만든다고 하자. 그렇다면, 50 개의 응답 작업 코드를 반복해서 만들어줘야 하는 것이다.
예를 들어, 게시판 API 를 모아 놓은 Controller 를 가져와 보았다.
@GetMapping("")
public CommonResponse<List<ArticleSimpleResponse>> getAllArticle(@RequestParam int limit,
@RequestParam(required = false) Long previousArticleId){
return new CommonResponse(HttpStatus.OK, articleService.getStagePage(limit, previousArticleId));
}
@GetMapping("/{articleId}")
public CommonResponse<ArticleDetailResponse> getArticle(@AuthenticationPrincipal User user, @PathVariable long articleId){
return new CommonResponse(HttpStatus.OK, articleService.getArticle(user,articleId));
}
@PostMapping("")
public CommonResponse<ArticleDetailResponse> insertArticle(@AuthenticationPrincipal User user, @Valid @RequestBody ArticleWriteRequest dto){
return new CommonResponse(HttpStatus.OK, articleService.insertArticle(user,dto));
}
@PutMapping("/{articleId}")
public CommonResponse<ArticleDetailResponse> modifyArticle(@AuthenticationPrincipal User user,
@Valid @RequestBody ArticleModifyRequest dto,
@PathVariable long articleId){
return new CommonResponse(HttpStatus.OK, articleService.modifyArticle(user,articleId, dto))
}
@DeleteMapping("/{articleId}")
public CommonResponse<Long> deleteArticle(@AuthenticationPrincipal User user, @PathVariable long articleId){
return new CommonResponse(HttpStatus.OK, articleService.deleteArticle(user,articleId));
}
@PostMapping("/save/{articleId}")
public CommonResponse<ArticleSaveResponse> articleSave(@AuthenticationPrincipal User user, @PathVariable Long articleId){
return new CommonResponse(HttpStatus.OK, articleService.saveOrDeleteArticleForUser(user, articleId));
}
이 무슨 비효율적인 짓이란 말인가... 의미 없는 리턴 타입을 반복적으로 적어 주고, 매 응답 시 return new CommonResponse 를 반복해 적어 줘야 한다. 그리고 개발을 하면, 게시판 관련 기능 뿐 아니라 다른 여러 API Endpoint 를 만들어 주어야 할 것이다.
개인적으로는, Springboot 서버 파트 개발에서 의미 없는 코드는 최대한 지양해야 한다고 생각한다. 과장 좀 보태서 100 번이나 이런 일을 반복해서 해 줘야 한다니...! 최대한 이런 일을 지양해야 할 것이다. 그런 의미에서, 위의 공통 응답 코드를 만드는 일을 반드시 해야 한다면, 모든 Controller 응답을 한 군데에서 받아, 공통 응답 코드를 만드는 곳에 집어 넣어 응답을 만든 뒤 JSON 으로 변환되는 것이 가장 이상적이라고 생각했다.
처음에는 HTTP Status Code 를 의미 있게 내보내 주고, 공통 응답 포맷으로 내보내지 말자는 생각을 하고 있었다. 그리고 다른 백앤드 팀원은, 모든 HTTP Status Code 를 200 으로 통일하고, HTTP Response Body 내에 status 정보를 담아 주는 것이 좋다고 생각했다. 프론트앤드 에서, 400 번대 Code 를 axios 를 활용해 try~catch 를 통해 개발해야 하고, 이는 프론트앤드 파트에서 더 효율적으로 개발할 수 있기 때문이라고 했다.
프론트 앤드 파트를 담당하는 동료가, 위처럼 Status Code 를 200 으로 모두 통일해 주는 방식을 더 선호했다. try~catch 를 쓰지 않아 아주 편하게 개발했다는 이유였다. 결국... 쓰는 사람이 하자는 대로 해 주기로 했다. 그것이 협업이니까... ^^
그럼 어떻게 위의 방식을 피해, 한 곳에서 공통 응답 코드를 생성해 내보낼 수 있을까?
일단, Spring Framework 의 LifeCycle 그림을 들고 와 보았다. 저기 어딘가에서 응답을 가로채, 공통 응답 포맷에다 Controller 가 반환한 응답 객체를 넣어 반환하면 좋을 것 같았다.
먼저 아래 그림을 보고 생각할 수 있는 방식은 크게 2가지였다.
1. HandlerInterceptor 에서 응답을 가로채 공통 응답 포맷 안에 넣어 주기
2. Filter 에서 응답을 가로채 공통 응답 포맷 안에 넣어 주기
생각 방식 1 : HandlerInterceptor (or Filter) 에서 가로채 응답을 수정하기
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
public class CommonInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
}
공통 Interceptor 를 만들어, postHandle 에서 응답을 가로채 바꿀 수 있지 않을까 생각했다. 하지만, 아래 글을 보고, HandlerInterceptor 를 통해 응답을 바꾸는 것이 불가능하다는 결론에 이르렀다.
Spring interceptor에서 Response 수정하기
how to modify response in spring interceptor? 와 같은 키워드를 구글에 검색할 누군가를 위해 최근에 알게 된 내용들을 정리하겠다.
medium.com
https://stackoverflow.com/questions/39725888/what-does-httpservletresponse-is-committed-mean
ServlerResponse.isCommited() checks if the response has been already committed to the client or not (Means the servlet output stream has been opened to writing the response content).
The committed response holds the HTTP Status and Headers and you can't modify it. It's important also to note that in this case the response content has NOT been written yet, as the headers and status are committed before the content itself.
(StackOverFlow 답변)
처음에는, 위의 글을 의심하고 다른 방식을 찾아보려 애썼지만, RestController 에서 응답을 보내 HandlerInterceptor 로 검증한 결과, 정말로 HandlerInterceptor 에 도달했을 때 isCommited() 가 true 로 나오게 되었다. 이렇게 되면, 당연히 DispatcherServlet 이전에 존재하는 Filter 에서 응답을 가로채 변경할 수 있을 리가 없다고 생각했다. 다른 방식을 생각해 보아야 한다.
시도 방식 2 : Spring AOP 를 통해 Controller 의 응답을 가로채고, 공통 응답을 만들어 주기
위의 방식을 통해 변경하는 것이 불가능하다면, 애초에 Controller 를 Spring AOP 를 통해 가로채 변경하면 되지 않을까 하고 생각했다. 애초에 Controller 를 빠져 나가야 사용자가 응답을 받던지 할 것이다. 그래서, 아래 코드를 만들어 응답을 가로채 주고, 반환하려고 했다.
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class CommonControllerAspect {
@AfterReturning(
pointcut = "execution(public * com.ssafy.*.*.controller.*.*(..))",
returning = "response"
)
public CommonWrapperResponse afterControllerResponse(JoinPoint joinPoint, Object response){
return wrapResponse(response, HttpStatus.OK);
}
@AfterReturning(
pointcut = "execution(public * com.ssafy.*.*.hadler.*.*(..))",
returning = "response"
)
public CommonWrapperResponse wrapExceptionResponse(JoinPoint joinPoint, Object response){
Object[] args = joinPoint.getArgs();
HttpStatus status = (HttpStatus) args[2];
return wrapResponse(response, status);
}
private CommonWrapperResponse wrapResponse(Object response, HttpStatus status){
return CommonWrapperResponse.builder()
.status(status.value())
.data(response)
.build();
}
}
이런 코드를 만들어 주고, Controller 를 만들어 응답을 반환하면 Spring AOP 로 처리하려고 했지만, MockMVC 를 통해 검증한 결과 실패로 돌아갔다.
@AfterReturning 을 사용해서 실패했는지, 그래서 @Around 를 사용하면 성공할 수 있는지 확신이 들지는 않는다. 추후, 직접 실험해 보아야 겠다고 생각한다.
정확히 왜 그런지 아시는 분은, 댓글 부탁드립니다 ( 꾸벅 )
최종 시도 방식 : ResponseBodyAdvice
ChatGPT 4 와 이를 놓고 이야기 하던 중, ResponseBodyAdvice 가 있다는 사실을 깨달았다. 그래서, 해당 class(혹은 interface) 를 직접 찾아보기로 했다.
IntelliJ 를 통해 직접 위의 키워드를 검색해 어떠한 것인지 확인해 보았다.
해석 : @ResponseBody 나 ResponseEntity 가 있는 Controller Method 를 실행하고 난 뒤 응답을 Customizing 해 주는데, HttpMessageConverter 로 넘어가기 전에 해 준다. 이 Interface 를 구현해 주기 위해서는 RequestMappingHandlerAdapter 와 ExceptionHandlerExceptionResolver 를 통해 구현체를 등록할 수 있지만, @ControllerAdvice 를 사용해 구현해 준다면 두 케이스 모두 자동으로 Spring 에 등록될 것이다.
딱 내가 찾던 기능이라고 생각했다. 이를 통해 응답을 가로채 수정한다면, 어차피 HttpMessageConverter 를 통해 JSON 으로 변환되기 전이므로, 성공적으로 공통 응답 포맷에 응답 객체를 넣어 반환할 수 있을 것이라고 보았다.
나는 현재 Global Exception Handler 를 통해 Exception Handling 을 하고, Custom Exception 을 통해 Client 에 400 번대 HTTP Status Code 를 내보내고 있다. 그리고, ErrorResponse 라는 객체의 리스트를 일괄적으로 내보내 주고 있다.
그래서, ErrorResponse 를 내보낼 때는, 그에 맞는 응답을 내보내 줄 수 있도록 하며, 에러가 아닐 때는 200 OK 를 CommonResponse 에 전달할 수 있도록 구현했다.
코드는 아래와 같다.
@RestControllerAdvice
public class CommonControllerAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return !Void.TYPE.equals(returnType.getParameterType());
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if(isErrorResponse(body)) {
List<ErrorResponse> errorResponses = (List<ErrorResponse>)body;
return wrapResponse(errorResponses, errorResponses.getFirst().status());
}
return wrapResponse(body, HttpStatus.OK);
}
private boolean isErrorResponse(Object body) {
return body instanceof List<?> list
&& !list.isEmpty()
&& list.getFirst() instanceof ErrorResponse;
}
private CommonWrapperResponse wrapResponse(Object response, HttpStatus status) {
return CommonWrapperResponse.builder()
.status(status.value())
.data(response)
.build();
}
}
ResponseBodyAdvice Interface 를 구현해 주기 위해서는, Class 단위에 @RestControllerAdvice Annotation 을 등록해 주고, supports() 와 beforeBodyWrite() method 를 Overriding 해야 한다.
나는 Void 를 내보낼 때는 위의 응답 변경 코드를 사용하지 않으려고 했다. 그러므로, supports() method 에는 해당 내용의 코드를 구현해 주었다.
Controller 예시
@RestController
@RequiredArgsConstructor
@RequestMapping("/test")
public class TestController {
private final TestService testService;
@GetMapping("/querydsl/{name}")
public List<TesterEntity> queryDslTest(@PathVariable String name) {
return testService.getTestUsingName(name);
}
@GetMapping("/exceptional")
public void exceptionHandlerTest() {
throw new ChildNotFoundException("Negative Test Message Example");
}
}
Custom Exception 구현
public class ChildNotFoundException extends CustomException {
public ChildNotFoundException(String message) {
super(message, HttpStatus.NOT_FOUND);
}
}
@Getter
public class CustomException extends RuntimeException {
protected HttpStatus status;
public CustomException(String message, HttpStatus status) {
super(message);
this.status = status;
}
}
Global Exception Handler 예시
@RestControllerAdvice
public class ChildExceptionHandler {
@ExceptionHandler(ChildNotFoundException.class)
public List<ErrorResponse> childNotFoundExceptionHandler(ChildNotFoundException e) {
return makeErrorResponse(e, "childId");
}
}
public class ExceptionHandlerTool {
public static List<ErrorResponse> makeErrorResponse(CustomException e, String fieldName) {
return List.of(ErrorResponse.builder()
.message(e.getMessage())
.errorType(e.getClass().getSimpleName())
.fieldName(fieldName)
.status(e.getStatus())
.build());
}
}
결과
개인정보가 될 만한 것들은 가렸습니다 ^^
Positive 응답 예시
Negative 응답 예시
성공적으로 잘 나옴을 볼 수 있다.
소감
위의 ResponseBodyAdvice 를 통해, Controller 는 Controller 가 해야 할 일만을 담당하게 해 주었고, 가장 위에서 서술했던 비효율적인 코드 작성을 피할 수 있었다. 그리고 supports() method 의 return 값이 true 인 Controller Method 만을 처리할 수 있어 더 좋았다.
글을 쓰면서 아쉬웠던 점은, Spring AOP 의 @AfterReturning 을 사용했을 때는 왜 실패했는지 명확하게 규명하지 못한 점이다. 글을 쓰고 난 뒤 차차 알아보고, 글을 수정해 보아야겠다고 생각한다.