나는 주로 포트폴리오 채우기만을 위한 프로젝트를 해왔었다 😂
늦게 개발을 시작했기도 했고, 학기를 잘 끝내면서 그 중간 중간 개발을 했어야했기 때문에 기능을 정의하고 관련된 코드를 검색해서 적용하는데 급급했었던 것이다.
진짜 개발자로서의 취준에 들어가면서, 나한테 부족했던 고민의 과정을 스스로 좀 가져보고자 작년 여름 방학에 했던 "학교 폭력 피해자 일상 복귀 서비스"를 리팩토링 하게 되었다 :)
다양한 삽질이 담기고 시간도 오래 걸리겠지만
그래도 그 과정이 나에게는 큰 양분이 될 수 있음을 알기에! 진행해보고자 한다 🙄
하지만 막상 리팩토링을 시작하고자하니.. 너~무 어렵고 복잡했다.
오랜만에 보니 로직도 이해가 잘 안됐고,
내가 작성하지 않은 부분은 코드의 역할도 잘 이해가 되지 않았다. 많은 사람이 협업하는 회사에서 클린코드가 중요한 이유. . . . 가 여기서 나오는구나 싶었다
일단 기능을 확인해보고자
.gitignore 처리해놨던 properties를 복구하고, API 문서에 맞게 Post Man을 설정했다.
근데 하나하나씩 응답을 테스트하던 도중 위와 같은 Response를 발견하게 됐다.
분명 Exception이 발생했다는 Response인데 Status Code가 200이라고??
뭔가 이상하다는 생각이 들어 예외 처리 관련 코드를 확인해보았다.
하지만 예외 처리 관련 코드는 개발 당시 다른 팀원 오빠가 작성한 부분이었어서 그런지 하나도 이해가 되지 않았다 😢
리팩토링을 시작하자마자 "개발 과정에서의 고민의 부재"로 인한 나의 무지가 발목을 잡은 것이다
내가 간과하고 있던 부분에 대한 직면, 이것이 바로 이번 삽질 리팩토링(1)의 시작점이다 😂
Spring Boot가 채택하는 예외 처리 과정을 공부해보았다. (참조 블로그)
해당 글은 리팩토링 과정이 중심인 글이기 때문에 구체적인 이해 내용을 작성하진 않을 계획이지만 관련해서 간단히 내용을 간략하게나마 정리해보고자 한다.
(시간이 된다면 노션에 중구난방 정리해놓은 내용을 블로그로 작성해보고 싶긴하다😂)
기존 프로젝트의 예외 처리 관련 코드이다.
이렇게 각종 예외에 대한 클래스가 존재했고
@RestControllerAdvice
가 붙어있는 ExControllerAdvice 클래스가 존재하였으며
그 안에서 @ExceptionHandler
가 붙어있는 메소드들이 예외를 return하고 있었다.
RestControllerAdvice? ExceptionHandler? 🙄
벌써부터 모르는 개념들이 슬금슬금 등장하기 시작했다.
스프링 부트는 개발자 편의를 위해 HandlerExceptionResolver를 미리 만들어서 제공해주고 있다. 즉, 어노테이션을 통해 간단하게 예외 처리 과정을 적용할 수 있도록 도와주고 있는 것이다.
Spring Boot가 제공하는 HandlerExceptionResolver의 우선 순위는 아래와 같다.
우선 2번 ResponseStatusExceptionResolver부터 살펴보자 👀
이름에서도 알 수 있듯! 예외 상태에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
이렇게 @ResponseStatus
어노테이션이 적용되었다면, 해당 Exception이 발생했을 때 ResponseStatusExceptionResolver
가 실행되어
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(UserException.class)
public ErrorResult joinUserExHandler(UserException e) {
return new ErrorResult(ExceptionCode.ALREADY_EXIST_USER);
}
...
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response) throws IOException {
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
} else {
String resolvedReason = this.messageSource != null ? this.messageSource.getMessage(reason, (Object[])null, reason, LocaleContextHolder.getLocale()) : reason;
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
}
sendError()
를 통해 status code를 설정한 뒤, 비어있는 modelAndView를 반환하는 것이다.
스프링이 제공하는 가장 기본적인 HanlderExceptionResolver이다.
스프링에서 내부적으로 발생하는 주요 예외를 처리해주는 표준 예외 처리 로직을 담고 있다.
가장 우선순위가 높은 resolver이다.
해당 Resolver는 @ExceptionHandler
어노테이션이 붙은 메서드를 통해 예외 처리를 할 수 있도록 도와주는 역할을 한다.
참조 블로그에서도 내용을 확인 할 수 있듯, 예외가 발생하면 해당 resolver로 인해
-> 예외가 컨트롤러 밖으로 던져짐 -> 우선순위가 가장 높은 ExceptionHandlerExceptionResolver가 실행됨 -> 해당 resolver는 해당 컨트롤러에 발생한 예외(ex.IllegalArgumentException)을 처리할 수 있는 @ExceptionHandler가 있는지 확인 -> 해당 메서드를 실행
과 같은 과정을 거치게 된다.
공부의 결과 기존 오빠가 작성했던 코드를 온전히 이해할 수 있게 되었다 :)
하지만 여전히 의문이 남는다.
위의 내용을 기반으로 하면 @ResponseStatus
어노테이션이 붙은 메서드는 알아서 status code가 지정한 대로 등록되는 것 같은데 왜 기존 코드에선 status code가 적용되지 않는걸까 ??
@ResponseStatus
를 처리하는 Resolver가 우선순위가 더 낮아서 발생하는 문제인건가??
관련된 내용을 찾아봐도 명확한 설명을 확인할 수 없어서 결국 내가 직접 디버깅을 진행하게 되었다
상단 메소드는 @PathVariable
로 받은 emotionId
와 일치하는 Emotion
Entity를 찾아 반환해주는 controller 메소드이다.
내부에서 호출되는 emotionService.read()
의 내용은 아래와 같은데
여기 WrongEmotionId()
를 던지는 곳에 break point를 걸어 디버깅을 진행했다.
잘못된 Emotion ID를 조회하는 Request를 던져보니
위와 같이 ServletInvocableHandlerMethod
클래스의 invokeAndHandle()
메소드 내부에서 setResponse(webRequest)
가 호출됨을 확인할 수 있었다.
Response를 세팅한다..는 명칭에서부터 뭔가 이 부분을 까봐야겠단 느낌이 든다.
webRequest 변수 안에는 ServletWebRequest객체가 들어있다.
해당 메소드는 아래와 같이 구현되어있다.
private void setResponseStatus(ServletWebRequest webRequest) throws IOException {
HttpStatus status = getResponseStatus();
if (status == null) {
return;
}
HttpServletResponse response = webRequest.getResponse();
if (response != null) {
String reason = getResponseStatusReason();
if (StringUtils.hasText(reason)) {
response.sendError(status.value(), reason);
}
else {
response.setStatus(status.value());
}
}
// To be picked up by RedirectView
webRequest.getRequest().setAttribute(View.RESPONSE_STATUS_ATTRIBUTE, status);
}
그 중 가장 눈에 들어오는 부분은
Http status = getResponseStatus()
일 것이다.
이때 받아온 status를 Response 객체에 .setStatus()
해주고 있기 때문이다
getResponseStatus()
는 아래와 같이 구현되어있다.
@Nullable
protected HttpStatus getResponseStatus() {
return this.responseStatus;
}
특별한 로직 없이 객체의 필드를 가져오기만 하는 단순한 Getter임이 확인된다. 그렇다면 여기서 return 되는 this.responseStatus
는 무엇일까?
getResponseStatus()
가 구현되어있고 responseStatus
를 보유하고 있는 클래스는 바로 HandlerMethod 클래스이다. 어떠한 시점에 해당 클래스가 생성이 되었고, 그 안에 설정된 responseStatus가 Response에 setting되는 것이다.
디버깅하는 시점의 responseStatus
는
이렇게 null로 되어있음을 확인할 수 있다 💧
설정할 response status가 없기 때문에 기본 status code 값인 200을 가진 Response를 반환받게 되는 것이다.
여기서 가장 중요한 부분은 바로 HandlerMethod 클래스일 것이다.
이것은 언제 생성되는 것이고 어떤 방식으로 responseStatus를 설정하게 되는 것일까?
우선 HandlerMethod 클래스의 정의에 대해 찾아보았다.
docs에서의 설명은 아래와 같다.
참조 블로그에서 더 명확한 설명을 제공해주고 있는데, 이 블로그를 통해 Handler Method란 @RequestMapping과 하위 어노테이션이 붙은 메소드의 정보를 추상화한 객체임을 알아낼 수 있었다.
메소드를 실행하기 위해 필요한 참조 정보를 담고 있는 객체로서 (빈 객체) (메소드 메타 정보) (메소드 파라미터 메타 정보) (메소드 어노테이션 메타 정보) (메소드 리턴 값 메타 정보) 등을 가지고 있다고 한다.
Dispatcher Servlet은 어플리케이션이 실행될 때, 모든 컨트롤러 빈의 메소드를 살펴서 매핑 후보가 되는 메소드들을 추출한 뒤, 이를 HandlerMethod 형태로 저장해둔다고 한다. 그리고 실제 요청이 들어오면 저장해 둔 목록에서 요청에 맞는 Handler Method를 참조해서 매핑되는 메소드를 실행한다.
실제로 위의 디버깅 과정에서 호출된 HandlerMethod는 myExceptionHandler객체의 wrongIdExceptionHandler와 관련되었음을 확인할 수 있었다.
내용을 공부해보니 wrongIdExceptionHandler 메소드를 건드릴 때 responseStatus 세팅이 되는 것 같은데 대체 왜 status 값이 적용되지 않을까?
개념을 공부하고 디버깅을 진행했음에도 의문이 해결되지 않았다 😢
한참을 고민하다가
답답한 마음을 붙들고 처음부터 코드를 뜯어보니
ㅎㅎ..
해당 메소드에만 @ResponseStauts
가 붙어있지 않아 발생한 문제였던 것이다.
확인했을 땐 정말 허탈해서 온 힘이 다 빠지는 것 같았지만😂 이것을 기회로 나름 스프링 부트의 예외 처리 과정을 이해해보고 . . . . 디버깅을 진행해봤음에 의미있었다고 생각한다 :)..
원인을 알아냈지만, 내 짐작이 맞는지 다시 한 번 확인해보고자 @ReponseStauts
어노테이션을 추가한 후 동일하게 디버깅을 진행했다.
적절한 어노테이션을 붙이니
이와 같이 위에서는 Null값이 들어와있던 responseStatus에 NOT_FOUND가 잘 들어있음을 확인할 수 있었다 :)
handler method가 invoke 될 때 @ResponseStatus
어노테이션에 의해 responseStatus가 설정되고, 이로 인해 우리가 원하는 status code를 가져올 수 있게 되는 것이다.
허무하게도 단 한줄의 오타로 인해 발생한 문제 상황이었지만,
그래도 문제를 발견하고 직접 디버깅해가며 모르는 개념을 공부해나갔다는 것에 나름 의미가 있었던 과정이었다 :)
하지만 예외 처리와 관련해서 이 정도에서 리팩토링을 마무리 짓기엔 너무 아쉬운 것 같아 다시 한 번 더 예외 처리 관련 코드를 뜯어보았다.
기존에 작성된 코드는
딱 보더라도 상당한 양의 클래스가 만들어져있었고
심지어 그 내부에선
Exception을 상속받는 것 외에는 아무것도 하고 있지 않음을 파악할 수 있었다.
과연 이것이 최선의 방법일까? 더 나은 custom exception 처리 방법은 없는 것인가? 아니, 일단 custom exception이 정말 필요한 것인가??
더 나은 코드를 위한 꼬리에 꼬리를 무는 고민의 과정을 바로 다음 게시글에 작성해보고자 한다 :)