인터셉터를 이용한 인증•인가 처리하기(feat. Filter와 AOP 비교)

밀크야살빼자·2024년 4월 3일
0

리팩토링하는 과정에서 국민학교 프로젝트에서 사용자의 인증 및 인가 처리를 맡게 되었습니다. 이를 위해 컨트롤러의 메서드에 요청이 도달하기 전에 헤더의 토큰을 확인하고 인가를 처리하는 방법을 찾던 중, Filter, Interceptor, AOP와 같은 스프링에서 제공하는 다양한 방법을 발견했습니다. 이러한 방법들을 알아보고 국민학교 프로젝트에 가장 적합한 방법을 선택하여 적용해보도록 하겠습니다.

0. Filter, Interceptor, AOP

Interceptor와 Filter는 Servlet 단위에서 실행됩니다. 반면 AOP는 메소드 앞에 Proxy 패턴의 형태로 실행됩니다.

실행 순서를 보면 Filter가 가장 바깥에 있고, 그 안에 Interceptor가 위치하고, 그 안에 AOP가 위치하는 형태입니다. 따라서 요청이 들어오면 Filter → Interceptor → AOP → Interceptor → Filter 순서로 거쳐가게 됩니다.

Filter, Interceptor, AOP는 각자 다른 영역에 존재하며, 호출되는 순서도 다릅니다.

  1. 서버를 실행시켜 서블릿이 올라오는 동안에 init이 실행되고, 그 후 doFilter가 실행됩니다.
  2. 컨트롤러에 진입하기 전에 preHandler가 실행됩니다.
  3. 컨트롤러에서 나온 후에는 postHandler, afterCompletion이 순차적으로 실행되며, 그 후 doFilter가 실행됩니다.
  4. 서블릿이 종료될 때 destroy가 실행됩니다.

1. Filter - 핸들러 동작 전 / 후 과정에 부가로직 처리, 웹 컨테이너에서 관리

필터란

요청과 응답을 가로채어 처리하는 역할을 합니다. Dispatcher Servlet에 요청이 도달하기 전과 후에 URL 패턴에 따라 추가적인 작업을 수행할 수 있습니다. 이 기능은 스프링 컨테이너가 아닌 웹 컨테이너(예: 톰캣)에서 관리되며, 스프링의 범위를 벗어나는 작업입니다. 이는 스프링에서 제공하는 기능이 아닌 자바 서블릿에서 제공하는 기능입니다.

Filter 메서드 종류

필터를 사용하기 위해서는 javax.servlet 패키지의 Filter 인터페이스를 구현해야 합니다.

  1. init()

    이 메소드는 필터 객체를 초기화하고 서비스에 추가하기 위한 것입니다. 웹 컨테이너가 한 번 init()을 호출하여 필터 객체를 초기화하면 이후 요청들은 doFilter()를 통해 처리됩니다.

  2. doFilter()

    URL 패턴에 맞는 모든 HTTP 요청이 디스패처 서블릿으로 전달되기 전에 웹 컨테이너에 의해 실행됩니다. doFilter() 메서드의 파라미터로는 FilterChain이 제공되는데, FilterChain의 doFilter()를 호출하여 다음 대상으로 요청을 전달할 수 있습니다. 이를 통해 chain.doFilter()를 사용하여 필요한 전/후 처리 과정을 추가함으로써 원하는 처리를 수행할 수 있습니다.

  3. destroy()

    이 메소드는 필터 객체를 제거하고 사용한 자원을 반환하기 위한 것입니다. 웹 컨테이너가 한 번 destroy()를 호출하여 필터 객체를 종료하면 이후에는 doFilter()에 의해 처리되지 않습니다.

2. AOP - 메서드 동작의 전 후 과정에 부가로직 처리

OOP를 보완하기 위해 나온 개념으로 관점 지향 프로그래밍이라 불립니다. 객체 지향 프로그래밍을 했을 때 중복을 줄일 수 없는 부분을 줄이기 위해 취하는 접근 방식입니다.

주로 로깅, 트랜잭션, 에러 처리와 같은 부가 기능을 구현할 때 사용되며, Filter나 Interceptor와는 달리 비즈니스 로직을 처리할 때도 사용됩니다. 메소드의 실행 전후로 자유롭게 설정할 수 있습니다. Interceptor나 Filter는 대상을 주소로 구분하여 걸러내야 하지만, AOP는 주소, 파라미터, 애노테이션 등 다양한 방법으로 대상을 지정할 수 있습니다.

AOP의 Advice와 HandlerInterceptor의 가장 큰 차이는 파라미터의 차이입니다. Advice의 경우 JoinPoint나 ProceedingJoinPoint 등을 활용하여 호출하지만, HandlerInterceptor는 Filter와 유사하게 HttpServletRequest와 HttpServletResponse를 파라미터로 사용합니다.

3. Interceptor - 컨트롤러로 이동하기 전 / 후에 처리, 스프링 컨테이너에서 관리


스프링 기술로 요청이 Controller로 이동하기 전과 후에 처리를 가로챕니다. 인터셉터는 Dispatcher Servlet이 Controller를 호출하기 전과 후에 끼어들어 요청과 응답을 참조하거나 가공할 수 있는 기능을 제공합니다. 인터셉터는 웹 컨테이너가 아닌 스프링 컨텍스트에서 동작합니다. Dispatcher Servlet은 핸들러 매핑을 통해 컨트롤러를 찾도록 요청합니다. 이 과정에서 실행 체인(HandlerExecutionChain)을 반환합니다. 인터셉터가 등록되어 있다면 실행 체인에 등록된 인터셉터들을 순차적으로 거쳐 컨트롤러가 실행되도록 하고, 없다면 요청에 대한 처리는 바로 컨트롤러로 이동합니다. 인터셉터는 스프링 기술이기 때문에 스프링에서 관리하는 빈들을 사용할 수 있습니다.

실제로 Interceptor가 직접 Controller로 요청을 위임하는 것은 아니다.

인터셉터 메서드 종류

인터셉터를 추가하기 위해서 org.springframework.web.servlet 패키지의 HandlerInterceptor 인터페이스를 구현해야 합니다.

package org.springframework.web.servlet;

public interface HandlerInterceptor {
  
	default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		HttpSession session = request.getSession(false);
		if(session != null) {
			Object authInfo = session.getAttribute("authInfo");
			if(authInfo != null) {
				return true; // 로그인 상태
				}
			}
		response.sendRedirect(request.getContextPath() + "/login");
		return false; // 로그인 상태 아님
	}

	default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			@Nullable ModelAndView modelAndView) throws Exception {
	}

	default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
			@Nullable Exception ex) throws Exception {
	}
}
  1. preHandle()

    인터셉터는 컨트롤러가 호출되기 전에 실행됩니다. 전처리 작업이나 요청 정보를 가공하거나 추가하는 경우에 사용할 수 있습니다.

  2. postHandle()

    인터셉터는 컨트롤러가 호출된 후에 실행됩니다. (View 렌더링 전) 후처리 작업이 필요할 때 사용할 수 있습니다. 이 메서드는 컨트롤러가 반환하는 ModelAndView 타입의 정보를 제공받습니다. 최근에는 JSON 형태로 데이터를 제공하는 RestAPI 기반의 컨트롤러(@RestController)가 자주 사용되므로 이 메서드는 그에 맞게 사용되지 않는 경우가 있습니다.

  3. afterCompletion()

    인터셉터는 모든 뷰에서 최종 결과를 생성하는 일을 포함해 모든 작업이 완료된 후에 실행됩니다. (View 렌더링 후) 요청 처리 중에 사용한 리소스를 반환할 때 사용할 수 있습니다.

4. Filter vs Interceptor

필터는 Request와 Response를 조작할 수 있지만, 인터셉터는 조작할 수 없습니다.

public class Sample implements Filter {
	
	@Override
	public void doFilter(ServletRerquest request, ServletResponse response, FilterChain chain) 
											throws	IOException, ServletException {
		// 다른 request와 response를 넣어줄 수 있음
		chain.doFilter(request, response);
	}
}			

필터가 다음 필터를 호출하기 위해서는 필터 체이닝(다음 필터 호출)을 해주어야 합니다. 이를 위해서는 doFilter 메소드 내에서 FilterChain 객체의 doFilter 메소드를 호출해야 합니다. 이때 doFilter 메소드에는 다음 필터로 전달할 request와 response 객체를 넘겨주어야 합니다. 이는 FilterChain 객체를 통해 이루어집니다.

인터셉터 처리과정

public class Sample implements HandlerInterceptor {
	public boolean preHandler(HttpServletReqeust request, HttpServletResponse response) throws Exception {
			// Request, Response를 교체할 수 없고 boolean 값만 반환 가능
			return true;
		}
	}

디스패처 서블릿은 여러 인터셉터를 가지고 있으며, 이들은 순차적으로 실행됩니다. 각 인터셉터는 preHandle 메소드를 호출하여 요청을 처리할 수 있습니다. 만약 인터셉터가 true를 반환하면, 다음 인터셉터가 실행되거나 요청이 컨트롤러로 전달됩니다. 그러나 false를 반환하면, 요청이 중단됩니다. 이때 다른 request나 response 객체를 넘겨줄 수 없습니다.

필터와 인터셉터의 사용 사례

필터 사용 사례

  • 보안 및 인증 / 인가 관련 작업
  • 모든 요청에 대한 로깅 또는 검사
  • 이미지 / 데이터 압축 및 문자열 인코딩
  • Spring과 분리되어야 하는 기능

필터는 기본적으로 스프링과 독립적으로 동작하여 전역적으로 처리해야 하는 작업들을 수행할 수 있습니다.

필터는 인터셉터보다 요청을 받아들이는 순서에서 앞단에 위치하기 때문에 보안 검사(XSS 방어 등)를 포함하여 올바르지 않은 요청을 차단할 수 있습니다. 이렇게 하면 스프링 컨테이너까지 요청이 전달되지 않고 요청이 차단되므로 웹 애플리케이션의 안전성을 높일 수 있습니다.

또한, 필터는 이미지나 데이터의 압축, 문자열 인코딩과 같이 웹 어플리케이션 전반에 걸쳐 사용되는 기능을 구현하는 데에 적합합니다. 이러한 기능들은 모든 요청에 대해 공통적으로 적용되어야 하므로 필터를 통해 구현하는 것이 효율적입니다.

인터셉터 사용 사례

  • 세부적인 보안 및 인증 / 인가 공통 작업
  • API 호출에 대한 로깅 또는 검사
  • Controller로 넘겨주는 정보(데이터)의 가공

인터셉터는 클라이언트의 요청과 관련하여 전역적으로 처리해야 하는 작업들을 담당할 수 있습니다. 이러한 작업들은 보안 검사, 권한 확인, 로깅, 세션 관리, 캐싱 등 다양한 것들을 포함할 수 있습니다. 예를 들어, 인터셉터를 사용하여 모든 요청에 대해 XSS(Cross-Site Scripting) 방어를 구현하거나, 인증 및 권한 부여를 검사하여 특정 요청에 대한 접근 권한을 제어할 수 있습니다. 또한, 클라이언트의 요청이 컨트롤러에 도달하기 전에 요청 및 응답 데이터를 가로채어 변형하거나 로깅하여 추적할 수도 있습니다. 이렇게 함으로써 인터셉터는 웹 애플리케이션의 전반적인 보안, 성능 및 기능에 대한 관리를 용이하게 할 수 있습니다.

대표적으로 세부적으로 적용해야 하는 인증이나 인가와 같은 작업은 특정 그룹의 사용자가 특정 기능을 사용하지 못하는 경우가 있으므로, 이러한 작업들은 컨트롤러로 요청이 넘어가기 전에 인터셉터에서 처리하는 것이 적합합니다. 인터셉터는 HttpServletRequest나 HttpServletResponse 등과 같은 객체를 제공받으므로 객체 자체를 조작할 수는 없지만, 해당 객체가 내부적으로 갖는 값은 조작할 수 있습니다. 이는 컨트롤러로 넘겨주기 위한 정보를 가공하기에 용이합니다. 예를 들어 JWT 토큰 정보를 파싱하여 컨트롤러에게 사용자의 정보를 제공하도록 가공할 수 있습니다. 또한, API 호출에 대한 정보를 기록해야 하는 상황에서 HttpServletRequest나 HttpServletResponse를 제공해주는 인터셉터는 클라이언트의 IP나 요청 정보를 기록하기에 용이합니다.

5. Spring AOP vs Spring Interceptor

“컨트롤러의 호출 과정에 적용되는 부가 기능은 핸들러 인터셉터를 사용하는 편이 낫다. 스프링 MVC의 컨트롤러는 타입이 하나로 정해져 있지 않고, 실행 메소드 또한 제각각이기 때문에 적용할 메소드를 선별하는 포인트컷 작성도 쉽지 않다. 게다가 파라미터나 리턴 값 또한 일정치 않다. 이러한 이유로 컨트롤러에 AOP를 적용하려면 꽤나 많은 수고가 필요하다. 반대로 스프링 MVC는 모든 종류의 컨트롤러에게 동일한 핸들러 인터셉터를 적용할 수 있게 해준다. 따라서 컨트롤러에 공통적으로 적용할 부가 기능이라면 핸들러 인터셉터를 이용하는 편이 낫다.”
출처 : 토비의 스프링

인터셉터 대신에 컨트롤러들에 적용할 부가기능을 어드바이스로 만들어 AOP를 적용할 수 있습니다. 하지만 컨트롤러는 타입과 실행 메소드가 모두 제각각이라 포인트컷(적용할 메서드 선별)의 작성이 어렵습니다. 컨트롤러는 또한 파라미터나 리턴 값이 일정하지 않습니다. 즉, 타입이 일정하지 않고 호출 패턴도 정해져 있지 않기 때문에 컨트롤러 AOP를 적용하려면 번거로운 부가 작업들이 생깁니다.

6. 적용

국민학교에서는 인터셉터를 이용하여 인증 • 인가 처리하기로 결정했습니다. 인증이 필요한 경우 커스텀 어노테이션인 @AuthenticatedMemberId를 이용하여 memberId를 바인딩하여 가져올 수 있도록 처리했습니다. 이는 컨트롤러에서 검증된 회원에 대해 다시 검증할 필요가 없어졌으며, 커스텀 어노테이션만 붙여주면 회원에 대한 정보인 memberId를 간단하게 전달 받을 수 있었습니다.

Controller.java

@PostMapping("/login")
  public SuccessResonse login(@RequestParam("kakaoAccessToken") String kakaoAccessToken) {
    AuthResponse authResponse = authService.login(kakaoAccessToken);
    return SuccessResonse.of(authResponse);
  }
  
  @GetMapping("/me")
  public SuccessResonse findMember(@AuthenticatedMemberId final Long memberId) {
    MemberInfoResponse memberInfoResponse = memberService.findMember(memberId);
    return SuccessResonse.of(memberInfoResponse);
  }

Interceptor.java

@Component
@AllArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {

  private final JwtTokenProvider jwtTokenProvider;

  @Override
  public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler){
    if (!(handler instanceof HandlerMethod)) {
      return true;
    }
    HandlerMethod handlerMethod = (HandlerMethod)handler;
    AuthenticatedMemberId AuthenticatedMemberId = handlerMethod.getMethodAnnotation(AuthenticatedMemberId.class);
    if (AuthenticatedMemberId == null) {
      return true;
    }

    final String token = AuthenticationExtractor.extractAccessToken(request);
    jwtTokenProvider.validateToken(token);

    return true;
  }

}

WebConfig.java
WebConfig에 WebMvcConfigurer을 구현하여 커스텀 인터셉터와 Argument Resolver를 등록합니다. Spring Argument Resolver는 요청이 컨트롤러에 도달했을 때, 요청을 기반으로 원하는 객체를 생성하는 역할을 수행합니다. 만약 컨트롤러 메서드의 파라미터 중 회원 인증 정보가 필요한 경우, 이를 위한 파라미터 바인딩을 지원하기 위해 등록합니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

  private final AuthenticationInterceptor authenticationInterceptor;
  private final JwtTokenProvider jwtTokenProvider;

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authenticationInterceptor)
        .addPathPatterns("/**")
        .excludePathPatterns("/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**")
        .excludePathPatterns("/api/v1/oauth/authorize", "/api/v1/sign-up", "/api/v1/login", "/api/v1/refresh")
        .excludePathPatterns("/test/**");
  }

  @Override
  public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
    resolvers.add(new AuthenticatedMemberResolver(jwtTokenProvider));
  }
}

AuthenticatedMemberResolver.java
ArgumentResolver가 실행되기를 원하는 파라미터 앞에 특정 어노테이션을 생성하여 추가합니다.

  • supportParameter : 요청받은 메서드의 파라미터에 특정 어노테이션이 붙어 있는지 확인하고, 해당 어노테이션이 있다면 true를 반환합니다.
  • resolverArgument : supportParameter 메소드에서 true를 반환한 경우, 파라미터를 원하는 형태로 정보를 바인딩하여 반환합니다.
@Component
@RequiredArgsConstructor
public class AuthenticatedMemberResolver implements HandlerMethodArgumentResolver {

  private final JwtTokenProvider jwtTokenProvider;

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

  @Override
  public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
      final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
    final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
    final String token = AuthenticationExtractor.extractAccessToken(Objects.requireNonNull(request));
    if (token == null) {
      throw new UnSupportedToken();
    }
    return Long.valueOf(jwtTokenProvider.getMemberId(token));
  }
}

결론

Filter를 이용한 방법은 Spring Context가 아닌 웹 어플리케이션에 직접 등록되어 스프링 IoC 컨테이너가 관리하는 빈에 직접 접근하지 못하기 때문에 선택하지 않았습니다. 반면에 AOP를 이용한 방식은 컨트롤러의 타입이 일정하지 않고 호출 패턴도 정해져 있지 않기 때문에 번거로운 부가 작업들이 생길 수 있습니다. 그래서 AOP보다는 Web Request 및 Response 처리에 특화된 인터셉터를 사용하여 구현하기로 결정했습니다.


참고 자료

profile
기록기록기록기록기록

0개의 댓글