욜로가 서비스의 대부분의 요청에서 반복되는 동일한 동작이 있다. 사용자 인증이 필요한 기능인 경우에 JWT 가 두벅스 사용자 서버에서 발급한 토큰이 맞는지 검증하고, 이를 추출한다. 또한 이전에 로그아웃을 했던 적이 있었는지 파악하기 위해 JWT 에 저장되어 있는 사용자의 아이디로 Refresh Token 을 조회한다. 코드로 작성해보면 얼마 되지 않는 코드이지만, 유지보수 측면에서 변경사항이 있을 경우 거의 대부분의 코드를 수정해야 한다.

비즈니스 로직과 인증 과정을 분리하기 위해 Controller 이전에 인증하도록 구성하려고 한다. 이렇게 Controller 로 진입하기 전에 로직을 수행할 수 있도록 도와주는 것은 Filter 와 Interceptor 가 있다.

Filter vs Interceptor

Filter 는 DispatcherServlet 으로 요청이 들어오기 전에 수행된다. 가장 큰 특징으로는 파라미터로 받아온 Request 와 Response 객체를 교체할 수 있다는 점이다. Filter 는 여러 개의 Filter 가 연쇄적으로 이어져 있어 다음 Filter 를 호출할 때 Request 와 Response 객체를 직접 지정해준다. 또한 모든 Filter 의 기능이 완수되어 Controller 로 넘어갈 때에도 마찬가지이다. 외부에서 받아온 Request, 또는 내부에서 요청이 완료된 Response 객체를 필터링 과정을 거쳐 건네준다. 즉, Filter 의 동작으로 다음에 수행할 계층이 영향을 받는다.

Interceptor 는 DispatcherServlet 이 관리한다. Filter 를 거친 후 DispatcherServlet 이 Interceptor 의 동작을 수행시킨다. Filter 와 다른 점은 로직을 수행시킬 뿐 다음의 Interceptor 에게 Request 나 Response 를 건네주지 않는다. 그저 로직만을 수행할 뿐이다.

이러한 차이점으로 비교해보았을 때 Filter 는 비즈니스 로직과는 무관한 처리를 하는 것이 적절하다고 판단하여 Interceptor 를 선택하였다.

ArgumentResolver

Interceptor 로 인증 과정을 분리는 했지만 서비스 계층에서도 헤더에 있던 값이 필요한 상황이었다. 즉, 인증 과정에서 거쳐왔던 과정들이 서비스 계층에서도 필요하였다. 이 또한 Controller 앞 단에서 과정을 처리하기 위해 ArgumentResolver 를 이용하였다. ArgumentResolver 는 Request 에 있던 값을 가공하여 Controller 에게 넘겨주는 역할을 한다.

Interceptor 에서는 헤더에 있던 값을 추출하여 Redis 에 저장되어 있는 값인지 검증하였고, ArgumentResolver 로는 헤더에 있던 값을 추출하여 서비스 계층에서 필요한 객체를 생성하여 넘겨주었다.

구현하기

AuthConfiguration.java

@RequiredArgsConstructor
@Configuration
public class AuthConfiguration implements WebMvcConfigurer {

    private final TokenExtractor tokenExtractor;
    private final TokenRepository tokenRepository;

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor(tokenExtractor, tokenRepository))
            .addPathPatterns("/api/**");
    }

    @Override
    public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new TokenResolver(tokenExtractor));
    }
}

Configuration 에 Interceptor 와 Resolver 를 등록한다. Interceptor 는 기본적으로 url 을 기준으로 처리를 수행할지 말지 결정한다. Resolver 는 내부적으로 처리 여부를 지정한다.

AuthInterceptor.java

@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {

    private final TokenExtractor tokenExtractor;
    private final TokenRepository tokenRepository;

    @Override
    public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
        if (!hasMethodAnnotation((HandlerMethod) handler, Authorized.class)) {
            return true;
        }
        final String jwt = extractAuthorizationHeader(request);
        final ServiceToken serviceToken = tokenExtractor.extract(jwt);
        final String savedRefreshToken = findRefreshToken(serviceToken);
        validateTheExistenceOfRefreshToken(serviceToken.token(), savedRefreshToken, handler);
        return true;
    }

		...
}

인증 과정 처리 여부를 url 로만 판단하기에는 유지보수가 좋지 않다고 판단하여 Annotation 을 이용하였다. @Authorized 어노테이션이 있을 경우에만 인증 과정을 수행하도록 구현한다.

헤더에 있는 JWT 를 추출하고 payload 로 자바 객체를 생성한다. 이후 Redis 에 Refresh Token 존재 유무로 로그인 여부를 판단하고, Acces Token 재발급 요청이었을 경우 Refresh Token 과의 일치 여부도 판단한다.

TokenResolver.java

@RequiredArgsConstructor
public class TokenResolver implements HandlerMethodArgumentResolver {

    private final TokenExtractor tokenExtractor;

    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        return parameter.hasParameterAnnotation(ExtractAuthorization.class);
    }

    @Override
    public Object resolveArgument(
        final MethodParameter parameter, final ModelAndViewContainer mavContainer,
        final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) throws Exception {
        final HttpServletRequest request = (HttpServletRequest)webRequest.getNativeRequest();
        return tokenExtractor.extract(extractAuthorizationHeader(request));
    }

		...
}

supportsParameter 에는 Resolver 처리 여부 기준을, resolveArgument 에는 실제 처리 동작을 작성한다. 헤더에 있는 JWT 를 추출하고 서비스 계층에서 필요한 자바 객체를 생성한다.