Spring

[인프런/스프링 MVC 2편] 9. API 예외 처리

주니어주니 2023. 5. 31. 00:33

 

 

 

HTML 페이지 - 4xx, 5xx같은 오류 페이지만 보여주면 예외 처리 O

API - 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려줘야 함

 

 

1. 서블릿 API 예외 처리

 

1) WebServerCustomizer 동작시키기 

  • WAS에 예외 전달 / response.sendError() 호출 -> 여기서 등록한 예외 페이지 경로 호출
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {

        // 상태 코드 지정
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");

        // Exception 발생
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);

    }
}

 

 

 

2) 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;
    }
}
  • API 니까 @RestController
  • getMember() : 회원 조회하는 메소드 ( URL에 전달된 id 값이 ex이면 예외 발생 )

 

 

3) Postman으로 테스트

 

* HTTP Headers -> Accept -> application/json 필수! (accept : 클라이언트가 나는 json을 받아서 읽을 수 있다는 뜻)

 

(1) 정상 호출

  • API로 JSON 형식의 데이터 정상 반환

 

 

(2) 예외 발생 호출

  • API 통신
    -> JSON으로 데이터를 주고 받아야 함
    -> accept : application/json (클라이언트는 json을 받아서 읽을 수 있음)
    -> HTML이 응답됨 (ex -> /error-page/500 -> 500.html)
    -> 클라이언트가 HTML을 읽을 수가 없음 (웹 브라우저 말고는 HTML을 직접 받아서 할 수 있는 것은 별로 없음)
    -> 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 함

 

 

4) API 응답 추가 - ErrorPageController

 

* 오류 페이지 컨트롤러가 JSON 응답할 수 있도록 수정 

 

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {

    log.info("API errorPage 500");

    Map<String, Object> result = new HashMap<>();
    Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
    result.put("status", request.getAttribute(ERROR_STATUS_CODE));  // 상태코드
    result.put("message", ex.getMessage());                         // Exception에서 던진 오류 메시지

    Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
  • produces = MediaType.APPLICATION_JSON_VALUE
    • 클라이언트가 요청하는 HTTP Header의 Accept 값이 application/json 일 때
      ( = 클라이언트가 받고 싶은 미디어 타입이 json이면 )
      해당 메소드 호출

      -> accept : 클라이언트 - 난 이 타입을 받을 거야
          produces : 그럼 그 타입으로 만들어줄게

  • Map result = new HashMap<>();
    • Jackson 라이브러리는 Map을 JSON 구조로 변환할 수 있음

  • ResponseEntity<객체, 상태코드>
    • HTTP 응답 - HTTP API -> 메시지 바디에 직접 입력해서 응답 - JSON 데이터 응답
    • HTTP 메시지 컨버터를 통해 JSON 형식으로 변환되어서 반환

 

 

* HTTP accept, produce 복습

 

 

 

5) 수정 버전 포스트맨으로 다시 실행 

  • JSON 형식으로 응답 ✔

 

  • 동작 흐름
    • ex 호출
      -> RuntimeException 발생 (오류 메시지: "잘못된 사용자")
      -> ErrorPage에 RuntimeException 객체로 담겨있음
      -> /error-page/500 으로 다시 호출하는데
      -> accept를 확인 -> json이면
      -> produces 가 json인 컨트롤러로 호출

    • HTTP Header의 Accept가 application/json이 아니면,
      -> ErrorPageController에서 produces타입이 json이 아닌, 그냥 기존 에러페이지 호출하는 컨트롤러 실행
      -> 기존 오류 응답인 HTML 응답 출력

 

 

 


 

2. 스프링 부트 API 예외 처리 

 

2-1. 스프링 부트 기본 오류 처리 

 

1) WebServerCustomizer 중지

  • 주석처리
  • 스프링부트가 기본으로 제공하는 BasicErrorController 사용 

 

 

2) 스프링부트가 제공하는 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);
	}

 

  • 스프링부트의 기본 설정 : 오류 발생 시 /error를 오류 페이지로 요청

  • BasicErrorController는 이 경로를 기본으로 받음
    ( 기본 경로 : /error, server.error.path로 수정 가능 )

  • errorHtml(), error() 두 메소드 모두 동일하게 /error 경로 처리 -> 둘 중 적절한 메소드 실행 

  • errorHtml()
    • produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept 헤더 값이 text/html인 경우 이 메소드 호출
    • ModelAndView 반환 : 오류 페이지 반환

  • error() 
    • 클라이언트 요청의 Accept 헤더 값이 text/html가 아닌 나머지 경우에 이 메소드 호출
    • ResponseEntity<> 반환 : ResponseEntity로 HTTP Body에 JSON 데이터를 직접 반환

 

 

3) 포스트맨 실행 

 

 

  • BasicErrorController가 제공하는 기본 정보들을 활용해서 오류 API 생성

  • 옵션 설정 시 더 자세한 오류 정보 추가 가능 ( BasicErrorController 확장 )
    • always로 설정
    • 하지만 보안상 위험
      -> 간결한 메시지만 노출, 로그를 통해서 확인 권장

 

* application.properties 

server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always

 

 

 

4) Html 페이지 vs API 오류 

 

Html 오류 처리 : BasicErrorController 사용 (오류 페이지만 개발)
API 오류 처리 : @ExceptionHandler 사용 

 

 

 

 

2-2. HandlerExceptionResolver 

 

 

예외 발생 -> 서블릿 넘어 WAS까지 예외 전달 -> 모두 HTTP 상태코드 : 500 (서버 내부)

근데 상태코드를 변경하고 싶을 때

(예: IllegalArgumentException - 파라미터 잘못 입력 -> 400 에러 (클라이언트가 파라미터 잘못 넘김) 기대 )

 

 

1) 현재 동작 확인 ( IllegalArgumentException 발생 시 500 에러 발생 )

 

* 컨트롤러 

  • bad 호출 -> IllegalArgumentException 발생
@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/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);
    }

 

* 실행 (500에러)

 

 

 

2) HandlerExceptionResolver ( = ExceptionResolver )

 

컨트롤러 밖으로 예외가 던져진 경우, 예외를 해결하고 동작을 새로 정의할 수 있는 방법 제공

 

  • WAS -> 서블릿 -> 인터셉터(preHandler) -> 핸들러 어댑터 -> 핸들러(컨트롤러) : 예외발생
  • WAS (예외 전달) -> 서블릿 <-  afterCompletion 호출 <- postHandler 호출 X <- 핸들러(컨트롤러)
  • 서블릿 밖인 WAS까지 예외가 전달돼 500 에러 반환

 

 

  • WAS -> 서블릿 -> 인터셉터(preHandle) -> 핸들러 어댑터 -> 핸들러(컨트롤러) : 예외발생
  • WAS <- ExceptionResolver 호출 <- 서블릿에 예외 전달 <- postHandler 호출 X <- 핸들러(컨트롤러)
  • 예외를 잡아서 정상적으로 나갈 수 있게 해줌

 

📌 ExceptionResolver 에서 처리할 수 있는 기회 

1. 처리해서 완전 정상적인 흐름으로 바꾸기
2. sendError() 호출해서 상태코드만 바꾸고 WAS에서 다시 오류페이지 내부요청

 

 

 

* 인터페이스 - HandlerExceptionResolver

  • handler : 핸들러(컨트롤러) 정보
  • Exception ex : 핸들러(컨트롤러)에서 발생한 예외
public interface HandlerExceptionResolver {
    ModelAndView resolveException(
    	HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}

 

 

 

3) HandlerExceptionResolver 구현 - MyHandlerExceptionResolver 

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

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

        try {
            if (ex instanceof IllegalArgumentException) {
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();  // 예외를 먹어서 400을 다시 sendError, 정상호출
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

 

  • 예외를 먹고 Exception 예외를 sendError로 바꿔서 상태코드도 변경해서 보냄
    (Exception : 서블릿 -> 서블릿 밖 WAS 까지 던짐)
    (sendError : 서블릿에서 오류 인지 -> 정상적으로 반환 -> WAS에서 response 확인 -> 오류 페이지 호출)

  • IllegalArgumentException 발생 -> response.sendError(400) 호출 -> 상태 코드를 400으로 지정 -> 빈 ModelAndView 반환

  • ExceptionResolver가 ModelAndView를 반환하는 이유 ??? 
    • try, catch 하듯이, Exception을 잡아서 처리해서 정상 흐름처럼 변경

  • 반환 값에 따른 DispatcherServlet 동작 방식
    • 빈 ModelAndView : 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿이 WAS에 리턴
      (근데 이제 sendError()를 호출했으니까 그거는 따로 처리)
    • ModelAndView 지정 : 뷰 렌더링
    • null : 다음 ExceptionResolver를 찾아서 실행, 만약 없으면 예외처리 안되고, 기존에 발생한 예외를 서블릿 밖으로 던짐 (Exception이 WAS까지 날아감 -> 500에러)

 

4) 리졸버 등록 - WebConfig

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
  • configureHandlerExceptionResolvers(...) 사용하면 
    -> 스프링이 기본으로 등록하는 ExceptionResolver가 제거됨
    -> extendHandlerExceptionResolvers 사용

 

 

5) 실행 

 

 

 

 

2-3. HandlerExceptionResolver 활용해서 API 예외 처리 

 

WAS -> 서블릿 -> 인터셉트 -> 컨트롤러 (예외 발생)

-> 예외 발생 -> 서블릿 -> WAS까지 예외 전달 -> WAS에서 오류 페이지 정보 찾아서 다시 /error 호출  

-> WAS에서 /error 호출 -> 컨트롤러 -> 뷰 (오류 페이지) 반환

 

--> 왔다갔다 복잡함 -> HandlerExceptionResolver 활용

 

  • ExceptionResolver 활용
    • 예외 상태 코드 변환
      • 예외를 sendError() 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
      • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출 (예: /error)

    • 뷰 템플릿 처리 
      • ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링해서 제공할 수 있음

    • API 응답 처리
      • response.getWriter().println("hello") 처럼 HTTP 응답 바디에 직접 데이터 넣기 가능
        JSON 을 넣으면 API 응답 처리 가능

 

 

1) 사용자 정의 예외 생성 - UserException 

package hello.exception.exception;

public class UserException extends RuntimeException {

    public UserException() {
        super();
    }

    public UserException(String message) {
        super(message);
    }

    public UserException(String message, Throwable cause) {
        super(message, cause);
    }

    public UserException(Throwable cause) {
        super(cause);
    }

    protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

 

 

2) 예외 추가 - ApiExceptionController 

  • user-ex 호출 시 UserException 예외 발생
@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/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);
    }

 

 

3) 예외 처리 - UserHandlerExceptionResolver

 

package hello.exception.resolver;

import com.fasterxml.jackson.databind.ObjectMapper;
import hello.exception.exception.UserException;
import lombok.extern.slf4j.Slf4j;
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;
import java.util.HashMap;
import java.util.Map;

@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) {
                log.info("UserException resolver to 400");
                // accept header 꺼내기
                String acceptHeader = request.getHeader("accept");
                // response 상태코드 변경
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                // accept header가 json일 때
                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    // 어떤 Exception이 터졌는지, 오류 메시지가 뭔지 저장
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());

                    // json 형태인 errorResult를 string으로 변환
                    String result = objectMapper.writeValueAsString(errorResult);

                    // 이 데이터를 response 바디에 넣어줌 (ModelAndView를 반환해야 돼서 -> json은 바디에 직접 반환)
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    // 빈 ModelAndView 반환 -> 예외는 먹어버리지만 정상 흐름으로 서블릿까지 response를 전달
                    return new ModelAndView();
                } else {
                    // accept header가 text/html 등 json이 아닐 때
                    return new ModelAndView("error/500");
                }

            }

        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}

 

  • HTTP 요청 헤더의 accept가 application/json일 때 : JSON으로 오류 출력
  • HTTP 요청 헤더의 accept가 application/json이 아닐 때 : error/500에 있는 HTML 오류 페이지 출력 

 

 

4) 리졸버 등록 - WebConfig

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
        resolvers.add(new UserHandlerExceptionResolver());
    }
  • ExceptionResolver에서 ModelAndView를 반환하지 않으면 다음 예외 리졸버 호출

 

 

5) 실행 

 

(1) 예외처리 안할 때

 

 

 

 

(2) accept : application/json일 때  

 

 

 

(3) accept : text/html 일 때

 

 

6) 정리 

  • 원래 이 과정
    • WAS -> 서블릿 -> 인터셉트 -> 컨트롤러 (예외 발생)
      -> 예외 발생 -> 서블릿 -> WAS까지 예외 전달 -> WAS에서 오류 페이지 정보 찾아서 다시 /error 호출 
      -> WAS에서 /error 호출 -> 컨트롤러 -> 뷰 (오류 페이지) 반환

  • ExceptionResolver 사용
    • 컨트롤러에서 예외가 발생
      -> 서블릿까지 전달 X, ExceptionResolver에서 예외 처리
      -> 스프링 mvc에서 예외를 먹고, 예외처리를 끝내버리는 것
      -> 빈 ModelAndView 반환 -> 서블릿은 정상 처리되었다고 판단 
      -> WAS 입장에서는 정상 처리 

 

 

-> 근데 ExceptionResolver를 직접 구현하기 복잡 -> 스프링이 제공하는 ExceptionResolver

 

 

 


 

3. 스프링이 제공하는 ExceptionResolver (예외처리)

 

3-1. 스프링 부트가 기본으로 제공하는 ExceptionResolver

 

HandlerExceptionResolverComposite에 순서대로 등록 (1순위에서 null 반환되면 2,3순위)

  1. ExceptionHandlerExceptionResolver 
    • @ExceptionHandler 처리 - API 예외 처리는 대부분 이 기능으로 해결
    • 너무 중요해서 뒤에서 설명
  2. ResponseStatusExceptionResolver
    • HTTP 상태 코드 지정
    • @ResponseStatus(value = HttaStatus.NOT_FOUND) 
  3. DefaultHandlerExceptionResolver
    • 스프링 내부 기본 예외 처리 

 

 

3-2. ResponseStatusExceptionResolver (HTTP 상태 코드 지정)

 

 

원래 Exception 예외는 500 반환하는데, 예외에 따라서 HTTP 상태 코드 지정

  • @ResponseStatus 어노테이션이 붙어있는 예외
  • ResponseStatusException 예외

 

 

1) @ResponseStatus 어노테이션 적용 -> HTTP 상태 코드 변경

 

 

(1) 사용자 지정 예외 만들기 - BadRequestException

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{
}
  • 이 예외가 컨트롤러 밖으로 넘어가면, ResponseStatusExceptionResolver가 해당 어노테이션 확인
    -> 오류코드(code)를 HttpStatus.BAD_REQUEST(400)로 변경, 메시지(reason)도 담음

 

 

+) 메시지 기능 (reason을 MessageSource에서 찾는 기능)

 

* BadRequestException 

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

 

* messages.properties

error.bad=잘못된 요청 오류입니다. 메시지 사용

 

 

 

(2) 예외 추가 - ApiExceptionController

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}

 

 

(3) 실행 

reason 그대로 출력
메시지 기능 사용

 

 

이게 어떻게 되냐? 

ResponseStatusExceptionResolver 코드 확인

 

 

exception을 잡아서 sendError(statusCode, resolvedReason) 호출 -> 상태코드 변경 -> 빈 모델 반환 -> 정상 동작

-> sendError(400) 을 호출했기 때문에 WAS에서 다시 오류페이지(/error) 내부 요청 

 

 

 

2) ResponseStatusException 예외 사용 -> HTTP 상태 코드 변경 

 

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

어노테이션이기 때문에 조건에 따라 동적으로 변경할 수 X

-> ResponseStatusException 예외를 던져버림

 

 

(1) 예외 추가 - ApiExceptionController

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

 

(2) 실행

 

 

 

 

3-3. DefaultHandlerExceptionResolver - 스프링 내부 예외의 상태코드 변경

 

 

ex) 파라미터 바인딩 시점에 타입이 맞지 않을 때 -> TypeMismatchException 예외 발생

-> 예외니까 서블릿 -> WAS까지 예외 전달 -> 500 오류 발생 (원래)

 

근데 파라미터 타입 오류는 클라이언트 잘못 -> 400 오류여야 함

-> DefaultHandlerExceptionResolver 가 해결

 

 

1) 예외 추가 - ApiExceptionController 

  • 요청 파라미터 타입 - Integer 
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
    return "ok";
}

 

 

2) 실행

 

(1) 올바른 파라미터 타입

 

(2) 틀린 타입 ( -> 알아서 400 오류 발생 ) 

 

 

3) DefaultHandlerExceptionResolver 코드 확인

  • sendError() 로 코드 변경 -> WAS에서 다시 오류 페이지(/error) 내부 요청

 

 

 

3-4. ExceptionHandlerExceptionResolver - 스프링 API 예외 처리 (우선순위 1등)

 

  • HTML 화면 오류 vs API 오류
    • HTML 화면 제공할 때 오류 발생 -> BasicErrorController 사용 -> 5xx, 4xx 관련 오류 화면 출력
    • API 오류 발생 -> @ExceptionHandler 사용

  • API 예외 처리의 어려운 점
    • API 응답에는 ModelAndView 반환이 필요 X (@RestController, ResponseEntity 등 - json으로 바로 반환)
    • API 응답을 ModelAndView로 반환하기 위해 HttpServletResponse에 직접 응답 데이터 넣는 것은 불편함
    • 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어려움
      (상품 컨트롤러 / 주문 컨트롤러에서 동일한 예외를 다른 방식으로 처리하고 싶을 때)

 

 

1) @ExceptionHandler 

 

 

(1) 예외 발생 시 API 응답으로 사용하는 객체 - ErrorResult

 

package hello.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

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

 

 

(2) 예외 추가 

 

package hello.exception.api;

import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
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("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandler] 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("[exceptionHandler] ex", e);
        return new ErrorResult("EX", e.getMessage());
    }

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

 

  • @ExceptionHandler 예외 처리 방법 
    • @ExceptionHandler 어노테이션 선언
    • 해당 컨트롤러에서 처리하고 싶은 예외를 지정 ( 지정한 예외 + 자식 클래스까지 처리)
    • 해당 컨트롤러에서 예외가 발생하면 이 메소드 호출 ( 해당 컨트롤러에서만 사용 가능 !

 

 

(3) 실행

 

예외 안잡았을 때 - 아래 응답은 서블릿 WAS까지 갔다가 재호출 돼서 내려온 응답임

 

 

 

① IllegalArgumentException 처리 

 

@Slf4j
@RestController
public class ApiExceptionV2Controller {

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

    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("bad")) {
            throw new IllegalArgumentException(("잘못된 입력 값"));
        }
        return new MemberDto(id, "hello " + id);
    }
  • /bad 호출 -> IllegalArgumentException 예외가 컨트롤러 밖으러 던져짐 (Exception 이니까)
  • 예외 발생 -> ExceptionResolver 작동 -> 가장 우선순위 높은 ExceptionHandlerExceptionResolver 실행
  • ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인
  • illegalExHandle() 실행 -> @RestController  -> illegalExHandle()에도 @ResponseBody 적용
    -> HTTP 컨버터 실행 -> ErrorResult 객체가 JSON으로 반환
  • @ResponseStatus(HttpStatus.BAD_REQUEST) -> HTTP 상태 코드 400으로 응답

 

 

 

② UserException 처리

 

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandler] ex", e);
        ErrorResult errorResult = new ErrorResult("USER_EX", e.getMessage());
        return new ResponseEntity(errorResult, HttpStatus.BAD_REQUEST);
    }

    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }
    }
  • @ExceptionHandler에 예외클래스 지정 X (생략) -> 해당 메소드의 파라미터를 예외로 사용
  • ResponseEntity -> HTTP 메시지 바디에 직접 응답 -> HTTP 응답 코드를 동적으로 변경 가능

 

 

 

③ Exception 처리 

 

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", e.getMessage());
    }

    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
    }
  • RuntimeException 발생 -> RuntimeException은 Exception의 자식 클래스 -> exHandle 호출
  • Exception은 모든 예외의 조상 -> 모든 예외를 잡을 수 있음
  • @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR로 상태 코드 500으로 응답 

 

 

 

 

 

 

2) @ControllerAdivce, @RestControllerAdvice

 

정상 코드와 예외 처리 코드 분리 

 

 

(1) 예외 처리 코드 컨트롤러 - ExControllerAdvice

  • 예외 처리 코드 모음
  • @RestControllerAdvice : @ControllerAdvice + @ResponseBody 
    • 대상 지정 X -> 모든 컨트롤러에 적용 
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("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandler] 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("[exceptionHandler] ex", e);
        return new ErrorResult("EX", e.getMessage());
    }
}

 

 

(2) 예외 발생 컨트롤러 - ApiExceptionV2Controller

  • @ExceptionHandler 모두 제거 
package hello.exception.api;

import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
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;
    }
}

 

 

(3) @ControllerAdvice 

  • 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @Initbinder 기능 부여
    (원래 @ExceptionHandler가 있는 해당 컨트롤러에서만 작동함 )
  • 대상을 지정하지 않으면 모든 컨트롤러에 적용 (글로벌 적용)
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody

 

 

* 대상 컨트롤러 지정 방법 

// @RestController가 붙은 컨트롤러
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// 특정 패키지에 있는 컨트롤러 
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 특정 클래스 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class})
public class ExampleAdvice3 {}

 

 

(4) 적용 예시 

 

* 예외 처리 컨트롤러 - 특정 패키지에 속한 컨트롤러에 모두 적용

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {

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

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandler] 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("[exceptionHandler] ex", e);
        return new ErrorResult("EX", e.getMessage());
    }
}

 

 

여기에 속한 컨트롤러에 모두 적용

api2 , api3 똑같이 적용

 

 

 

 

📌 예외 처리 방법 

1. @ExceptionHandler 우선 고려 

2. try ~ catch