안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
velog를 사용하다, 체계적으로 글을 작성해보고 싶어서, tistory로 옮긴 후 이번이 두 번째 글입니다.

코드 리팩토링 과정에서 아무렇지 않게 사용했던, @RequestHeader를 보며, 순간 `아차` 하는 생각이 들었습니다.
HttpRequest 요청받을 때, 꼭 필요한 Header라는 것을 명시하고, 필터링하기 위해 사용했지만,
해당 필터는 이미 JwtFilter에 적용된 상태였습니다. Controller를 리팩토링하면서, 해당 어노테이션을 제거하며
해당 어노테이션의 쓰임새에 대해 고찰해보는 시간을 가지게 되었습니다.

1. @RequestHeader

@RequestHeader Annotation은 HTTP 요청 헤더 값을 컨트롤러 메서드의 파라미터로 전달한다(메서드 파라미터가 String가 아니라면 타입변환을 자동으로 적용한다)
만약 헤더가 존재하지 않으면 에러가 발생하며, required 속성을 이용해 필수여부를 설정할 수 있다.
또한 defaultValue 속성을 이용해 기본 값도 설정 가능하다.

@RequestHeader은 이처럼 사용자 환경에서 커스텀하게 사용할 수 있는 이점이 있습니다.

 

2. 해당 어노테이션을 삭제한 과정

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

        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();

        if (isLoginCheckPath(requestURI)) {

            log.info("jwt = {}", jwt);

            if (
                    StringUtils.hasText(jwt)
                    && tokenProviderImpl.validateToken(jwt)
                    && isNotLogoutAccessToken(jwt)
            ) {
                Claims claims = tokenProviderImpl.getClaims(jwt);
                log.info("claims.getExpiration() = " + claims.getExpiration());

                Authentication authentication = tokenProviderImpl.getAuthentication(jwt);

                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("authentication.getName() = {}", authentication.getName());

            } else {
                log.info("유효한 JWT 토큰이 없습니다, uri = {}", requestURI);
                return ;
            }

        }

        filterChain.doFilter(request, response);

    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        log.info("bearToken = {}", bearerToken);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }

 

해당 코드는 JwtFilter.class 로 클라이언트 요청이 발생하면 수행되는 필터입니다.

아래 그림과 같이 클라이언트에게 Request 요청이 발생하면 필터를 거친 후, 디스패처 서블릿에 도달합니다.

 

Filter 작동 과정

a. Why ?

필터를 사용하는 이유는 디스패처 서블릿에 요청 과부하를 줄여주는 역할을 수행합니다. 사용자가 설정한 Filter 조건을 만족하지 않는다면 doFilter 메소드가 호출되지 않고, return 하여 해당 요청의 처리를 중단하여, 예외 처리가 이루어질 수 있도록 합니다.

스프링은 filter가 chain 형태로 복잡하게 이뤄진 것을 확인할 수 있습니다. 제가 진행하고 있는 토이 프로젝트에는 이처럼 다양한 필터가 존재하는 것을 확인할 수 있습니다. (현재, JwtFilter는 8번째 인덱스에 속해있습니다.)

따라서, 디스패처 서블릿의 부담을 덜어주어 서버 과부화를 줄이는데 많은 역할을 수행하는 것이 filter라고 할 수 있습니다.

 

b. How ?

필터를 사용하는 방법은,  OncePerRequestFilter 혹은 해당 클래스의 부모 클래스를 상속받거나, Filter 인터페이스를 구현함으로써 사용할 수 있습니다. 저는 OncePerRequestFilter를 상속받아 사용하는데, 이는 필터 작동 원리가

그림 <Filter 작동 과정>의 모습처럼 Request -> 처리 -> Response로 이어지는 과정에서 두 번 처리되는 것을 볼 수 있습니다. OncePerRequestFilter는 해당 요청이 두 번 실행되지 않도록 하는 Filter 구현체 입니다.

 

두 가지 방법을 활용하여 해당 메소드를 재정의 혹은 구현하게 된다면, doFilterInternal 메소드를 오버라이딩 하게 됩니다.

이 메소드에서 request에 대한 헤더를 파싱하거나 검증하는 과정을 수행할 수 있습니다.

이후, 해당 필터를 Bean 등록하여 사용할 수 있도록 Config 클래스를 생성합니다. 스프링 Security5 이상부터는, Security 설정하는 방법이 바뀌었는데, 이에 대한 글도 추가로 작성하도록 하겠습니다.

맨 아래의 .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)는 UsernamePasswordAuthenticationFilter가 수행되기 전에 해당 필터를 적용하라는 것 입니다.

@Bean
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http,
                                                  TokenProviderImpl tokenProviderImpl) throws Exception {

        http
                .httpBasic().disable()
                .csrf().disable()

                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                .and()
                .authorizeRequests((requests) -> requests
                .antMatchers(
                        "/resources/**",
                        "/api/v1/register",
                        "/api/v1/login",
                        "/"
                )
                .permitAll()
                .anyRequest().authenticated())
                
                .formLogin().disable()

                .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(
                        userInfoEndpointConfig -> userInfoEndpointConfig
                                .userService(customOAuth2UserService)
                                .oidcUserService(customOidcUserService)))

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();

    }

c. Where ?

해당 JwtFilter에서는 resolveToken 메소드에 해당 검증 내용이 담겨 있습니다. AUTHORIZATION_HEADER 파이널 상수는 "Authorization" 헤더의 문자열을 할당하였습니다.  따라서, JwtFilter가 적용되는 과정에서 해당 토큰 값만 파싱한 후, 토큰 검증하여 해당 클라이언트의 요청이 유효한 유저의 요청인지 판단합니다. 따라서, Controller에서 해당 헤더를 판단하게 된다면 불필요한 체킹이 될 수 있습니다.

private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

    log.info("bearToken = {}", bearerToken);

    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }

    return null;
}

 

3. @RequestHeader 사용처

그렇다면 해당 어노테이션은 어디에 사용할까요? 다양한 사용처가 있겠지만,

저는 RefreshToken을 헤더에 담아서 사용할 때, 해당 어노테이션이 중요한 역할을 수행할 수 있다고 생각합니다.

만약, 사용자에게 만료 기간 전에 RefreshToken을 재발급 해야 하거나, 사용자가 먼저 재발급 요청을 하게 될 때 필터를 통과한 후, 컨트롤러 해당 RefreshToken이 담겨있다면, 이를 확인해서 토큰을 재발급하는 과정이 수행될 수 있습니다.

 

이처럼 토큰을 재발급해야하는 상황이 생긴다면, 해당 해더에 "RefreshToken"이라는 커스텀 헤더를 요청받아, 로직을 수행할 수 있습니다. Filter에 해당 요청을 받는 로직도 생각해보았지만, refreshToken은 매번 검증이 필요한 토큰이 아니라 판단하여, 재발급 요청 시에만 해당 토큰을 검증하면 된다고 생각하였습니다. 따라서, 효율성 측면에서 해당 요청에만 커스텀 헤더를 적용하였습니다.

 

아무렇지 않게 사용했던 것들을 다시 자세히 살펴보는 과정에서, 정말 배울 점이 많다는 것을 느끼게 되었습니다.

긴 글 읽어주셔서 감사드립니다.!

+ Recent posts