개발자로 후회없는 삶 살기

spring PART.예외 처리와 오류 페이지 본문

[백엔드]/[spring | 학습기록]

spring PART.예외 처리와 오류 페이지

몽이장쥰 2023. 4. 26. 07:47

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

본론

- 예외 처리와 오류 페이지

유튜브 : 오류페이지

프로젝트를 하다보면 예외가 터질 수 있습니다. 그러면 고객에게 정돈된 예쁜 오류 페이지를 보여줘야 합니다. 인프런의 경우 "ufo가 나오면서 죄송합니다. 시스템에 오류가 있습니다." 를 보여준다고 합니다. 어떤 오류 페이지를 언제 배치하는지 알아보겠습니다.

 

- 서블릿 예외처리

서블릿은 2가지 방법으로 예외를 지원합니다. 하나는 진짜 예외(exception)가 터져서 예외가 날라갈 때로 was에게 예외가 날라간다. 두번째는 resp.sendError로 임의로 예외를 일으켜 was가 예외가 터졌다고 생각하게 하는 것입니다.

 

1. exception

자바의 경우 main 메서드를 직접 실행하는 경우 main이라는 쓰레드가 실행됩니다. 실행 도중 예외를 못 잡으면 예외라는 것은 나를 호출한 상위 콜 스택까지 예외를 던집니다.(throw) 그러다가 메인 메서드 밖으로 예외가 던져지면 예외 정보를 남기고 해당 쓰레드가 종료됩니다.

 

웹 어플은 하나의 쓰레드가 있는게 아니라 사용자 요청별로 별도의 쓰레드가 할당되고 그 쓰레드가 서블릿도 호출하고 컨트롤러도 호출한다. 이걸 어플리케이션에서 try로 잡아버리면 문제가 없는 데 못 잡고 나를 호출한 쪽까지 나가게 되어 서블릿 밖으로 예외가 전달되면 was > 필터 > 서블릿 > 인터셉터 > 컨트 흐름이라서 컨트에서 예외가 발생하면 was까지 전파됩니다.

 

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

톰캣까지 예외가 올라오면 어떻게 될까요? 컨트롤러를 사용해서 웹 어플리케이션에서 예외를 발생시켜 보겠습니다.

 

실행해보면 굉장히 투박한 디자인의 500 에러 메세지가 보입니다. 예외가 발생한 경우 was까지 올라오면 서버 내부에서 발생한 예외인 500대 예외가 발생한 것으로 인지합니다.

 

없는 페이지를 호출하면 404를 보여줍니다. was까지 올라오면 이렇게 톰캣이 오류 페이지를 보여주는 겁니다. 지금까지 본 것은 예외가 터졌을 때 was까지 올라오면 500대 예외를 보여준다는 것 입니다.

 

2. response.sendError

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

얘는 당장 예외가 발생하는 것은 아니지만 서블릿 컨테에 오류가 발생했다는 점은 전달할 수 있습니다. resp라는 것은 들어와다가 서블릿 컨테이너까지 쭉 전달이 되기 때문에 http 상태 코드와 오류 메세지를 담아서 서블릿 컨테이너에게 보낼 수 있습니다.

 

어떻게 이렇게 된 거냐면 컨트롤러에서 sendErr해서 예외가 올라가서 was까지 가서 was가 sendErr를 까보는 데 resp.sendErr를 호출하면 resp 내부에 오류가 발생했다는 상태를 저장해둡니다. 그러면 was는 sendErr가 호출되는지 확인합니다. 이게 잘 호출되면 설정한 오류 코드와 기본 오류 페이지를 보여줍니다.

 

+ 1번 Exception은 무조건 500 오류를 보여주는데 sendErr는 상태와 메세지를 지정할 수 있다는 장점이 있습니다. 1번은 진짜 예외가 발생한 것이고 2번은 개발자가 발생시킨 것입니다. 중요한 것은 어플리케이션에서 발생한 오류가 was까지 올라오면 예외 페이지를 보여준다는 것입니다.

 

- 오류화면 제공

이렇게 기본 예외 페이지를 제공하면 사용자 입장에서 이 서비스가 망했나 생각을 할 것입니다. 서블릿이 제공하는 예외에서 예쁜 오류 화면을 제공해보겠습니다. 서블릿은 예외가 발생해서 서블릿 밖으로 전달되거나 resp.sendError가 was에서 호출되었을 때 각각의 상황에 맞춘 오류 처리 기능을 제공합니다. 스프링 부트가 제공하는 기능을 사용해서 서블릿 오류 페이지를 등록하면 됩니다.

 

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/errorpage/404");
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/errorpage/500");

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

클래스를 만들고 WebServerFactoryCustomizer를 구현해야합니다. 이름 그대로 was를 커스텀하는 것으로 기본 오류 페이지를 커스텀 하겠다는 것입니다. 먼저 오류 페이지를 만들어야합니다. new Errorpage로 스프링이 제공하는 것으로 404가 발생하면 /error-page/400이라는 곳으로 이동하여 이 컨트롤러를 호출하게 할 것입니다. 이것을 스프링 빈으로 등록하면 됩니다.

 

500이 발생하면 동일하게 에러 페이지 500으로 가게 할 것이고 RuntimeEx가 발생하면 500으로 가게 합니다. 이것을 addErrorpages로 등록을 해야합니다. 위 2개는 sendErr가 호출된 경우이고 RuntimeEx는 진짜 예외가 발생한 경우입니다. 이 경우 RuntimeEx의 자식이 발생해도 호출됩니다.

 

이제 여기서 지정한 errorpage 컨트롤러를 만들어야 합니다. 컨트롤러에서 오류가 발생해서 was까지 갔다가 다시 맵핑 메서드를 호출해서 컨트롤러가 호출되는 것입니다. 

 

-> 실행

// 1. 어플리케이션에서 오류 발생
@GetMapping("/error-ex")
public void errorEx() {
    throw new RuntimeException("예외 발생!");
}

// 2. 스프링이 뜰 때 custom을 등록 was에서 RuntimeEx가 떠서 등록된 NeWError를 봄
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/errorpage/500");

// 3. 에러 페이지 보여주는 맵핑 메서드 실행
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
    log.info("errorPage 500");
    return "error-page/500";
}

오류가 났을 때 예쁜 오류 페이지를 보여주기 위한 컨트롤러로 req, resp을 받습니다. return에 뷰 페이지를 넣고 여기에 예쁜 뷰 페이지를 넣으면 됩니다. 이제 error-ex를 호출하면 만든 뷰가 나옵니다. 스프링 부트가 뜰 때 customizer를 봐서 was에 에러 페이지를 이렇게 등록해주고 error-ex에서 예외가 발생하면 RunTimeEx가 터지고 was까지 가는데 등록된 에러 페이지를 봐서 에러 컨트롤러 맵핑 메서드가 호출됩니다.

 

- 오류페이지 작동 원리

지금까지 한 게 어떻게 됐는지 자세히 보겠습니다. 서블릿은 예외가 발생해서 서블릿 밖으로 예외가 전달되거나 resp.Error가 호출되었을 때 설정한 오류 페이지를 찾습니다. 실제 예외가 발생하든 senderr가 발생하든 was로 올라갑니다.

 

 

그러면 was는 해당 예외를 처리하는 오류 페이지 정보를 찾습니다. 예를 들어 Runtime에러가 was까지 전달되면 was는 오류 페이지 정보를 확인해보고 WebSeverCustomizer를 스프링 부트가 뜰 때 등록을 해놓았으면 오류 페이지 맵핑 메서드를 처음부터 다시 호출합니다. 컨트롤러에서 예외가 터졌는데 다시 컨트롤러로 오는 것 입니다.

 

★ 중요한 점은 브라우저는 이런 일이 일어났는지 전혀 모르고 서버 내부에서 오류 페이지를 찾기 위해 추가적인 호출을 합니다. 요청이 2번 온 것처럼 컨트롤러를 2번 호출하지만 실제 고객의 요청은 1번인 것입니다.

 

- 오류 정보 추가

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에 속성을 추가해서 어떤 오류인지 오류페이지를 보여주는 컨트롤러 측에서 받아볼 수 있습니다. 상수로 들어있는게 있는데 오류 메세지는 뭐고 타입은 뭔지 다 was가 담어서 재요청할 때 넣습니다. 이런 것으로 그냥 오류 페이지만 보여주는 게 아니라 개발자는 오류의 원인을 파악할 수 있습니다.

 

ex를 호출해보면 예외 타입, 지정한 메세지, 어떤 요청에서 에러가 발생했나(URI), 상태코드, 디스패쳐 타입을 보여줍니다.

404를 하면 예외가 터진게 아니니 null이 보입니다. 상태코드와 메세지는 custom에서 설정한 것입니다.

 

- 필터

서블릿 예외처리를 할 때 was까지 오고 다시 was가 컨트롤러를 호출한다고 했습니다. 이때 필터가 2번 호출이 될 수 있습니다. 그런데 로그인 인증체크라면 이미 한 번 로그인 체크를 완료했으니 또 인터셉터와 필터가 호출되는 것은 매우 비효율적입니다. 따라서 클라의 호출인지, was의 내부 호출인지 구분할 수 있어야하고 서블릿이 이를 해결하기 위해 디스패쳐 타입이라는 추가 정보를 제공합니다.

 

- 디스패쳐 타입

출력해보면 Error라고 나옵니다. 고객의 처음 요청에서는 타입이 REQUEST입니다. 내부 요청인 경우 ERROR로 갑니다. 

디스패쳐 타입은 많은 정보가 있는데 forward는 서블릿에서 다른 서블릿을 호출하는 것 등 입니다.

 

try {
    log.info("REQUEST [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI);
    chain.doFilter(request, response);
} catch (Exception e) {
    throw e;
} finally {
    log.info("RESPONSE [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI);
}

디스패쳐 타입에 따라 필터가 어떻게 호출되는지 테스트를 해보겠습니다. 로그 필터를 호출할 것입니다. doF에 log에 req.getDispatcherType을 넣고 실행해봅니다.

 

@Bean
public FilterRegistrationBean logFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
    filterRegistrationBean.setFilter(new LogFilter());
    filterRegistrationBean.setOrder(1);
    filterRegistrationBean.addUrlPatterns("/*");
    filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
    return filterRegistrationBean;
}

webconf에 필터를 등록해야 하는데 set 디스패텨 타입으로 request와 error를 셋팅하면 이 필터는 req, err 두가지 경우에 호출이 된다는 것입니다.

 

-> 실행

ex를 호출하면 로그 필터가 처음 요청은 request 타입으로 들어오고 uri가 error-ex으로 들어옵니다. 예외가 터진 후에는 Request가 다시 들어오는데 (이거는 로그를 Request라고 문자를 남겨놔서 그렇습니다.) 타입이 Error로 uri가 error-page/500으로 들어옵니다. 로그 필터가 2번 호출된 것입니다. 필터 등록할 때 set에 error를 빼면 처음 고객 요청에서만 필터가 호출되고 서버 내부 호출에서는 호출이 안 됩니다.

 

- 인터셉터

인터셉터는 스프링 기술이라서 중복 호출 제거를 하려면 서블릿과 다른 방법이 필요합니다. 로그 인터셉터를 만들고 log를 남길 때 똑같이 디스패쳐 타입을 찍게했습니다.

 

Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LogInterceptor())
            .order(1)
            .addPathPatterns("/**")
            .excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**"); //오류 페이지 경로);
}

등록을 똑같이 합니다. 근데 인터셉터는 필터처럼 set으로 호출될 디스패쳐 타입을 지정할 수 없습니다. 대신 exclude에 에러 뷰 컨트롤러 경로를 넣으면 서버 내부 호출이 빠집니다. 예외가 발생해서 was까지 올라오면 was에서 예외에 필요한 정보를 담으면서 등록된 오류 페이지를 확인하고 오류 페이지가 있으면 디스패쳐 타입을 error로 하고 다시 컨트롤러를 호출하는 것입니다.

 

실행해보면 처음 호출에서는 예외가 발생하였기에 post 핸들은 호출되지 않고 after의 예외 로그가 찍힙니다. exclude에 /error를 해놓았기에 서버가 내부에서 재호출하는 것은 로그 인터셉터를 호출하지 않습니다.

 

- 오류 페이지1

전체 흐름은 이해가 됐는데 오류 페이지 등록하고 하는게 너무 번접합니다. 스프링이 이를 자동화해놨습니다. 지금까지 예외처리 페이지를 보여주려고 custom에 오류 페이지 직접 다 추가하고 예외 처리용 컨트롤러도 만들었습니다. 스프링이 이를 모두 자동으로 기본 제공합니다.

 

- 스프링 제공

errorPage를 자동으로 등록해줍니다. 이때 /error라는 경로로 기본 오류 페이지를 설정합니다. 기본 오류 페이지는 뭔가 예외가 터지거나 404가 터졌을 때 다른 예외 페이지가 없으면 이게 등록이 되어있어서 상태 코드와 예외를 설정하지 않으면 기본 오류 페이지를 사용합니다. 서블릿 밖으로 예외가 발생하거나 resp.sendErr가 호출되면 모든 오류는 /error를 호출하게 됩니다.

 

/error가 왔을 때 이를 처리할 컨트롤러로 BasicErrorController를 자동으로 등록해주고 여기에 맵핑 메서드들을 적으면 됩니다. 이제 WeBCustom 컨트롤러를 주석처리할 것입니다.(오류 등록을 스프링이 제공하는 것으로 할 것입니다.) 이제 오류가 발생했을 때 /error가 기본 호출이 되고 Basic이 이를 받습니다. /error가 예외가 터졌을 시에 컨트롤러를 재호출하는 경로고 이를 받는 것이 basic입니다.

> 이렇게 되면 basic을 열어보면 requestmapping으로 경로가 없으면 /error로 들어오고 내부에 맵핑 메서드가 다 만들어져있기에 개발자는 오류 페이지 화면만 basic 컨트롤러가 제공하는 룰과 우선순위에 따라서 등록하면 됩니다. html만 만들면 됩니다.

 

-> 템플릿

규칙에 맞게 템플릿에 error 폴더를 만들고 4xx을 만들면 400대가 발생하면 다 이게 호출이되도록 컨트롤러가 개발이 되어있습니다. 404.html을 만들면 404만 이게 호출이 되고 나머지가 4xx가 호출이 됩니다. 디테일 한 것이 우선순위가 높습니다.

 

 

/error-ex를 실행해보면 이제는 템플릿에 만들어놓은 뷰가 잘 나옵니다. /error가 아닌데?라고 생각할 수 있는데 /error-ex는 에러를 만드는 최초 호출이었고 /error는 내부 서버가 basic의 맵핑 메서드를 호출하는 경로입니다.

 

-> 룰

처음에 templates를 찾아서 없으면 정적 리소스를 찾고 그래도 없으면 error.html을 찾습니다. 정말 편리해집니다. 개발자는 뷰 파일만 넣어두면 끝입니다. 다 basic 컨트롤러에 이렇게 작성이 되어있습니다.

 

- 오류 페이지2

basic 컨트롤러가 제공하는 기본 정보들을 알아보겠습니다. basic이 오류 뷰를 호출할 때 여러가지 정보를 넘겨서 랜더링할 수 있기에 정적이 아닌 동적 페이지에 짜는 것입니다. 컨트롤러는 다음 정보를 모델에 담아서 뷰에 전달합니다. 뷰 템플릿은 이 값을 화면에 출력할 수 있습니다.

 

500.html에 오류 정보를 추가해보면 여러 정보가 나옵니다. 시간, 예외가 발생된 경로, 메세지 등이 나옵니다. 근데 여러 정보가 null입니다. 이런 오류 관련해서 내부 정보를 고객에게 노출하는 것은 보안상 문제가 될 수도 있습니다. 하지만 이를 포함해서 보내려면 properties에 담아서 보내면 뷰에 포함해서 보낼 수 있습니다. error는 어떤 종류의 에러인지, ex는 어떤 예외가 발생했는지, trace도 쭉 다 나옵니다. 그래서 보안상으로 숨겨야합니다.

 

-> 스프링 오류 관련 옵션

4xx나 error.html 화면이 없을 시 기본 에러 페이지를 보여줄지 말지, basic 컨트롤러의 오류 페이지 RequestMapping 경로를 바꿀지도 옵션으로 줄 수 있습니다.

'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글

spring PART.파일 업로드  (0) 2023.04.28
spring PART.API 예외처리  (0) 2023.04.27
spring PART.필터  (0) 2023.04.24
spring PART.로그인, 세션관리  (0) 2023.04.23
spring PART.빈 검증  (0) 2023.04.21
Comments