본문 바로가기
Spring/스프링 MVC 활용

#9 API 예외 처리

by 히포파타마스 2021. 8. 3.

API 예외 처리

 

HTML 페이지의 경우 BasicErrorController에 의해서 4xx, 5xx와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있었다.

하지만 API의 경우 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.

 

BasicErrorController는 클라이언트 요청의 Accept 헤더 값에 따라 에러를 다르게 처리한다.

 

[BasicErrorController 코드]

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

동일한 경로(/error)를 처리하는 errorHtml(), error() 두 메서드를 확인할 수 있다.

 

● errorHtml()의 produces = MediaType.TEXT_HTML_VALUE 는 클라이언트 요청의 Accept 헤더 값이 text/html일 경우에 호출됨을 의미한다.

 

● error()는 Accept 헤더가 text/html이 아닌 경우에 호출되고 ResponseEntity로 HTTP Body에 JSON 데이터를 반환한다.

※ 오류 페이지 경로는 기본적으로 /error 이고 application.properties에서 sercer.error.path로 수정 가능하다.

 

[API 컨트롤러]

@Slf4j
@RestController
public class ApiExceptionController {
	
    @GetMapping("/api/members/{id}")
	public MemberDto getMember(@PathVariable("id") String id) {
		if (id.equals("ex")) {
			throw new RuntimeException("잘못된 사용자"); 
		}
	
		return new MemberDto(id, "hello " + id); 
	}

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

경로 변수로 id를 받아서 JSON 형식으로 데이터를 응답하는 컨트롤러를 만들었다.

id가 ex일 경우 RuntimeException이 발생한다.

 

오류가 발생할 경우 스프링 부트는 BasicErrorController가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다.

 

[API 오류 결과 - BasicErrorController]

{
	"timestamp": "2021-04-28T00:00:00.000+00:00", "status": 500,
	"error": "Internal Server Error",
	"exception": "java.lang.RuntimeException",
	"trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat 
	hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController .java:19...,
	"message": "잘못된 사용자", "path": "/api/members/ex"
}

 

 

 

 

1. API 예외 처리 - HandlerExceptionResolver

BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다. 

하지만 이 방법으로는 각 API 마다 오류 메시지, 형식 등을 다르게 처리하기 어렵다.

스프링 MVC은 상태코드, 오류 메시지, 형식 등을 API마다 다르게 처리할 수 있게 하는 HandlerExceptionResolver를 제공한다.

 

HandlerExceptionResolver, 줄여서 ExceptionResolver는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 새롭게 동작을 정의할 수 있는 방법을 제공한다.

 

[예외 처리 기본 흐름]

기본적으로 컨트롤러에서 예외가 발생하면 서블릿을 거쳐 WAS 까지 예외가 전달된다.

 

[예외 처리 흐름 - ExceptionResolver 적용]

ExceptionResolver를 적용하면 컨트롤러에서 예외가 발생하면 서블릿까지 예외가 전달되는 것은 같지만, 서블릿은 ExceptionResolver를 호출에 예외 해결을 시도한다. 

이후, 예외를 해결하면 ModelAndView를 반환하고 다시 정상 흐름대로 작동한다.

※ ExceptionResolver로 예외를 해결해도 postHandle()은 호출되지 않는다.

 

[HandlerExceptionResolver - 인터페이스]

public interface HandlerExceptionResolver {   
	
	ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
		Object handler, Exception ex);
}

● handler :  핸들러(컨트롤러) 정보

● Exception ex : 핸들러(컨트롤러)에서 발생한 예외

 

ExceptionResolver를 사용하면 예외가 발생할 때 ExceptionResolver에서 예외를 처리하고 끝낼 수도 있다.

 

[API 컨트롤러 - ExceptionResolver]

@Slf4j
@RestController
public class ApiExceptionController {
	
    @GetMapping("/api/members/{id}")
	public MemberDto getMember(@PathVariable("id") String id) {
		
		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;
	}
}

id가 user-ex 일때 UserException(사용자 정의 예외) 예외를 발생시키는 컨트롤러이다.

 

UserException을 처리하는 UserHandlerExceptionResolver를 작성한다.

 

[UserHandlerExceptionResolver]

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

	private final ObjectMapper objectMapper = new ObjectMapper(); 

	@Override
	public ModelAndView resolveException(HttpServletRequest request, 
		HttpServletResponse response, Object handler, Exception ex) {

		try {
			if (ex instanceof UserException) {
				String acceptHeader = request.getHeader("accept");
				response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
				
				if ("application/json".equals(acceptHeader)) {
					Map<String, Object> errorResult = new HashMap<>();
					errorResult.put("ex", ex.getClass());                     
					errorResult.put("message", ex.getMessage());
					
					String result = objectMapper.writeValueAsString(errorResult);
                    
					response.setContentType("application/json");
					response.setCharacterEncoding("utf-8");
					response.getWriter().write(result);
					
					return new ModelAndView(); 
				} else {

				//TEXT/HTML
				return new ModelAndView("error/500"); 
				}
			}
		} catch (IOException e) {
			log.error("resolver ex", e);
		}
		
        return null; 
	}
}

서블릿을 사용할 때 ObjectMapper를 사용해서 JSON 형식으로 변환해서 응답한 것처럼 ExceptionResolver에서도 자료구조를 JSON 형식으로 변환해서 response에 담을 수 있다.

 

● reponse.setStatus(HttpServletResponse.SC_BAD_REQUEST) : 오류 상태 코드를 404로 응답하게 한다.

● ModelAndView("error/500") : Accept 헤더가 application/json이 아니면 "error/500" 뷰를 렌더링 한다.

 

ExceptionResolver는 반환 값에 따라 예외 발생 이후 동작이 달라진다.

 

● 빈 ModelAndView : 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.

● ModelAndView 지정 : ModelAndView에 View, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.

● null : null을 반환하면, 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExcptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

 

[UserhandlerExceptionResolver 추가]

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
	resolvers.add(new UserHandlerExceptionResolver());
}

WebMvcConfigurer를 상속받아 extendHandlerExceptionResolvers를 사용해 등록한다.

 

UserException 예외를 발생시키면 UserHandlerExceptionResolver에 의해서 다음과 같은 결과가 나온다.

 

[UserHandlerExceptionResolver 결과]

{
	"ex": "hello.exception.exception.UserException", 
	"message": "사용자 오류"
}

http://localhost:8080/api/members/user-ex 을 application/json 헤더로 요청하면 위와 같은 결과를 얻을 수 있다.

 

 

 

 

2. 스프링이 제공하는 ExceptionResolver

스프링 부트는 기본으로 몇 가지 ExcptionResolver를 제공해준다.

 

■ ResponseStatusExceptionResolver

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

 

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

 

● @ResponseStatus가 달려있는 예외

● ResponseStatusException 예외

 

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

 

[@ResponseStatus 사용 예]

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

위 예제처럼 예외에 @ResponseStatus가 붙으면, 예외가 발생했을 때 ResponseStatusExceptionResolver이 해당 애노테이션을 확인하고 오류 코드를 지정된 코드(HttpStatus.BAD_REQUEST)로 변경하고 메시지(reason)도 담는다.

 

reason은 MessageSource에서 찾는 기능도 제공한다.

ex) reason = "error.bad"

 

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없으며, 조건에 따라 동적으로 변경하는 것도 어렵다.

이때는 ResponseStatusException 예외를 사용하면 된다.

 

[API 컨트롤러 - ResponseStatusException]

@GetMapping("/api/response-status-ex2") 
public String responseStatusEx2() {
	throw new ResponseStatusException(HttpStatus.NOT_FOUND, 
		"error.bad", new IllegalArgumentException());
}

 

 

 

■ DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외로 발생하는 상태 코드를 적절하게 변경해준다.

 

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 예외가 WAS까지 올라가고 결과적으로 500 오류가 발생해야 한다.

그런데 파라미터 바인딩은 대부분 클라이언트 측 문제이다.

이런 이유로 DefaultHandlerExceptionResolvers는 이 경우 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.

 

그 외의 수많은 스프링 내부 오류를 어떻게 처리할지 정의되어 있다.

 

 

 

■ @ExceptionHandler

API 오류 응답의 경우 HandlerExceptionResolver를 직접 사용하는 것은 다음과 같은 이유 때문에 적절하지 않다고 볼 수 있다.

 

● HandlerExceptionResolver는 ModelAndView를 반환한다. 이것은 API 응답에는 필요하지 않다.

● API 응답을 위해 HttpServletResponse에 직접 응답 데이터를 넣어주었다. 이것은 매우 매우 불편한 작업이다.

● 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다.

 

스프링은 이 문제를 해결하기 위해 ExceptionHandlerExceptionResolver를 제공한다. 

이 ExceptionResolver는 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공한다.

 

무엇보다, 스프링이 기본으로 제공하는 ExceptionResolver 중에 우선순위가 가장 높다.

 

[@ExceptionHandler 사용 예]

@Slf4j
@RestController
public class ApiExceptionV2Controller {

	@ResponseStatus(HttpStatus.BAD_REQUEST)
	@ExceptionHandler(IllegalArgumentException.class)
	public ErrorResult illegalExHandle(IllegalArgumentException e) {         
		return new ErrorResult("BAD", e.getMessage()); 
	}

	@ExceptionHandler
	public ResponseEntity<ErrorResult> userExHandle(UserException 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) {         
		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;
	}
}

ErrorResult는 String code와 String message로 이루어진 데이터 객체이다.

 

getMember는 id를 경로 변수로 받고 특정 id에 따라 예외를 발생시키는 컨트롤러이다.

 

getMember에서 에러가 발생하면 에러에 맞는 @ExceptionHandler 애노테이션이 붙은 메서드가 실행된다.

 

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

해당 컨트롤러에서 예외가 발생하면 이 애노테이션이 붙은 메서드가 호출된다.

 

●다양한 예외를 한 번에 처리할 수 있다.

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

 

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

 

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

ex) 응답으로 ErrorResult(사용자 지정 객체), ResponseEntity 등 사용 가능

 

[IllegalArgumentException 처리 - @ExceptionHandler]

ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {     
	return new ErrorResult("BAD", e.getMessage());
}

 

● Accept : application/json으로 http://localhost:8080/api2/members/bad 를 요청하면 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.

 

● 예외가 발생했으므로 ExceptionResolver가 작동하고 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.

 

● ExceptionHandlerExceptionResolver는 해당 컨트롤러에 예외를 처리할 수 있는 @ExceptionHandler가 있는지 확인하고 illegalExHandle()을 실행한다. 

 

● @RestController 이므로 응답이 JSON으로 반환된다.

 

[IllegalArgumentException 처리 결과 - @ExceptionHandler]

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

 

[UserException 처리 - @ExceptionHandler]

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

@ExceptionHandler에 예외를 지정하지 않았기 때문에 해당 메서드의 파라미터 예외인 UserException이 사용된다.

@ExceptionHandler는 ResponseEntity를 반환할 수 있다.

ResponseEntity를 사용해서 HTTP 응답 코드를 동적으로 변경할 수 도 있다.

※ @ExceptionHandler는 ModelAndView도 반환할 수 있기 때문에 오류 화면(HTML)을 응답하는 데 사용할 수도 있다.

 

 

□ @ControllerAdvice

@ControllerAdvice 또는 @RestControllerAdvice를 사용해서 컨트롤러와 @ExceptionHandler 메서드를 분리할 수 있다.

 

[@RestControllerAdvice 사용 예]

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

	@ResponseStatus(HttpStatus.BAD_REQUEST)
	@ExceptionHandler(IllegalArgumentException.class)
	public ErrorResult illegalExHandle(IllegalArgumentException e) {         
		return new ErrorResult("BAD", e.getMessage()); 
	}

	@ExceptionHandler
	public ResponseEntity<ErrorResult> userExHandle(UserException 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) {         
		return new ErrorResult("EX", "내부 오류"); 
	}
}

@RestControllerAdvice는 @RestController와 같이 반환 값을 응답 메시지 본문에 직접 반환한다.

 

@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다.

※ 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.

 

[대상 컨트롤러 지정 방법]

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

위 예시처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수 도있다.

패키지의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다.

물론 특정 클래스를 지정할 수 있다.

'Spring > 스프링 MVC 활용' 카테고리의 다른 글

#11 파일 업로드  (0) 2021.08.03
#10 스프링 타입 컨버터  (0) 2021.08.03
#8 예외 처리와 오류 페이지  (0) 2021.08.02
#7 로그인 처리 - 필터, 인터셉터  (0) 2021.08.01
#6 로그인 처리 - 쿠키, 세션  (0) 2021.07.30

댓글