Search

서블릿 세션, 필터, 인터셉터, 예외 처리

Created
2024/11/06 05:03
상태
Done
태그
spring
web service

쿠키와 세션

쿠키

웹 애플리케이션에서 로그인 상태를 유지하는 방법은 여러가지가 있다. 그 중 한 가지 방법으로 쿠키를 통해 로그인 상태를 유지 할 수 있다.
이와 같이 로그인 성공 시에 쿠키를 발급하여 브라우저의 쿠키 저장소에 보관해두고,
이후 클라이언트에서 요청 시마다 쿠키를 요청 헤더에 담아보내 서버에서 해당 쿠키를 보고 로그인 상태를 확인하는 것이다.
이러한 쿠키에는 만료 날짜를 지정하여 해당 날짜까지 유지되는 영속 쿠키와, 만료 날짜를 지정하지 않고 브라우저 종료까지만 유지되는 세션 쿠키가 있다.
쿠키를 발급하는 방법은,
@PostMapping("/login") public String login(@Valid @ModelAttribute LoginFrom from, BindingResult bindingResult, HttpServletResponse response) { ... Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); if (loginMember == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm"; } Cookie idCookie = new Cookie("memberId, String.valueOf(loginMember.getId())); response.addCookie(idCookie); return "redirect:/"; }
Java
복사
이와 같이 로그인에 성공하면 HttpServletResponse에 특정 값을 쿠키로 만들어 넣어주면 된다.
쿠키를 열어 로그인 상태를 확인하는 쪽에서는,
@GetMapping("/") public String homeLogin( @CookieValue(name = "memberId", required = false) Long memberId, Model model) { if (memberId == null) { return "home"; } Member loginMember = memberRepository.findById(memberId); if (loginMember == null) { return "home"; } model.addAttribute("member", loginMember); return "loginHome"; }
Java
복사
이와 같이 쿠키에 있는 특정 값을 꺼내 해당 값으로 사용자의 정보를 찾으면 된다.
@PostMapping("/logout") public String logout(HttpServletResponse response) { Cookie cookie = new Cookie(cookieName, null); cookie.setMaxAge(0); response.addCookie(cookie); return "redirect:/"; }
Java
복사
로그 아웃의 경우, 이처럼 setMaxAge(0)을 통해 쿠키를 삭제하면 된다.

쿠키의 보안 문제

쿠키는 몇 가지 이유로 보안에 취약하다.
1.
쿠키의 값을 클라이언트에서 임의로 변경할 수 있다.
2.
쿠키에 보관된 정보를 쉽게 탈취할 수 있다.
3.
한 번 탈취된 쿠키를 재사용 할 수 있다.
이러한 이유로 쿠키에는 중요한 값을 보관하지 않고, 예측 불가능한 임의의 토큰 값을 사용하며, 서버에서 토큰을 관리 해야한다. 또한 토큰은 일정 시간이 지나면 만료되도록 하여, 탈취 후 재사용을 막아야 한다.
이러한 쿠키의 보안상의 문제를 보완하기 위해 세션을 통해 토큰을 관리한다.

세션

세션은 서버에서 데이터를 보관하여 상태를 유지하는 방법을 말하는데, 쿠키와 같이 사용하는 경우 다음과 같이 사용된다.
먼저 로그인 요청이 오게 되면 로그인 정보를 기반으로 회원 정보를 찾고,
찾은 회원 정보를 세션 저장소에 저장하면 UUID와 같은 랜덤 토큰으로 세션 Id를 생성한다.
이렇게 생성한 세션 Id를 쿠키에 담아 클라이언트에 응답하고,
클라이언트에서 해당 쿠키를 사용하여 매 요청마다 로그인 상태를 유지한다. 서버에서는 세션 Id를 기준으로 유저 정보를 찾아 사용자 정보를 확인한다.
위 방식을 사용하면 기존의 쿠키가 가졌던 보안 상의 문제를 해결할 수 있다.
1.
쿠키 값을 변조 가능하더라도, 예측 불가능한 세션 Id를 통해 변조를 방지
2.
쿠키에 보관하는 정보는 랜덤 토큰인 세션 Id 값으로, 중요한 정보가 없음
추가적으로 세션 저장소 세션들의 만료 시간을 짧게 유지하면, 토큰이 탈취 당하더라도 특정 시간 이후 다시 사용이 불가능해진다.

서블릿의 HTTP 세션

이러한 세션 기능은 구조가 복잡하지 않기 때문에, 직접 구현하여 사용해도 무방하다. 하지만 서블릿에서 HttpSession이라는 기능을 제공하여 위의 문제들을 해결할 수 있다.
@PostMapping("/login") public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) { ... Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); if (loginMember == null) { bindingResult.rejcet("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm"; } HttpSession session = request.getSession(); session.setAttribute("loginMember", loginMember); return "redirect:/"; }
Java
복사
세션을 가져오는 request.getSession(true)은 세션이 있으면 기존 세션을 반환하고 없다면 새로운 세션을 생성해서 반환한다. request.getSession(false)은 세션이 있으면 반환하고 없으면 null을 반환한다. 기본 옵션은 true로 생략 가능하다.
세션에 session.setAttribute(…)을 통해 데이터를 저장할 수 있고,
@GetMapping("/") public String homeLoginV3(HttpServletRequest request, Model model) { HttpSession session = request.getSession(false); if (session == null) { return "home"; } Member loginMember = (Member)session.getAttribute("loginMember"); if (loginMember == null) { return "home"; } model.addAttribute("member", loginMember); return "loginHome"; }
Java
복사
session.getAttribute(…)을 통해 저장한 데이터를 꺼낼 수 있다.
위와 같이 단순히 세션에 특정 사용자의 정보를 저장하기만 하면, 서블릿 HTTP 세션 내부에서 랜덤 토큰 값 생성 및 쿠키 발급까지 자동으로 처리해준다. 실제 쿠키를 열어보면 Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05와 같이 JSESSIONID로 시작하는 랜덤 토큰 값이 들어있는 것을 확인할 수 있다. 꺼낼 때는 쿠키에서 위의 랜덤 세션 토큰을 꺼내 해당 세션 토큰과 일치하는 데이터를 반환한다.
@PostMapping("/logout") public String logoutV3(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } return "redirect:/"; }
Java
복사
로그아웃은 위와 같이 session.invalidate()를 호출하여 해당 세션을 만료 시키면 된다.
스프링에서는 이러한 서블릿 HTTP 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute 애노테이션을 지원한다.
@GetMapping("/") public String homeLoginV3Spring( @SessionAttribute(name = "loginMember", required = false) Member loginMember, Model model) { if (loginMember == null) { return "home"; } model.addAttribute("member", loginMember); return "loginHome"; }
Java
복사
@SessionAttribute 애노테이션을 사용하여 위와 간편하면서도 깔끔하게 세션 기능을 사용할 수 있다.
서블릿 HTTP 세션을 사용하면, 첫 로그인 시도 시에 URL이 http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872 이와 같이 jsessionid를 포함하고 있다.
이는 서블릿 HTTP 세션에는 웹 브라우저가 쿠키를 지원하지 않는 경우를 대비하여, 쿠키 대신 URL을 통해 세션을 유지할 수 있는 방법을 제공하는 기능이다. 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 여부를 알 수 없으니, 최초 요청 시에 쿠키와 URL 모두에 jsessionId를 담아서 보내는 것이다.
server: servlet: session: tracking-modes: cookie
YAML
복사
URL에 jsessionId를 보내는 것을 없애고 싶다면, application.yml 파일에 위와 같이 트래킹 모드를 쿠키만 사용하도록 설정하면 된다.
위에서 request.getSession()을 통해 가져오는 세션에는 여러가지 정보가 담겨있다.
sessionId : 세션 ID, JSESSIONID 값
maxInactiveInterval : 세션의 유효 시간(default 1800초)
creationTime : 세션 생성 일시
lastAccessTime : 세션과 연결된 사용자가 최근 세션 접근 시간
isNew : 새로 생성된 세션인지, 과거에 만들어졌던 세션인지 여부
이 중 maxInactiveInterval 항목을 보면 알겠지만, 위에서 언급했던 보안상의 문제로 서블릿 HTTP 세션은 기본적으로 유효 기간을 두고 lastAccessTime부터 일정 시간동안 세션에 접근하지 않으면 내부적으로 해당 세션을 제거한다.
만약 이 시간을 변경하고 싶다면,
server: servlet: session: timeout: 60
YAML
복사
이와 같이 application.yml 파일에 설정을 추가하여 글로벌로 설정하거나 session.setMaxInactiveInterval(60)과 같이 특정 세션을 지정하여 설정할 수 있다.

ArgumentResolver

이전의 스프링 MVC를 공부하며 배웠던, 매핑 핸들러에서 컨트롤러로 인자를 넘길 때 호출되는 Argument Resolver를 커스터마이징하여 파라미터를 간편하게 넘기게 만들 수 있다. 이를 활용하여 위의 Login 세션 파라미터를 간편하게 전달 받아보자.
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface Login { }
Java
복사
먼저 이와 같이 파라미터에 적용할 애노테이션을 추가한 후,
@Slf4j public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { log.info("supportsParameter 실행"); boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); return hasLoginAnnotation && hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { log.info("resolveArgument 실행"); HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); HttpSession session = request.getSession(false); if (session == null) { return null; } return session.getAttribute("memberId"); } }
Java
복사
이처럼 HandlerMethodArgumentResolver를 구현하여 커스텀 Argument Resolver를 작성한다.
해당 Resovler를 적용할지 여부를 결정하는 supportsParamter에서는 @Login 애노테이션이 달려 있는지와 인자가 Member 객체인지를 확인한다.
실제 파라미터를 변환하여 넘겨주는 resolverArgument에서는 기존의 세션에서 사용자 정보를 가져오는 로직을 넣어주면 된다.
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new LoginMemberArgumentResolver()); } ... }
Java
복사
그 후 새로 커스텀 Argument Resolver를 등록해주면,
@GetMapping("/") public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) { //세션에 회원 데이터가 없으면 home if (loginMember == null) { return "home"; } //세션이 유지되면 로그인으로 이동 model.addAttribute("member", loginMember); return "loginHome"; }
Java
복사
이와 같이 새로 추가한 @Login 애노테이션을 달아 간단하게 Member 정보를 조회해올 수 있다.

필터와 인터셉터

웹 애플리케이션을 개발하다보면, 해당 사용자가 로그인을 했는지와 같은 공통된 관심 사항들이 있다. 이러한 공통 관심사(cross-cutting concern)를 모든 컨트롤러에 추가하는 것은 비효율적이고 유지보수가 어려워진다.
이러한 공통 관심사를 스프링 AOP를 통해 해결할 수도 있지만, HTTP의 헤더나 URL의 정보들이 필요한 요청에 대한 공통 관심사는 HttpServletRequest를 제공하는 서블릿 필터나 스프링 인터셉터를 사용하는 것이 좋다.

서블릿 필터

필터는 다음과 같은 순서로 동작한다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
Plain Text
복사
이처럼 필터를 적용하면 필터가 호출된 이후 서블릿이 호출되는데, 필터는 특정 URL 패턴에 적용할 수 도 있고, 모든 요청에 적용할 수도 있다. 때문에 많은 요청에서 처리해야 하는 공통 관심사를 필터에서 처리할 수 있다.
스프링을 사용하는 경우, 여기에서의 서블릿은 디스패처 서블릿을 말한다.
// 로그인 사용자 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 // 비 로그인 사용자 HTTP 요청 -> WAS -> 필터 -> 차단(서블릿 호출 X)
Plain Text
복사
만약 비인가 요청과 같은 적합하지 않은 요청은 필터에서 판단하여 요청 처리를 거부할 수도 있다.
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
Plain Text
복사
필터는 위와 같이 체인으로 구성되는데, 중간에 필터를 자유롭게 추가할 수 있고 어떤 필터를 먼저 적용할 것인지 순서를 조정할 수 있다.
public interface Filter { public default void init(FilterConfig filterConfig) throws ServletException {} public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; public default void destroy() {} }
Java
복사
필터 인터페이스는 다음과 같이 구성되어 있다.
init : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
doFilter : 필터 로직 부분, 고객 요청 시 필터가 적용되어 수행되는 동작이다.
destroy : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.
이러한 필터 인터페이스를 구현하여 등록하면, 서블릿 컨테이너가 해당 필터 구현체를 싱글톤 객체로 생성하고 관리한다.

요청 로그 필터 추가하기

먼저 모든 요청에 대해 로그를 남기는 로그 필터를 만들어보자.
@Slf4j public class LogFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { log.info("log filter init"); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String requestURI = httpRequest.getRequestURI(); String uuid = UUID.randomUUID().toString(); try { log.info("REQUEST [{}][{}]", uuid, requestURI); chain.doFilter(request, response); } catch (Exception e) { throw e; } finally { log.info("RESPONSE [{}][{}]", uuid, requestURI); } } @Override public void destroy() { log.info("log filter destroy"); } }
Java
복사
이와 같이 Filter 인터페이스를 구현하는 로그 필터 객체를 만들고, init(), doFilter(), destroy() 메서드를 재정의 해준다. 필터는 HTTP 요청이 들어오면 doFilter 메서드가 호출되기 때문에, 해당 부분에 UUID와 요청 받은 URL을 로그로 남기도록 추가한다.
여기서 중요한 것은 chain.doFilter(request, response) 부분으로, 이를 호출해야 이후의 필터 혹은 서블릿이 호출되어 컨트롤러로 요청이 넘어가게 된다.
이렇게 만든 필터를 등록해야하는데, 필터를 등록하는 방법은 여러가지가 있지만 스프링 부트에서는 Configuration을 통해 등록하면 된다.
@Configuration public class WebConfig { @Bean public FilterRegistrationBean logFilter() { FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>(); filterRegistrationBean.setFilter(new LogFilter()); filterRegistrationBean.setOrder(1); filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; } }
Java
복사
이와 같이 해당 필터를 추가하는 빈을 만들어 등록할 수 있다.
setFilter : 등록할 필터를 지정한다.
setOrder : 필터 체인에서 동작할 순서를 지정한다.(낮을수록 먼저 동작)
addUrlPattern : 필터를 적용할 URL 패턴을 지정한다.(한번에 여러 패턴 지정 가능)
필터를 등록하는 다른 방법도 있는데, @ServletComponentScan @WebFilter(filterName = "logFilter", urlPatterns = "/*")을 통해서도 등록 가능하다. 하지만 필터의 순서 조절이 되지 않아 그냥 빈 등록해서 사용하는 것을 권장한다.

인증 체크 필터 추가하기

@Slf4j public class LoginCheckFilter implements Filter { private static final String[] whitelist = {"/", "/members/add", "/login", "/logout","/css/*"}; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String requestURI = httpRequest.getRequestURI(); HttpServletResponse httpResponse = (HttpServletResponse) response; try { log.info("인증 체크 필터 시작 {}", requestURI); if (isLoginCheckPath(requestURI)) { log.info("인증 체크 로직 실행 {}", requestURI); HttpSession session = httpRequest.getSession(false); if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { log.info("미인증 사용자 요청 {}", requestURI); //로그인으로 redirect httpResponse.sendRedirect("/login?redirectURL=" + requestURI); return; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝! } } chain.doFilter(request, response); } catch (Exception e) { throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함 } finally { log.info("인증 체크 필터 종료 {}", requestURI); } } /** * 화이트 리스트의 경우 인증 체크X */ private boolean isLoginCheckPath(String requestURI) { return !PatternMatchUtils.simpleMatch(whitelist, requestURI); } }
Java
복사
인증 체크 필터는 로그인 인증이 필요 없어도 통과할 수 있는 whitelist를 두고, 해당 URL 패턴에 맞지 않는 것에 대해 인증 체크를 수행한다. 세션을 꺼내 해당 사용자가 인증을 받은 상태인지 확인하고, 받았다면 다음 필터로 전달하고 받지 않은 상태라면 로그인 화면으로 리다이렉트 시킨다.
@Bean public FilterRegistrationBean loginCheckFilter() { FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>(); filterRegistrationBean.setFilter(new LoginCheckFilter()); filterRegistrationBean.setOrder(2); filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; }
Java
복사
인증 체크 필터를 등록하는 과정은 이전과 동일하게 WebConfig와 같은 Configuration에 빈으로 등록하면 된다.
이와 같이 인증 체크 필터를 통해 인증 여부를 공통 관심사로 묶어 처리하면, 이후 추가되는 모든 URL API에 대해 별다른 작업을 하지 않아도 인증 검사를 수행하게 된다. 또한 해당 필터가 인증에 대한 단일 책임을 가지기 때문에, 향후 로그인 정책이 변경된다면 위 코드만 수정을 하면 된다.

스프링 인터셉터

스프링 인터셉터는 서블릿 필터와 동일하게 공통 관심사를 처리할 수 있는 기술이다. 서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다. 스프링 인터셉터는 서블릿 필터보다 편리하며, 더 세밀하고 다양한 기능을 지원한다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
Plain Text
복사
스프링 인터셉터는 위와 같이 디스패처 서블릿 이후 컨트롤러가 호출되기 직전에 불린다. 스프링 인터셉터도 URL 패턴을 통해 특정 URL에 대해서만 동작하도록 지정할 수 있는데, 서블릿 필터에 비해 세밀하게 지정 가능하다.
// 로그인 사용자 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 // 비 로그인 사용자 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 차단(컨트롤러 호출 X)
Plain Text
복사
스프링 인터셉터에서도 마찬가지로 적절하지 못한 요청을 차단할 수 있다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
Plain Text
복사
또한 스프링 인터셉터도 체인으로 구성되어, 자유롭게 추가하고 순서를 지정할 수 있다.
public interface HandlerInterceptor { default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {} default void postHandle(HttpServletRequest request, HttpServletResponseresponse, Object handler, @Nullable ModelAndView modelAndView) throws Exception {} default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Excpetion ex) throws Exception {} }
Java
복사
스프링 인터셉터를 사용하기 위해서는 위의 HandlerInterceptor 인터페이스를 구현하여 등록하면된다.
preHandle : 컨트롤러 호출 전 동작, 반환값이 true이면 다음 인터셉터를 호출하고 false라면 더 진행하지 않는다.
postHandle : 컨트롤러 호출 후 동작, 예외 발생 시 호출되지 않는다.
afterCompletion : 요청 응답 완료 이후 동작, 예외 발생하더라도 호출되며 어떤 예외가 발생했는지도 알 수 있다.
스프링 인터셉터의 요청 흐름은 다음과 같다.
요청이 정상적으로 처리되는 경우,
1.
컨트롤러 호출 전 preHandle 호출
2.
핸들러 어댑터를 통해 API에 맞는 적절한 컨트롤러 호출
3.
컨트롤러 동작 완료 이후 postHandle 호출
4.
view 렌더링
5.
afterCompletion 호출
이와 같은 순서로 인터셉터가 처리된다.
반면 요청에서 예외가 발생하는 경우,
1.
컨트롤러 호출 전 preHandle 호출
2.
핸들러 어댑터를 통해 API에 맞는 적절한 컨트롤러 호출
3.
예외 발생 및 클라이언트에 예외 전달
4.
afterCompletion 호출
위 내용을 보면 알 수 있듯, 스프링 인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다고 볼 수 있다. 때문에 특별한 이유로 서블릿 필터를 사용해야하는 경우가 아니라면, 스프링 인터셉터를 사용하는 것이 더 편리하고 권장된다.

요청 로그 인터셉터 추가하기

@Slf4j public class LogInterceptor implements HandlerInterceptor { public static final String LOG_ID = "logId"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); String uuid = UUID.randomUUID().toString(); request.setAttribute(LOG_ID, uuid); //@RequestMapping: HandlerMethod //정적 리소스: ResourceHttpRequestHandler if (handler instanceof HandlerMethod) { //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다. HandlerMethod hm = (HandlerMethod) handler; } log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler); return true; //false 진행X } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { log.info("postHandle [{}]", modelAndView); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { String requestURI = request.getRequestURI(); String logId = (String)request.getAttribute(LOG_ID); log.info("RESPONSE [{}][{}]", logId, requestURI); if (ex != null) { log.error("afterCompletion error!!", ex); } } }
Java
복사
이와 같이 preHandle, postHandle, afterCompletion에서 각각 필요한 로직과 로그를 출력하도록 작성한다.
서블릿 필터의 경우 UUID를 지역변수로 처리 가능하지만, 스프링 인터셉터는 호출 시점이 완전히 분리되어 있어 request에 attribute로 담아두는 방식으로 처리해야 한다.
handler 객체의 경우 뒤에서 호출하는 컨트롤러의 여러 정보들을 담고 있는데, 해당 내용을 출력하려면 다운 캐스팅 후 꺼내야한다. 일반적으로 사용되는 @Controller와 @RequestMapping을 활용한 핸들러 매핑 방식에서는 HandlerMethod가 넘어온다. 반면 /resource/static과 같은 정적 리소스가 호출되는 경우에는 ResourceHttpRequestHandler가 넘어오게 된다.
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LogInterceptor()) .order(1) .addPathPatterns("/**") .excludePathPatterns("/css/**", "/*.ico", "/error"); } //... }
Java
복사
스프링 인터셉터의 등록은 위와 같이 WebMvcConfigurer를 구현하여 addInterceptors 메서드 내부에서 추가해주면 된다.
order : 인터셉터의 호출 순서 지정, 낮을수록 먼저 호출된다.
addPathPatterns : 인터셉터를 적용할 URL 패턴 지정
excludePathPatterns : 인터셉터에서 제외할 URL 패턴 지정
이와 같이 서블릿 필터에서는 별개의 로직에서 처리했던 화이트 리스트 URL을 메서드 체인 형태로 간편하게 지정할 수 있다.
스프링에서 URL 경로 패턴을 지정할 때는 세밀하게 설정 할 수 있다.
? 한 문자 일치 * 경로(/) 안에서 0개 이상의 문자 일치 ** 경로 끝까지 0개 이상의 경로(/) 일치 {spring} 경로(/)와 일치하고 spring이라는 변수로 캡처 {spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring" {spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처 {*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처 /pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/ toast.html /resources/*.png — matches all .png files in the resources directory /resources/** — matches all files underneath the /resources/ path, including / resources/image.png and /resources/css/spring.css /resources/{*path} — matches all files underneath the /resources/ path and captures their relative path in a variable named "path"; /resources/image.png will match with "path" → "/image.png", and /resources/css/spring.css will match with "path" → "/css/spring.css" /resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the value "spring" to the filename variable
Plain Text
복사

인증 체크 인터셉터 추가하기

@Slf4j public class LoginCheckInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); log.info("인증 체크 인터셉터 실행 {}", requestURI); HttpSession session = request.getSession(false); if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { log.info("미인증 사용자 요청"); //로그인으로 redirect response.sendRedirect("/login?redirectURL=" + requestURI); return false; } return true; } }
Java
복사
인증 검사를 수행할 preHandler만 재정의하여 구현하면 된다.
@Override public void addInterceptors(InterceptorRegistry registry) { ... registry.addInterceptor(new LoginCheckInterceptor()) .order(2) .addPathPatterns("/**") .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error" ); }
Java
복사
작성한 인터셉터를 등록하면 적용시킬 수 있다.

예외 처리

서블릿 예외 처리

스프링이 아닌 순수 서블릿은 2가지 방식으로 예외 처리를 지원한다.
1.
Exception
@GetMapping("/error-ex") public void errorEx() { throw new RuntimeException("예외 발생!"); }
Java
복사
자바의 스레드에서 예외가 발생하면 예외 정보를 남기고 해당 스레드는 종료된다. 웹 애플리케이션은 사용자 요청 시마다 별도의 스레드가 할당되고 서블릿 컨테이너 안에서 실행된다.
컨트롤러(예외 발생) -> 인터셉터 -> 서블릿 -> 필터 -> WAS
Plain Text
복사
때문에 위와 같이 예외가 발생하여 서블릿 밖으로 예외가 전달되면, 톰캣과 같은 WAS까지 전달된다. 톰캣에서는 각 예외에 따라 기본으로 제공하는 오류 화면을 띄운다.
2.
response.sendError(HTTP code, errorMessage)
오류가 발생했을 때, HttpServletResponse가 제공하는 sendError 메서드를 사용하는 방법도 있다.
@GetMapping("/error-404") public void error404(HttpServletResponse response) throws IOExcpetion { response.sendError(404, "404 예외 발생!"); }
Java
복사
이 메서드를 호출한다고 하여 바로 예외가 발생하는 것은 아니고, 서블릿 컨테이너에게 오류가 발생했다는 사실을 전달한다.
컨트롤러 -> 인터셉터 -> 서블릿 -> 필터 -> WAS(sendError 호출 기록 확인)
Plain Text
복사
sendError 메서드를 호출하면 response 내부에 오류가 발생했다는 상태를 저장하고, 이를 서블릿 컨테이너에서 확인하여 설정한 오류 코드에 맞춰 오류 페이지를 보여준다.

서블릿 예외 처리 - 오류 화면 제공

서블릿에서 기본적으로 제공하는 오류 페이지는 불친절하고 고객 친화적이지 않다. 하지만 서블릿에는 각 상황에 맞춘 오류 처리 기능을 제공하기 때문에, 이를 사용하여 오류 처리 화면을 커스터마이징 할 수 있다.
@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); } }
Java
복사
이와 같이 특정 오류 코드가 발생했을 때, 어떻게 처리할 것인지 지정할 수 있다. 서블릿에서는 해당 오류가 발생하면 ErrorPage의 두 번째 인자로 넘어가는 path 문자열을 보고, 다시 서블릿의 매핑 핸들러를 호출하여 해당 이름과 동일한 컨트롤러를 찾는다.
@Slf4j @Controller public class ErrorPageController { @RequestMapping("/error-page/404") public String errorPage404(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 404"); return "error-page/404"; } @RequestMapping("/error-page/500") public String errorPage500(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 500"); return "error-page/500"; } }
Java
복사
이와 같이 해당 경로를 처리하는 컨트롤러에서 오류 화면 뷰를 반환하도록 만들면, 우리가 원하는 화면을 사용자에게 전달하여 오류 페이지를 보여주는 것이 가능하다.
서블릿에서 오류 처리를 하는 흐름을 다시 살펴보면,
// 예외 발생 컨트롤러(예외 발생) -> 인터셉터 -> 서블릿 -> 필터 -> WAS -> ErrorPage의 Path 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 // sendError 컨트롤러 -> 인터셉터 -> 서블릿 -> 필터 -> WAS(sendError 호출 기록 확인) -> ErrorPage의 Path 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
Plain Text
복사
이와 같은 순서로 처리되며 필터, 서블릿, 인터셉터, 컨트롤러 모두 다시 호출된다. 하지만 이 과정은 서버 내부적으로 발생하기 때문에, 클라이언트(웹 브라우저)에서는 어떤 일이 벌어지는지 전혀 모른다.
예외 처리를 하는 과정에서, HttpServletRequest에 발생한 오류에 대한 정보를 다시 담아준다.
@Slf4j @Controller public class ErrorPageController { //RequestDispatcher 상수로 정의되어 있음 public static final String ERROR_EXCEPTION = "javax.servlet.error.exception"; public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type"; public static final String ERROR_MESSAGE = "javax.servlet.error.message"; public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri"; public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name"; public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code"; @RequestMapping("/error-page/404") public String errorPage404(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 404"); printErrorInfo(request); return "error-page/404"; } @RequestMapping("/error-page/500") public String errorPage500(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 500"); printErrorInfo(request); return "error-page/500"; } 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()); } }
Java
복사
이와 같이 requset에서 오류에 대한 정보들을 꺼내 볼 수 있다.
javax.servlet.error.exception : 예외
javax.servlet.error.exception_type : 예외 타입
javax.servlet.error.message : 오류 메시지
javax.servlet.error.request_uri : 클라이언트 요청 URI
javax.servlet.error.servlet_name : 오류가 발생한 서블릿 이름
javax.servlet.error.status_code : HTTP 상태 코드

서블릿 예외 처리 - 필터와 인터셉터

// 예외 발생 컨트롤러(예외 발생) -> 인터셉터 -> 서블릿 -> 필터 -> WAS -> ErrorPage의 Path 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 // sendError 컨트롤러 -> 인터셉터 -> 서블릿 -> 필터 -> WAS(sendError 호출 기록 확인) -> ErrorPage의 Path 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
Plain Text
복사
서블릿의 예외 처리 과정에서 내부적으로 다시 호출이 발생하는데, 그 과정에서 로그인 인증이나 로그 처리와 같은 모든 필터와 인터셉터를 다시 호출하는 것은 매우 비효율적이다. 이를 서블릿 필터와 스프링 인터셉터에서는 어떻게 처리해야 하는 지 알아보자.
먼저 서블릿에서는 이런 문제를 해결하기 위해 DispatcherType이라는 추가 정보를 제공한다. DispatcherType은 HttpServletRequst의 getDispatcherType() 메서드를 통해 얻을 수 있는데, 서블릿에서는 이를 통해 해당 요청이 어떤 타입인지를 나타낸다.
public enum DispatcherType { FORWARD, INCLUDE, REQUEST, ASYNC, ERROR }
Java
복사
REQUEST : 클라이언트 요청 시
ERROR : 오류 요청 시
FORWARD : 다른 서블릿이나 JSP를 호출할 때(RequestDispatcher.forward(request, response))
INCLUDE : 다른 서블릿이나 JSP 결과를 포함할 때(RequestDispatcher.include(request, response))
ASYNC : 서블릿 비동기 호출 시
우리는 이를 통해서 서버 내부적으로 오류 처리를 위해 다시 호출하는 경우, 특정 필터가 동작하지 않도록 만들 수 있다.
@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; }
Java
복사
이와 같이 필터를 등록할 때, setDispatcherType를 통해 해당 필터가 동작할 요청들을 지정할 수 있다.
스프링 인터셉터는 오류 발생 시 postHandle이 호출되지 않고 afterCompletion만 호출된다. 하지만 오류 처리를 위해 내부적으로 다시 호출하는 경우에는 preHandle, postHandle, afterCompletion 모두 다시 불리게 된다.
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LogInterceptor()) .order(1) .addPathPatterns("/**") .excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**"); //오류 페이지 경로 }
Java
복사
스프링 인터셉터에서는 이 문제를 위와 같이 세밀한 URL 경로 지정을 통해 해결할 수 있다. 오류 처리에 대한 URL을 등록하여, 해당 요청 시에는 동작하지 않도록 만드는 것이다.
오류 처리의 전체 흐름을 다시 정리하면 다음과 같다.
1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 2. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생) 3. WAS 오류 페이지 확인 4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러(/error-page/500) -> View
Plain Text
복사

스프링 부트 - 오류 페이지

위 과정을 살펴보면 예외 페이지를 보여주기 위해 WebServerCustomizer를 만들고, 예외에 따라 ErrorPage를 추가하고, 이를 처리하는 ErrorPageController를 만들어야 했다.
스프링 부트에서는 자동으로 /error 경로에 있는 ErrorPage를 등록하고 BasicErrorController라는 스프링 컨트롤러를 자동으로 등록하여, 우리가 별도의 등록 없이 해당 오류 처리에 대한 뷰 템플릿만 넣어두면 알아서 보여지도록 만들어 두었다.
BasicErrorController의 처리 순서는 다음과 같다.
1.
뷰 템플릿
resources/templates/error/500.html
resources/templates/error/5xx.html
2.
정적 리소스(static, public)
resources/static/error/500.html
resources/static/error/5xx.html
3.
적용 대상이 없을 때 뷰 이름(error)
resources/templates/error.html
위 경로상에 HTTP 상태 코드의 이름의 뷰 파일을 넣어두기만 하면, 스프링에서 알아서 처리해준다.
추가적으로 이 과정에서 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`)
Plain Text
복사
이러한 정보들은 설정을 통해 어떤 정보를 포함할지를 선택할 수 있다.
server: error: include-exception: false # exception 포함 여부 include-message: always # message 포함 여부 include-stacktrace: never # trace 포함 여부 include-binding-errors: on_param # errors 포함 여부 # never : 사용하지 않음 # always : 항상 사용 # on_param : 이름이 동일한 쿼리 파라미터가 있을 때 사용
YAML
복사
하지만 이러한 오류 관련 내부 정보들을 외부에 노출하는 것은 좋지 않은 선택이다. 고객 입장에서는 읽어도 혼란만 더해지고, 보안상으로는 오히려 공격받기에 더 좋기 때문이다.
오류가 발생하면 고객에게는 잘 구성된 오류 화면과 간단한 오류 메세지를 보여주는 것이 좋고, 해당 오류에 대한 내용은 서버에 로그를 남겨 로그로 확인하는 것이 권장된다.

API 예외 처리

JSON 데이터를 주고 받는 API 방식에서는 위에서 처리했던 방법으로는 오류를 해결할 수 없다. API를 요청 받았을 때 정상 동작이라면 JSON 데이터가 반환되겠지만, 오류가 발생하면 렌더링된 HTML이 반환될 것이다. 웹 브라우저가 아닌 이상 API를 받아도 처리할 수 있는 것은 별로 없다. 때문에 오류 페이지 컨트롤러도 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()); Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); return new ResponseEntity(result, HttpStatus.valueOf(statusCode)); }
Java
복사
이와 같ㅇ니 오류 페이지 컨트롤러에 produces = MediaType.APPLICATION_JSON_VALUE를 추가한 메서드를 만들자. 이는 HTTP 요청의 헤더에 Accept 값이 application/json인 경우에 해당 메서드가 호출된다는 의미이다. 다시 말해서, 클라이언트가 받고 싶은 미디어 타입이 json이라면 해당 메서드가 호출된다는 의미이다.
{ "message": "잘못된 사용자", "status": 500 }
JSON
복사
테스트를 해보면 이처럼 오류가 발생하더라도, HTML 응답이 아닌 JSON과 HTTP 응답 코드를 통해 오류가 발생했음을 전달하는 것을 확인할 수 있다.

스프링 부트 기본 오류 처리

당연하지만, 위와 같이 API 요청 시 오류를 JSON으로 처리하도록 하는 기능도 스프링 부트에서 기본적으로 제공되어 있다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE) public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {} @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
Java
복사
BasicErrorController를 보면, 위와 같이 동일한 /error 경로를 처리하는데 errorHtml()error() 두 개의 메서드를 두고 있다.
errorHtml의 경우 produces = MediaType.TEXT_HTML_VALUE로, 요청의 Accept 헤더 값이 text/html인 경우 호출된다. error의 경우 그 외에 호출되어, ResponseEntitiy로 JSON 데이터를 반환한다.
이를 통해 application/json으로 요청을 보내보면,
{ "timestamp": "2021-04-28T00:00:00.000+00:00", "status": 500, "error": "Internal Server Error", "exception": "java.lang.RuntimeException", "trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController. java:19..., "message": "잘못된 사용자", "path": "/api/members/ex" }
JSON
복사
이와 같이 스프링 부트가 BasicController가 제공하는 기본 정보들을 활용해 오류 API를 생성해 응답을 보내준다.
추가적으로 위에서 했었던 옵션들을 설정하여 더 자세한 오류 정보를 추가할 수 있다.
server: error: include-exception: false # exception 포함 여부 include-message: always # message 포함 여부 include-stacktrace: never # trace 포함 여부 include-binding-errors: on_param # errors 포함 여부 # never : 사용하지 않음 # always : 항상 사용 # on_param : 이름이 동일한 쿼리 파라미터가 있을 때 사용
YAML
복사
물론 위에서도 언급했듯이, 오류 메세지에 과도한 정보를 노출하는 것은 보안상 위험할 수 있다.

HandlerExceptionResolver

스프링에서 기본적으로 JSON 데이터로 오류를 처리하는 기능을 제공하지만, 모든 예외가 500 에러로 처리된다. 만약 IllegalArgumentException이 발생한다면 클라이언트에는 HTTP 상태 코드를 400으로 처리하고 싶다면, HandlerExceptionResolver를 사용하면된다.
기존의 오류 처리 흐름은 위와 같이, 컨트롤러에서 예외가 발생하면 Dispatcher Servlet에 예외를 전달하고 해당 예외가 그대로 클라이언트까지 전달된다.
HandlerExceptionResolver를 적용하면, 컨트롤러에서 예외가 발생했을 때 해당 예외를 해결할 수 있는지 ExceptionResolver를 불러본다. 그 후 해결이 된다면 ModelAndView를 반환하여 정상적으로 렌더링하거나 응답을 보내게 된다.
ExceptionResolver로 예외를 잡아서 해결하더라도 스프링 인터셉터의 postHandle()은 호출되지 않는다.
public interface HandlerExcpetionResolver { ModelAndView resolveException( HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex); }
Java
복사
HandlerExceptionResolver의 인터페이스는 위와 같다.
Object handler : 핸들러(컨트롤러) 정보
Exception ex : 핸들러(컨트롤러)에서 발생한 예외 정보
@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(); } } catch (IOException e) { log.error("resolver ex", e); } return null; } }
Java
복사
HandlerExceptionResolver를 구현하여, 위와 같이 IllegalArgumentException이 발생하는 경우 해당 예외를 잡아서 sendError(...)로 바꾸고 ModelAndView를 반환하여 정상 흐름으로 서블릿이 돌아가도록 만든다.
만약 여기서 에러를 잡지 못하면 null을 반환하는데, null이 반환되면 다음 ExceptionResolver를 찾아 실행하고 만약 처리할 수 있는 Resolver가 없다면 기존에 발생한 예외를 서블릿 밖으로 다시 던진다.
@Override public void extendHandlerExceptionResolvers( List<HandlerExceptionResolver> resolvers) { resolvers.add(new MyHandlerExceptionResolver()); }
Java
복사
그 후 구현한 를 위처럼 등록해주면 해당 ExceptionResolver가 동작하게 된다.
서블릿에서 response.sendError를 까보고 오류가 있는 것을 확인 후, 상태 코드에 따른 오류를 처리한다.
위에서는 ModelAndView에 아무런 데이터도 넣지 않고 반환했는데, 그 경우에는 뷰를 찾지 않고 스프링 부트가 기본으로 설정한 /error가 호출된다. 만약 뷰 렌더링을 하고 싶다면, 값을 넣어 오류 화면을 제공할 수 있다.
추가적으로 response.getWriter().println("...")처럼 응답 HTTP body에 직접 데이터를 넣어주는 것도 가능하다.
HandlerExceptionResolver 활용하여 뷰 렌더링을 던져준다면, 기존의 내부적으로 추가적인 프로세스를 타서 다시 컨트롤러까지 호출되는 로직을 깔끔하게 해결할 수 있다.
@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"); String acceptHeader = request.getHeader("accept"); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); if (acceptHeader.equals("application/json")) { Map<String, Object> errorResult = new HashMap<>(); errorResult.put("ex", ex.getClass()); errorResult.put("message", ex.getMessage()); String result = objectMapper.writeValueAsString(errorResult); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().write(result); return new ModelAndView(); } else { // TEXT/HTML return new ModelAndView("error/400"); } } } catch (IOException e) { log.error("resolver ex", e); } return null; } }
Java
복사
이와 같이 요청 헤더의 Accept 값이 application/json이라면 직접 HTTP body에 데이터를 넣어서 JSON으로 오류 데이터를 보내고, 그 외의 경우에는 error/400에 저장되어 있는 HTML 오류 페이지를 렌더링하도록 뷰로 넘겨준다.
@Override public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) { resolvers.add(new MyHandlerExceptionResolver()); resolvers.add(new UserHandlerExceptionResolver()); }
Java
복사
그 후 만든 UserHandlerExceptionResolver를 등록해주면,
{ "ex": "hello.exception.exception.UserException", "message": "사용자 오류", }
JSON
복사
JSON 형태의 데이터를 잘 받아오고 accept 값을 바꾸면 렌더링 된 HTML을 받아오는 것을 확인할 수 있다.
이와 같이 HandlerExceptionResolver에서 오류를 처리하면, 예외가 발생하더라도 서블릿 컨테이너까지 전달되지 않고 스프링 MVC 내에서 예외 처리가 끝나게 된다. 때문에 추가적인 내부 프로세스를 통해 다시 컨트롤러를 호출하는 번거롭고 비효율적인 과정이 수행되지 않는 것이 핵심이다.

스프링의 ExceptionResolver

스프링에서는 몇 가지 기본적인 ExceptionResovler를 제공한다.
1.
ExceptionHandlerExceptionResolver : @ExceptionHandler 애노테이션을 처리하는 resolver
2.
ResponseStatusExceptionResolver : HTTP 상태 코드를 지정해주는 resolver
3.
DefaultHandlerExceptionResolver : 스프링 내부의 기본 예외들을 처리해주는 resolver
위에서부터 적용하여 예외에 대한 resolve가 되면, 이후의 Resolver는 호출하지 않는다.

ExceptionHandlerExceptionResolver

위의 HandlerExceptionResolver 코드를 보면 알 수 있듯, API 오류 응답에서 직접 response를 구현하는 과정이 상당히 복잡하고 번거로운 작업이다. 또한 ModelAndView를 반환하는 것도 잘 맞지 않는다. 그리고 동일한 예외에 대해 서로 다른 컨트롤러에서 각자 다르게 처리하고 싶은 경우에도 방법이 없다.
스프링에서는 ExceptionHandlerExceptionResolver을 제공하여, 이러한 문제를 해결할 수 있다.
@Data @AllArgsConstructor public class ErrorResult { private String code; private String message; }
Java
복사
@Slf4j @RestController public class ApiExceptionV2Controller { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExHandle(IllegalArgumentException e) { log.error("[exceptionHandle] ex", e); return new ErrorResult("BAD", e.getMessage()); } @ExceptionHandler public ResponseEntity<ErrorResult> userExHandle(UserException e) { log.error("[exceptionHandle] 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("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류"); } ... }
Java
복사
이와 같이 컨트롤러 내부에서 @ExceptionHandler 애노테이션을 추가한 매서드를 정의하여, 해당 컨트롤러에서 처리하고 싶은 예외를 지정하여 처리할 수 있다.
{ "code": "BAD", "message": "잘못된 입력 값" }
JSON
복사
응답으로 보낸 객체를 JSON 형태로 바꾸어 전달되는 것을 확인할 수 있다.
기본적으로 ExceptionHandlerExceptionResolver는 하위 클래스까지 모두 처리할 수 있다.
@ExceptionHandler(부모예외.class) public String 부모예외처리()(부모예외 e) {} @ExceptionHandler(자식예외.class) public String 자식예외처리()(자식예외 e) {}
Java
복사
이와 같이 부모 예외 클래스와 자식 예외 클래스를 모두 등록하게 되면, 스프링에서는 더 자세한(더 정확한) 것을 선택해 수행한다. 때문에 자식 클래스를 받으면 자식에서 처리하고, 부모 클래스를 받으면 부모에서 처리하게 된다. 다만 추가하지 않은 자식 클래스 예외가 발생하게 되면, 해당 클래스의 부모 클래스에서 받아 처리하게 된다.
@ExceptionHandler({AException.class, BException.class}) public String ex(Exception e) { log.info("exception e", e); }
Java
복사
이와 같이 공통된 예외를 묶어 파라미터로 넘기고, 여러 예외에 대해 일괄적으로 처리하도록 구현하는 것도 가능하다.
@ExceptionHandler public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
Java
복사
반대로 이와 같이 예외를 생략하는 것도 가능한데, 이러한 경우 파라미터로 전달받은 인자와 동일한 예외가 지정된다.
이러한 ExceptionHandlerExceptionResolver는 스프링에서 기본으로 제공하고, 여러 ExceptionResolver 중 우선순위도 가장 높기 때문에 실무에서 API 예외 처리는 대부분 이 기능을 사용하여 처리한다.

ControllerAdvice

@ExceptionHandler 애노테이션을 사용하면 예외를 깔끔하게 처리할 수 있지만, 하나의 컨트롤러에 정상 코드와 예외 처리 로직이 섞여있고 여러 컨트롤러에서 적용하려면 같은 코드를 반복해서 넣어야 한다.
스프링에서는 @ControllerAdvice 애노테이션 혹은 @RestControllerAdvice 애노테이션을 사용해 분리할 수 있다.
@Slf4j @RestControllerAdvice public class ExControllerAdvice { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExHandle(IllegalArgumentException e) { log.error("[exceptionHandle] ex", e); return new ErrorResult("BAD", e.getMessage()); } @ExceptionHandler public ResponseEntity<ErrorResult> userExHandle(UserException e) { log.error("[exceptionHandle] 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("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류"); } }
Java
복사
이처럼 별도의 예외 처리 컨트롤러를 만들어, AOP의 Advice처럼 여러 컨트롤러에 @ExceptionHander와 @InitBinder 기능을 부여해주는 역할을 한다.
위처럼 대상을 지정하지 않으면 모든 컨트롤러에 적용되고,
// Target all Controllers annotated with @RestController @ControllerAdvice(annotations = RestController.class) public class ExampleAdvice1 {} // Target all Controllers within specific packages @ControllerAdvice("org.example.controllers") public class ExampleAdvice2 {} // Target all Controllers assignable to specific classes @ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class}) public class ExampleAdvice3 {}
Java
복사
이처럼 특정 컨트롤러를 지정하여 적용할 수 있다.

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 발생되는 예외에 따라 HTTP 상태 코드를 지정해주는 역할을 하고, 다음 두 가지 경우에 대해서 동작하여 예외를 처리한다.
@ResponseStatus 애노테이션이 달려있는 예외
ResponseStatusException 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") public class BadRequestException extends RuntimeException { }
Java
복사
이와 같이 @ResponseStatus 애노테이션이 달려있는 BadRequestException이 발생하면, 해당 예외가 컨트롤러 밖으로 넘어갈 때 ResponseStatusExceptionResolver에서 오류 코드를 HttpStatus.BAD_REQUEST(400)로 변경하고 메세지를 담는다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad") public class BadRequestException extends RuntimeException { }
Java
복사
error.bad=잘못된 요청 오류입니다.
Java
복사
메세지 기능도 지원하여, 위와 같이 reson에 경로를 넣어두면 MessageSource를 찾아 메세지로 사용할 수 있다.
하지만 @ResponseStatus 애노테이션은 직접 생성한 예외가 아니라면 적용할 수 없다는 문제가 있다. 이러한 경우에는 ResponseStatusException 예외를 사용하면 된다.
@GetMapping("/api/response-status-ex2") public String responseStatusEx2() { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException()); }
Java
복사
이러한 경우에도 ResponseStatusExceptionResolver에서 해당 에러의 오류 코드를 변경하고 메세지를 담는다.

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 기본적인 스프링 예외들을 해결한다. 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않는 경우 TypeMismatchException이 발생하는데, 여기서 500 에러가 아닌 400 에러로 바뀌어 발생한다. 실제로 DefaultHandlerExceptionResolver의 handleTypeMismatch 메서드 내부를 살펴보면 response.sendError(HttpServletResponse.SC_BAD_REQUEST)를 확인할 수 있다.
이처럼 DefaultHandlerExceptionResolver 내부적으로 정의되어 있는 많은 내용들에 따라, 스프링에서 발생하는 기본적인 예외에 대해 처리하도록 되어있다.