Backend/SpringBoot

[Springboot] Filter와 Interceptor

anxi 2024. 5. 8. 18:42

학습하게 된 계기

지금 하고 있는 프로젝트가 기능 구현은 다 끝났지만, 인증과 관련해서 액세스 토큰만 사용하기 때문에 짧은 유효시간으로 잦은 로그인이 발생했다. 그래서 리프레시 토큰을 추가해서 안정성을 높이고자 했는데,, 

 

모든 에러 처리,,,

모든 에러를 처리해줬음에도,,

 

500에러만 계속,,

 

500 에러만 계속 떠서 무슨 이유에서 에러가 발생하는지 알 수 없었다..

 

그 이유를 최근에서야 알게 됐는데 바로 Filter 때문이었다.

ExceptionHandleFilter

 

필터에서 내가 커스텀한 ServiceException이 발생 시 에러 메시지를 보내고, 그 외에 모든 에러는 500 서버 에러를 보낸다. 하지만 ServiceException은 거진 Service 레이어에서 발생하기 때문에 필터에서 디스패처 서블릿으로 넘어간 후 에러가 발생하여 필터에서 catch하지 못하는 상황이었다... 

 

그래서 결국은 ControllerAdvice를 통해 해결했지만 필터에 대한 학습이 필요하다고 생각하여 학습하게 되었고 필터를 검색하면 따라 나오는게 인터셉터이기 때문에 둘 다 학습을 해보고자 한다 !


Filter (필터)

  • Web Application에서 관리되는 영역이다.
  • Client로부터 오는 요청/응답에 대해서 최초/최종 단계의 위치에 존재한다. 이를 통해 요청/응답의 정보를 변경하거나, Spring에 의해서 데이터가 변환되기 전의 순수한(객체 변환 전) Client의 요청/응답 값을 확인할 수 있다.
  • 주로 request/response의 Logging 용도로 활용하거나, "인증"과 관련된 Logic들을 해당 Filter에서 처리한다. 

    예시로 들면, 나는 커스텀 에러처리는 ControllerAdvice에서 하게 하였고, JWT관련 인증은 Filter에서 처리하게 하였다.
  • 필터에서 선/후 처리를 함으로써 Service Business Logic과 분리한다.
유일하게 ServletRequest, ServletResponse의 객체를 변환할 수 있다.

 

Filter

 

 

Filter 인터페이스

 

Filter 인터페이스에는 3가지 메서드가 존재한다.

 

1. init() : 웹 컨테이너에 의해 호출되며, 필터가 필터링 작업을 하기 전에 서블릿 컨테이너는 필터를 인스턴스화한 후에 1회 init() 메서드를 호출한다.

 

2. doFilter() : 각 컨테이너가 Client의 요청으로 인해 request/response 쌍이 FilterChain을 통해 전달될 때 호출된다. FilterChain에 연결된 필터로 넘어가는 행위라 볼 수 있다.

request (ServletRequest) : 객체에는 클라이언트의 요청이 포함되어 있습니다.
response (ServletResponse) : 객체에는 필터의 응답이 포함되어 있습니다.
chain (FilterChain) : 다음 필터나 리소스를 호출하기 위한 것입니다.

3. destroy() :  필터의 doFilter 메소드 내의 모든 스레드가 종료되거나 제한 시간이 경과한 후에만 호출된다.

 

Filter 활용

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {

    // 1번
    if (request.getServletPath().contains("/api/v1/auth")) {
      filterChain.doFilter(request, response);
      return;
    }

    final String authHeader = request.getHeader("Authorization");
    final String jwt;
    final String userEmail;

    // 2번
    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
      filterChain.doFilter(request, response);
      return;
    }

    jwt = authHeader.substring(7);
    userEmail = jwtService.extractUsername(jwt);

    /* user가 처음 로그인할 때 */
    if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
      UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

      if (jwtService.isTokenValid(jwt, userDetails)) {
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities()
        );

        authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authToken);
      }
    }
    // 3번
    filterChain.doFilter(request, response);
  }

 

이 코드는 JWT 인증 관련한 코드인데 filterChain.doFilter(request, response)가 실행되는 부분의 전(처리)과 후(처리)를 보자.

 

1번 : request에서 path에 /api/v1/auth가 포함되면 doFilter를 실행한다. 상세 설명을 하면 /api/v1/auth는 회원가입, 로그인과 관련되기 때문에 토큰이 필요하지 않아서 현재 필터는 종료하고 chain에 걸린 다음 필터로 넘어가라는 의미이다. 전처리, 후처리 모두 없다.

 

2번 : authHeader가 null이거나 Bearer로 시작하지 않으면 토큰이 유효하지 않다고 볼 수 있기 때문에, 현재 필터는 종료하고 다음 필터로 넘어가라는 의미이다. doFilter가 실행되기 전에는 authHeader를 뽑아오는 전처리 과정이 이뤄지고 후처리는 이뤄지지 않는다.

 

3번 : 전처리 과정에서 토큰이 유효할 경우, 사용자에게 권한 부여를 하게된다. 이 전처리 과정을 통해 필터가 하는 일을 다 수행했으므로 후처리는 없다.


Interceptor (인터셉터)

  • Filter와 매우 유사한 형태로 존재한다.
  • 차이점은 Spring Context에 존재하기 때문에 AOP와 유사한 기능을 제공할 수 있다.
  • 주로 인증 단계를 처리하거나, Logging를 하는데 사용한다.
  • Filter와 마찬가지로 Service business logic과 분리시킨다.

인터셉터는 위 필터 사진에서 Controller 단에 위치하고 있다. 

필터 -> 디스패처 서블릿 -> 인터셉터 순으로 볼 수 있다.

 

Interceptor 활용

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

}

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

    @GetMapping("/hello")
    public String hello(){
        log.info("private hello controller");
        return "private hello";
    }
}
---
@RestController
@RequestMapping("/api/public")
public class PublicController {
    // 모든 사용자들이 쓸 수 있는 api

    @GetMapping("/hello")
    public String hello() {
        return "public hello";
    }
}

 

@Auth 어노테이션을 가진 PrivateController와 그렇지 않은 PublicController가 있다. @Auth를 가진 PrivateController가 인터셉터에서 인증 단계를 거칠 수 있도록 하고자 한다.

 

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(url)
                .query(request.getQueryString())
                .build()
                .toUri();

        log.info("request url : {}", url);
        boolean hasAnnotation = checkAnnotation(handler, Auth.class);
        log.info("has annotation : {}", hasAnnotation);

        // 나의 서버는 모두 public으로 동작을 하는데
        // 단, Auth 권한을 가진 요청에 대해서는 세션, 쿠키,
        if(hasAnnotation){
            // 권한 체크
            String query = uri.getQuery();
            if(query.equals("name=han")) {
                return true;
            }
            throw new AuthException();
        }

        return true;
    }

    private boolean checkAnnotation(Object handler, Class clazz){

        // resource javascript, html, ...
        if(handler instanceof ResourceHttpRequestHandler){
            return true;
        }

        // annotation check
        HandlerMethod handlerMethod = (HandlerMethod) handler;

        if(handlerMethod.getMethodAnnotation(clazz) != null || handlerMethod.getBeanType().getAnnotation(clazz) != null){
            // Auth annotation이 있을 때 true 반환
            return true;
        }
        return false;
    }
}

 

AuthInterceptor은 HandlerInterceptor를 implement한다. HandlerInterceptor에서 preHandle() 메서드를 오버라이딩하는데 preHandle() 메서드는 필터의 doFilter와 유사하다. 하지만 반환 값이 true일 경우 인터셉터가 작동하고 그렇지 않으면 작동하지 않는다.

 

preHandle() 메서드 구현 내용은 다음과 같다.

1. @Auth 어노테이션을 갖고 있는지 checkAnnotation에서 검증한다. 

2. PublicController는 모두에게 열어주고 @Auth를 가진 PrivateController는 QueryParameter가 name=han일 경우 true를 반환할 수 있게 한다.

 

즉, 인터셉터에서 true가 반환되면 핸들러(컨트롤러)를 실행할 수 있고, 그렇지 않으면 핸들러에 도달할 수 없게 된다.

 

추가로, 인터셉터 클래스를 만들었다고 인터셉터가 작동하는 것은 아니다.

@Configuration
@RequiredArgsConstructor // final로 생성된 애들을 생성자로 주입
public class MvcConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor).addPathPatterns("/api/private/*");
    }
}

 

위와 같이 WebMvcConfigurer를 상속받아서 addInterceptors에 인터셉터를 추가해줘야 동작한다. 그리고 해당 인터셉터는 PrivateController에서 @Auth를 가졌는지, QueryParameter값이 설정 값과 같은지 비교하기 때문에 Path에 넣어주면 동작한다.