본문 바로가기

Web/Spring Framework

스프링부트. @ControllerAdvice를 이용한 HTML과 JSON 요청에 대한 예외 처리

이번에는 스프링부트에서 예외를 처리하는 예제에 대해 설명하려고합니다.

모든 코드는 Github에 있기 때문에 같이 보길 추천합니다.


발단


실무에서 스프링을 이용한 프로젝트를 진행할 때, 예외를 어떻게 처리할 것인지는 매우 중요하다.

메소드 혹은 컨트롤러마다 예외를 처리할 수도 있지만, 여간 귀찮은 일이 아니다.

예외가 발생하면 서버에서는 로그를 기록하고 사용자에게는 특정 메시지만 표시하게 전역으로 처리하면 좋다고 생각했다.


본문


스프링부트에서 예외처리하는 방법은 3가지다.

  1. 전역 처리 @ControllerAdvice
  2. 컨트롤러단에서의 처리 @ExceptionHandler
  3. 메소드단위에서의 처리 try/catch
예외를 전역으로 처리하기 위해서는 1(@ControllerAdvice)와 2(@ExceptionHandler) 두 가지를 사용한다.


@ControllerAdvice

컨트롤러를 보조하는 클래스에 사용하는 어노테이션.
컨트롤러에서 쓰이는 공통기능들을 모듈화하여 전역으로 사용한다.

@ExceptionHandler

컨트롤러 메소드에 사용하는 어노테이션.

어노테이션 인자로 전달된 예외를 처리한다.

전달된 예외클래스와 확장된 클래스까지 처리한다.


우리는 위의 두 가지 어노테이션을 이용하여 예외 전역 처리를 구성할 수 있다.

이제 예제 애플리케이션을 구현해보자.


기본 프로젝트 구성

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예외
404, 403, 500 페이지는 간단하므로 적지 않겠다. 
마지막으로 테스트용 JSP 페이지(exceptionTest.jsp)를 생성하자.
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으로 바꿔서 커밋할 예정입니다.

앞으로도 실무에서 마주치는 문제를 정리해서 예제로 만들어 공유하고 싶습니다.


읽어주셔서 감사합니다.