Framework/Spring

[Spring] Filter와 Intercepter의 차이는 무엇일까?

KOOCCI 2022. 8. 19. 00:19

목표 : 스프링의 Filter와 Intercepter의 차이를 설명할 수 있다.


아마 가장 많은 비교와 사용에 대한 질문을 받는 영역 중 하나일 것이다.

Filter와 Interceptor는 대체 무엇이길래 이렇게 헤깔리게 하는것일까?

 

Filter & Interceptor

 

나는 어디에 써보았나?

Filter의 경우, 전체 Req/Res 로그를 남기길 원할 때, XSS 를 막을 때, 사용하였고, Interceptor의 경우는 AccessToken에 대한 확인을 요할 때나, 권한에 따라 다른 Menu 정보를 가져오고 싶을 때 사용하였다.

 

과연 나는 적절한 방법으로 설정하였을까? AOP를 사용해야 하진 않았을까?

 

하나씩 개념부터 정리하며, 적절했는지를 알아보자.

 

Filter

개념부터 정리

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

 

정리하면 다음과 같다.

 

  1. Web Application에서 관리되는 영역
  2. Client로부터 오는 요청/응답에 대해, 최초/최종 단계에 존재 (DispatcherServlet 외부)
  3. 요청/응답의 정보를 변경하거나 Client의 요청/응답 값을 확인
  4. 유일하게 ServletRequest, ServletResponse의 객체를 변환할 수 있음

 

Web Application 영역

먼저 Web Application에서 관리되는 영역이라고 하였다. 이게 무슨 의미일까?

모든 요청을 처리하는 DispatcherServlet의 앞단에서 실행된다는 것이다.

 DistpatcherServlet도 하나의 Servlet이고 Servlet Container가 관리한다.

즉, Servlet Container에 의해 관리되는 영역이라는 뜻이다.

필터의 실행

한가지 의문인 것은 Filter도 @Component 로 등록해서 사용했다는 것이다.

이건 새롭게 알게된 부분인데, 아무렇지 않게 Filter를 적용하여 썼으나 사실 이전에는 서블릿 컨테이너가 관리하는 영역이다 보니, Bean으로 등록이나 주입이 불가능했다.

다만, DelegatingFilterProxy라는 것을 지원하기 시작하면서부터, 스프링에서 관리가 가능해졌다.

DelegatingFilterProxy는 Servlet Filter와 Spring Bean Filter를 서로 연결해주는 매개체 역할을 하는 proxy다.

 

Client로부터 오는 요청/응답에 대해, 최초/최종 단계에 존재

앞서 설명했지만, DispatcherServlet의 앞에 존재한다는 내용으로 보면 될 것이다.

 

요청/응답의 정보를 변경하거나 Client의 요청/응답 값을 확인

Pure한 Client의 요청/응답(객체로 매핑되지 않은 상태)을 알 수 있고, 그에 따라 요청/응답의 정보를 임의로 변경할 수도 있다.

그러니, XSS나 CORS, Encoding, Logging 혹은 인증과 관련된 로직을 한번에 처리하는 역할을 할 수 있다.

 

유일하게 ServletRequest, ServletResponse의 객체를 변환할 수 있음

아래, 실습으로 확인하겠지만, ServletRequest와 ServletResponse의 객체를 변환할 수 있는 유일한 곳이다.

 

Filter 실습

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
  private String name;
  private int age;
}
@Slf4j
@RestController
@RequestMapping("/api")
public class ApiController {

  @PostMapping("")
  public User user(@RequestBody User user) {
    log.info("User : {}, {}", user, user); // {} 안에 toString 값 들어감
    return user;
  }
}
@Slf4j
@Component
public class GlobalFilter implements Filter {
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    // 전처리

    // 형변환 -> Filter에서는 형변환이 가능하고, HttpServletRequest는 ServletRequest를 상속받기 때문에 가능
    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(httpServletRequest, httpServletResponse);

    // 후처리
    
  }
}

위 Filter에 사용하는 Override된 method들은 다음 문서를 참고하자.

위 내용은 에러(Cannot call getInputStream(), getReader() already called)가 나온다.

더보기

2022-08-18 23:32:04.906  INFO 20120 --- [  XNIO-1 task-1] TestFilter                               : url : /api, line : {
2022-08-18 23:32:04.908  INFO 20120 --- [  XNIO-1 task-1] TestFilter                               : url : /api, line :     "name": "name",
2022-08-18 23:32:04.908  INFO 20120 --- [  XNIO-1 task-1] TestFilter                               : url : /api, line :     "age": 1
2022-08-18 23:32:04.908  INFO 20120 --- [  XNIO-1 task-1] TestFilter                               : url : /api, line : }
2022-08-18 23:32:04.960 ERROR 20120 --- [  XNIO-1 task-1] io.undertow.request                      : UT005023: Exception handling request to /api

org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.IllegalStateException: UT010003: Cannot call getInputStream(), getReader() already called
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-5.3.22.jar:5.3.22]
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909) ~[spring-webmvc-5.3.22.jar:5.3.22]

 

주석에 나온 것처럼, BufferdReader는 Stream으로 데이터를 처리하고 소비하는 역할이지, 저장하지 못한다.

 

이를 해결하기 위해, SpringBoot에서는 ContentCachingRequestWrapper, ContentCachingResponseWrapper 를 제공한다.

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    LOG.debug("doFilter");
    // 전처리

    ContentCachingRequestWrapper httpServletRequest = new ContentCachingRequestWrapper((HttpServletRequest) request);
    ContentCachingResponseWrapper httpServletResponse = new ContentCachingResponseWrapper((HttpServletResponse) response);

    String url = httpServletRequest.getRequestURI();

    BufferedReader br = httpServletRequest.getReader();

    br.lines().forEach(line -> {
        log.info("url : {}, line : {}", url, line);
    });

    chain.doFilter(httpServletRequest, httpServletResponse);
    // 후처리
}

그러나 또다른 에러(Required request body is missing)가 나온다.

더보기

2022-08-18 23:30:12.314  INFO 15352 --- [  XNIO-1 task-2] TestFilter                               : url : /api, line : {
2022-08-18 23:30:12.314  INFO 15352 --- [  XNIO-1 task-2] TestFilter                               : url : /api, line :     "name": "name",
2022-08-18 23:30:12.314  INFO 15352 --- [  XNIO-1 task-2] TestFilter                               : url : /api, line :     "age": 1
2022-08-18 23:30:12.314  INFO 15352 --- [  XNIO-1 task-2] TestFilter                               : url : /api, line : }
2022-08-18 23:30:12.314  WARN 15352 --- [  XNIO-1 task-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public com.example.demo.cli.dto.User com.example.demo.cli.Controller.ApiController.user(com.example.demo.cli.dto.User)]

그 생성자를 한번 보자.

public ContentCachingRequestWrapper(HttpServletRequest request) {
   super(request);
   int contentLength = request.getContentLength();
   this.cachedContent = new ByteArrayOutputStream(contentLength >= 0 ? contentLength : 1024);
   this.contentCacheLimit = null;
}

request의 길이를 저장해두고, 실제로 내용은 나중에 넣어준다.

즉, 후처리 때 적용된다는 것이다.

 

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        LOG.debug("doFilter");
        // 전처리
        ContentCachingRequestWrapper httpServletRequest = new ContentCachingRequestWrapper((HttpServletRequest) request);
        ContentCachingResponseWrapper httpServletResponse = new ContentCachingResponseWrapper((HttpServletResponse) response);

        chain.doFilter(httpServletRequest, httpServletResponse);
        // 후처리
        String url = httpServletRequest.getRequestURI();
        String reqContent = new String(httpServletRequest.getContentAsByteArray());
        LOG.info("request url : {}, request body : {}", url, reqContent);

        String resContent = new String(httpServletResponse.getContentAsByteArray());
        int httpStatus = httpServletResponse.getStatus();

        LOG.info("response status : {}, response body : {}", httpStatus, resContent);
    }
}

로그는 잘 찍혔는데, 정작 응답값은 비정상으로 나오는 것이 확인될 것이다.

getContentAsByteArray 역시, Stream이기 때문이다.

 

@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);
    // 후처리
    String url = httpServletRequest.getRequestURI();
    // req
    String reqContent = new String(httpServletRequest.getContentAsByteArray());
    LOG.info("request url : {}, request body : {}", url, reqContent);

    String resContent = new String(httpServletResponse.getContentAsByteArray());
    int httpStatus = httpServletResponse.getStatus();

    httpServletResponse.copyBodyToResponse();
    LOG.info("response status : {}, response body : {}", httpStatus, resContent);
}

이제 정상적으로 적용되는 것을 볼 수 있다.

위와 같이, Logging 처리에 사용할 수 있고, Session 확인 후 Response를 바로 내버리는(401, 403 등) 역할이 가능하다.

다만, 인증은 Interceptor에서 구현을 많이 하는 편이니 참고하자.

또한, @WebFilter를 사용하면, 특정 URL에 대해서만 적용할 수도 있다.

 

@Slf4j
@WebFilter(urlPatterns = "/api/user/*") // 특정 URL 지정
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);
    // 후처리
    String url = httpServletRequest.getRequestURI();
    // req
    String reqContent = new String(httpServletRequest.getContentAsByteArray());
    log.info("request url : {}, request body : {}", url, reqContent);

    String resContent = new String(httpServletResponse.getContentAsByteArray());
    int httpStatus = httpServletResponse.getStatus();

    httpServletResponse.copyBodyToResponse();
    log.info("response status : {}, response body : {}", httpStatus, resContent);
  }

}

 

Interceptor

이제 Interceptor로 가보자.

앞서, Filter랑 비교해서 보는 것도 좋다.

 

  1. Spring Context에서 관리
  2. 어떤 메서드를 사용하는지 알 수 있다.
  3. AOP와 유사하게 사용할 수 있다.

Spring Context에서 관리

이미 앞에서 보았지만, Interceptor는 DispatcherServlet 뒤에 존재한다.

즉, Spring Context에 등록된다.

 

어떤 메서드를 사용하는지 알 수 있다.

 이미 Controller 매핑까지 끝난 상태로 전달받기 때문에, 어떤 메서드를 사용하는지도 알 수 있다.

또한, 선/후처리로 Service Business Logic과 분리하여 사용할 수 있다.

 

AOP와 유사하게 사용할 수 있다.

하단의 예시와 이전 AOP 예시를 비교해보자.

 

Interceptor 실습

@Documented // javadoc2으로 api 문서를 만들 때 어노테이션에 대한 설명도 포함하도록 지정해주는 것
@Retention(RetentionPolicy.RUNTIME) // 어노테이션의 유지 정책을 설정
@Target({ElementType.TYPE, ElementType.METHOD}) // 어노테이션을 적용할 수 있는 위치
public @interface Auth {
}
@Auth
@Slf4j
@RestController
@RequestMapping("/api/private")
public class PrivateController {
    private final Logger LOG = LoggerFactory.getLogger(this.getClass().getSimpleName());

    @PostMapping("hello")
    public String user() {
        return "PRIVATE HELLO";
    }
}
@Slf4j
@RestController
@RequestMapping("/api/public")
public class PublicController {
    private final Logger LOG = LoggerFactory.getLogger(this.getClass().getSimpleName());

    @PostMapping("hello")
    public String user() {
        return "PUBLIC HELLO";
    }
}
@Slf4j
@Component
public class AuthInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String url = request.getRequestURI();
    URI uri = UriComponentsBuilder.fromUriString(request.getRequestURI())
        .query(request.getQueryString()) // Query 가져오기
        .build()
        .toUri();
    log.info("request URL : {}", url);
    // 권한 체크할 예정
    boolean hasAnnotation = checkAnnotation(handler, Auth.class);
    log.info("hasAnnotation : {}", hasAnnotation);

    // 조건 : 모두 Public으로 동작하지만, Auth 권한을 가진 요청에 대해서는 세션/쿠키/RequestParam 등을 체크하겠다.
    if(hasAnnotation) {
      // 권한 체크
      String query = uri.getQuery();
      log.info("query : {}", query);
      if(query.equals("name=steve")) {
        return true;
      }
      return false;
    }
    return true;
  }

  private boolean checkAnnotation(Object handler, Class clazz) {
    // resource는 통과 시켜야 함
    if(handler instanceof ResourceHttpRequestHandler){
      return true;
    }

    // Annotation 체크
    HandlerMethod handlerMethod = (HandlerMethod) handler;
    // 해당 클래스에 Annotation이 있는지 확인
    if(null != handlerMethod.getMethodAnnotation(clazz) || null != handlerMethod.getBeanType().getAnnotation(clazz)) {
      // Auth annotation이 있는 경우 True
      return true;
    }

    return false;
  }
}
@Configuration
@RequiredArgsConstructor
public class MvcConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor);
    }
}

마지막으로 AuthException을 적용해주면서, 최종적으로 확인해보자.

public class AuthException extends RuntimeException{
}
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(AuthException.class)
    public ResponseEntity authException() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String url = request.getRequestURI();
    URI uri = UriComponentsBuilder.fromUriString(request.getRequestURI())
            .query(request.getQueryString()) // Query 가져오기
            .build()
            .toUri();
    log.info("request URL : {}", url);
    // 권한 체크할 예정
    boolean hasAnnotation = checkAnnotation(handler, Auth.class);
    log.info("hasAnnotation : {}", hasAnnotation);

    if(hasAnnotation) {
        // 권한 체크
        String query = uri.getQuery();
        log.info("query : {}", query);
        if(query.equals("name=steve")) {
            return true;
        }
        throw new AuthException();
    }
    return true;
}

 

Wrap Up

 

그럼 전체적으로 정리해보자.

 

Filter는 Web Application 관리 영역으로, ServletRequest/ServletResponse를 변환할 수 있는 유일한 구간이자, Client의 Pure한 요청을 보고, Request/Response의 양 종단간의 데이터를 확인할 수 있다. (DispatcherServlet 외부)

보통, Logging, XSS, CORS, Encoding 등에 사용한다.

 

Interceptor는 Spring Context 영역으로, 빈으로 관리되어, 메서드 단위까지 확인이 가능하며, AOP기능과 유사하게 사용할 수 있다.

보통, 인증이나 Logging에 사용한다.

 

그럼 내가 사용한 방법은 적절했을까?

 

전체 Req/Res 로그를 남기길 원할 때, XSS 를 막을 때에 대해 적절히 Filter를 썼으며, Auth와 관련한 로직은 Interceptor로 쓰며, 메뉴 관리까지 하였다.

 

앞으로는 더 다양한 방법을 사용해 볼 수 있도록 하자.