이번에는 스프링부트에서 예외를 처리하는 예제에 대해 설명하려고합니다.
모든 코드는 Github에 있기 때문에 같이 보길 추천합니다.
발단
실무에서 스프링을 이용한 프로젝트를 진행할 때, 예외를 어떻게 처리할 것인지는 매우 중요하다.
메소드 혹은 컨트롤러마다 예외를 처리할 수도 있지만, 여간 귀찮은 일이 아니다.
예외가 발생하면 서버에서는 로그를 기록하고 사용자에게는 특정 메시지만 표시하게 전역으로 처리하면 좋다고 생각했다.
본문
스프링부트에서 예외처리하는 방법은 3가지다.
- 전역 처리 @ControllerAdvice
- 컨트롤러단에서의 처리 @ExceptionHandler
- 메소드단위에서의 처리 try/catch
@ControllerAdvice
컨트롤러 메소드에 사용하는 어노테이션.
어노테이션 인자로 전달된 예외를 처리한다.
전달된 예외클래스와 확장된 클래스까지 처리한다.
우리는 위의 두 가지 어노테이션을 이용하여 예외 전역 처리를 구성할 수 있다.
이제 예제 애플리케이션을 구현해보자.
기본 프로젝트 구성
HTTP 에러페이지도 처리하기위해 스프링부트 + 스프링 시큐리티로 구성한다.
build.gradle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | apply plugin: 'java' apply plugin: 'eclipse-wtp' apply plugin: 'org.springframework.boot' apply plugin: 'war' group = 'springboot.exception.handling' version = '0.0.1-SNAPSHOT' sourceCompatibility = 1.8 repositories { mavenCentral() } configurations { providedRuntime } dependencies { compile('org.springframework.boot:spring-boot-starter-web') compile('org.springframework.boot:spring-boot-starter-security') providedRuntime('org.springframework.boot:spring-boot-starter-tomcat') testCompile('org.springframework.boot:spring-boot-starter-test') compile('org.apache.tomcat.embed:tomcat-embed-jasper') compile('javax.servlet:jstl:1.2') } | cs |
템플릿으로 jsp를 사용하므로 tomcat-embed-jasper와 jstl을 추가한다.
에러 도메인에 사용할 클래스들은 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | package springboot.exception.handling.domain; public class ExceptionDto { private String msg; private String cause; private ExceptionType type; public ExceptionDto(String msg, String cause, ExceptionType type) { super(); this.msg = msg; this.cause = cause; this.type = type; } @Override public String toString() { return msg + ", " + type.getTitle() + ": " + cause; } public String getMsg() { return msg; } public String getCause() { return cause; } public ExceptionType getType() { return type; } } package springboot.exception.handling.domain; public enum ExceptionType { SQL("Code"), SERVER("Exception"); final private String title; private ExceptionType(String title) { this.title = title; } public String getTitle() { return title; } } | cs |
컨트롤러의 작업 중에 예외가 발생했을 경우 이를 캐치하여 전역 처리를 하기 위해 @ControllerAdvice 클래스를 생성한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | package springboot.exception.handling.mvc; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import springboot.exception.handling.domain.ExceptionDto; import springboot.exception.handling.domain.ExceptionType; @ControllerAdvice("springboot.exception.handling") @Order(Ordered.LOWEST_PRECEDENCE) public class ExceptionLowestControllerAdvice { Logger logger = LoggerFactory.getLogger(ExceptionLowestControllerAdvice.class); @ExceptionHandler(Exception.class) public String handleAnyException(HttpServletRequest request, HttpServletResponse response, Exception e) { ExceptionDto exception = new ExceptionDto("Exception on server occurred", e.toString(), ExceptionType.SERVER); request.setAttribute("msg", exception.toString()); logger.info(exception.toString()); return "forward:/handling"; } } | cs |
현재 내가 근무하는 회사의 경우, SQL 에서 발생하는 에러는 따로 코드를 두고 관리한다.
SQLException을 따로 처리하기 위해 @ExceptionHandler(SQLException.calss)를 처리하는 메소드를 추가하면 될 것 같지만,
SQLException이 Exception을 확장하기 때문에 문제가 된다.
이 문제를 해결하기 위해서는 @Order 어노테이션을 이용해야 한다.
@ControllerAdvice 객체가 여러 개 있을 경우 우선 순위를 지정하는 어노테이션이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | package springboot.exception.handling.mvc; import java.sql.SQLException; import javax.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import springboot.exception.handling.domain.ExceptionDto; import springboot.exception.handling.domain.ExceptionType; @ControllerAdvice("springboot.exception.handling") @Order(Ordered.HIGHEST_PRECEDENCE) public class ExceptionHighstControllerAdvice { Logger logger = LoggerFactory.getLogger(ExceptionHighstControllerAdvice.class); @ExceptionHandler(SQLException.class) public String sqlException(HttpServletRequest request, Exception e) { ExceptionDto exception = new ExceptionDto("SQLException occurred", e.getCause().getMessage(), ExceptionType.SQL); request.setAttribute("msg", exception.toString()); logger.info(exception.toString()); return "forward:/handling"; } } | cs |
모든 예외를 처리하는 객체는 LOWEST_PRECEDENCE로 지정하고 SQL 예외를 처리하는 객체는 HIGHEST_PRECEDENCE로 설정한다.
이렇게 하면 SQL 예외만 따로 처리하도록 구현할 수 있다.
두 어드바이스 클래스 모두 다른 요청에 포워딩하는 이유가 궁금할 수도 있다.
일반 애플리케이션에서 요청은 HTML 혹은 다른 템플릿 뷰를 반환하지만 API 애플리케이션은 JSON 형식의 객체를 반환한다.
하지만 API 애플리케이션을 따로 두지 않고 하나의 애플리케이션에 섞여있는 경우가 많으므로 이를 분리하기 위해서다.
포워딩의 특성을 이용해 헤더와 본문이 그대로 유지되므로 알맞게 분리된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @Controller public class ExceptionController { @RequestMapping(value = "/handling") public ModelAndView sampleTest(HttpServletRequest request) { String msg = (String) request.getAttribute("msg"); ModelAndView mv = new ModelAndView("/500"); mv.addObject("msg", msg); return mv; } @RequestMapping(value = "/handling", produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_UTF8_VALUE}) @ResponseBody public ResponseEntity<String> handleCustomException(HttpServletRequest request) { String msg = (String) request.getAttribute("msg"); return new ResponseEntity<String>(msg, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR); } } | cs |
에러 도메인을 정의하고 요청을 매핑시켜 기본적인 작업은 끝났다.
추가로 403, 404과 같은 HTTP 에러도 처리해보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | package springboot.exception.handling.config; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.embedded.ConfigurableEmbeddedServletContainer; import org.springframework.boot.web.servlet.ErrorPage; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; @Configuration public class HttpErrorConfiguration extends ServerProperties { @Override public void customize(ConfigurableEmbeddedServletContainer container) { super.customize(container); container.addErrorPages(new ErrorPage(HttpStatus.FORBIDDEN, "/403")); container.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND, "/404")); container.addErrorPages(new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/500")); } } package springboot.exception.handling.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .csrf() .disable() .authorizeRequests() .antMatchers("/forbidden").denyAll() .antMatchers("/**").permitAll(); } } | cs |
테스트 구성
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | package springboot.exception.handling.controller; import java.sql.SQLException; import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.servlet.ModelAndView; @Controller public class ExceptionController { @RequestMapping("/") public String exceptionTest() { return "/exceptionTest"; } @RequestMapping("/403") public String forBiddenPage() { return "/403"; } @RequestMapping("/404") public String notFoundPage() { return "/404"; } @RequestMapping("/405") public String internalServerErrorPage() { return "/405"; } @RequestMapping(value = "/handling") public ModelAndView sampleTest(HttpServletRequest request) { String msg = (String) request.getAttribute("msg"); ModelAndView mv = new ModelAndView("/500"); mv.addObject("msg", msg); return mv; } @RequestMapping(value = "/handling", produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_UTF8_VALUE}) @ResponseBody public ResponseEntity<String> handleCustomException(HttpServletRequest request) { String msg = (String) request.getAttribute("msg"); return new ResponseEntity<String>(msg, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR); } @GetMapping("/exceptionOnHtmlRequst") public String exceptionOnHtmlRequst() throws NullPointerException { throw new NullPointerException(); } @GetMapping("/sqlExceptionOnHtmlRequest") public String sqlExceptionOnHtmlRequest() throws SQLException { throw new SQLException("SQLExceptin", new SQLException("SQL_101")); } @GetMapping("/exceptionOnJsonRequest") @ResponseBody public List<Map<String, Object>> exceptionOnJsonRequest() throws Exception{ throw new Exception(); } @GetMapping("/sqlExceptionOnJsonRequest") @ResponseBody public List<Map<String, Object>> sqlExceptionOnJsonRequest() throws SQLException { throw new SQLException("SQLExceptin", new SQLException("SQL_101")); } } | cs |
4가지 상황에 대한 테스트 URL을 작성했다.
- 일반 요청 중 예외
- 일반 요청 중 SQL예외
- JSON 요청 준 예외
- JSON 요청 중 SQL예외
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>Insert title here</title> <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> </head> <body> <ul id="authControl"> <li><button id="exceptionOnHtmlRequst"><span>Exception on HTML request</span></button></li> <li><button id="sqlExceptionOnHtmlRequest" ><span>SqlException on HTML reuqest</span></button></li> <li><button id="exceptionOnJsonRequest"><span>Exception on JSON request</span></button></li> <li><button id="sqlExceptionOnJsonRequest"><span>SqlException on JSON request</span></button></li> <li><button id="forbidden"><span>HTTP 403 Exception</span></button></li> <li><button id="notFound"><span>HTTP 404 Exception</span></button></li> </ul> <script> $(function () { $.ajaxSetup({ contentType: "application/json" }); $(document).ajaxError(function(event, request, settings) { alert(request.responseText); }); }); $("#exceptionOnHtmlRequst").click(function() { location.href="/exceptionOnHtmlRequst"; }); $("#sqlExceptionOnHtmlRequest").click(function() { location.href="/sqlExceptionOnHtmlRequest"; }); $("#exceptionOnJsonRequest").click(function() { $.getJSON("/exceptionOnJsonRequest"); }); $("#sqlExceptionOnJsonRequest").click(function() { $.getJSON("/sqlExceptionOnJsonRequest"); }); $("#forbidden").click(function() { location.href = "/forbidden"; }); $("#notFound").click(function() { location.href = "/notFound"; }); </script> </body> </html> | ㅇcs |
ajax 요청에서 에러가 발생할 경우 공통으로 alert을 띄우기 위해 콜백을 등록한다.
실행
프로젝트를 실행하고 localhost:8080/ 에 접속하면 아래와 같은 허접한 페이지가 등장한다.
request 버튼을 누르면 아래와 같이 서버 로그와 alert창이 띄워질 것이다.
마무리
예외를 전역으로 처리하는 방법에 대해 알아보았습니다.
테스트 코드 작성에 익숙해지면 테스트 환경을 J4Unit으로 바꿔서 커밋할 예정입니다.
앞으로도 실무에서 마주치는 문제를 정리해서 예제로 만들어 공유하고 싶습니다.
읽어주셔서 감사합니다.
'Web > Spring Framework' 카테고리의 다른 글
mybatis에서 mssql 프로시저 호출 시 raise error을 catch하지 못하는 경우 (0) | 2017.05.05 |
---|---|
Mybatis 추가 시 mybatis egovframework.com.cmm.EgovMessageSource (0) | 2017.01.18 |