Post

[SPRING] 스프링 예외처리 방법과 전략, @ControllerAdvice

작성 계기

개발을 하다보니 예외처리가 필요한일이 많았다. 평소 자바에서는 try ~ catch 구문으로 예외를 잡아주었다. 하지만 이렇게하니까 try ~ catch 구문이 너무 많아지면서 코드의 가독성이 떨어진다고 생각했다. 많은 사람이 사용하는 SPRING 프레임워크가 이런 문제도 고려안했을까? 라고 생각하고 찾아봤더니 전역으로 예외처리를 해주는 어노테이션이 존재했다. 그리고 이를 이용한 예외처리 전략도 존재했다. 먼저 SPRING 의 예외 구조를 소개하고 SPRING 의 예외처리 방식을 포스팅할려고 한다.

SPRING MVC 의 예외 구조

이미지

스프링의 처리과정에서 예외는 크게 2가지가 발생한다.

  • Dispatcher Servlet 내에서 발생하는 예외
  • Filter 에서 발생하는 예외

Dispatcher Servlet 예외

Dispatcher Servlet 예외는 대부분 Controller, Service, Repository 에서 발생하는 예외들이다. Controller, Service, Repository 에서 따로 예외처리를 해주지 않아도 클라이언트쪽으로 다시 던져주긴 하지만, 복잡한 흐름을 가진다. 스프링은 에러처리를 위한 BasicErrorController 가 있고, 스프링 부트는 예외가 발생하면 기본적으로 /error 로 에러요청을 다시 전달하도록 WAS 설정을 했다.

일반적으로 예외가 발생하지 않았을 때 흐름은 다음과 같다.

1
2
컨트롤러 수신 : WAS -> 필터 -> 디스패처 서블릿 -> 인터셉터 -> 컨트롤러
컨트롤러 송신 : 컨트롤러 -> 인터셉터 -> 디스패처 서블릿 -> 필터 -> WAS

만약 예외가 발생하면 다음과 같은 흐름을 가진다.

1
2
3
4
컨트롤러 수신 : WAS -> 필터 -> 디스패처 서블릿 -> 인터셉터 -> 컨트롤러
컨트롤러 송신 : 컨트롤러(예외발생) -> 인터셉터 -> 디스패처 서블릿 -> 필터 -> WAS -> 필터 
-> 디스패처 서블릿 -> 인터셉터 -> 컨트롤러(BasicErrorController)
-> 인터셉터 -> 디스패처 서블릿 -> 필터 -> WAS

즉 예외가 발생하면 컨트롤러를 2번 거치게 된다. 하지만 예외를 처리해주면 1번으로 보낼 수 있다.

Dispatcher Servlet 예외 처리 방법

자바를 평소에 사용해왔다면 try ~ catch 로 예외를 잡는게 익숙할것이다. 그런데 catch 로 잡은 예외는 어떻게 할것인가? 클라이언트에게 예외에 대한 정보를 전달해주어야 할것이다. 스프링에서 예외는 HandlerExceptionResolver 를 이용해 해결한다.

HandlerExceptionResolver

1
2
3
4
public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, 
            HttpServletResponse response, Object handler, Exception ex);
}

HandlerExceptionResolver 는 스프링에서 지원하는것으로, HandlerExceptionResolver 구현체들을 빈으로 등록해서 관리한다. 해당 구현체는 총 4가지지만 실제로 예외처리를 하는건 DefaultErrorAttributes 를 제외한 3가지다.

  • DefaultErrorAttributes : 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
  • ExceptionHandlerExceptionResolver : 에러 응답을 위한 Controller 나 ControllerAdvice 에 있는 ExceptionHandler 를 처리한다.
  • ResponseStatusExceptionResolver : http 상태 코드를 지정하는 @ResponseStatus, ResponseStatusException 를 처리한다.
  • DefaultHandlerExceptionResolver : 스프링 내부의 기본 예외들을 처리한다.

예외처리 우선순위는 ExceptionHandlerExceptionResolver -> ResponseStatusExceptionResolver -> DefaultHandlerExceptionResolver 순서로 이루어진다.

스프링에서 예외를 해결하는 방법은 위와 같고, 스프링에서는 예외처리를 하는 방법을 코드레벨로 보면 크게 3가지로 나눌 수 있다.

  • 첫째는 @ResponseStatus, ResponseStatusException 를 사용해서 예외처리를 한다. 권장되는 방법은 아니다.
  • 두번째로는 Controller 에서 @ExceptionHandler 를 사용해 예외처리를 맵핑시켜서 유연하게 관리할 수 있다.
  • 세번째로는 @ControllerAdvice, @RestControllerAdvice 를 이용해 글로벌로 예외를 관리한다.

@ResponseStatus

@ResponseStatus 는 Controller 단에서 함수위에 붙이거나, 커스텀예외 클래스 위에 선언하여 사용한다.

Controller layer 에서 사용

1
2
3
4
5
6
7
8
9
10
@RestController
@RequiredArgsConstructor
public class UserApi {
    
    @ReponseStatus(value = HttpStatus.OK)
    @GetMapping(value = "/api/v1/user/hello")
    public String hello() {
        return "Hello";
    }
}

커스텀 예외 클래스에 사용

1
2
3
4
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NoSuchElementFoundException extends RuntimeException {
  ...
}

ResponseStatusException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequiredArgsConstructor
public class UserApi {

    @GetMapping(value = "/api/v1/user/id")
    public User findUser(@PathVariable String id) {
        try {
            User user = userService.findUser(id);
        } catch (NoSuchElementException e) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User Not Found");
        }
        
        return user;
    }
}

@ResponseStatus 는 어노테이션으로 사용을 한다. 이럴 경우 외부 라이브러리에는 적용이 불가능하다는 단점이 있다. 그래서 스프링은 ResponseStatusException 를 지원한다. ResponseStatusException 의 경우 HttpStatus 와 선택적으로 예외설명을 추가해서 반환할 수 있다.

ResponseStatusResponseStatusExceptionResponseStatusExceptionResolver 가 동작해서 처리한다. 처리할 수 있는 예외가 있다면, ServletResponse 의 sendError() 로 예외를 서블릿까지 전달하고, 서블릿이 BasicErrorController 로 요청을 전달한다. 그 이유는 ResponseStatusResponseStatusException 은 직접 에러를 반환하지 않으므로 무조건 BasicErrorController 거쳐야 되기 때문이다. 이 방법은 BasicErrorController 를 거쳐야되서 앞서 말한 컨트롤러를 2번 거치게 된다. 굳이 2번거칠 필요없는 방법이 있는데 이 방법을 사용할 필용는 없다고 생각한다.

ExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequiredArgsConstructor
public class UserApi {

    @GetMapping(value = "/api/v1/user/id")
    public User findUser(@PathVariable String id) {
        
        User user = userService.findUser(id);
        return user;
    }

    @ExceptionHandler(NoSuchElementFoundException.class)
    public ResponseEntity<String> handleNoSuchElementFoundException(NoSuchElementFoundException exception) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
    }
}

@ExceptionHandler 는 에러를 유연하게 처리하는 방법을 제공한다. 해당코드에서 findUser 에서 NoSuchElementFoundException 이 발생한다면 handleNoSuchElementFoundException 함수가 실행된다. @ExceptionHandler 는 Controller 에서만 사용할 수 있고 Service, Entity 등에서는 사용이 불가능하다. 그래서 Service, Entity 도 @ExceptionHandler 로 처리하고 싶다면 직접적으로 처리하지 못하고 Service 와 Entity 에서 Controller 로 예외를 던져줘야 한다.

자바에서는 예외가 발생하면 가장 구체적인 예외 핸들러를 찾고, 없으면 부모핸들러를 찾는다. 예를 들어 NoSuchElementFoundException 발생했는데 해당 예외를 처리해주는 핸들러가 없다면 부모 핸들러인 RuntimeException 예외 핸들러를 찾는다. 없으면 부모 핸들러인 Exception 예외 핸들러를 찾는다. 이렇게 계속 올라가는 구조이지만 @ExceptionHandler 의 경우 등록된 예외만 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequiredArgsConstructor
public class UserApi {

    @GetMapping(value = "/api/v1/user/id")
    public User findUser(@PathVariable String id) {

        User user = userService.findUser(id);
        return user;
    }

    @ExceptionHandler(Exception.class)
    public String handleNoSuchElementFoundException(NoSuchElementFoundException exception) {
        return exception.getMessage();
    }
}

위 코드는 @ExceptionHandler(NoSuchElementFoundException.class) 에서 @ExceptionHandler(Exception.class) 으로 바꿨다. 여기서 NoSuchElementFoundException 예외 터져도 handleNoSuchElementFoundException 함수를 실행되지 않는다. 여기서 @ExceptionHandler 는 오직 Exception 예외만 적용이 되기때문이다. @ExceptionHandler 에서 등록된 예외클래스와 파라미터로 받는 예외 클래스가 다르다면 작동하지 않는다.

@ControllerAdvice @RestControllerAdvice

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestControllerAdvice(assignableTypes = {UserApi.class})
public class UserExceptionController {

    @ExceptionHandler(UserException.class)
    public ErrorResult UserExceptionHandler(UserException e) {
        return new ErrorResult(ErrorResult.CODE_CLIENT_ERROR, e.getUserExceptionGroup().getDesc());
    }

    @ExceptionHandler(BindException.class)
    public ErrorResult MethodArgNotValidExHandler(BindException e) {
        return new ErrorResult(ErrorResult.CODE_CLIENT_ERROR, ErrorResult.MESSAGE_BAD);
    }
}

@ControllerAdvice@RestControllerAdvice 의 차이점은 @RestConrollerAdvice 에는 @ReponseBody 가 적용된다는 점이다.

ControllerAdvice 는 모든 컨트롤러에 대해 글로벌로 ExceptionHandler 를 적용해준다. 만약 글로벌로 적용하기 싫고 특정 클래스에만 적용하고 싶다면 @RestControllerAdvice(assignableTypes = {UserApi.class}) 처럼 범위를 지정해줄 수 있다.

만약에 동일한 예외가 ControllerAdvice@ExceptionHandler 에 존재하고, 특정 Controller 내부에 @ExceptionHandler 도 존재한다면, 특정 Controller 내부의 @ExceptionHandler 가 우선으로 처리된다.

참고로 ErrorResult 의 경우 에러코드와 에러메세지를 반환하기위해 만든 커스텀 클래스다.

Filter 예외

이미지

SPRING MVC 예외 처리 구조를 보면 Filter 는 Dispatcher Servlet 밖에서 발생한 예외임을 알 수 있다. 그렇기에 Dispatcher Servlet 예외를 처리하는데 사용되는 HandlerExceptionResolver 의 처리를 받을 수 없다.

필터 예외 처리 해결 방법에는 3가지가 있다.

  • web.xml 에 error-page 를 등록해서 에러를 사용자에게 응답한다
  • Filter 내부에서 예외를 처리하기 위한 필터를 따로 두고 예외를 처리한다.
  • Filter 내부에서 예외 발생시 Dispatcher Servlet 예외 처리하는데 사용되는 HandlerExceptionResolver 을 빈으로 주입받아 @ExceptionHandler 에서 처리한다.

세번째 방법을 예제와 함께 설명하겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                ...
                .and()
                .addFilterBefore(ExceptionHandlerFilter, JwtTokenFilter.class)
                .build();
    }
}

먼저 Spring Security 설정에서 JwtTokenFilter 에서 발생하는 예외를 ExceptionHandlerFilter 가 처리할 수 있게 등록한다.

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
public class ExceptionHandlerFilter extends OncePerRequestFilter {

    @Autowired
    private HandlerExceptionResolver resolver; // HandlerExceptionResolver를 빈으로 주입 받는다.

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 다음 필터를 호출하기 전에 doFilter를 try/catch문으로 감싼다.
        try {
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {
            //토큰의 유효기간 만료
            log.error("만료된 토큰입니다");
            resolver.resolveException(request, response, null, e);

        } catch (JwtException | IllegalArgumentException e) {
            //유효하지 않은 토큰
            log.error("유효하지 않은 토큰이 입력되었습니다.");
            resolver.resolveException(request, response, null, e);

        } catch (NoSuchElementException e) {
            //사용자 찾을 수 없음
            log.error("사용자를 찾을 수 없습니다.");
            resolver.resolveException(request, response, null, e);
        }
    }
}

ExceptionHandlerFilter 는 필요한 예외를 처리한다. HandlerExceptionResolver 을 빈으로 주입하고 예외를 실어서 보낸다. 그러면 해당 예외가 등록된 @ExceptionHandler 가 처리해준다.

내가 사용했던 예외처리 전략

해당 예외처리 전략을 구상한건 Filter 예외에대해 몰랐을때 구상한것을 인지해주길 바란다. 그래서 Dispatcher Servlet 예외만 생각하고 예외처리 전략을 구상했다. Filter 예외를 추가한다면 Filter 내부에서 HandlerExceptionResolver 을 빈으로 주입받아 @ControllerAdvice@ExceptionHandler 에서 글로벌로 처리할거라고 생각한다.

1
2
3
4
5
6
7
8
@Data
@AllArgsConstructor
public class ErrorResult {
    
    private String status;
    private String code;
    private String message;
}

나는 클라이언트에게 통일된 양식의 예외를 반환하기 위해 Error Result 커스텀 클래스를 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Getter
public enum UserExceptionGroup {

    USER_NULL("400", "U001", "유저가 없습니다."),
    USER_SCORE_NULL("400", "U002", "아직 채점이 완료되지 않은 상태입니다."),
    USER_QUESTION_INVALID_SAVE("400", "U003", "저장된 질문의 순서와 전달받은 질문의 순서가 올바르지 않습니다."),
    USER_QUESTION_NULL("400", "U004", "저장된 질문에 대한 답변이 없습니다.");
    
    private final String status;
    private final String userErrorCode;
    private final String desc;

    UserExceptionGroup(String status, String userErrorCode, String desc) {
        this.status = status;
        this.userErrorCode = userErrorCode;
        this.desc = desc;
    }
}

그리고 예외를 관리하기 편하게 enum 으로 제작했다. 기본적으로 Http 상태코드, 그리고 특정 에러 번호를 지정할 수 있는 userErrorCode (있으면 나중에 문서화하기 편할거라고 생각한다), 그리고 에러에대한 설명을 값으로 넣었다.

1
2
3
4
5
6
7
8
9
@Getter
public class UserException extends RuntimeException {

    private final UserExceptionGroup userExceptionGroup;

    public UserException(UserExceptionGroup userExceptionGroup) {
        this.userExceptionGroup = userExceptionGroup;
    }
}

enum 에 있는 예외를 날리기 위해 커스텀 예외도 제작했다. Exception 이 아닌 RuntimeException 을 상속했는데 그 이유는 Exception 을 상속하면 해당 예외를 사용할때 명시적으로 예외처리를(try ~ catch 또는 throws) 를 사용해야 한다. 하지만 try ~ catch 를 사용하면 가독성이 떨어져서 지양하고 싶었다. 그렇다고 throws 를 사용하면 객체지향 스럽지않다. 예를들어 Service 단에서 예외를 던지면 Service 와 Service 를 호출한 Controller 에서도 throws 를 사용하게 된다. Service 단에서 발생하는 예외때문에 Controller 도 수정해야 된다는것은 객체지향의 OCP 를 위배한다.

하지만 RuntimeException 을 상속하면 명시적으로 예외를 표시하지 않아도 되고, 예외가 발생하면 알아서 호출한 쪽으로 넘겨준다. 결국에는 Controller 단까지 던지기에, @ControllerAdvice@ExceptionHandler 에서 처리하게 된다.

1
2
3
4
5
6
7
8
@RestControllerAdvice
public class ExceptionController {

    @ExceptionHandler(UserException.class)
    public ErrorResult UserExceptionHandler(UserException e) {
        return new ErrorResult(e.getUserExceptionGroup().getStatus(), e.getUserExceptionGroup().getCode(), e.getUserExceptionGroup().getDesc());
    }
}

마지막으로 @RestControllerAdvice 를 활용해 글로벌로 예외처리를 하였다. @RestControllerAdvice 이므로 json 형태로 클라이언트에게 전달하게 된다. 예외를 던지는 예시는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {
    
    private void checkUserNull(User user) {
        if (user == null) {
            throw new UserException(UserExceptionGroup.USER_NULL);
        }
    }

}

checkUserNull 에서 Usernull 값이라면 예외를 던진다. Service 단에서 예외를 던졌기에 Service 를 호출한 Controller 가 받게된다. Controller 에서도 예외를 던지면 @RestControllerAdvice 선언된 ExceptionController 클래스가 받는다. 거기서 @ExceptionHandler 통하여 UserException 예외에 맵핑되는 UserExceptionHandler 함수가 처리를 하게된다.

이렇게 예외 처리 전략을 구상하면 Service, Entity 에서 예외를 던져도 처리가 가능하고 일관된 포맷으로 클라이언트에게 반환해준다. 그리고 enum 을 통해 예외를 쉽게 관리할 수 있다.

이 외에도 여러가지 예외처리 전략이 존재하니 참조하면 좋을것 같다.
https://cheese10yun.github.io/spring-guide-exception/

Reference

https://terasolunaorg.github.io/guideline/5.3.0.RELEASE/en/ArchitectureInDetail/WebApplicationDetail/ExceptionHandling.html#exception-handling-basic-flow-label
https://github.com/binghe819/TIL/blob/master/Spring/%EA%B8%B0%ED%83%80/%EC%8A%A4%ED%94%84%EB%A7%81%20%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%20%EA%B0%9C%EB%85%90%20%EB%B0%8F%20%EC%A0%84%EB%9E%B5.md
https://cheese10yun.github.io/spring-guide-exception/
https://velog.io/@coastby/SpringSecurity-filter-%EB%82%B4%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0
https://inkyu-yoon.github.io/docs/Language/SpringBoot/FilterExceptionHandle
https://supawer0728.github.io/2019/04/04/spring-error-handling/
https://gngsn.tistory.com/153

This post is licensed under CC BY 4.0 by the author.