Post

스프링 MVC 2

메시지, 국제화

  • 스프링에서는 다양한 메시지를 한 곳에서 관리하는 기능인 메시지 기능과 메시지의 국제화를 할 수 있는 기능을 제공한다.

메시지

  • 우리가 어떤 개발한 애플리케이션에서 상품 이름과 관련된 문구를 상품명이라고 정했다고 가정하자.

  • 근데 요구사항이 변경되어 상품명 -> 상품이름으로 변경해야 되고, 이에 따라 변경해야되는 파일들이 수십개 이상이라면 하드코딩된 상품명을 모두 고쳐야하는 상황이 발생한다.

  • 이런 상황을 messages.properties에 key-value로 값을 설정한 뒤 그 값을 사용함으로써 해결 가능하다.

    1
    2
    3
    
    item=상품
    item.id=상품 ID
    item.itemName=상품명
    

국제화

  • 메시지 기능은 국제화 기능 또한 제공한다.
  • 만약, 영어와 한글을 지원하는 애플리케이션을 만들었다면, messages_en.properties(영어)와 messages.properties(한글) 두 파일을 만들어 관리하면 된다.
  • 국제화 기능은 HTTP accept-language 헤더 값을 이용하는 등의 방법으로 사용자의 선호 언어에 맞춰 기능한다.

스프링 메시지 소스

  • 스프링에서 메시지 관리 기능을 사용하기 위해선 MessageSource를 스프링 빈에 등록시켜야된다.

  • 스프링 부트에서는 기본적으로 등록해주기 때문에 개발자가 따로 MessageSource를 구현할 필요는 없다. 별도의 설정을 하지 않으면 messages라는 이름으로 기본 등록된다.

  • MessageSource 사용 방법

    1
    2
    3
    
    //messages.properties
    hello=안녕
    hello.name=안녕 {0}
    
    • 정상 작동

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      
      @SpringBootTest
      public class MessageSourceTest { 
          @Autowired
      	MessageSource ms; 
          @Test
      	void helloMessage() {
              Stirng result = ms.getMessage("hello", null, null);
              assertThat(result).isEqualto("안녕");
          } 
      }
      
    • 메시지가 없는 경우, 기본 메시지 설정

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      
      @Test
      void notFoundMessageCode() {
      	assertThatThrownBy(() -> ms.getMessage("no_code", null, null)) 
                  .isInstanceOf(NoSuchMessageException.class);
      }
      @Test
      void notFoundMessageCodeDefaultMessage() {
      	String result = ms.getMessage("no_code", null, "기본 메시지", null); 
      	assertThat(result).isEqualTo("기본 메시지");
      }
      

      메시지가 없는 경우 NoSuchMessageException이 발생하고, 메시지가 없어도 defaultMessage를 사용하면 기본 메시지가 반환된다.

    • 매개변수 사용

      1
      2
      3
      4
      5
      
      @Test
      void argumentMessage() {
      	String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null); 
      	assertThat(result).isEqualTo("안녕 Spring");
      }
      

      매개변수를 사용해 치환이 가능하다. hello.name=안녕 {0} -> 안녕 Spring

    • 타임리프에서의 사용법

      1
      2
      3
      4
      
      <div th:text="#{hello}"/>
      //파라미터 hello.name=안녕 {0}
      //() 안에 원하는 값을 넣으면 치환이 가능
      <p th:text="#{hello.name(...)}"></p>
      

      #{…}으로 메시지를 편리하게 조회할 수 있다.

  • 스프링 국제화

    • 스프링에서 제공하는 MessageSource는 Locale 정보를 가지고 언어를 한다.
    • 스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver 인터페이스를 제공한다.
    • 스프링 부트는 기본으로 HTTP의 Accept-Language를 활용하는 AcceptHeaderLocaleResolver를 사용하는데 만약 Locale 선택 방식을 바꾸고 싶다면 직접 LocaleResolver를 구현하면 된다.

Bean Validation

  • 스프링에서는 요청 시 들어온 데이터를 검증할 수 있는 Validation을 제공한다.
  • @Validated와 BindingResult를 사용해 검증을 할 수 있다.
  • BindingResult를 통해 validation과 표시할 메시지는 errors.properties에 따로 정의해 스프링 메시지와 함께 사용 가능하다.
  • 주의할 점은 API 설계시 @RequestBody를 사용할 때 주의해야한다.
    • Form으로 넘어온 데이터의 경우 Type이 맞지 않을 때 TypeDismatch 오류가 BindingResult의 FieldError로 생성된다.
    • 하지만 JSON으로 넘어온 데이터의 Type이 맞지 않을 시 HttpMessageConverter에서 요청 JSON으로 객체를 생성하지 못하기 때문에 BindingResult로 가기 전에 예외를 던진다.
    • 이럴 때 HttpMessageConverter 단계에서 예외 발생 시 우리가 원하는 모양으로 만들기 위해 @ExceptionHandler을 사용해야한다.

쿠키와 세션

  • 로그인 유지를 위해 보통 세션을 사용한다.
  • 세션이 어떻게 동작하는지 알아보자.
  • 세션에 대해 얘기 하기 전 쿠키에 대해 먼저 알 필요가 있다.

쿠키

  • 로그인 시 쿠키를 사용할 시 Response에 쿠키 생성해 응답해주면 된다.

    1
    2
    3
    4
    5
    6
    7
    
    @PostMapping("/login")
    public String login(@ModelAttribute LoginForm from, HttpServlcetResponse response) {
        // member의 id를 찾는 로직 생략
        Cookie idCooki = new Cookie("memberId", String.valueOf(loginMember.getId())); // 쿠키는 <String, String>
        response.addCookie(idCookie);
        return "redirect:/";
    }
    
  • 이와 같이 쿠키를 생성하면 각 url 요청시 헤더에 Cookie로 member의 id를 받아 어떤 유저가 로그인했는지 식별이 가능하다.

  • 문제점 : 쿠키로도 충분히 로그인 유지가 가능하지만 심각한 보안 문제가 존재한다.

    • 쿠키 값은 임의로 변경 가능
    • 쿠키에 보관된 정보도 훔칠 수 있고, 해커가 쿠리를 한번 훔쳐가면 평생 사용 가능
  • 해결책

    • 쿠키에 중요한 값을 노출 X, 사용자 별로 예측 불가능한 임의의 토큰을 노출하고 서버에서 토큰과 사용자 Id를 매핑해서 인식. 그리고 서버에서 토큰을 관리
    • 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 해야함(UUID 사용)

세션

  • 위의 해결책을 모두 충족하는 것이 보통 우리가 사용하는 세션이다.

  • 사실 세션은 Cookie를 기반으로 동작한다.

    image

  • 세션은 요청이 들어오면 사용자를 검증한 뒤 세션 저장소(Map 사용)에 UUID로 생성한 sessionId와 객체를 저장해준다.

  • 그 다음 sessionId를 쿠키에 담아서 클라이언트에 보내준다.

    image

  • 결국 클라이언트와 서버는 쿠키로 연결 되어야 하고, 중요한 포인트는 회원과 관련된 정보는 클라이언트에 전달하지 않는다는 것이다.

  • 로그인 후 요청시에는 sessionId 쿠키를 항상 전달한다.

  • 서버에서는 클라이언트가 전달한 sessionId 쿠키 정보로 세션 저장소에서 보관한 화원 정보를 사용하게 된다.

    image

  • 이로써 보완과 관련된 문제가 해결되었다.

  • 세션의 생존 시간은 최근에 요청한 시간을 기준 업데이트 된다. HttpSession도 이런 방식으로 동작한다.

서블릿 필터와 스프링 인터셉터

  • Spring은 공통적으로 필요한(공통 관심사) 여러 처리들을 도와줄 수 있는 기능들을 지원한다.
  • 그 중 서블릿이 지원해주는 서블릿 필터와 스프링이 제공하는 스프링 인터셉터에 대해 알아보자.
  • 이 둘의 특징으로는 Request 객체와 Response 객체를 제공해 웹과 관련된 처리를 편리하게 할 수 있다. (AOP와 다른 점)

Servlet Filter

  • 서블릿 필터는 서블릿 컨테이너에서 제공하는 공통 처리 기능이다.

  • 필터 흐름

    1
    2
    3
    
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
    //필터에 걸릴 시
    HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출 X)
    

    필터 체인

    1
    
    HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
    
  • 필터 인터페이스

    1
    2
    3
    4
    5
    6
    7
    
    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() {}; 
    }
    
    • init() : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출
    • doFilter() : 요청이 올 때 마다 해당 메서드가 호출. 핵심 로직
    • destroy() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출
  • 필터 구현

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    public MyFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
            // 로직 생략
              
            try {
                chain.doFilter(request, response);
            } catch (Exception e) {
    			throw e;
            } finally {
            }
        }
    }
    
    • 필터를 구현할 때 doFilter()를 제외한 메서드는 default 메서드로 구현하지 않아도 된다.
    • doFilter() 에서는 필터링 로직을 구현한 뒤 chain.doFilter로 다음 필터가 있을 시 호출 해줘야 한다.
    • 만약 필터가 하나일 때도 chain,doFilter()를 꼭 호출 해줘야 한다. 호출 하지 않는다면 다음으로 넘어가지 않는다. 즉, 서블릿이 띄워지지 않는다.
  • 필터 설정

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    @Configuration
    public class WebConfig { 
        @Bean
    public FilterRegistrationBean logFilter() {
    	FilterRegistrationBean<Filter> filterRegistrationBean = new
    	FilterRegistrationBean<>();        filterRegistrationBean.setFilter(new LogFilter());
            filterRegistrationBean.setOrder(1);
            filterRegistrationBean.addUrlPatterns("/*");
    	return filterRegistrationBean;
        } 
    }
    
    • 필터는 위와 같이 FilterRegistrationBean을 사용해 등록하면 된다.
    • setFilter : 등록할 필터를 지정, setOrder : 필터 체인 순서 지정, addUrlPatterns : 필터를 적용한 URL 패턴 지정 등 다양한 메서드를 지원한다.
  • 서블릿 필터 VS 스프링 시큐리티

    • 필터와 시큐리티는 사용자 인증 및 인가 기능을 구현하는데 쓰인다.

    • 스프링 시큐리티도 결국 서블릿 필터를 기반으로 동작한다.
    • 하지만 서블릿 필터의 경우 설정과 구현에 다소 번거롭고, 구체적인 인증 및 인가 로직은 직접 구현해야된다. 또 다른 스프링 모듈과의 통합이 복잡할 수 있다.
    • 이런 점을 편리하게 사용할 수 있게 스프링에서 인증 및 인가를 위한 스프링 시큐리티를 제공한다.
    • 스프링 시큐리티는 더욱 강력한 보안 기능(CSRF 방지 등)을 제공하고 보다 높은 추상화를 제공해 설정이 편리하다.

스프링 인터셉터

  • 스프링 인터셉터도 필터와 같이 공통 관심 사항 처리를 도와주는 기능이다.

  • 둘 다 웹과 관련된 공통 관심 사항 처리를 도와주지만, 적용되는 순서와 범위, 사용 방법에 차이가 있다.

  • 필터 VS 인터셉터

    • 필터 : 공통된 보안 및 인증/인가, 모든 요청에 대한 로깅 또는 감사 등

    • 인터셉터 : 세부적인 보안 및 인증/인가 작업, API 호출에 대한 로깅 또는 감사, Controller로 넘겨주는 데이터의 가공 등

  • 스프링 인터셉터 흐름

    1
    
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
    
  • 스프링 인터셉터 제한

    1
    2
    
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링인터셉터 -> 컨트롤러 //로그인 사용자
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X) // 비 로그인 사용자
    
  • 스프링 인터셉터 체인

    1
    
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
    
  • 스프링 인터셉터 인터페이스

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    public interface HandlerInterceptor {
          
    	default boolean preHandle(HttpServletRequest request, HttpServletResponse 
    response, Object handler) throws Exception;
      	
        default void postHandle(HttpServletRequest request, HttpServletResponse 
    response, Object handler, @Nullable ModelAndView modelAndView) throws Exception;
          
    	default void afterCompletion(HttpServletRequest request, HttpServletResponse response, javaObject handler, @Nullable Exception ex) throws Exception;
          
    }
    
  • 스프링 인터셉터 호출 흐름

    image

  • 예외 발생 시 흐름

    • 컨트롤러에서 예외 발생 시 postHandle는 호출 되지 않음
    • 하지만 afterCompletion은 예외와 상관 없이 호출 됨
    • 예외와 무관하게 공통 처리를 하려면 afterCompletion을 사용하면 됨
  • 구현

    • preHandle를 구현 시 우리가 작성한 핸들러들의 정보를 받아올 수 있게 handler를 파라미터로 제공한다.

    • 하지만 다양한 핸들러들을 지원하기 위해 Object 클래스로 되어있어 HandlerMethod로 캐스팅 해주면 해당 컨트롤러의 다양한 정보들을 사용 가능하다.

      1
      2
      3
      
      if (handler instanceof HandlerMethod) {
      	HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
      }
      
    • HandlerMethod : @RequestMapping이 붙은 메소드의 정보를 추상화한 객체로 해당 메소드의 여러 메타 정보들을 가지고 있다 (파라미터 정보, 리턴 값 등)

  • 인터셉터 등록

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    @Configuration
    public class WebConfig implements WebMvcConfigurer { 
        @Override
    	public void addInterceptors(InterceptorRegistry registry) { 
            registry.addInterceptor(new LogInterceptor()) 
                    .order(1)
                    .addPathPatterns("/**")
                    .excludePathPatterns("/css/**", "/*.ico", "/error"); 
        }
    }
    
    • addInterceptors를 통해 등록하면 된다.
    • 서블릿 필터와 다르게 url 패턴을 더욱 상세히 적용할 수 있다.

서블릿 예외 처리

  • 서블릿 예외 처리에는 두 가지 종류가 있다.

  • Exception : 예외

    • 웹 애플리케이션에서 예외가 발생 시 중간에 예외를 처리하는 로직이 없다면 WAS까지 예외가 던져지게 된다.

      1
      
      WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
      
    • 이럴 시 WAS는 기본으로 제공하는 해당 오류에 맞는 오류 페이지를 제공해준다.

  • response.sendError(HTTP 상태코드, 오류 메시지)

    • 오류가 발생 시 예외를 던지지 않고 response.sendError() 메서드를 사용해도 된다.

    • 이 메소드를 호출하면 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.

      1
      2
      
      WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 
      (response.sendError())
      
    • response 내부에 오류가 발생했다는 상태를 저장한 뒤 WAS에서 sendError가 호출 되었다면 오류 코드에 맞춰 기본 오류 페이지를 제공한다.

  • 하지만 이런 방식은 서블릿이 기본 제공하는 오류페이지가 나오기 때문에 우리가 직접 제공하는 오류 페이지를 설정할 필요가 있다.

서블릿 예외 처리 페이지

  • 서블릿에 직접 오류페이지를 등록(ErrorPage 사용)하고 예외 발생 시 해당 페이지를 출력하는 기능을 제공한다

  • 예외 발생 흐름

    1
    
    WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
    

    이렇게 WAS까지 예외가 전파되면 설정한 ErrorPage 정보를 확인 후 해당 에러 페이지를 응답하기 위해 해당 컨트롤러까지 다시 요청을 보낸다.

    1
    2
    3
    
    1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
    2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/ 
    500) -> View
    

    이런 방식으로 클라이언트는 서버 내부에서 이런 흐름이 일어난다는 사실을 전혀 모르게 할 수 있고, 오류 페이지를 응답 받을 수 있다.

  • 위와 같은 방식 덕분에 에러에 대한 정보를 request 객체에 담아서 보낼 수 있다. (새로운 요청을 보내기 때문에 가능)

    • 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가 새로운 요청을 보낸다.

  • 이런 과정에서 필터, 서블릿, 인터셉터도 모두 다시 호출된다. 이런 방식은 매우 비효율적이다.

  • 결국 오류를 위한 요청인지, 클라이언트가 보낸 정상 요청인지 구분할 수 있어야 한다.

  • 이런 문제를 해결하기 위해 서블릿은 DispatcherType이라는 추가 정보를 제공한다.

  • DispatcherType

    1
    2
    3
    4
    5
    6
    7
    8
    
    //javax.servlet.DispatcherType
    public enum DispatcherType { 
        FORWARD, //FORWARD 시
        INCLUDE, //INCLUDE 시
        REQUEST, // 클라이언트 요청 시
        ASYNC, //서블릿 비동기 호출 시
        ERROR //오류 요청 시
    }
    
    • 고객이 처음 요청하면 dispatcherType = REQUEST이고, 오류 페이지 요청 시는 dispatcherType = ERROR로 설정된다.
  • 필터를 특정 DispatcherType에만 적용하고 싶으면 WebConfig에서 필터를 적용할 때 설정할 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    
    @Configuration
    public class WebConfig implements WebMvcConfigurer { 
        @Bean
    	public FilterRegistrationBean logFilter() {
            //... 기존 로직
            filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        	//...
        }
    
    • 이와 같이 설정하면 REQUEST와 ERROR 일 시 필터가 적용된다.
    • 설정을 따로 하지 않으면 디폴트가 REQUEST이기 때문에 상황에 맞게 설정하면 된다.

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

  • 필터의 경우 등록 시 DispatcherType 필터링이 가능했다. 하지만 인터셉터의 경우 서블릿이 아니라 스프링이 제공하는 기능이다. 따라서 DispatcherType과 무관하게 항상 호출된다.
  • 대신 인터셉터는 요청 경로에 따라서 추가나 제외가 가능하기 때문에 excludePathPatterns을 이용해 “/error”, “/error-page/**” 등과 같이 설정해 오류 발생 시 호출이 안되게 설정할 수 있다.

스프링 부트 예외 페이지

  • 스프링 부트 이전에는 직접 오류 페이지를 등록하고 컨트롤러를 만드는 등 복잡한 과정을 거쳤다.
  • 하지만 스프링 부트에서는 이런 과정들을 모두 기본으로 제공한다.
  • BasicErrorController라는 클래스가 자동으로 빈에 등록되어 오류 요청들을 처리해준다.
  • 뷰 선택 우선순위
    • 뷰 템플릿
      • resources/templates/error/500.html
      • resources/templates/error/5xx.html 2. 정적 리소스( static , public )
    • resources/static/error/400.html
    • resources/static/error/404.html
    • resources/static/error/4xx.html
    • 적용 대상이 없을 때 뷰 이름( error )
      • resources/templates/error.html
  • 위의 경로에 HTTP 상태 코드 이름의 뷰 파일들을 넣어두면 스프링 부트에서 알아서 처리해준다.

API 예외 처리

  • API는 특별한 예외 처리가 필요하다

  • API 요청의 경우 예외가 발생할 때 각각의 컨트롤러나 리소스에 따라 예외의 결과가 바뀌어야 하는 경우가 많이 발생한다.

  • 그렇기 때문에 API 예외에는 세밀하고 복잡한 처리가 필요하다.

    1
    2
    
    @RequestMapping(value = "/error-page/500", produces = 
    MediaType.APPLICATION_JSON_VALUE)
    
  • Controller에 위와 같은 produces = MediaType.APPLICATION_JSON_VALUE 를 붙이면 HTTP Header의 Accept 값이 application/json 일 때 이 메서드가 호출된다는 뜻이다.

  • 이와 같은 방법으로 우리가 위에서 봤던 BasicErrorController도 API 예외 처리를 지원한다.

  • 하지만 BasicErrorController에서 생성한 예외의 경우 API 예외를 처리하기에는 세밀한 처리를 지원하지는 않는다.

  • 물론 BasicErrorController를 확장해서 JSON 메시지를 처리할 수도 있다.

  • 하지만 BasicErrorController는 HTML 화면 처리할 때 더 적합하고, API 의 경우 @ExceptionHandler를 사용하면 더 편리하게 처리가 가능하다.

  • 그렇다면 복잡한 API 오류는 어떻게 처리해야되는지 단계별로 알아보자.

HandlerExceptionResolver

  • 스프링 MVC는 핸들러 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.

  • 그것이 바로 HandlerExceptionResolver이다. 줄여서 ExceptionResolver라고 부른다.

    image

  • HandlerExceptionResolver 인터페이스

    1
    2
    3
    
    public interface HandlerExceptionResolver {
    	ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
    }
    
  • HandlerExceptionResolver 예제

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
      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;
      }
    
    • ExceptionResolver가 ModelAndView를 반환하는 이유는 마치 try,catch 처럼 Exception을 처리해서 정상 흐름으로 변경하는 목적이 있기 때문이다.
    • 반환 값에 따른 동작 방식
      • 빈 ModelAndView : new ModelAndView와 같이 빈 ModelAndView를 반환하면 뷰를 랜더링하지 않고, 정상 흐름으로 서블릿이 리턴. 위의 예제에서는 sendError()를 사용해 오류 페이지를 요청
      • ModelAndView 지정 : 리턴한 뷰와 모델을 기반으로 랜더링
      • null : 다음 ExceptionResolver를 찾아서 실행. 많약 없으면 예외 처리가 안되고 기존에 발생한 예외를 서블릿 밖으로 던짐
  • ExceptionResolver 등록
    1
    2
    3
    4
    5
    
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> 
    resolvers) {
      resolvers.add(new MyHandlerExceptionResolver()); 
    }
    
  • ExceptionResolver 활용

    • 예외 상태 코드 변환 : response.sendError()를 호출해 상태코드를 변경, 이후 WAS에서는 해당 코드에 맞는 오류 페이지 호출
    • 뷰 템플릿 처리 : ModelAndView에 값을 채워서 예외에 따른 새로운 뷰 랜더링
    • API 응답 처리 : response.getWriter().println();와 같이 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능. JSON으로 응답하면 API 응답 처리를 할 수 있다.
  • ExceptionResolver를 활용하면 WAS에 예외가 올라가기 전에 Resolver에서 예외를 정상 흐름으로 돌리는 등 다양한 기능이 제공된다.

  • 하지만 직접 구현하기에는 너무 복잡한 부분이 많다. 스프링에서는 쉽게 사용 가능한 ExceptionResolver들을 제공해준다.

스프링이 제공하는 ExceptionResolver

  1. ExceptionHandlerExceptionResolver : @ExceptionHandler 처리, API 예외 처리는 대부분 이 기능으로 해결, 따로 파트를 나눠서 설명

  2. ResponseStatusExceptionResolver : HTTP 상태 코드 지정

    • @ResponseStatus가 달려 있는 예외 처리

      1
      
      @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
      
      • 예외 예외 클래스 위에 @ResponseStatus 어노테이션이 붙은 예외의 상태 코드와 메시지(reason)을 변경해준다.

      • reason의 값은 messages.properties의 있는 값으로 설정도 가능하다.

        1
        2
        3
        
        reason = "error.bad"
        //messages.properties
        error.bad=잘못된 요청입니다.
        
    • ResponseStatusException 예외 처리

      • 만약 라이브러리에 포함된 예외 등 @ResponseStatus를 못 붙이는 상황에는 ResponseStatusException을 사용하면 된다.

        1
        2
        3
        
        public String responseStatusEx2() {
        	throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
        }
        
  3. DefaultHandlerExceptionResolver : 스프링 내부 기본 예외 처리

    • DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 예외들을 처리한다.
    • 만약 파라미터 바인딩 시점에서 TypeDismatchException이 발생하는데 이런 예외를 DefaultHandlerExceptionResolver 내부에서 처리해준다.
    • DefaultHandlerExceptionResolver를 직접 찾아가보면 수많은 스프링 내부 예외를 처리해주는 것을 확인할 수 있다.

API 예외 처리 - @ExceptionHandler

  • 웹 브라우저에서 HTML 화면을 제공할 때 오류가 발생하면 BasicErrorController를 사용하는게 편함

  • 하지만 API 예외는 고려해야되는 사항이 많고 세밀한 처리가 필요하다.

  • 지금까지 살펴본 BasicErrorController와 HandlerExceptionResolver를 직접 구현해서 API 예외를 다루기 쉽지 않다.

  • API 예외처리의 어려움 점

    • HandlerExceptionResolver은 ModelAndView를 반환한다. API는 필요 X
    • 같은 예외라도 다른 방식으로 처리하고 싶다면?
  • @ExceptionHandler

    • 스프링은 이러한 API 예외 처리 문제를 해결해주는 어노테이션을 지원해준다.

      1
      2
      3
      4
      5
      6
      7
      
      //예외 발생 시 응답으로 사용할 객체
      @Data
      @AllArgsConstructor
      public class ErrorResult { 
      	private String code; 
      	private String message;
      }
      
      1
      2
      3
      4
      5
      6
      
      @ResponseStatus(HttpStatus.BAD_REQUEST) //상태 코드 지정 - 없을 시 200으로 설정
      @ExceptionHandler(IllegalArgumentException.class) // 처리할 예외 지정
      public ErrorResult illegalExHandle(IllegalArgumentException e) { 
          log.error("[exceptionHandle] ex", e);
      	return new ErrorResult("BAD", e.getMessage());
      }
      
    • 다음과 같이 예외를 처리할 각 컨트롤러에 정의하면 된다.

    • 만약 해당 컨트롤러에서 예외가 발생 시 ExceptionHandlerExceptionResolver에서 컨트롤러 안에 @ExceptionHandler 어노테이션이 붙은 메소드를 찾고 처리가 가능한 예외라면 해당 메소드를 호출해준다.

    • REST 컨트롤로의 응답과 비슷하게 동작하므로 ResponseEntity 등으로 구현해도 된다.

      1
      2
      3
      4
      5
      6
      
      @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);    
      }
      

####

@ControllerAdvice

  • 위의 코드 @ExceptionHandler를 적용시키려면 예외 처리할 컨트롤러 안에 설정하면 된다고 했다.

  • 하지만 이런 방식은 정상 코드와 예외 처리 코드가 한 컨트롤러에 있는 문제가 있다.(관심사 분리)

  • 이럴 때 사용하는 것이 @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
    
    @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", "내부 오류");
        } 
    }
    
  • 위 코드와 같이 분리가 가능하다.

  • @ControllerAdvice

    • @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
    • @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.

스프링 타입 컨버터

  • HTTP 요청 시 넘어오는 데이터는 모두 String 타입이다. 하지만 우리가 @ModelAttribute나, @RequestParam 등을 사용할 땐 Integer나 여러 타입으로 사용이 가능했다. 스프링은 이런 부분을 어떻게 처리할까?

  • 정답은 스프링은 타입 컨버터라는 컨버터 인터페이스를 제공하고 이를 통해 타입을 변환해준다.

    1
    2
    3
    
    public interface Converter<S, T> {
    	T convert(S source);
    }
    
    • 제네릭으로 된 부분에 S는 변환할 값의 타입, T는 변환 결과의 타입을 지정해주면 된다.

      ex) String -> Integer : Converter<String, Integer>

    • 스프링은 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.

  • 하지만 이렇게 타입 컨버터를 하나하나 직접 찾아서 적용하기는 불편하다. 그래서 스프링은 컨버터들을 모아두고 편리하게 사용할 수 있는 기능을 제공한다. 바로 컨버전 서비스(ConversionService)이다.

ConversionService

  • ConversionService 인터페이스

    1
    2
    3
    4
    5
    6
    7
    
    public interface ConversionService {
          
    	boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType); 
    	boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
    	<T> T convert(@Nullable Object source, Class<T> targetType);
    	Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
    }
    
    • 컨버팅이 가능한지 확인하는 기능과 컨버팅 기능을 제공한다.
  • ConversionService에 직접 구현한 Converter 등록

    • Config 파일에서 스프링 내부에 있는 ConversionService에 등록이 가능하다

      1
      2
      3
      4
      5
      6
      7
      
      @Override
      public void addFormatters(FormatterRegistry registry) {
              registry.addConverter(new StringToIntegerConverter());
              registry.addConverter(new IntegerToStringConverter());
              registry.addConverter(new StringToIpPortConverter());
              registry.addConverter(new IpPortToStringConverter()); 
      }
      
  • ConversionService 처리 과정

    • Config 파일에 등록하기만 했을 뿐인데 Converter는 스프링 내부에서 어떻게 처리될까?
    • 스프링은 ArgumentResolver를 처리하는 RequestParamMethodArgumentResolver에서 ConversionService를 사용해서 타입을 변환한다.
  • ConversionService 뷰 템플릿

    • 뷰 템플릿에서도 랜더링 시 ConversionService를 사용해서 컨버팅이 가능하다

      1
      2
      
      일반 변수 표현식 : ${...}
      컨버전 서비스 적용 : $
      

포맷터 - Formatter

  • Converter는 범용 타입 변환 기능을 제공한다

  • 하지만 웹 애플리케이션 환경에서는 단순히 객체를 변환하는 것보다 객체를 문자로, 문자를 객체로 바꾸는 일이 대부분이다. 예를 들어 다음과 같다.

    • Integer -> String 출력 시점에서 숫자 1000 -> 문자 1,000 과 같이 쉼표를 넣어서 출력하거나 반대인 상황
    • 날짜 객체를 문자인 “2024-05-31 xx:xx:xx”와 같이 출력하거나 반대인 상황
  • 이렇게 객체를 특정한 포맷에 맞춰 컨버팅해주는 것이 포맷터이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다.

  • Converter VS Formatter

    • Converter : 범용 (객체 -> 객체)
    • Formatter : 문자에 특화 (객체 -> 문자, 문자 -> 객체) + 현지화 (Locale)
  • Formatter 인터페이스

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    public interface Printer<T> {
    	String print(T object, Locale locale); 
    }
      
    public interface Parser<T> {
    	T parse(String text, Locale locale) throws ParseException; 
    }
      
    public interface Formatter<T> extends Printer<T>, Parser<T> { 
    }
    
  • Formatter 등록

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    @Override
    public void addFormatters(FormatterRegistry registry) {
            //registry.addConverter(new StringToIntegerConverter());
            //registry.addConverter(new IntegerToStringConverter());
            registry.addConverter(new StringToIpPortConverter());
            registry.addConverter(new IpPortToStringConverter());
          
        	//포맷터 추가
        	registry.addFormatter(new NumberFormatter);
    }
    
    • StringToIntegerConverter와 IntegerToStringConverter는 주석 처리가 필요하다. 만약 Converter와 Formatter가 같은 타입을 지원하면 우선순위 상 컨버터가 우선되어 포맷터가 적용되지 않는다.
  • 스프링 기본 포맷터 사용

    • 스프링에서는 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 제공한다.

    • 근데 위에서 알아봤듯이 컨버터와 포맷터가 같은 타입을 지원하면 컨버터가 적용된다고 했다.

    • 그럼 특정 변수에 컨버터 대신 포맷터를 적용하기 싶다면 어떻게 해야될까?

    • 스프링은 이런 문제를 해결하기 위해 어노테이션으로 원하는 형식을 지정할 수 있게 지원한다.

    • 대표 어노테이션

      • @NumberFormat : 숫자 관련 형식 지정 포맷터
      • @DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용
    • 예)

      1
      2
      3
      4
      5
      6
      7
      8
      
      @Data
      public class Member {
          @NumberFormat(pattern = "###,###")
          private Integer number;
              
          @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
          private LocalDateTime dateTime;
      }
      
  • 주의!!!!

    • 이런 컨버터나 포맷터는 메시지 컨버터(HttpMessageConverter)에는 적용되지 않는다.
    • 즉, 객체를 JSON으로 파싱하는데 사용되는 메시지 컨버터는 HTTP 메시지 바디의 내용을 객체로 변환하거나 그 반대로 바꾸는 역할을 하고 그 내부에서 Jackson 같은 라이브러리를 사용한다.
    • 때문에 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶다면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야한다.
    • 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없고, 컨버전 서비스는 @RequestParam , @ModelAttribute , @PathVariable , 뷰 템플릿 등에서 사용할 수 있다

출처)

[스프링 MVC 2편 - 백엔드 웹 개발 활용 기술김영한 - 인프런 (inflearn.com)](https://www.inflearn.com/course/스프링-mvc-2)
This post is licensed under CC BY 4.0 by the author.