글 작성자: beaniejoy

 

  • Overview
  • Interceptor와 Resolver
  • 기존 방식
  • 리팩토링 진행 후 코드
  • 정리

 

📌 1. Overview

최근 프로젝트를 개발하면서 로그인 인증 처리와 세션 연장 처리 부분을 Spring Security 사용하지 않고 구현해보는 과정을 진행하였습니다. Interceptor를 사용하였고 인증된 UserSession 객체를 handler parameter에 전달하기 위해 HandlerMethodArgumentResolver를 사용하였습니다.

문제는 각각의 interceptor와 Resolver 구현체에서 Redis 조회가 이루어져 비용측면에서 비효율적인 부분이 보였습니다. 이를 개선해본 내용들을 블로그에 글로 정리해봤습니다.

 

📌 2. Interceptor와 Resolver

Interceptor와 Resolver부분에 대한 자세한 내용은 단순 구글링해도 자료가 많이 있기 때문에 대략적으로 언급하겠습니다.

  • Interceptor
    • DispatcherServlet에서 handler mapping 이후 해당 handler로 접근하기 전에 Request, Response를 가로채는 역할 수행
    • HandlerInterceptor를 구현해서 적용
  • Resolver
    • handler에 들어오는 parameter를 조작하거나 객체를 전달하는데 사용
    • HandlerMethodArgumentResolver를 구현해서 적용

Interceptor는 들어온 요청이 handler(쉽게 말해 Controller단)로 전달되기 전, 그리고 handler에서 return 함으로써 응답을 전달하려고 할 때 가로채서 원하는 처리를 수행하도록 지시할 수 있습니다. 프로젝트 진행하면서 주로 로그인 관련된 프로세스에 대해 Interceptor를 적용하였습니다.

Resolver는 로그인 이후에 패스워드 변경과 같은 인증된 사용자 정보가 고정적으로 parameter에 전달되어야 하는 상황에서 적용하였습니다.

 

📌 3. 기존 방식

public class AuthenticationInterceptor implements HandlerInterceptor {

        //...

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if(!(handler instanceof HandlerMethod)){
            return true;
        }

        Authentication authAnnotation = ((HandlerMethod)handler).getMethodAnnotation(Authentication.class);

        Optional<String> bearerValue = Optional.ofNullable(request.getHeader("Authorization"));

        if (authAnnotation != null) {
            bearerValue.orElseThrow(TokenNotExistedException::new);

            Optional<UserSessionDto> userSession = Optional.ofNullable(redisUtils.getSession(bearerValue.get().substring(7)));
            userSession.orElseThrow(() -> new AuthenticationException("로그인이 필요합니다."));

            isSeller(userSession.get());
        }

        return true;
    }

    private void isSeller(UserSessionDto userSession) {
        if (userSession.getSeller() == null ||
                !userSession.getSeller()) {
            throw new AuthenticationException("판매자 권한이 필요합니다.");
        }
    }
}

로그인 인증여부 확인과 판매자 권한 여부를 확인하는 Interceptor입니다. 여기서 RedisUtils 객체를 통해 Redis에 해당 key값으로 조회합니다.

public class SessionExtensionInterceptor implements HandlerInterceptor {

    //...

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        Optional<String> bearerValue = Optional.ofNullable(request.getHeader("Authorization"));

        // bearerValue 존재하지 않을 때 세션 연장 로직 패스
        bearerValue.ifPresent(bearer -> {
            UserSessionDto sessionDto = redisUtils.getSession(bearer.substring(7));
            // 세션 만료시간 갱신
            redisUtils.renewSessionExpire(sessionDto, bearer);
        });

        return true;
    }
}

요청을 할 때마다 로그인 인증된 사용자의 세션 만료시간을 연장처리해주는 Interceptor입니다. 여기서도 RedisUtils를 통해 Redis에 해당 UserSession 데이터를 조회합니다.

public class SecurityUserResolver implements HandlerMethodArgumentResolver {

        //...

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        return methodParameter.hasParameterAnnotation(LoginUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {

        String bearerValue = webRequest.getHeader("Authorization");

        if (bearerValue != null) {
            return redisUtils.getSession(bearerValue.substring(7));
        }

        return null;
    }
}

Resolver에서도 Redis에서 해당 인증된 사용자 정보 객체를 조회합니다.

여기서 발견할 수 있는 문제점은 다음과 같습니다.

  • 매 요청마다 인증된 사용자 정보 데이터라는 같은 객체를 3번이나 불필요하게 Redis에서 가져옵니다.
  • @LoginUser를 handler parameter에 적용하기 위해 @Authentication도 해당 handler method에 설정해야 합니다.

이 부분을 하나하나 해결해보겠습니다.

 

📌 4. 리팩토링 진행 후 코드

불필요한 Redis 조회부분 제거

// SessionExtensionInterceptor.java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    Optional<String> bearerValue = Optional.ofNullable(request.getHeader("Authorization"));

    bearerValue.ifPresent(bearer -> {
        UserSessionDto userSession = redisAuthUtils.getSession(bearer.substring(7));
        // 세션 만료시간 갱신
        redisAuthUtils.renewSessionExpire(userSession, bearer.substring(7));
        // interceptor 전달을 위한 처리
        request.setAttribute("authSession", userSession);
    });

    return true;
}

세션 만료 연장 처리하는 Interceptor를 첫 시작점으로 설정했습니다. 여기서 Redis 조회를 통해 인증된 사용자 정보를 조회합니다. 그리고 request에 해당 객체를 담아서 다음 Interceptor에 전달하게 됩니다.

// AuthenticationInterceptor.java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    // 모든 handler가 method 타입이 아닐수도 있다.
    if(!(handler instanceof HandlerMethod)){
        return true;
    }

    Authentication authAnnotation = ((HandlerMethod)handler).getMethodAnnotation(Authentication.class);

    Optional<UserSessionDto> userSession
            = Optional.ofNullable((UserSessionDto) request.getAttribute("authSession"));

    if (authAnnotation != null) {
        userSession.orElseThrow(() -> new AuthenticationException("로그인이 필요합니다."));

        isSeller(userSession.get());
    }

    return true;
}

앞선 Interceptor에서 전달해준 사용자 정보를 가져와서 @Authentication에 대한 로그인 인증 여부, 판매자 권한 여부 처리를 하게 됩니다. 이렇게 함으로써 한 번의 Redis 조회를 통해 모든 처리를 할 수 있게 됐습니다.

 

@LoginUser Custom Annotation 하나로 처리하기

// UserController.java
@PatchMapping("/password")
@Authentication
public ResponseDto<?> changePassword(@RequestBody UserPasswordRequestDto resource,
                                     @LoginUser UserSessionDto userSessionDto) {
    userService.updatePassword(userSessionDto.getEmail(), resource.getPassword());

    return new ResponseDto<>(0, "성공적으로 비밀번호를 변경하였습니다.");
}

기존에는 @LoginUser를 parameter에 적용하기 위해서 handler method에 @Authentication을 같이 적용해야 했습니다. @LoginUser를 처리해주는 Resolver에는 로그인 인증 여부 처리에 대한 로직이 없기 때문에 해당 로직을 처리해주는 @Authentication이 있어야 제대로 동작할 것입니다.

@LoginUser 하나만으로 인증 여부 처리까지 할 수 있도록 로직을 추가하였습니다.

// SecurityUserResolver.java
@Override
public Object resolveArgument(MethodParameter parameter,
                              ModelAndViewContainer mavContainer,
                              NativeWebRequest webRequest,
                              WebDataBinderFactory binderFactory) throws Exception {

    HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

    return Optional.ofNullable(request.getAttribute("authSession"))
            .orElseThrow(() -> new AuthenticationException("로그인이 필요합니다."));
}

또한 여기서도 request를 통해 Interceptor에서 정의한 인증된 사용자 정보를 가져옴으로써 불필요한 Redis 조회를 줄일 수 있었습니다.

 

📌 5. 정리

  • Filter, Interceptor, Resolver 등 클라이언트 요청 후 handler에 도달하기 까지의 과정 중에 어떠한 로직 처리가 필요할 때 사용할 수 있는 여러 방법들이 존재
  • Filter → DispatcherServlet → Interceptor → Resolver 순으로 진행되기에 로직의 성격에 따라 어디에 적용해야 할지 고민해야함
  • DB와 비슷하게 Redis access를 최대한 줄이는 방식으로 로직을 작성하면 효율적인 애플리케이션 운영이 가능

 

위의 예시 코드는 부족한 부분이 많기 때문에 참고용으로 보시면 될 것 같습니다.

 

틀린 내용이 있을 수 있습니다. 이에 대한 코멘트 언제나 환영입니다!