[Spring] 예외처리 - ExceptionHandler, ControllerAdvice

2022. 10. 30. 20:31

스프링은 api요청, 응답에서 발생하는 예외들을 간편하게 처리할 수 있는 애너테이션을 지원한다.

 

예제로 쓰이는 패키지의 구조는 다음과 같다.

@ExceptionHandler

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExceptionHandler {

	/**
	 * Exceptions handled by the annotated method. If empty, will default to any
	 * exceptions listed in the method argument list.
	 */
	Class<? extends Throwable>[] value() default {};

}

ExceptionHandler 애너테이션은 value에 처리할 예외를 지정하면 해당 예외가 발생했을 때 처리할 수 있다.

만약 value를 지정하지 않으면 argument에 있는 예외를 잡아서 처리한다.

- @ExceptionHandler({AException.class, BException.class})

 

@ExceptionHandler를 Controller의 메서드에 붙여주면 해당하는 예외가 발생하면 처리해서 응답해준다.

@Getter
@Setter
public class Member {
    private long id;
    private String name;
    private int age;
}

@Getter
public class MemberPostDto {

    @NotBlank(message = "공백이 아니어야 합니다")
    private String name;

    @Positive(message = "1 이상이어야 합니다")
    private int age;
}

@Getter
@Setter
public class MemberResponseDto {
    private long id;
    private String name;
    private int age;
}


@Mapper(componentModel = "spring")
public interface MemberMapper {
    Member memberPostDtoToMember(MemberPostDto memberPostDto);

    MemberResponseDto memberToMemberResponseDto(Member member);
}

@Controller
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberMapper mapper;

    @PostMapping
    public ResponseEntity memberPost(@Valid @RequestBody MemberPostDto memberPostDto) {
        Member member = mapper.memberPostDtoToMember(memberPostDto);

        member.setId(1L);
        MemberResponseDto response = mapper.memberToMemberResponseDto(member);
        return new ResponseEntity(response, HttpStatus.CREATED);
    }
}

MemberPostDto 클래스는 validation 애너테이션으로 name과 age에 대해 검증한다. 검증에 통과하면 문제가 없지만 검증에 실패하면 자체적으로 에러 메시지를 응답해준다.

{"name" : "kim", "age" : 21}

 

{"name" : "kim", "age" : 0}

자체적으로 응답이 전달되기는 하지만 클라이언트 측에선 왜 예외가 발생했는지 알 수 없는 불친절한 응답을 전달받는다. @ExceptionHandler를 이용해서 필요한 정보들을 응답으로 보내보자.

@Getter
@AllArgsConstructor
public class ErrorResponse {

    private List<FieldError> fieldErrors;

    @Getter
    @AllArgsConstructor
    public static class FieldError {
        private String field;
        private Object value;
        private String message;
    }
}

api 응답으로 field, value, message만을 담아서 보낼 클래스를 만든다.

 

@Controller
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    //...

    @ExceptionHandler
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();

        List<ErrorResponse.FieldError> response = fieldErrors.stream()
                .map(err -> new ErrorResponse.FieldError(
                        err.getField(),
                        err.getRejectedValue(),
                        err.getDefaultMessage()
                )).collect(Collectors.toList());
        return new ResponseEntity(new ErrorResponse(response), HttpStatus.BAD_REQUEST);
    }
}

예외를 처리할 메서드를 만들어서 메서드에 @ExceptionHandler 애너테이션을 붙이고 정보들을 담아서 응답하면 된다.

 

{"name" : "kim", "age" : 0}

이제 클라이언트는 어느 정보에서 예외가 발생하고 발생한 이유에 대해 알 수 있게 되었다. 하지만 @ExceptionHandle는 문제가 있다.

작성한 코드들은 엔티티가 Member하나만 있고 예외도 하나만 처리했지만, 엔티티가 많아지고 처리할 예외들을 Controller에 지정하면 Controller의 코드들은 길어지고 같은 예외들을 모든 엔티티의 Controller에 작성해야 하는 번거로움이 있다. 

 

전역으로 예외를 처리할 수 있는 방법은 @ControllerAdvice를 사용하는 것이다.


@ControllerAdvice

@ControllerAdvice는 예외를 공통적으로 처리할 클래스에 애너테이션을 붙여주면 예외가 발생하면 해당 클래스에서 예외를 처리해준다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice { }

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice { }

@RestControllerAdvice 애너테이션도 있는데, @RestController와 @Controller의 차이와 비슷하다고 생각하면 된다.

@RestControllerAdvice에는 @ResponseBody와 @ControllerAdvice가 애너테이션이 추가되어 있다.

 

@RestControllerAdvice를 적용해서 예외를 전역적으로 처리해보자.

 

Controller에 작성했던 예외 처리 메서드를 지우고 새로운 클래스 안에 코드를 작성한다.

@RestControllerAdvice
public class GlobalException {

    @ExceptionHandler()
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();

        List<ErrorResponse.FieldError> response = fieldErrors.stream()
                .map(err -> new ErrorResponse.FieldError(
                        err.getField(),
                        err.getRejectedValue(),
                        err.getDefaultMessage()
                )).collect(Collectors.toList());
        return new ResponseEntity(new ErrorResponse(response), HttpStatus.BAD_REQUEST);
    }
}

이제 유효하지 않은 api 요청을 보내면 Controller에 적용했던 예외 처리 응답과 같은 응답을 받는 것을 확인할 수 있다.

ControllerAdvice의 옵션으로 예외를 처리할 컨트롤러를 지정할 수 있다.

// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice

 

Web on Servlet Stack

Spring Web MVC is the original web framework built on the Servlet API and has been included in the Spring Framework from the very beginning. The formal name, “Spring Web MVC,” comes from the name of its source module (spring-webmvc), but it is more com

docs.spring.io

 

 

BELATED ARTICLES

more