본문 바로가기
공부/Spring

[Spring MVC](13) 기본 예외 처리

by 다음에바꿔야지 2024. 2. 20.

예외를 어떻게 처리해야하는지 알아보자.

 

만약 애플리케이션에서 예외가 발생했는데 어디선가 try ~ catch로 잡아 처리하지 않으면 어떻게 될까?

WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 까지 전달되어버린다.

 

오류가 발생했을때 HttpServletResponse가 제공하는 sendError라는 메서드를 사용해도 된다.

 

ServletExController

package hello.exception.servlet;

import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import java.io.IOException;

@Slf4j
@Controller
public class ServletExController {
    @GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("예외 발생!");
    }

    @GetMapping("/error-404")
    public void error404(HttpServletResponse response) throws IOException {
        response.sendError(404, "404 오류!");
    }

    @GetMapping("/error-500")
    public void error500(HttpServletResponse response) throws IOException {
        response.sendError(500);
    }
}

이렇게 서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 사용자에게 의미가 없다. 의미있는 오류 화면을 제공해보자.

 

WebServerCustomizer - 서블릿 오류 페이지 등록

package hello.exception;

import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

@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");

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

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
    }
}

 

이 오류들을 처리할 컨트롤러를 추가해준다.

 

ErrorPageController

package hello.exception.servlet;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Controller
public class ErrorPageController {
    @RequestMapping("/error-page/404")
    public String error404(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 404");
        printErrorInfo(request);
        return "error-page/404";
    }

    @RequestMapping("/error-page/500")
    public String error500(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 500");
        printErrorInfo(request);
        return "error-page/500";
    }

    @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());

        Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        return new ResponseEntity<>(result, HttpStatusCode.valueOf(statusCode));
    }

    private void printErrorInfo(HttpServletRequest request) {
        log.info("ERROR_EXCEPTION: ex=", request.getAttribute(ERROR_EXCEPTION));
        log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
        log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE)); //ex의 경우 NestedServletException 스프링이 한번 감싸서 반환
        log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
        log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
        log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
        log.info("dispatchType={}", request.getDispatcherType());
    }
}

WAS는 오류 정보를 request의 attribute에 추가해서 넘겨준다. 필요하면 오류 페이지에서 전달된 오류 정보를 사용할 수 있다.

(html 화면은 생략한다.)

 

예외가 발생하면,

1. WAS까지 예외가 전파된다.

2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다.

3. 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.

 

 

DispatcherType

오류가 발생하면 오류 페이지 출력을 위해 WAS 내부에서 다시 한번 호출이 발생한다. 이때 필터, 서블릿, 인터셉터도 무두 다시 호출된다. 그런데 로그인 인증 체크 같은 경우를 생각해보면, 이미 한번 필터나 인터셉터에서 로그인 체크를 완료했다. 따라서 서버 내부에서 오류 페이지를 호출한다고 해서 해당 필터나 인터셉터가 한번 더 호출되는 것은 매우 비효율적이다.

결국 클라이언트로부터 발생한 정상 요청인지, 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야 한다. 이를 위해 서블릿은 DispatcherType이라는 추가 정보를 제공한다.

 

**DispatcherType**
*REQUEST : 클라이언트 요청
*ERROR : 오류 요청

*ASYNC : 서블릿 비동기 호출
*FORWARD : MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때

  **RequestDispatcher.forward(request, response);
*INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때

  **RequestDispatcher.include(request, response);

필터의 경우, 필터를 등록할 때 어떤 DispatcherType인 경우에 필터를 적용할지 선택할 수 있다.

인터셉터의 경우, DispatcherType과 무관하게 항상 호출된다. 대신 요청 경로에 따라 추가하거나 제외하기 쉽게 되어있어 오류 페이지 경로를 excludePathPatterns를 사용해서 빼주면 된다.

 

 

/hello 정상 요청
WAS(/hello, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> View 


/error-ex 오류 요청
필터는 DispatchType 으로 중복 호출 제거: dispatchType=REQUEST
인터셉터는 경로 정보로 중복 호출 제거: excludePathPatterns("/error-page/**")

 

전체 흐름
1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
2. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
3. WAS 오류 페이지 확인
4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러(/error-page/500) -> View

 

 

스프링 부트 - 오류 페이지

스프링 부트는 예외 처리 페이지를 기본으로 제공한다.

: /error라는 경로로 기본 오류 페이지를 설정한다.

BasicErrorController라는 스프링 컨트롤러를 자동으로 등록한다. 이 컨트롤러는 기본적인 로직이 모두 개발되어 있어, 개발자는 오류 페이지 화면만 등록하면 된다. 정적 HTML이면 정적 리소스, 동적 오류 화면은 뷰 템플릿 경로에 오류 페이지 파일을 넣어두기만 하면 된다.

 

BasicErrorController는 다음 정보를 model에 담아서 뷰에 전달한다. 뷰 템플릿은 이 정보를 활용할 수 있다.

* timestamp: Fri Feb 05 00:00:00 KST 2021
* status: 400
* error: Bad Request
* exception: org.springframework.validation.BindException * trace: 예외 trace
 * message: Validation failed for object='data'. Error count: 1
 * errors: Errors(BindingResult)
* path: 클라이언트 요청 경로 (`/hello`)

 

하지만 이 정보를 고객에게 노출하는 것은 좋지 않다. 서비스에 맞는 오류 화면과 간단한 오류 메시지를 보여주고 오류는 서버에 로그로 남겨서 로그로 확인해야 한다.