[개발] 예외처리와 @ExceptionHandler

도현김·2023년 7월 27일
post-thumbnail

1. 예외처리와 @ExceptionHandler

웹애플리케이션을 개발할 때 발생하는 예외는 HTML 화면에 표시되는 오류페이지로 처리될 수 있고 API를 사용시 JSON으로 전송되는 API 메시지로 예외처리될 수 있다.

@ExceptionHandler는 둘 중에 API를 사용할 때 발생하는 예외를 처리하는 Spring 애노테이션이다.

1.1 @ExceptionHandler란?

예외가 발생했을 때 HTML로 오류페이지를 보내주는 방법은 어렵지 않다. 그저 상태코드에 따라서 정해진 오류페이지와 메시지를 내려주면 되기 때문이다. 그러나 API 예외처리는 다르다. API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 다르기 때문에 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다.

예를 들어 상품 API와 주문 API는 응답 모양이 완전히 다를 수 있다. 따라서 예외가 발생하면 같은 예외일지라도 각각 다른 모양의 응답을 만들어야 하는 경우가 발생할 수 있다.

그런데 @ExceptionHandler를 사용하면 컨트롤러마다 예외를 별도로 처리할 수 있기 때문에 API 마다 다른 응답 모양을 지정해줄 수 있다.

1.2 @ExceptionHandler의 사용 방법과 장점

  • ErrorResult - 예외가 발생했을 때 API 응답으로 처리될 객체
package hello.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ErrorResult {
	private String code;
	private String message;
}
  • ApiExceptionController - @ExceptionHandler 사용 예시
package hello.exception.exhandler;
import hello.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
	@ExceptionHandler
	public ErrorResult exHandle(Exception e) {
		log.error("[exceptionHandle] ex", e);
		return new ErrorResult("EX", "내부 오류");
	}
 
	@GetMapping("/api2/members/{id}")
	public MemberDto getMember(@PathVariable("id") String id) {
		if (id.equals("ex")) {
			throw new RuntimeException("잘못된 사용자");
		}
		if (id.equals("bad")) {
			throw new IllegalArgumentException("잘못된 입력 값");
		}
		return new MemberDto(id, "hello " + id);
	}

	@Data
	@AllArgsConstructor
	static class MemberDto {
		private String memberId;
		private String name;
	}
}
  • @ExceptionHandler 사용 방법 :
    • @ExceptionHandler를 선언하고 그 인자에 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 그러면 컨트롤러에서 지정했던 예외가 발생했을 때 @ExceptionHandler를 적용한 메서드가 호출된다. 아래의 코드는 위의 코드에서 발췌한 일부이다.
// 컨트롤러에서 IllegalArgumentException.class 발생시 아래의 메소드가 호출됨

@ExceptionHandler(IllegalArgumentException.class) // @ExceptionHandler와 예외지정
public ErrorResult illegalExHandle(IllegalArgumentException e) {
	log.error("[exceptionHandle] ex", e);
	return new ErrorResult("BAD", e.getMessage());
}
  • 위의 @ExceptionHandler 사용 예시를 보면 마치 스프링 MVC의 컨트롤러에서 API를 쓰는 것과 비슷하게 동작한다.

    • 스프링 MVC에서 매핑된 URL이 호출될 때 컨트롤러 메서드가 호출되는 것처럼 매핑된 예외가 발생했을 때 @ExceptionHandler가 적용된 메서드가 호출된다.
    • 그리고 반환되는 값이 MVC의 API 방식과 같다. MVC와 @ExceptionHandler이 동일하게 @ResponseBody나 ResponseEntity를 사용해서 JSON을 메시지로 반환한다.
      • 물론 @ExceptionHandler를 사용할때 ModelAndView를 통한 뷰 렌더링(오류화면 HTML)이 가능하다. 하지만 기본 오류페이지를 사용하는 것이 더 편리하기 때문에 일반적으로 사용하지 않는다.
  • URL로 매핑된 컨트롤러에서 예외가 발생되었을 때 예외에 따라@ExceptionHandler가 매핑된 메소드가 호출되는 것을 볼 수 있다. 이는 아래와 같은 실행 흐름으로 진행된다.

  1. IllegalArgumentException 예외 발생. (예시 예외 : IllegalArgumentException)
  2. 스프링은 해당 컨트롤러에 IllegalArgumentException 예외를 처리할 수 있는 @ExceptionHandler가 있는지 확인.
  3. @ExceptionHandler가 있다면 해당 메서드를 수행해서 메시지를 보내고 없다면 기본 API 예외를 메시지로 보냄.
    1. 해당 메서드는 @RestController 애노테이션이 있어서 @ResponseBody가 자동 적용되기 때문에 응답이 JSON 메시지로 반환된다.
    2. @ResponseStatus(HttpStatus.BAD_REQUEST)로 적용되어있기 때문에 상태 코드를 400으로 응답.
  • 핵심적으로 @ExceptionHandler는 하나의 컨트롤러, 즉 하나의 클래스 안에서만 호출이 되는 것이기 때문에 클래스마다 별도의 예외처리를 할 수 있다.
    • 예를 들면 주문 API는 OrderController 이고 상품 API는 ItemController이라고 가정했을 때, 두 컨트롤러에서 각각 @ExceptionHandler을 구현해야하기 때문에 예외 발생시 다른 모양의 메시지로 응답할 수 있다.

1.3 @ExceptionHandler 상세 기능

  • @ExceptionHandler에 예외를 지정했을 때, @ExceptionHandler는 해당 예외는 물론이고 그 예외의 자식 예외도 잡아서 메소드를 호출한다.
  • 그런데 만일 아래처럼 자식 예외와 그 부모 예외가 모두 @ExceptionHandler에 지정되어 있다면 더 자세한 것이 우선권을 가지므로 자식 예외가 발생했을 때 자식 예외처리() 가 호출된다.
    • 부모 예외가 발생했을 때는 자식 예외는 무관하기 때문에 부모 예외처리() 가 호출된다.
    • 그러나 아래의 코드에 @ExceptionHandler(자식예외.class)가 없다면 자식 예외 발생시 @ExceptionHandler(부모예외.class)로 간주하여 부모 예외처리가 호출된다.
@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}

@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}
  • 배열로 여러 예외를 지정할 수 있다.
// AException.class, BException.class (두가지 예외 등록)
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
	log.info("exception e", e);
}
  • 파라미터의 예외 정보를 통해서 @ExceptionHandler의 예외를 생략할 수 있다.
// @ExceptionHandler("생략") => 메소드의 파라미터인 UserException 예외로 대체됨.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
	// ...
}

1.4 @ControllerAdvice

  • @ExceptionHandler의 한계

    • @ExceptionHandler로 예외를 깔끔하게 처리할 수 있게 되었지만 컨트롤러의 정상코드와 예외처리 코드를 분리할 수 없다. ⇒ 컨트롤러 책임이 많다. 책임을 분리해야한다.
    • 여러 컨트롤러에서 @ExceptionHandler를 공통으로 사용해서 API 오류 응답을 처리하고 싶다. ⇒ 두 API에서 공통으로 예외를 처리해야하는 경우가 있을 수 있다.
    • @ControllerAdvice를 사용해서 해결할 수 있다.
  • ExControllerAdvice - @ControllerAdvice 예시

package hello.exception.exhandler.advice;
import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

	@ResponseStatus(HttpStatus.BAD_REQUEST)
	@ExceptionHandler(IllegalArgumentException.class)
	public ErrorResult illegalExHandle(IllegalArgumentException e) {
		log.error("[exceptionHandle] ex", e);
		return new ErrorResult("BAD", e.getMessage());
	}
	
	@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);
	}

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

}
  • @ControllerAdvice 사용 방법 :

    • 이전의 클레스(ApiExceptionController)의 코드에서 @ExceptionHandler가 적힌 메소드(예외처리 코드)를 모두 새로운 클래스(ExControllerAdvice)로 잘라내어 옮긴다.
    • 새로운 클래스 상단에 @RestControllerAdvice를 붙인다.
      • @ControllerAdvice를 사용해도 되지만 대부분 API 예외처리에 사용하기 때문에 @RestControllerAdvice를 사용한다.
      • @RestControllerAdvice = @ControllerAdvice + @Responsebody
    • 그리고 @RestControllerAdvice에 예외처리를 위임할 대상 컨트롤러를 지정한다.
      • 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
  • @ControllerAdvice에 대상 컨트롤러 지정하는 방법

    • 어노테이션, 패키지, 클래스 단위로 API 예외 처리를 공통 적용할 수 있다.
// 어노테이션 단위 (ex) @RestController가 달린 모든 컨트롤러)
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// 특정 패키지 단위 (ex) "org.example.controllers" 패키지 내의 모든 컨트롤러)
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 특정 클래스 단위 (ex) ControllerInterface.class, AbstractController.class를 선언한 모든 컨트롤러)
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
  • @ExceptionHandler와 @ControllerAdvice의 충돌

    • 컨트롤러 내부에 있는 @ExceptionHandler와 특정 @ControllerAdvice에 위임된@ExceptionHandler 사이에서 충돌이 일어나면 컨트롤러 내부에 있는 @ExceptionHandler가 우선순위를 가진다.
  • @ControllerAdvice의 이점

    • @ControllerAdvice에 예외처리 코드를 위임함으로써 컨트롤러의 정상코드와 예외처리 코드를 분리할 수 있었고 컨트롤러의 책임을 줄일 수 있다.
    • @ControllerAdvice에 예외처리 코드를 위임하고 대상 컨트롤러를 패키지나 클래스 단위로 지정함으로써 여러 컨트롤러에 공통으로 API 예외를 처리할 수 있었다.
profile
안녕하세요! 신입 개발자 김도현입니다.

0개의 댓글