✏️ [Spring] Spring API 예외 처리

박상민·2024년 1월 1일

Spring

목록 보기
2/12
post-thumbnail

API 예외 처리는 어떻게 해야할까?
API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.

API 예외 처리를 하는 방법에는 Servlet 오류 페이지 방식, BasicErrorController 응용 방식, HandlerExceptionResolver 등이 있지만 이번 글에서는 스프링이 제공하는 방식을 적어보려고 한다.

⭐️ 스프링이 제공하는 ExceptionResolver

스프링 부트가 기본적으로 제공하는 ExceptionResolver는 다음과 같다.
HandlerExceptionResolverComposite에 다음 순서로 등록
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver -> 우선 순위가 가장 낮다.

ExceptionHandlerExceptionResolver
@ExceptionHandler을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다. 조금 뒤에 자세히 설명한다.

ResponseStatusExceptionResolver
HTTP 상태 코드를 지정해준다.

  • 예) @ResponseStatus(value = HttpStatus.NOT_FOUND)

DefaultHandlerExceptionResolver
스프링 내부 기본 예외를 처리한다.

먼저 가장 쉬운 ResponseStatusExceptionResolver부터 알아보자.

📌 ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.

다음 두 가지 경우를 처리한다.

  • @ResponseStatus가 달려있는 예외
  • ResponseStatusException 예외

예외에 다음과 같이 @ReponseStatus 애노테이션을 적용하면 HTTP 상태 코드를 변경해준다.

package hello.exception.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST(400)으로 변경하고, 메시지도 담는다.

ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason) 를 호출하는 것을 확인할 수 있다.
sendError(400)를 호출했기 때문에 WAS에서 다시 오류 페이지(/error)를 내부 요청한다.

메시지 기능
reasonMessageSource에서 찾는 기능도 제공한다 reason = "error.bad"

package hello.exception.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

//@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}

ResponseStatusException
@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.

(애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.)

📌 @ExceptionHandler

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver이다.
스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다.

실무에서 API 예외 처리는 대부분 이 기능을 사용한다.

ErrorResult

package hello.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

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

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

ApiExceptionController

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 ApiExceptionV2Controller {

	@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", "내부 오류");
	}
    
	@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("잘못된 입력 값");
 	}
 	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;
 }
 
}

@ExceptionHanlder 예외 처리 방법
@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.

다음 예제는 illegalArgumentException 또는 그 하위 자식 클래스를 모두 처리할 수 있다.

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

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

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

📌 @ControllerAdvice

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

ExControllerAdvice

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", "내부 오류");
 	}
}

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 ApiExceptionV2Controller {

	@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("잘못된 입력 값");
 	}
 	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;
 }
 
}

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

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-anncontroller-advice (스프링 공식 문서 참고

정리
@ExceptionHandler@ontrollerAdvice를 조합하면 예외를 깔끔하게 해결할 수 있다.


출처
김영한님의 Spring MVC 2편

0개의 댓글