Spring의 API 예외처리

dev_314·2023년 3월 20일
0

Spring - Trial and Error

목록 보기
5/7

참고: Servlet, Spring의 Web MVC 예외처리

@RestController
public class MyController {
	@GetMapping("/exception-thrower")
    public void throwException() {
    	throw new RuntimeException("there's error with server");
    }
} 

기존에 사용하던 예외 매핑 정보(WebServerFactoryCustomizer)에 연결된 Handler는 HTML을 반환한다. API에 맞게 JSON을 반환해보자.

기존 Servlet 방식으로 예외 처리하기

@Controller
public class ErrorPageController {
    @RequestMapping(name = "/error")
    public String handleError(
    	HttpServletRequest request,
        HttpServletResponse response
    ) {
    	...
    }

    @RequestMapping(name = "/error", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String,Object>> handleError(
    	HttpServletRequest request,
        HttpServletResponse response
    ) {
    	Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
        Integer statusCode = (Integer) request.getAttribute(ERROR_STATUS_CODE);
        
        Map<String, Object> result = new HashMap<>();
        result.put("status", statusCode);
        result.put("message", ex.getMessage());
        
        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }
}
1. Controller에서 발생한 RuntimeException이 WAS까지 전파된다.
2. WAS에서 예외 매핑 정보에 따라 `예외 처리 요청`을 발생시킨다.
3. 적절한 Handler가 선택되어 ResponseEntity를 반환한다.
(이전 포스트 참고)

Q: 예외 응답 처리 Handler가 2개 있는데, 어떤걸 사용할지 어떻게 결정할까?
A: request header에 accept: application/json이 있는데,
handler에 produces = MediaType.APPLICATION_JSON_VALUE이 있으므로, 더 specific한 handler가 trigger된다.

BasicErrorController으로 처리하기

API 방식에도 Spring Boot의 BasicErrorController을 사용할 수 있다.

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
	...
	@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
		HttpStatus status = getStatus(request);
		Map<String, Object> model = Collections
			.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
		response.setStatus(status.value());
		ModelAndView modelAndView = resolveErrorView(request, response, status, model);
		return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
	}

	@RequestMapping
	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
		HttpStatus status = getStatus(request);
		if (status == HttpStatus.NO_CONTENT) {
			return new ResponseEntity<>(status);
		}
		Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
		return new ResponseEntity<>(body, status);
	}
    ...
}

accept: text/html인 경우 errorHtml, 그 외에는 error가 호출된다.

옵션을 통해 구체적인 오류 정보를 응답에 담을 수 있으나, 오류는 최대한 노출하지 말고, 로깅하는게 좋다.

BasicErrorController는 4xx, 5xx 처럼 한 번에 비슷한 예외 응답을 보내기에 적합하다.
그런데 API마다, 상황마다 다른 예외 응답을 보내야하므로 사용의 한계가 존재한다.

HandlerExceptionResolver

지금까지(Servlet, BasicErrorController) 방식

	App에서 발생한 예외를 따로 처리하지 않으면,
    WAS는 모든 예외 상황을 전부 *서버의 문제*로 인식해서 500번대 예외 응답을 보냄

RuntimeException이든, IllegalArgumentException이든 전부 5xx

HandlerExceptionResolver를 사용하면 예외 상황에 대한 응답을 구체적으로 다룰 수 있다.

HandlerExceptionResolver 구현체 만들기

package doodlin.greeting.test.exceptionResolver;

import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

반환값에 따라 다른 작업을 수행한다.
1. new ModelAndView() (empty ModelAndView)

  • 예외가 정상적으로 처리된 것으로 인식하여 View를 반환하지 않고, WAS로 응답이 넘어간다.

2. ModelAndView(viewName, data)

  • 예외가 정상적으로 처리된 것으로 인식하여 View를 반환한다.

3. null

  • 예외가 해당 Resolver에서 정상적으로 처리되지 않은 것으로 인식하고, 다음 Resolver로 넘긴다.
  • 최종적으로 처리되지 않으면 최초의 예외가 WAS로 넘어간다.

참고

  1. HandlerExceptionResolver의 작동 여부에 상관없이, Controller에서 예외가 발생하면 Inteceptor의 postHandle은 호출되지 않는다.

HandlerExceptionResolver 등록하기

package doodlin.greeting.test.exceptionResolver;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class Config implements WebMvcConfigurer {

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

참고

  1. WebMvcConfigurer는 Resolver를 등록하기 위해 두 가지 메서드를 제공한다.
    • extendHandlerExceptionResolvers
    • configureHandlerExceptionResolvers
  2. configureHandlerExceptionResolvers를 사용하면 Spring이 기본으로 등록하는 ExceptionResolver가 제거된다.
    • extendHandlerExceptionResolvers 사용 권장

HandlerExceptionResolver 특징

지금까지 내용을 통해, 기존 방식과 달리 HandlerExceptionResolver를 사용하면

1. 구체적인 status code 사용 가능
2. 다양한 Exception 처리 가능

할 수 있다는 HandlerExceptionResolver의 장점을 알았다.

그런데 더 큰 차이점이 있는데, HandlerExceptionResolver는 예외 처리 응답을 발생시키지 않는다는 점이다.

그러니까

기존 방식(?)
	WAS -> 
    Controller (최초 요청에서 예외 발생) -> 
	WAS (예외 처리 요청 발생) -> 
    Controller (예외 처리 응답)

HandlerExceptionResolver 사용
	WAS -> 
    Controller(최초 요청에서 예외 발생) -> 
    HandlerExceptionResolver (예외 처리 응답)
    

의 흐름으로 진행된다.

Spring이 제공하는 HandlerExceptionResolver

HandlerExceptionResolver을 사용해서 직접 예외 응답을 하면, request header의 accept의 값을 전부 고려해야 한다는 단점이 있다.

스프링은 여러 종류의 HandlerExceptionResolver 구현체를 제공한다.

ResponseStatusExceptionResolver

  • Exception에 따라 status code를 지정하는 역할

해당 Resolver에서 두 예외를 처리한다.

  1. @ResponseStatus가 붙은 예외
  2. ResponseStatusException

@ResponseStatus

public class MyException extends RuntimeException {
	...
}

Controller에서 위 Exception이 터지면 WAS는 기본적으로 500을 응답한다.

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

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "You made Bad Request")
public class MyException extends RuntimeException {
	...
}

@ResponseStatus를 사용해서 status code와 error message를 지정할 수 있다.

예외가 발생할 상황에서 그냥 가져다 사용하면 된다.

@GetMapping("/throw-bad-request")
public void throwBadRequest() {
	throw new MyException(); // 원래라면 500인데, @ResponseStatus에 설정한대로 400이 뜬다.
}

발생한 Exception에 @ResponseStatus이 붙어있으면 ResponseStatusExceptionResolver가 반응해서 설정한 status code, error message를 가지고 response를 만든다.

public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver implements MessageSourceAware {
	...
    
	protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
			throws IOException {

		if (!StringUtils.hasLength(reason)) {
			response.sendError(statusCode);
		}
		else {
			String resolvedReason = (this.messageSource != null ?
					this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
					reason);
			response.sendError(statusCode, resolvedReason);
		}
		return new ModelAndView();
	}
}

ResponseStatusExceptionResolver 역시 response.sendError를 호출 & ModelAndView를 반환하는 것이다.

참고

  • @ResponseStatusreason에 사용될 메시지를 messages.properties로 분리할 수 있다.
    • ResponseStatusExceptionResolver가 처리해준다.
// Custome Exception
@ResponseStatus(code = HttpStatus.BAD_REQUEST, code = "error.bad")

// messages.properties
error.bad = "잘못된 요청입니다."

ResponseStatusException

내가 만든 예외 말고, 이미 존재하는 예외에는 @ResponseStatus를 붙일 수 없다. 그런 경우에 ResponseStatusException을 던진다.

@GetMapping("/response-status-exception")
public void throwEx() {
	throw new ResponseStatusException(HttpStatus.NOT_FOUND, "not found exception"); // messages.properties에 분리한 예외 메시지를 사용할 수 있다.
}

마찬가지로 ResponseStatusExceptionResolver가 처리한다.

DefaultHandlerExceptionResolver

@GetMapping(...)
public String throwTypeMissmatchException(@RequestParam Integer data) {...}

파라미터 바인딩이 실패하면 TypeMissmatchException이 WAS까지 전파된다.

이렇게 데이터 타입이 불일치 하는 경우는 주로 클라이언트의 실수일 경우가 많다. 이런 경우를 500으로 응답하면 안되므로, 스프링은 DefaultHandlerExceptionResolver을 사용해서 400대로 응답을 보낸다.

이처럼 DefaultHandlerExceptionResolver은 스프링 내부적으로 발생하는 Exception을 처리한다.

public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionResolver { 
	@Override
	@Nullable
	protected ModelAndView doResolveException(
			HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) 
	{
		...
	}
}

ExceptionHandlerExceptionResolver

  • HandlerExceptionResolver는 ModelAndView를 반환해야 한다.
  • 특정 컨트롤러에서만 발생하는 예외를 별도 처리하기 어렵다.

이런 불편함을 해결하기 위해 스프링은 @ExceptionHandler와 이를 처리하는 ExceptionHandlerExceptionResolver을 제공한다.

@Data
@AllArgsConstructor
public class ErrorResult {
	private String statusCode;
    private String mssage;
}

예외가 발생하면 다음과 같은 객체를 JSON으로 넘기려고 한다.

@RestController
public class MyController {
	
    @Getmapping("/throw-exception")
    public void throwException() {
    	throw new RuntimeException();
    }
    
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(RuntimeException.class)
    public ErrorResult exceptionHandler(RuntimeException e) {
    	return new ErrorResult("BAD", e.getMessge());
    }
}
  1. @Getmapping("/throw-exception")이 호출되면 예외가 발생한다.
  2. Controller -> interceptor -> DispatcherServlet으로 예외가 전파된다.
  3. ExceptionResolver 우선순위에 따라, DispatcherServlet에서 제일 먼저 ExceptionHandlerExceptionResolver을 통해서 @ExceptionHandler(class)에 부합하는 메서드가 있는지 확인한다.
  4. 적합한 ExceptionHandler에서 예외를 반환한다.

따라서 정리하자면

  1. 지금까지 살펴본 ExceptionResolver들과 마찬가지로, ExceptionHandlerExceptionResolver도 WAS까지 예외를 전파시키지 않는다. 따라서 WAS에서 Controller로 예외 처리 요청이 발생하지 않는다.
  2. @ExceptionHandler은 예외를 던지는게 아니고, 요청을 처리하는 것이다. 따라서 @ResponseStatus를 통해 따로 설정하지 않으면, 기본적으로 200 OK 응답이 발생한다.

위 코드를 다음처럼 개선 가능하다.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
	ErrorResult result = new ErrorResult("BAD", e.getMessage());
    return new ResponseEntity(result, HttpStatus.BAD_REQUEST);
}
  1. ResponseEntity<>를 사용해서 일관적인 반환 타입을 유지 가능
  2. @ExceptionHandler에 명시적으로 Exception 타입을 생략
    • 파라미터로 사용된 예외의 타입을 처리

참고

  1. 눈치껏 알 수 있듯, @ExceptionHandler에서 처리하는 Exception은 상속 계층을 따른다. 즉, 부모 Exception을 잡으면 자식 Exception들도 동일한 ExceptionHandler에서 처리된다.

  2. @ExceptionHandler는 마치 @Controllr처럼 다양한 파라미터를 사용할 수 있다. (공식문서)

  3. @ExceptionHandler은 자신이 위치한 Controller 내부에서 발생한 예외에만 반응한다.

@ControllerAdvice

@ExceptionHandler은 자신이 위치한 Controller 내부에서 발생한 예외에만 반응한다.

이러한 특징 때문에(?) 다음의 문제가 발생한다.

  1. 예외 처리 코드 중복 발생
  2. 정상 처리 handler, 예외 처리 handler 분리 불가능

이를 Spring boot의 @ControllerAdvice(@RestControllerAdvice)로 해결할 수 있다.

참고: @RestControllerAdvice = @ControllerAdvice + @ResponseBody

// Controller
@RestController
public class MyController {
	
    @Getmapping("/throw-exception")
    public void throwException() {
    	throw new RuntimeException();
    }
}

// ControllerAdvice
@RestControllerAdvice
public class MyControllerAdvice {
	    
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(RuntimeException.class)
    public ErrorResult exceptionHandler(RuntimeException e) {
    	return new ErrorResult("BAD", e.getMessge());
    }
}

기본적으로 @ControllerAdvice는 모든 Controller에서 발생한 예외에 전부 반응한다.

// 특정 Annotation에만 반응하기
@RestControllerAdvice(annotations = RestController.class)

// 특정 패키지 경로에만 반응하기
@RestControllerAdvice("org.example.controllers")

// 특정 컨트롤러에만 반응하기 (상속 관계 적용됨)
@RestControllerAdvice(assignableTypes = {MyController.class})

세 가지 방법으로 특정 컨트롤러에만 반응하도록 할 수 있다.

권장

@ExceptionHandler + @ControllerAdvice 조합을 사용하자.

profile
블로그 이전했습니다 https://dev314.tistory.com/

0개의 댓글