본문 바로가기
Spring(boot)

API 예외 처리 - @ExceptionHandler와 @ControllerAdvice

by 글발 2024. 9. 12.
728x90
반응형

API 예외 처리 - @ExceptionHandler와 @ControllerAdvice

API를 개발할 때, 예외 처리는 중요한 부분입니다.

특히 웹 브라우저에 HTML 화면을 제공하는 것과 API 응답에서 예외를 처리하는 방법은 다릅니다.

HTML 화면을 제공할 때는 스프링이 제공하는 기본 오류 처리 컨트롤러인 BasicErrorController를 사용하는 것이 편리합니다.

하지만 API는 시스템마다 응답의 형식이 다르며, 다양한 상황에 맞춰 구체적인 예외 처리가 필요합니다.

이를 해결하기 위해 스프링은 편리한 예외 처리 기능을 제공하는 @ExceptionHandler를 지원합니다.

 

***참고로 스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같습니다.

HandlerExceptionResolverComposite에 다음 순서(우선순위)로 등록됩니다.

1. ExceptionHandlerExceptionResolver : @ExceptionHandler 처리

2. ResponseStatusExceptionResolver : HTTP 상태 코드를 지정

(@ResponseStatus 달려있는 예외, ResponseStatusException 예외 처리)

3. DefaultHandlerExceptionResolver : 스프링 내부 기본 예외를 처리. 우선 순위가 가장 낮다

(ex) TypeMismatchException은 원래 500오류이지만 400으로 변환함)

 

*** 위의 2, 3번처럼 HandlerExceptionResolver를 직접 사용하기는 복잡합니다.

API 오류 응답의 경우 response에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭습니다.

ModelAndView를 반환해야 하는 것도 API에는 잘 맞지 않습니다.

스프링은 이 문제를 해결하기 위해 @ExceptionHandler라는 매우 혁신적인 예외 처리 기능을 제공합니다.

이 글에서는 @ ExceptionHandler를 처리하는 ExceptionHandlerExceptionResolver에 대해 알아봅시다.

1. API 예외 처리의 어려움

API 예외 처리는 다음과 같은 특징이 있습니다:

  • API 응답에 ModelAndView가 필요하지 않으며, 서블릿 시절처럼 직접 HttpServletResponse에 데이터를 넣어주는 방식은 불편합니다.
  • 동일한 예외라도 발생한 컨트롤러에 따라 다른 방식으로 처리할 수 있어야 합니다.

2. @ExceptionHandler를 활용한 예외 처리

스프링의 @ExceptionHandler를 사용하면 컨트롤러에서 발생하는 예외를 처리할 수 있습니다.

다음은 예외 발생 시 처리하는 예제입니다.

먼저 예외를 하나 만들겠습니다.

public class UserException extends RuntimeException {

	public UserException() {
		super();
	}

	public UserException(String message) {
		super(message);
	}

	public UserException(String message, Throwable cause) {
		super(message, cause);
	}

	public UserException(Throwable cause) {
		super(cause);
	}

	protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
		super(message, cause, enableSuppression, writableStackTrace);
	}
}

RuntimeException을 상속 받고 안의 내용은 그대로 받아온 것입니다.

 

다음으로 예외가 발생했을 때 API 응답으로 사용하는 객체를 정의했습니다.

@Data
@AllArgsConstructor
public class ErrorResult {
 private String code;
 private String message;
}

 

다음은 컨트롤러 입니다.

@RestController
@Slf4j
public class ApiExceptionV3Controller {

	// @ExceptionHandler : 여기 컨트롤러에서만 적용
	@ResponseStatus(HttpStatus.BAD_REQUEST) // 뺴면 정상흐름으로 코드 200
	@ExceptionHandler(IllegalArgumentException.class)
	public ErrorResult illegalExHandler(IllegalArgumentException e) {
		log.error("[exceptionHandler] ex", e);

		return new ErrorResult("BAD", e.getMessage());
	}

	// @ExceptionHandler(UserException.class)
	@ExceptionHandler // @ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다
	public ResponseEntity<ErrorResult> userExHandler(UserException e) {
		log.error("[exceptionHandler] ex", e);

		ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
		return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);

	}

	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 뺴면 정상흐름으로 코드 200
	@ExceptionHandler
	public ErrorResult exHandler(Exception e) { // 위에서 잡지 못한 예외들이 다 넘어옴 (RuntimeException은 위에서 안잡아줘서 여기로 옴)
		log.error("[exceptionHandler] ex", e);
		
		return new ErrorResult("EX", "내부오류");
	}

	@GetMapping("/api3/members/{id}")
	public MemberDto getMember(@PathVariable("id") String id) {

		if (id.equals("ex")) {
			throw new RuntimeException("잘못된 사용자");
		}

		if (id.equals("bad")) {
			throw new IllegalArgumentException("잘못된 입력 값");
		}

		if (id.equals("user-ex")) {
			throw new UserException("사용자 오류");
		}

		return new MemberDto(id, "hello " + id);
	}

	@Data
	@AllArgsConstructor
	static class MemberDto {
		private String memberId;
		private String name;
	}

}

실행흐름은 잠시 뒤에 알아보고

위의 코드와 밑의 @ExceptionHandler 예외처리 방법 글을 함께 읽어봅시다.

@ExceptionHandler 예외처리 방법

@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 됩니다.

해당 컨트롤러에서 지정해준 예외가 발생하면 이 메서드가 호출됩니다.

참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있습니다.

우선순위

스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.

예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다면

@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}

@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}

@ExceptionHandler에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있습니다.

따라서 자식예외가 발생하면 부모 예외처리() , 자식예외처리() 둘다 호출 대상이 됩니다.

그런데 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리()가 호출됩니다.

물론 부모예외가 호출되면 부모예외처리()만 호출 대상이 되므로 부모예외처리()가 호출됩니다.

다양한 예외

다음과 같이 다양한 예외를 한번에 처리할 수 있습니다.

@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
 log.info("exception e", e);
}

예외 생략

@ExceptionHandler에 예외를 생략할 수 있습니다. 생략하면 메서드 파라미터의 예외가 지정됩니다.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

파라미터와 응답

@ExceptionHandler에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있습니다.

자세한 파라미터와 응답은 다음 공식 메뉴얼을 참고하세요.

Exceptions :: Spring Framework

 

Exceptions :: Spring Framework

@Controller and @ControllerAdvice classes can have @ExceptionHandler methods to handle exceptions from controller methods, as the following example shows: @Controller public class SimpleController { // ... @ExceptionHandler public ResponseEntity handle(IOE

docs.spring.io

 

이제 위에서 만들었던 컨트롤러의 실행흐름을 살펴보겠습니다.

하나씩 다시 코드를 보겠습니다.

IllegalArgumentException 처리, UserException 처리, Exception 처리 순으로 보겠습니다.

IllegalArgumentException 처리

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
 log.error("[exceptionHandle] ex", e);
 return new ErrorResult("BAD", e.getMessage());
}

실행 흐름

  • 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져집니다.
  • 예외가 발생했으로 ExceptionResolver가 작동합니다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행됩니다.
  • ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리 할 수 있는 @ExceptionHandler가 있는지 확인합니다.
  • illegalExHandle() 를 실행합니다. @RestController이므로 illegalExHandle()에도 @ResponseBody가 적용됩니다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환됩니다.
  • @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP 상태 코드 400으로 응답합니다.

결과

{
 "code": "BAD",
 "message": "잘못된 입력 값"
}

 

UserException 처리

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
 log.error("[exceptionHandle] ex", e);
 ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
 return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}

실행 흐름

  • @ExceptionHandler에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용합니다. 여기서는 UserException 을 사용합니다.
  • ResponseEntity를 사용해서 HTTP 메시지 바디에 직접 응답합니다. 물론 HTTP 컨버터가 사용됩니다. ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있습니다. 앞서 살펴본 @ResponseStatus는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없습니다.

Exception 처리

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
 log.error("[exceptionHandle] ex", e);
 return new ErrorResult("EX", "내부 오류");
}

실행 흐름

  • throw new RuntimeException("잘못된 사용자") 이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException이 던져집니다.
  • RuntimeException은 Exception의 자식 클래스입니다. 따라서 이 메서드가 호출됩니다.
  • @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)로 HTTP 상태 코드를 500으로 응답합니다.

기타(참고 - HTML 오류 화면)

다음과 같이 ModelAndView를 사용해서 오류 화면(HTML)을 응답하는데 사용할 수도 있습니다.

@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
 log.info("exception e", e);
 return new ModelAndView("error");
}

 

 

@ControllerAdvice 사용

@ExceptionHandler를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있습니다.

@ControllerAdvice 또는 @RestControllerAdvice를 사용하면 둘을 분리할 수 있습니다.

위 컨트롤러에서 @ExceptionHandler를 사용한 예외 처리 부분만

@RestControllerAdvice를 사용한 새로운 클래스에 떼서 옮기겠습니다.

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api") // 패키지 지정
// @RestControllerAdvice
// @RestControllerAdvice(annotations = RestController.class) // @RestController가 붙은 곳에만 적용
// @RestControllerAdvice("org.example.controllers") 		 // 패키지 경로로도 지정 가능
// @RestControllerAdvice(assignableTypes = {Controller1.class, Controller2.class}) // 특정 클래스에만 적용
public class ExControllerAdvice {

	@ResponseStatus(HttpStatus.BAD_REQUEST) // 뺴면 정상흐름으로 코드 200
	@ExceptionHandler(IllegalArgumentException.class)
	public ErrorResult illegalExHandler(IllegalArgumentException e) {
		log.error("[exceptionHandler] ex", e);

		return new ErrorResult("BAD", e.getMessage());
	}

	// @ExceptionHandler(UserException.class)
	@ExceptionHandler // @ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다
	public ResponseEntity<ErrorResult> userExHandler(UserException e) {
		log.error("[exceptionHandler] ex", e);

		ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
		return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);

	}

	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 뺴면 정상흐름으로 코드 200
	@ExceptionHandler
	public ErrorResult exHandler(Exception e) { // 위에서 잡지 못한 예외들이 다 넘어옴 (RuntimeException은 위에서 안잡아줘서 여기로 옴)
		log.error("[exceptionHandler] ex", e);
		
		return new ErrorResult("EX", "내부오류");
	}
}

예외 처리 부분만 잘라내서 옮겼습니다.

이제 컨트롤러를 볼까요? 

@RestController
@Slf4j
public class ApiExceptionV3Controller {

	@GetMapping("/api3/members/{id}")
	public MemberDto getMember(@PathVariable("id") String id) {

		if (id.equals("ex")) {
			throw new RuntimeException("잘못된 사용자");
		}

		if (id.equals("bad")) {
			throw new IllegalArgumentException("잘못된 입력 값");
		}

		if (id.equals("user-ex")) {
			throw new UserException("사용자 오류");
		}

		return new MemberDto(id, "hello " + id);
	}

	@Data
	@AllArgsConstructor
	static class MemberDto {
		private String memberId;
		private String name;
	}
}

전에는 컨트롤러 로직의 코드와 @ExceptionHandler를 사용한 예외처리 코드가 혼재 되어 있었는데,

이제 예외 처리하는 코드가 사라져서 깔끔해졌습니다.

@RestControllerAdvice 어너테이션을 사용하면 예외 처리 코드를 깔끔하게 분리하여 관리할 수 있으며, 전역적으로 예외를 처리하거나 특정 컨트롤러에 대해서만 예외 처리를 적용할 수 있습니다.

 

@ControllerAdvice

@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 합니다. @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용됩니다. (글로벌 적용) @RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있습니다. @Controller , @RestController 의 차이와 같습니다.

 

대상 컨트롤러 지정 방법

위 코드에도 설명이 있지만 대상 컨트롤러 지정 방법에 대해 스프링 공식 문서를 참고하자면 아래와 같습니다.

// 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 {}

더 자세한 내용은 아래 페이지를 참고하세요.

스프링 공식 문서 예제에서 보는 것 처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정 할 수도 있습니다. 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 됩니다. 그리고 특정 클래스를 지정할 수도 있습니다. 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용됩니다.

Controller Advice :: Spring Framework

 

Controller Advice :: Spring Framework

@ExceptionHandler, @InitBinder, and @ModelAttribute methods apply only to the @Controller class, or class hierarchy, in which they are declared. If, instead, they are declared in an @ControllerAdvice or @RestControllerAdvice class, then they apply to any c

docs.spring.io

정리

@ExceptionHandler@ControllerAdvice를 사용하면 API 예외 처리를 보다 유연하고 깔끔하게 구현할 수 있습니다. 컨트롤러의 정상 처리 코드와 예외 처리를 분리함으로써 유지보수성과 가독성을 높일 수 있습니다.

 

출처 : 인프런, 김영한-MVC2편

'Spring(boot)' 카테고리의 다른 글

오류 페이지(BasicErrorController)  (0) 2024.09.12
ArgumentResolver 활용  (0) 2024.09.10
RequestMappingHandlerAdapter  (0) 2024.09.09
스프링 인터셉터(Interceptor)  (0) 2024.09.09
서블릿 필터 (Filter)  (0) 2024.09.09