ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring filter, interceptor
    백엔드 2023. 9. 14. 11:32
    728x90
    반응형
    SMALL

    오늘은 filter, interceptor에 대해 배워보도록 하겠습니다. 이전에 배웠던 AOP와 같이 서비스 로직으로부터 여러가지 다른 관점의 로직을 분리시켜줄 수 있는 기능입니다. 각각의 기능마다 어떤 차이가 있는지 살펴보도록 하겠습니다.

     

    https://justforchangesake.wordpress.com/2014/05/07/spring-mvc-request-life-cycle/
    https://justforchangesake.wordpress.com/2014/05/07/spring-mvc-request-life-cycle/

     

     

    Filter

    Web Application에서 관리되는 영역으로써 Spring Boot Framework에서 Client로부터 오는 요청/응답에 대해서 최초/최종 단계의 위치에 존재하며, 이를 통해서 요청/응답의 정보를 변경하거나, Spring에 의해서 데이터가 변환되기 전의 순수한 Client의 요청 응답 값을 확인할 수 있습니다.

     

    유일하게 ServletRequest, ServletResponse의 객체를 변환할 수 있습니다.

     

    주로 SpringFramework에서는 request / response의 Logging 용도로 활용하거나 인증과 관련된 Logic들을 Filter에서 처리하게 됩니다.

     

    이와 같은 선/후 처리 과정을 Service business logic과 분리 시킵니다.

     

     

    예제에 사용될 Controller와 User 객체

    Controller

    @RestController
    @RequestMapping("/api")
    @Slf4j
    public class ApiController{
        @PostMapping("")
        public User user(@RequestBody User user){
            log.info("User: {}"+user);
            return User;
        }
    }

     

    User

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class User{
        private String name;
        private int age;
    }

     

     

    그럼 이제 filter를 사용하는 방법에 대해 알아보도록 하겠습니다.

     

    GlobalFilter

    @Slf4j
    @Component
    public class GlobalFilter implements Filter{
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            
            // 전처리
            HttpServletRequest httpServletRequest = (HttpServletRequest)request;
            HttpServletResponse httpServletResponse = (HttpServletResponse)response;
            
            String url = httpServletRequest.getRequestURI();
            
            BufferedReader br = httpServletRequest.getReader();
            
            br.lines().forEach(line -> {
                log.info("url : {}, line: {}", url, line);
            })
            
            chain.doFilter(request, response);
            
            // 후처리
        }
    }

    GlobalFilter 클래스가 javax.servlet 패키지의 Filter를 구현하도록합니다. 또한 빈에 등록되야 되므로 @Component 어노테이션을 선언해줍니다.

     

    doFilter라는 메서드 상에 요청에 대한 값들은 request와 응답에 대한 값들은 response에 위치합니다.

    chain.doFilter(request, response)를 선언하는 위치에 따라 위에는 전처리, 아래는 후처리가 됩니다.

     

    HttpServletRequest, HttpServletResponse는 각각 ServletRequest와 ServletResponse를 상속받고 있으므로 형변환 시켜서 안에 내용을 확인할 수 있습니다.

    HttpServletRequest의 내용을 통해 어디서 요청이 왔는지 요청의 내용은 무엇인지 확인할 수 있습니다.

     

     

    하지만, 위와 같은 방식은 문제를 일으킵니다.

    BufferedReader는 커서를 통해 읽게 되는데, 위와 같은 경우 요청 안의 내용을 모두 읽어버렸기 때문에 읽기 커서가 가장 마지막에 위치하게 됩니다.

    이 상태에서 Controller 상에서 User객체를 읽으려고 스트림을 얻을 경우 이미 안의 내용을 모두 읽었기 때문에 읽을 수 없는 상태가 됩니다.

    그러므로 Filter상에서 요청 값을 읽을 경우 해당 내용을 Controller에서 접근할 수 있게 유지해주어야 합니다.

     

    이에 대한 해결 방법으로는 HttpServletRequest로 형변환해주었던 것을 새로운 ContentCachingRequestWrapper 객체를 만들어 해결할 수 있습니다.

    ContentCachingRequestWrapper는 캐싱을 통해 내용을 읽어도 다시 Controller에서 접근할 수 있습니다.

    @Slf4j
    @Component
    public class GlobalFilter implements Filter{
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            
            // 전처리
            ContentCachingRequestWrapper httpServletRequest = new ContentCachingRequestWrapper((HttpServletRequest)request);
            ContentCachingResponseWrapper httpServletResponse = new ContentCachingResponseWrapper((HttpServletResponse)response);
            
            chain.doFilter(request, response);
            
            // 후처리
            
            //req
            String url = httpServletRequest.getRequestURI();
            String reqContent = new String(httpServletRequest.getContentAsByteArray());
            
            log.info("request url : {}, request body {} ", url, reqContent);
            
            //res
            String resContent = new String(httpServletResponse.getContentAsByteArray());
            int httpStatus = httpServletResponse.getStatus();
            
            httpServletResponse.copyBodyToResponse();
            
            log.info("response status: {}, responseBody : {}",httpStatus, resContent);
        }
    }

    ContentCachingRequestWrapper 객체는 내용물의 길이만 초기화해서 가지고 있게 되므로 전처리 영역에서 read를 처리할 경우다시 Controller에서 내용물을 못 읽게 됩니다.

    그러므로 chain.doFilter를 호출해 내부 스트림 안으로 들어가야지만 캐싱되므로 후처리 영역에서는 읽을 수 있게 됩니다.

     

    httpServletResponse의 내용도 마찮가지로 모든 내용을 읽은 뒤 끝나게 되면 read커서가 가장 마지막에 위치한체로 유지되므로 response body가 빈 상태로 응답하게 됩니다. 그러므로 copyBodyToResponse를 통해 캐싱된 내용을 다시 body에 복사해주어야 합니다.

     

    위와 같이 후처리 영역에서 모든 내용을 기록하면 Controller에서도 요청 내용에 접근할 수 있고 response body에도 모든 내용이 들어있게 됩니다.

     

     

    특정 영역의 Filter

    이와 같은 Filter를 Global 영역 말고 특정 영역에 사용하고 싶다면 다음과 같이 해주어야 합니다.

     

    Main

    @SpringBootApplication
    @ServletComponent
    public class FilterApplication {
        public static void main(String[] args) {SpringApplication.run(FilterApplication.class, args);}
    }

     

    Filter

    @Slf4j
    @WebFilter(urlPatterns = "/api/user/*")
    public class GlobalFilter implements Filter{
        ...

    @WebFilter 어노테이션의 urlPattern을 입력할 수 있습니다. 해당 url로의 요청에 대해서만 이 Filter를 사용하겠다는 것을 지정할 수 있습니다.

     

     

     

     

    Interceptor

    Interceptor란 Filter와 매우 유사한 형태로 존재하지만, Spring Context에 등록이 된다는 것입니다.

    AOP와 유사한 기능을 제공할 수 있으며, 주로 인증 단계를 처리하거나, Logging을 하는데에 사용합니다.

    Filter와 마찮가지로 선/후 처리에 대한 로직을 Service business logic과 분리시킵니다.

     

    Controller와 같은 영역 안에 존재하게 되고 어떤 Controller에 매핑됐는지도 알 수 있습니다.

     

     

    예제에 사용될 Controller

    public controller

    @RestController
    @RequestMapping("/api/public")
    public class PublicController{
    
        @GetMapping("/hello")
        public String hello(){
            return "public hello";
        }
    }

    public controller는 권한이 없는 모든 사용자의 요청이 도달할 수 있습니다.

     

    private controller

    @RestController
    @RequestMapping("/api/private")
    public class PrivateController{
    
        @GetMapping("/hello")
        public String hello(){
            return "private hello";
        }
    }

    private Controller는 권한이 있는 사용자의 요청만 도달할 수 있습니다.

     

     

    위에서 권한의 유무를 확인하는 것을 어노테이션으로 설정해보도록 하겠습니다.

     

    @Auth

    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.TYPE, Element.METHOD})
    public @interface Auth {
    
    }

    위의 어노테이션을 privateController에 붙여줍니다.

    @RestController
    @RequestMapping("/api/private")
    @Auth
    public class PrivateController{
        ...

     

    AuthInterceptor

    @Component
    public class AuthInterceptor implements HandlerInterceptor{
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            return HandlerInterceptor.super.preHandle(request, response, handler);
        }
    }

    요청 응답에 대한 값을 모두 확인할 수 있습니다.

    preHandle 메서드는 컨트롤러가 호출되기 전에 실행합니다. 컨트롤러에 요청이 들어가기 전에 처리해야하는 전처리 작업을 할 수 있습니다.

    postHandle 메서드는 컨트롤러 호출된 후에 실행합니다. 컨트롤러 이후 처리해야 하는 후처리 작업을 할 수 있습니다.

    afterCompletion 메서드는 모든 작업이 완료된 후 실행합니다. 요청에 대한 예외가 발생하더라도 반드시 호출됩니다. 

     

    preHandle 메서드에서 request와 response를 모두 읽어버리면 Controller 상에서 데이터를 읽지 못하거나, response body가 빈 상태로 응답을 하게 됩니다. 그러므로 똑같이 캐싱 처리를 해주어야지만 Controller나 response body에 온전한 데이터를 주고받을 수 있습니다.

    만약 Filter에서 ContentCachingRequestWrapper 객체를 생성하고 이를 chain.doFilter에 넣어주었다면 Interceptor에서 request를 ContentCachingRequestWrapper로 형변환해서 사용할 수 있습니다. 당연하게도 ContentCachingRequestWrapper 객체를 넘겨주지 않는다면 Interceptor에서 형변환이 불가능합니다.

     

    ContentCachingRequestWrapper 형변환 예시)

    Filter

    public class GlobalFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            ContentCachingRequestWrapper httpServletRequest = new ContentCachingRequestWrapper((HttpServletRequest)request);
            ContentCachingResponseWrapper httpServletResponse = new ContentCachingResponseWrapper((HttpServletResponse)response);
            
            chain.doFilter(httpServletRequest, httpServletResponse);
            ...
        }
    }

    ContentCachingRequestWrapper 객체를 chain.doFilter 메서드에 인자로 넣어주었습니다.

     

    Interceptor

    @Component
    public class Interceptor implements HandlerInterceptor{
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            ContentCachingRequestWrapper httpRequest = (ContentCachingRequestWrapper)request
            ...
            return false;
        }
    }

    이전 Filter에서 형변환된 객체를 넣어주었기 때문에 Interceptor에서 이를 형변환해서 사용할 수 있습니다.

     

    권한 확인 구현

    AuthInterceptor

    @Slf4j
    @Component
    public class AuthInterceptor implements HandlerInterceptor{
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String url = request.getRequestURI();
            log.info("request url : {}", url);
            
            boolean hasAnnotation = checkAnnotation(handler, Auth.class);
            
            if(hasAnnotation){
                if( 권한 있다면 ){
                    return true;
                }
                return false;
            }
            
            return true;
        }
        
        private boolean checkAnnotation(Object handler, Class clazz){
            if (handler instanceof ResourceHttpRequestHandler){
                return true;
            }
            
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            
            if(null != handlerMethod.getMethodAnnotation(clazz) || null != handlerMethod.getBeanType().getAnnotation(clazz)){
                return true;
            }
            
            return false;
        }
    }

     

    preHandler 메서드에서 false를 반환할 경우 이 요청은 Controller까지 도달하지 않습니다. true일 경우 다음 Interceptor가 있을 경우 Interceptor로 넘어가거나 Controller로 요청이 도달합니다.

     

    우선 request handler과 어노테이션을 확인하는 checkAnnotation 메서드를 구현합니다.

    만약 resource(html, javascript)에 대한 요청일 경우 true를 반환해줍니다.

    clazz 어노테이션이 있을 경우 true를 반환해줍니다.

    위의 조건을 모두 만족하지 않을 경우 false를 반환해줍니다.

     

    @Auth 어노테이션이 있는지 여부를 확인한 뒤 어노테이션이 선언된 Controller에 매핑된 요청과 같은 경우 권한 정보를 확인하는 로직을 추가해주면 됩니다.

    @Auth 어노테이션이 없는 Controller인 경우 권한 여부를 확인하지 않아도 되므로 모두 true를 반환해줍니다.

     

    MvcConfig

    @Configuration
    @RequredArgsContructor
    public class MvcConfig implements WebMvcConfigurer {
        private final AuthInterceptor authInterceptor;
        
        @Override
        public void addInterceptors(InterceptorRegistry registry){
            registry.addInterceptor(authInterceptor).addPathPatterns("/api/private/*").excludePathPatterns("/api/public/*");
        }
    }

    authInterceptor를 등록해줍니다.

    등록해주는 순서대로 interceptor가 동작하게 되므로 인증의 depth를 두고 확인할 수 있습니다.

     

    위와 같이 모든 요청 중 어노테이션이 붙은 Controller에 대해서만 권한 확인을 하는 로직을 쓸 수도 있고, addPathPatterns를 통해 특정 url pattern에 대해서만, excludePathPatterns를 통해 특정 url pattern을 제외하고 Interceptor가 적용되도록 설정할 수 있습니다.

     

     

    Exception Handling

    AuthException

    public class AuthException extends RuntimeException{}

     

    Interceptor

    @Slf4j
    @Component
    public class AuthInterceptor implements HandlerInterceptor{
            ...
            if(hasAnnotation){
                if( 권한 있다면 ){
                    return true;
                }
                return new AuthException();
            }
            ...
    }

     

    ExceptionHandler

    @RestControllerAdvice
    public class GlobalExceptionHandler {
        @ExceptionHandler(AuthException.class)
        public ResponseEntity authException(){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }

    위와 같이 AuthException이 발생했을 때의 handler로 응답을 처리해줄 수 있습니다.

     

     

     

    Filter vs Interceptor

      Filter Interceptor
    관리되는 컨테이너 서블릿 컨테이너 스프링 컨테이너
    스프링의 예외처리 여부 X O
    Request/Response 객체 조작 가능 여부 O X
    용도 - 공통된 보안 및 인증/인가 관련 작업
    - 모든 요청에 대한 가공되지 않은 데이터 logging
    - 이미지 데이터 압축 및 문자열 인코딩
    - 세부적인 인증 작업
    - Api 호출에 대한 logging
    - Controller로 넘겨주는 데이터 가공 (인코딩, 디코딩)

     

    반응형
    LIST

    '백엔드' 카테고리의 다른 글

    Spring Boot 독립된 test DB 구성 (MySQL, H2)  (0) 2023.09.15
    Rest Template으로 Server(Client) to Server 통신하기  (0) 2023.09.15
    Spring Validation  (0) 2023.09.13
    Jpa Embedded  (0) 2023.09.06
    JPA @Query, Native Query, Converter  (0) 2023.09.05

    댓글

Designed by Tistory.