Spring
Spring Boot
Interceptor & Filter

Filter

필터는 톰캣에서 관리하는 영역으로 Spring 영역에 진입하기 전에 raw한 데이터를 볼 때 사용된다. 예를 들어서 Api 호출을 할 경우 어떠한 데이터가 들어왔는지 정확히 알 수 는 없고

{
  "user_name" : "홍길동",
  "phoneNumber" : "010-0000-0000",
  "age" : 100,
  "email" : "abc@gmail.com"
}

위와 같은 요청이 들어 왔을 때

{
  "name": null,
  "phone_number": null,
  "email": "abc@gmail.com",
  "age": 100
}

아래와 같은 응답이 나갔다고 가정할 때 서버에서 Request 메시지와 Response 메시지를 확인하고 이를 조작까지 할 수 있다.

기존에 사용하던 방식

원래는 아래와 같은 방식을 사용해서 Request를 확인했다. 그러나 다음과 같은 방식을 사용하면 17번 라인에서 getReader() 메서드로 request를 읽어왔을 때 pop()과 같은 동작을 해서 request가 비어버린다는 문제점이 있다.

@Slf4j
@Component
public class LoggerFilter implements Filter { 
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    // 진입 전
    log.info(">>>>> 진입 전");
 
    var req = new HttpServletRequestWrapper((HttpServletRequest) request);
 
    var res = new HttpServletResponseWrapper((HttpServletResponse) response);
 
    // 컨트롤러에서 작업을 처리한다.
    chain.doFilter(req, res);
 
    // getReader()를 사용하게되면 InputStream을 이미 사용했기 때문에 Api를 요청했을 때 에러가 발생한다.
    var br = req.getReader(); // buffer
    var list = br.lines().collect(Collectors.toList());
    list.forEach(it -> log.info("{}", it));
 
 
    // 진입 후
    log.info("<<<<< 리턴");
  }
}

개선된 방식

위와 같은 문제를 해결하기 위해서 ContentCaching이라는 개념이 등장했는데, Content를 Caching하기 위해서 Wrapper class를 사용하고 이 Wrapper Class는 용청 및 응답의 내용을 캐싱해서 여러 번 읽을 수 있게 해준다.
copyBodyToResponse를 실행해주지 않으면 Response가 비어있는 상태로 응답이 가게 된다.

@Slf4j
@Component
public class LoggerFilter implements Filter { // Filter를 implements해서 사용
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    // 진입 전
    log.info(">>>>> 진입 전");
 
    // ByteArrayOutputStream라는 Stream에서 데이터를 읽어올 수 있게 해준다.
    var req = new ContentCachingRequestWrapper((HttpServletRequest) request);
 
    var res = new ContentCachingResponseWrapper((HttpServletResponse) response);
 
    // 컨트롤러에서 작업을 처리한다.
    // ContentCachingWrapper를 이용해서 캐싱하기 위해서는 ServeletRequest / ServletResponse가 아닌
    // 위에서 생성한 Wrapper를 사용해야 한다.
    // 즉, chain.doFilter(request, response); 이런식으로 사용하면 req는 캐싱이 안된다.
    chain.doFilter(req, res);
    
    var reqJson = new String(req.getContentAsByteArray());
    log.info("req : {}", reqJson);
    // 여기에서 한 번 읽었기 때문에 copyBodyToResponse()를 사용해서
    // 다시 response에 덮어씌워줘야 한다.
    var resJson = new String(res.getContentAsByteArray());
    log.info("res : {}", resJson);
 
    res.copyBodyToResponse();
 
    // 진입 후
    log.info("<<<<< 리턴");
  }
}

Interceptor

Interceptor는 Handler Mapping에서 접근하는 구간이기 때문에 어떤 Controller로 보내줄지 어떤 주소에 Mapping할지 등을 정하며, 주로 인증 로직을 구현할 때에 사용한다.

Catch할 Annotation 작성

Interceptor에서 Catch할 Annotation을 작성한다.

@Target(value = {ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface OpenApi {
}

Interceptor 작성

handler interceptor를 implements해서 interceptor를 등록한다.

@Slf4j
@Component
public class OpenApiInterceptor implements HandlerInterceptor {
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    log.info("pre handle");
    // HandlerMethod에는 어떠한 Annotation을 가지고 있는지
    // Method Level과 Class Level을 확인할 수 있다.
    var handlerMethod = (HandlerMethod) handler;
 
    // Method가 해당 Annotation을 가지고 있는지 확인한다.
    var methodLevel = handlerMethod.getMethodAnnotation(OpenApi.class);
    if(methodLevel != null) {
      log.info("method level");
      return true;
    }
 
    // Class가 해당 Annotation을 가지고 있는지 확인한다.
    var classLevel = handlerMethod.getBeanType().getAnnotation(OpenApi.class);
    if(classLevel != null) {
      log.info("class level");
      return true;
    }
 
    // 만약에 Annotation이 없다면
    // False를 통해서
    log.info("Open Api가 아닙니다.");
    // true일 경우 controller 전달, false일 경우 전달하지 않는다.
    return false;
  }
 
  @Override
  public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    log.info("post handle");
//    HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
  }
 
  @Override
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    log.info("after completion");
//    HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
  }
}

Config 등록

Spring Bean에 위에서 작성한 Interceptor를 등록한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
  @Autowired
  private OpenApiInterceptor openApiInterceptor;
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    // "/**"의 의미는 "모든 주소를 다 맵핑하겠다."라는 의미이다.
    // exclude : 특정 경로는 포함하지 않겠다.
    registry.addInterceptor(openApiInterceptor).addPathPatterns("/**");
  }
}

사용 방법 및 해설

다음과 같은 API가 있다고 가정할 때 register() 메서드는 OpenApi Annotation을 가지고 있기 때문에 true를 return 해주게 되는데 이는 reponse를 보내준다는 것을 의미한다. 즉, return false를 하게 되면 응답이 가지 않고 true를 하게 되면 응답이 가게 된다.
전체적인 코드는 서버의 모든 경로로 들어오는 Api 요청 중에서 OpenAPI Annotation이 붙어있는 Api만 응답을 보내주는 코드가 된다.


아래 코드에서 hello() 메서드에 OpenApi Annotation을 붙여주게 되면 reponse 값으로 "Hello"라는 스트링이 보내지지만 OpenApi Annotation이 없을 경우 `응답 코드는 200OK 지만 Content에 아무런 응답값이 적혀있지 않는 것을 볼 수 있다.

@RequestMapping("/api/user")
public class UserApiController {
 
  @OpenApi
  @PostMapping("")
  public UserRequest register(
      @RequestBody UserRequest userRequest
//      HttpEntity http
  ) {
    log.info("{}", userRequest);
    return userRequest;
  }
 
  @GetMapping("/hello")
  public String hello() {
    log.info("hello");
    return "Hello";
  }
}

RequestContextHolder

RequestContextHolder는 Spring에서 전역으로 Request에 대한 정보를 가져오고자 할 때 사용하는 클래스이다.
Cookie, Header 등에 접근이 가능하며 Servelet이 생성될 때 초기화된다.

RequestContextHolder는 Spring에서 전역으로 Request에 대한 정보를 가져오고자 할 때 사용하는 클래스이다.
Controller가 아닌 다른 곳에서 Request에 대한 정보를 가져올 때 사용하는데, 예를 들면

  void someFunction() {
    // request holder에서 찾아옴
    var requestContext = RequestContextHolder.getRequestAttributes();
 
    var userId = requestContext.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);
  }

다음과 같이 어떤 함수에서든 Request를 불러와서 사용할 수 있다.

실사용 예시

Exception에서 request의 Token을 가져와서 검증한 후

@Slf4j
@RequiredArgsConstructor
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
  private final TokenBusiness TokenBusiness;
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    log.info("Authorization Interceptor url : {}", request.getRequestURI());
    
    // Chrome의 경우 OPTIONS 메소드를 먼저 호출해서 GET이나 POST등 메서드를 지원하는지 먼저 확인한다.
    // 그걸 통과하는 로직
    if(HttpMethod.OPTIONS.matches(request.getMethod())) {
      return true;
    }
 
    // js, html, png를 요청하는 경우 Pass
    if(handler instanceof ResourceHttpRequestHandler) {
      return true;
    }
 
    var accessToken = request.getHeader("authorization-token");
    if(accessToken == null) {
      throw new ApiException(TokenError.AUTHROIZATION_HEADER_NOT_FOUND);
    }
 
    var userId = TokenBusiness.validationAccessToken(accessToken);
 
    // 인증 성공
    if (userId != null) {
      var requestContext = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
      requestContext.setAttribute("userId", userId, RequestAttributes.SCOPE_REQUEST);
      return true;
    }
 
    throw new ApiException(TokenError.INVALID_TOKEN, "인증실패");
  }
}

controller에서 Business Logic으로 넘겨준다.

  @GetMapping("/me")
  public Api<UserResponse> me() {
    var requestContext = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
    var userId = requestContext.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);
    if(userId == null) {
      throw new ApiException(TokenError.INVALID_TOKEN, "인증실패");
    }
    var response = userBusiness.me(Long.parseLong(userId.toString()));
    return Api.OK(response);
  }
ℹ️

RequestContextHolder는 Servlet이 생성될 때 초기화된다.

Resolver

위의 코드에서 보면

    var requestContext = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
    var userId = requestContext.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);
    if(userId == null) {
      throw new ApiException(TokenError.INVALID_TOKEN, "인증실패");
    }

이 로직이 Controller에서 이 로직이 존재하면 안될거 같다. 그래서 나는 Business 로직에서 처리하면 되는거 아닌가? 하고 있었는데 강의에서는 이 로직을 Resolver에서 처리하고 있었다.

전체적인 로직인 일단 아래와 같다.

  1. UserSession annotation 선언
@Target({java.lang.annotation.ElementType.PARAMETER})
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
public @interface UserSession {}
  1. Resolver에서 사용할 모델 작성
public class User {
  private Long id;
  private String name;
  private String email;
  private String password;
  private UserStatus status;
  private String address;
  private LocalDateTime registeredAt;
  private LocalDateTime unregisteredAt;
  private LocalDateTime lastLoginAt;
}
  1. Resolver 작성

supportsParameter에 만족해서 true로 넘어가는 경우 resolveArgument가 실행된다.

@Component
@RequiredArgsConstructor
public class UserSessionResolver implements HandlerMethodArgumentResolver {
  private final UserService userService;
 
  @Override
  public boolean supportsParameter(MethodParameter parameter) {
    // 지원하는 파라미터 체크, 어노테이션 체크하는 영역
    // 1. 어노테이션이 있는지 체크
    var annotation = parameter.hasParameterAnnotation(UserSession.class);
    // 2. parameter type 체크
    boolean parameterType = parameter.getParameterType().equals(User.class);
 
    return annotation && parameterType;
  }
 
  @Override
  @Nullable
  public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
      NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
    // support parameter가 true일 때 실행되는 영역
 
    // request holder에서 찾아옴
    var requestContext = RequestContextHolder.getRequestAttributes();
 
    var userId = requestContext.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);
 
    var userEntity = userService.getUserWithThrow(Long.parseLong(userId.toString()));
 
    // 사용자 정보 세팅
    return User.builder().id(userEntity.getId()).name(userEntity.getName()).email(userEntity.getEmail())
        .password(userEntity.getPassword()).status(userEntity.getStatus()).address(userEntity.getAddress())
        .registeredAt(userEntity.getRegisteredAt()).unregisteredAt(userEntity.getUnregisteredAt())
        .lastLoginAt(userEntity.getLastLoginAt()).build();
  }
}
  1. WebConfig에 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
  ...
  private final UserSessionResolver userSessionResolver;
  ...
  @Override
  public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
    resolvers.add(userSessionResolver);
  }
}
  1. 적용

원래

    @RequestHeader("authorization-token") String authorization

이런식으로 받아야 하지만 대신 위에서 선언한 UserSession annotation을 한다. 그러면 Resolver에서 supportsParameter가 true가 되어서 resolveArgument가 실행되고 User 객체가 반환된다.

  @GetMapping("/me")
  public Api<UserResponse> me(
    @UserSession User user
  ) {
    var response = userBusiness.me(user.getId());
    return Api.OK(response);
  }

지금 당장의 필요성은 못 느끼겠지만 이런식으로해서 user에 대한 모든 Request를 한 곳에서 검증하고 받아오면 좀 더 코드가 간결해질 것 같은 느낌이 든다.

Error

Resolver Null Point Exception

Resolver에서 Null Point Exception이 발생했다. 에러가 발생한 부분은

var userId = requestContext.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);

이 부분이었는데 userId가 Attribute에 없어서 발생했던 에러였다. 왜 없지? userId를 처음에 누가 Header에서 받아서 할당해주지 하면서 디버깅을 하다가 AuthorizationInterceptor

// 인증 성공
if (userId != null) {
  var requestContext = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
  requestContext.setAttribute("userId", userId, RequestAttributes.SCOPE_REQUEST);
  return true;
}

이 부분에서 해주고 있다는 것을 기억해냈다. 그래서 이 부분에 DebugPoint를 걸어보니 아예 진입을 하지 않았다.

Kotlin 으로 Migration을 해주는 과정에서 WebConfig을 통해서 Interceptor를 제대로 등록해주지 않았다는걸 알게 되었다.
WebConfig.kt에 다음 코드를 추가하고 에러가 해결되었다.

	override fun addInterceptors(registry: InterceptorRegistry) {
		registry
			.addInterceptor(authorizationInterceptor)
			.excludePathPatterns(OPEN_API)
			.excludePathPatterns(DEFAULT_EXCLUDE)
			.excludePathPatterns(SWAGGER)
	}