예외처리를 위한 어노테이션 - @ControllerAdvice, @ExceptionHandler

JooMal·2021년 12월 6일
1

Java와 Spring

목록 보기
1/2

GlobalExceptionHandler

예시 코드

@ControllerAdvice
public class CustomAdvice {
	@ExceptionHandler(ExampleException.class)
    	public ResponseEntity<ErrorMessage> customHandler (ExampleException e) {
        	if(e.getCause() instanceof ExampleDetail_1_Exception) {
            		ErrorMessage errorMessage = new ErrorMessage("custom Error Message");
                    	return ResponseEntity.status(401).body(errorMessage);
                }
                else if(e.getCause() instanceof Example_Detail_2_Exception) {
                       	ErrorMessage errorMessage = new ErrorMessage("custom Error Message2");
                    	return ResponseEntity.status(402).body(errorMessage);
                }
                ...
                else {
               		ErrorMessage errorMessage = new ErrorMessage("Unhandled Custom Error Message");
                    	return ResponseEntity.status(400).body(errorMessage);
                }
           }
      }

흐름

  1. @ControllerAdvice

    • 모든 컨트롤러 전반에 걸쳐 부가적인 동작(예외 처리, 바인딩 설정, 모델 객체 적용)을 해주고 싶은 경우에 사용할 수 있는 어노테이션이다.
    • @ExceptionHandler와 함께 사용해 예외 처리에 주로 사용되긴 하지만, @InitBinder와 함께 사용하여 바인딩/검증 설정 추가(ex. 특정 필드를 사용하지 않도록 설정하거나, 특정 데이터가 들어오면 에러 처리 해주기), @ModelAttribute와 함께 사용하여 전반에 걸친 모델 정보 설정을 해주는 등의 목적으로 사용할 수 있다.
  2. @ExceptionHandler(ExampleException.class)

    • @Controller@RestController가 적용된 Bean 내부에서 발생하는 특정 예외를 하나의 메서드에서 처리해주기 위한 어노테이션
  3. e.getCause() instanceof ExampleDetail_1_Exception

    • Exception의 근본 원인 예외(e.getCause())가 특정 예외 클래스의 instance인지를 체크하여, 상황에 맞게 커스텀된 에러 코드 & 에러 메세지 & status를 반환한다.
    • 실제 코드에서는 코드, 메세지, 상태가 Enum으로 별도 분리되어 관리되고 있었습니다!

한 줄씩 살펴보기

어노테이션 인터페이스

@ControllerAdvice 어노테이션 인터페이스

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

@ExceptionHandler 어노테이션 인터페이스

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

메타 어노테이션

어노테이션 선언에 사용되는 어노테이션

@Target(ElementType.TYPE)

  • 어노테이션을 붙일 수 있는 대상을 지정해준다. ElementType.TYPE의 경우에는 클래스, 인터페이스, Enum 타입에 어노테이션을 붙일 수 있게 해준다.
  • 그 외에 @Target 어노테이션의 매개변수로 CONSTRUCTOR(생성자), METHOD(메소드), FIELD(멤버 변수) 등이 있다.

@Retention

  • 어노테이션 메모리 유지 기간을 관리하기 위한 어노테이션
  1. RetentionPolicy.SOURCE
  • 소스 코드(.java)까지만 남아있는다
  • ex) 롬복의 @Getter, @Setter
    - 코드를 작성할 때 delombok을 누르면 나오는, 클래스의 getter와 setter가 .java 소스 파일에 삽입된다.
    - 바이트코드로 바뀔 때에는 어노테이션 정보가 사라지고, lombok이 생성한 getter와 setter 자바 코드로 컴파일 될 것이다.
  1. RetentionPolicy.CLASS (default)
  • .class 파일(=바이트코드)까지만 어노테이션이 유지된다.
  • JVM이 기동되며 런타임에 클래스로더로부터 로딩되는 시점에 사라지게 된다.
  • ex) 롬복의 @NonNull
    - @NonNull을 넣은 파라미터에 대한 null 체크를 메소드 앞에 삽입해주게 된다. 해당 파라미터에 값을 할당하는 메소드가 있다면, 해당 메소드에도 null 체크 로직을 삽입해준다.
    - 클래스 파일을 열어보면 RuntimeVisibleAnnotation, RuntimeInVisibleAnnotation 으로 구분되어 들어가있는데, RuntimeInVisibleAnnotation이면 로드할 때에 같이 로딩되지 않는 것 같다.
  1. RetentionPolicy.RUNTIME
  • 런타임 때에도 JVM에 별도의 메모리에 어노테이션 정보를 갖고 사용해야하는 경우에 사용하는 정책
  • ex) 스프링의 @Autowired
    - 실행될 때 빈을 주입해야하는 경우 등 실행되는 중에도 JVM에서 어노테이션의 정보를 갖고 있어야 하는 경우에 사용한다.
  • 대부분의 어노테이션이 런타임 정책이다.

@Inherited

  • 자식 클래스에서 부모 클래스에 선언된 어노테이션을 상속받게 된다.

@Repeatable

  • 동일한 어노테이션을 여러 개 선언할 수 있게 된다.
  • 사용하지 않는 경우, 중복해서 어노테이션을 선언하면 에러가 발생한다.

그 외에,

@Documented

  • javadoc으로 문서를 생성할 때 현재 어노테이션에 대한 설명을 추가해주는 어노테이션

@Compoment

  • 스프링 IoC 컨테이너에 Bean으로 등록하기 위한 어노테이션
  • @Bean 과의 차이 : @Bean은 메소드 레벨에서 선언 & 반환되는 객체를 개발자가 수동으로 빈으로 등록할 수 있고, @Component의 경우에는 클래스 레벨에서 선언 & 런타임에 스프링이 컴포넌트 스캔을 해서 자동으로 빈을 찾고 등록하게 된다.

결국

@ControllerAdvice 어노테이션 인터페이스

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

@ExceptionHandler 어노테이션 인터페이스

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExceptionHandler {
    // ...
}
  • ControllerAdvice : TYPE(클래스, 인터페이스, Enum)에 대한 어노테이션인데, (런타임 에러까지 잡기 위하여) 로딩 이후에도 JVM에 남아 있는, 스프링 빈으로 등록되는(@Component) 어노테이션이다.
  • ExceptionHandler : 메소드에 적용되는 어노테이션이고, 마찬가지로 로딩 이후에도 JVM에 남아있는 어노테이션이다.

내부 살펴보기

인터페이스밖에 없는데... 어떻게 동작하는 걸까?

스프링의 동작

  1. 클라이언트는 URL 주소를 통해 서버에 요청을 보낸다.
  2. 클라이언트의 요청이 Dispatcher Servlet으로 들어오고,
  3. Dispatcher Servlet은 요청과 매핑되는 컨트롤러를 찾아서
    • 컨트롤러들에 대한 정보를 갖고 있는 HandlerMapping 에서 요청받은 URL, Http Method(GET/POST 등), 리턴 타입, 매개변수 정보 등을 이용하여 적합한 컨트롤러를 검색한다.
  4. 해당 컨트롤러에게 처리를 요청한다.
  5. ...

Dispatcher Servlet 코드

public class DispatcherServlet extends FrameworkServlet {
   protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
     ....
     ....
     processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
     ....
     .... 
  }

   private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
			@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
			@Nullable Exception exception) throws Exception {
       ....
	   if (exception != null) {
			if (exception instanceof ModelAndViewDefiningException) {
				...
			}
			else {
				Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
				mv = processHandlerException(request, response, handler, exception);
				errorView = (mv != null);
			}
		}
      ....
   }

  protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
			@Nullable Object handler, Exception ex) throws Exception {

		...

		// Check registered HandlerExceptionResolvers...
		ModelAndView exMv = null;
		if (this.handlerExceptionResolvers != null) {
			for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
				exMv = resolver.resolveException(request, response, handler, ex);
				if (exMv != null) {
					break;
				}
			}
		}
		....
        ....
		throw ex;
	}
}
  1. Dispatcher Servlet이 동작을 하다가
  2. 에러가 발생(exception != null) 하면,
  3. processHandlerExcpetion 으로 들어가서,
  4. HandlerExceptionResolver를 받아와 resolveException을 하게 되는데
  5. HandlerExceptionResolver에는 @ControllerAdvice 빈이 등록되어 주입됨

총정리

흐름

사용자의 요청이 들어오면 디스패처 서블릿은 요청에 알맞은 컨트롤러를 찾아 처리를 요청하는데,
이때 exception이 발생하게 되면 (등록된 ControllerAdivce 빈이 주입된) HandlerExceptionResolver가 익셉션을 잡게 되고,
ControllerAdvice 빈에서 익셉션을 잡아 작성한 ExceptionHandler 클래스 내부의 ExceptionHandler로 넘어가는데,
ExceptionHandler는 명시된 익셉션 클래스에 속하는 익셉션을 잡는다.

  • 발생한 익셉션의 근본 원인 익셉션이 특정 익셉션의 인스턴스인지에 따라, 각기 다른 에러 메세지, status를 넣어주도록 작성할 수 있다.
  • 에러 메세지와 status는 별도의 열거형으로 분리하여 관리하면 체계적인 익셉션핸들링이 가능하다.

왜 이런 일을 하는 걸까?

  • exception이 발생했을 때 내부 구조를 알지 못하는 사용자도 구체적으로 문제를 유추할 수 있다.
    • ex) 검색 조건에서 존재하지 않는 ID와 검색에 실패했을 때, "javax.persistence.EntityExistsException"라는 에러 대신 "ID를 입력하지 않았습니다." 라는 내부의 익셉션을 출력해주면 사용자가 구체적으로 원인을 파악할 수 있을 것이다.
  • exception이 발생했을 때 모듈 내부의 정보(실제 사용중인 주소, DB 이름, 패키지 구조 등)를 사용자에게 숨길 수 있다.

    • ex) "{패키지구조}.api.controller.abc.types.AbcType.HTTP로의 변환에 실패했다"라는 에러 대신 "존재하지 않은 enum type입니다."라는 내부 익셉션을 출력해주면 내부 정보를 사용자에게 드러내지 않을 수 있다.
  • exception을 모아서 관리할 수 있다.

    • 코드 이곳 저곳에 try-catch-throw를 적어두는 대신, 동일한 원인의 익셉션에 대하여 일관적인 Error Response(에러 메세지, 코드)를 반환하기 위하여 사용한다.
  • 예외 생성 비용을 절감할 수 있다.

    • 자바의 stack trace는 예외가 발생할 경우, 정확한 예외 발생 위치를 제공하기 위해 스택에 메소드 리스트를 저장해 출력한다. try/catch나 advice를 통해 필요에 따라 예외를 처리한다면, stack trace 생성에 비용을 소모하지 않고 메세지만 넘겨주는 등의 동작을 해줄 수 있다.

의 목적으로 exception handler를 사용해, 발생하는 예외를 잡아 특정 메서드에서 처리해주게 된다.


다음에 공부하고 싶은 것들

  • 스프링의 진짜 동작... (Dispatcher Servlet의 동작, Handler Mapping의 동작, Resolver는 무엇인지, ...)
  • 직접 어노테이션을 만드는 방법
  • @Bean과 @Component의 차이
  • Java의 exception 동작 원리

참고한 곳들

profile
🏄‍♂️ 𝐒𝐭𝐮𝐝𝐲𝐢𝐧𝐠

0개의 댓글