웹 어플리케이션에서 발생할 수 있는 에러의 종류는 무궁무진하다. 서버의 소프트웨어 오류일 수도 있고, 하드의 오류일 수도 있고, 유저의 input 오류일 수도 있고, 네트워크 오류일 수도 있고...
그 모든 예외나 오류들을 직접 처리하기는 힘들지만, 적어도 개발한 어플리케이션 안에서 발생할 수 있는 예외들은 최대한 핸들링해야한다. 실제 개발하는 시간보다 디버깅 하는 시간이 훨씬 길듯, 예외 처리 역시 신중하고 확실하게! 해야한다.
1. Spring Boot에서 Custom Exception 처리의 동작 과정
@ControllerAdvice class+ @ExceptionHandler method 구조로 동작한다.
@ControllerAdvice가 붙은 클래스의 method들은 @Controller 클래스들의 global aspect로서 작동한다. @ControllerAdvice 내부의 method들에 @InitBinder 혹은 @ModelAttribute를 붙여 요청으로로 들어온 값들을 바인딩하는 작업을 직접 수행할 수도 있고, @ExceptionHandler를 붙여 모든 컨트롤러를 아우르는 예외 처리 핸들러로서 작동하게 할 수도 있다.
스프링에는 예외를 처리하는 HandlerExceptionResolver 인터페이스가 있는데, mvc의 컨트롤러 아래에서 예외가 발생하면 그를 구현한 3개의 Resolver들이 순차적으로 작동하여 예외 처리를 하게된다.
- ExceptionHandlerExceptionResolver : 예외가 발생한 컨트롤러 클래스 -> @ControllerAdvice 클래스 순으로 컴포넌트를 탐색하며 해당 에러에 맞는 @ExceptionHandler를 찾는다. 핸들러를 찾으면 method를 작동시킨다.
- ExceptionHandler에선 HttpRequestServlet, HttpResponseServlet, Model 등을 인자로 받아 이를 활용할 수 있다. controller의 일반적인 handler method와 비슷하게 동작할 수 있다.
- @ExceptionHandler 어노테이션의 value 값으로 특정 Exception class을 설정해 해당 예외가 발생했을때 exception을 처리하는 방식이다. exception 객체는 handler method의 인자로 바인딩된다.
- 전술했듯 @ControllerAdvice와 함께 쓰여 전역적 예외 핸들링이 가능하다.
- ResponseStatusExceptionResolver : 적절한 @ExceptionHandler가 존재하지 않을 때, 던져진 예외 클래스에 @ResponseStatus가 붙었는지, 혹은 예외 자체가 ResponseStatusException인지 확인한다. 맞다면 예외의 response status를 수정하여 서블릿에 전달하고, 서블릿이 에러를 다시 BasicErrorController로 전달하여 처리하게끔 한다.
- ResponseStatus / ResponseStatusException : 스프링 환경에서 발생한 에러에 status를 설정할 수 있게 해주는 annotation / exception class
- status만을 수정할 수 있다는 점에서 ExceptionHandler보다 유연성이 떨어진다
- 결국 에러가 서블릿까지 전달되고 WAS에 의해 서블릿으로 에러 요청이 한 번 더 일어난다
- DefaultHandlerExceptionResolver : 발생한 예외가 ResponseStatusExcepion도 아닐때 작동하는 resolver. 스프링에서 발생한 예외를 처리한다.
- 위 Resolver들로 처리가 안 된 예외는 스프링부트의 자동설정에 맞게 구성된 뒤 BasicController로 전달된다.
ExceptionHandlerExceptionResolver를 제외하고는 모두 예외가 서블릿까지 다시 전달된다. 그리고 이를 받은 WAS(톰캣 등)가 에러 요청을 서블릿에 한 번 더 보낸다. 이때 이 에러 요청에 동작하는 컨트롤러는 스프링 컨텍스트에 기본으로 등록된 BasicErrorController이고, ResponseStatus를 따로 설정하지 않았을 때 상태 코드의 기본 값은 500이다.
개발중인 스프링 프로젝트에서 나올 수 있는 예상 가능한 에러는 전부 커버해야한다. 더해서 그것이 어떤 종류의 에러인지, 왜 발생했는지 구체적으로 알수록 좋다. 그래야 그에 맞는 대처를 할 수 있기 때문이다. 이런 이유 때문에 스프링에서 예외 처리를 할 땐 예외 처리에 유연성이 가장 좋은 @ExceptionHandler를 작성하는 것이 선호된다. 이렇게 해야한다
서블릿 컨텍스트 안에서 에러가 발생했을 때 @ExceptonHandler가 예외를 처리하는 방식이 정리된 그림이다. @ExceptionHandler에 의해 response가 구성되었으므로 응답 객체는 클라이언트에게 도착하게 된다.
@ExceptionHandler로서 처리가 가능한 것은 언제나 Dispatcher Servlet의 범위 안임을 다시 한 번 명심하자. 예외가 Spring Security의 경우와 같이 filter단에서 발생한다면 @ControllerAdvice에서 설정한 예외 핸들러는 작동하지 않는다.
이와 관련해서 삽질한 내용은 아래 글 참고.
Spring Security의 Security Filter Chain 관련 Exception handling하기
Security Filter Chain에 등록된 authorization에 맞지 않는 client request가 들어왔을 때, 내가 해당 요청을 핸들링 하고 싶었다. Spring에서 상식적(?)으로 알려진 Exception 처리 방법이다.. @ControllerAdvice @Slf4j pub
buchu-doodle.tistory.com
2. Custom Exception Class / Error Code작성
부추 농장 프로젝트에서 던질 예외는 많다. 자신이 작성하지 않은 일기를 삭제하려고 한다거나, 이미 다른 유저가 사용하고 있는 아이디를 사용하려고 한다거나, 존재하지 않는 페이지를 방문한다거나..
이런 식으로 정상적이지 않은 요청이 들어오거나 동작이 일어나면 예외를 던져 ExceptionHandler가 동작할 수 있도록 해야한다. 이때 RuntimeException을 상속받는 프로젝트 전용의 Exception class를 하나 만들어 이용하는 것이 편리하다. 웹 어플리케이션 서버가 동작하는 도중 발생하는 RuntimeException을 상속받는 GreenFarmException 클래스를 하나 작성한다.
GreenFarmException.java :
@Getter
public class GreenFarmException extends RuntimeException {
private final GreenFarmErrorCode greenFarmErrorCode;
private final String detailMessage;
public GreenFarmException(GreenFarmErrorCode greenFarmErrorCode) {
super(greenFarmErrorCode.getDetailMessage());
this.greenFarmErrorCode = greenFarmErrorCode;
this.detailMessage = greenFarmErrorCode.getDetailMessage();
}
}
생성자의 인자로 GreenFarmErrorCode라는 Enum 객체를 받았다. GreenFarmErrorCode는 부추 농장 프로젝트에서 발생하는 에러의 상세 메세지를 담은 Enum 클래스이다.
GreenFarmErrorCode.java :
@Getter
@AllArgsConstructor
public enum GreenFarmErrorCode {
NO_FARM_LOG_ERROR("해당하는 농장 일기가 존재하지 않습니다."),
NO_USER_ERROR("해당하는 유저가 존재하지 않습니다."),
INVALID_REQUEST("잘못된 요청입니다."),
DUPLICATED_USER_ID("중복된 유저 아이디입니다."),
INTERNAL_SERVER_ERROR("서버에 오류가 발생했습니다."),
INVALID_USER_ID_ERROR("적절한 아이디가 아닙니다."),
INVALID_DATA("적절한 데이터가 아닙니다."),
CANNOT_FOLLOW("팔로우 할 수 없는 유저입니다!"),
CANNOT_EDIT("수정할 수 없습니다."),
INVALID_OAUTH("잘못된 로그인 시도입니다."),
NO_AVAILABLE_FOLLOWING("로그인 뒤 팔로우할 농장주를 찾아보세요."),
INVALID_REQUEST_USER("요청에 적절한 사용자가 아닙니다."),
ACCESS_DENIED("허용된 경로가 아닙니다."),
NEED_LOGIN("로그인을 해주세요.");
private final String detailMessage;
}
프로젝트 상황에서 발생할 수 있는 여러 예외의 예시를 모아뒀다...
3. @ControllerAdvice + @ExceptionHandler
@ControllerAdvice 클래스 안에 @ExceptionHandler method를 두어 전역적 예외 처리를 할 것이다.
@ControllerAdvice
@Slf4j
public class GreenFarmExceptionHandler {
@ExceptionHandler(GreenFarmException.class)
public String handleGreenFarmException(
final GreenFarmException e,
final HttpServletRequest request,
Model model) {
log.warn("Green Farm Exception!! URL: {}\nStack Trace: {}",
request.getRequestURI(),
e.getStackTrace());
model.addAttribute("error",
GreenFarmErrorResponse.builder()
.errorName(e.getGreenFarmErrorCode())
.detailMessage(e.getDetailMessage())
.build());
return "error.html";
}
@ExceptionHandler(Exception.class)
public String handleRemainException(
Exception e,
HttpServletRequest request,
Model model) {
log.warn("Unknown error! URL: {}\nStack Trace: {}",
request.getRequestURI(),
e.getStackTrace());
model.addAttribute("error",
GreenFarmErrorResponse.builder()
.detailMessage(e.getMessage())
.build());
return "error.html";
}
}
- 전반적 예외 처리 과정이다.
- GreenFarmException 예외 발생
- GreenFarmExceptoinHandler.handelGreenFarmException() 호출
- warning 로그 찍힘
- view단으로 나가는 model 객체에 GreenFarmException 정보를 담은 GreenFarmErrorResponse 객체 바인딩
- 타임리프에 의해 "templates/error.html" view 응답
- 현재 단계에선 GreenFarmException이 아닌 예외는 전부 GreenFarmExceptoinHandler.handleRemainException()이 처리하도록 했다. @ExceptionHandler 어노테이션의 value값 확인. GreenFarmException을 제외하고 Exception을 상속받는 모든 예외는 해당 핸들러에 의해 처리될 것이다.
model 객체로 넘겨주는 GreenFramErrorResponse DTO는 아래처럼 구성했다. 에러 이름과 메세지로 구성된 간단한 DTO다.
GreenFarmErrorResponse.java :
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@ToString
public class GreenFarmErrorResponse {
private String errorName = "ERROR!";
private String detailMessage = "에러가 발생했습니다.";
}
4. ThymeLeaf
error.html에 DTO의 errorName과 detailMessage를 보여줄 수 있도록 view 코드를 작성했다.
<h3 class="text-center" th:text="${error.errorName}"></h3>
<h5 class="text-center mb-3" th:Text="${error.detailMessage}"></h5>
5. 예시
유저를 수정하는 service 코드에서, 유저가 userId를 변경했을 경우 변경한 id가 DB에 이미 존재하면 예외를 던지는 로직이 존재한다. (userId는 중복되면 안되므로)
@Transactional
public void editUser(final String userId,
final UserProfileDto userProfileDto) {
validateUserId(userProfileDto.getUserId());
httpSession.setAttribute(
"user",new SessionUser(
getUserByUserId(userId)
.editByDto(userProfileDto)));
}
@Transactional
private void validateUserId(String userId) {
// validate if modified userID is already in DB
userRepository.findByUserId(userId)
.ifPresent(user -> {
throw new GreenFarmException(
GreenFarmErrorCode.DUPLICATED_USER_ID);
});
}
throw new GreenFarmException(GreenFarmErrorCode.DUPLICATED_USER_ID); 부분을 보면 알 수 있다.
이제 이미 다른 회원이 사용하고 있는 ID로 수정 요청을 해보면,
구성한 오류 페이지에 오류메세지가 잘 담겨나왔다.
REFERENCE}
https://mangkyu.tistory.com/204