[Spring] 컨트롤러 내 회원 인증 중복 코드 개선기

 

 아이돔 프로젝트 리팩 도중, 인증 관련 로직을 개선하고 싶어 졌습니다.

 

기존 코드의 문제점

 기존 아이돔의 인증 기능은 Spring Security에서 기본으로 제공하는 기능들로 구현되어 있었습니다.

 

*Controller.java

private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService;

// 회원 권한 필요
@GetMapping("/member")
public ResponseEntity<DefaultResponseDto<Object>> findOneMember(
        HttpServletRequest servletRequest
) {
    long loginMemberId = Long.parseLong(jwtTokenProvider.getUsername(servletRequest.getHeader(AUTHENTICATION_HEADER_NAME)));
    Member member = memberService.findById(loginMemberId);
}

 

 컨트롤러 진입 전 필터에서 인증에 대한 유효성을 체크합니다.

 

 유효하지 않다면, 필터에서 401 혹은 403 예외를 반환하고 컨트롤러는 진입에 실패합니다. 따라서 위의 컨트롤러 메서드를 진입하는 시점에서는 유효한 토큰을 가진 servletRequest 객체가 파라미터로 들어오게 됩니다.

 

 만약 컨트롤러에서 인증한 회원에 대한 정보가 필요하다면, 위와 같이 토큰을 통해 회원을 조회하는 로직이 또 필요합니다. 또한 컨트롤러는 memberService 뿐만 아니라 jwtTokenProvider에도 의존합니다.

 

 아이돔 대부분의 API는 회원에 대한 정보가 필요하며, 이는 컨트롤러에 수많은 중복된 코드를 유발합니다.

 

컨트롤러의 중복된 토큰 기반 유저 판별 로직을 개선하고 싶었고, 인증한 회원에 대한 정보를 컨트롤러 파라미터에 넣어줄 수 있는 방법은 없을까?라는 고민을 해결해 보도록 하겠습니다.

 

스프링에서 공통 로직 처리 : Filter, Interceptor, AOP

  1. Filter : 핸들러 동작의 전 후 과정에 부가 로직 처리, 웹 컨테이너(서블릿 컨테이너)에서 관리
  2. Spring Interceptor : 이하 비슷함, 스프링 컨테이너에서 관리
  3. Spring AOP : 메서드 동작 전 후 과정에서 부가 로직 처리

출처 : https://goddaehee.tistory.com/154

  • Interceptor와 FilterServlet 단위에서 실행 ↔ AOP메서드 앞에 Proxy 패턴의 형태로 실행
  • 실행 순서는 request → Filter → Interceptor → AOP → Interceptor → Filter → response 순

 

  1. 서버를 실행시켜 서블릿이 올라오는 동안에 init이 실행되고, 그 후에 Filter의 doFilter가 실행된다.
  2. 컨트롤러에 들어가기 전 Interceptor의 preHandler가 실행된다.
  3. 컨트롤러에서 나와 postHandler, after Completion, do Filter 순으로 진행이 된다.
  4. 서블릿 종료 시 destroy가 실행된다.

 

1. Filter를 이용하는 방법

출처 : https://gowoonsori.com/spring/architecture/

 

 스프링 기능이 아닌 자바 서블릿에서 제공하는 기능이다. 스프링에 들어온 요청이 DispatcherServlet에 의해 컨트롤러에 매핑되는데 Filter는 그 전, 후에 동작한다.

 

 서블릿 필터는 DispatcherServlet 이전에 실행이 되므로 필터가 동작하도록 지정된 자원의 앞단에서 요청 내용을 변경하거나, 여러 가지 체크를 수행할 수 있다. 또한 자원의 처리가 끝난 후 응답 내용에 대해서도 변경하는 처리를 할 수 있다.

 

 보통 web.xml에 등록하고, 일반적으로 인코딩 변환 처리, XSS 방어 등의 요청에 대한 처리로 사용된다. 요청이 DispatcherServlet에 전달되기 전에 헤더를 검사해 인증 토큰의 유효성을 검사할 수도 있다.

 

필터의 실행 메서드
init() : 필터가 생성될 때 수행되는 메서드
doFilter() : Request, Response가 필터를 거칠 때 수행되는 메서드
destroy() : 필터가 소멸될 때 수행되는 메서드
<filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>
        org.springframework.web.filter.CharacterEncodingFilter
    </filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
    <init-param>
        <param-name>forceEncoding</param-name>
        <param-value>true</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>encodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

 

2. Interceptor를 이용하는 방법

Intercept the execution of a handler.

 

 스프링의 기술로, DispatcherServlet과 Controller 사이에서 전, 후로 낚아챈다. 서버에 들어온 Request 객체를 컨트롤러의 핸들러로 도달하기 전에 낚아채서 부가적인 기능이 실행되게 끔 만들어준다.

 

인터셉터는 여러 개를 사용할 수 있고 로그인 체크, 권한 체크, 프로그램 실행 시간 계산 작업, 로그 확인 등에서 사용된다.

 

인터셉터의 실행 메서드
PreHandler() : 컨트롤러 메서드가 실행되기 전
postHandler() : 컨트롤러 메서드 실행 직후 view 페이지 렌더링 되기 전
afterCompletion() : view 페이지가 렌더링 되고 난 후
// 이런식으로 사용가능
public class AuthCheckInterceptor implements HandlerInterceptor {

    @Override
    public 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; // 로그인 상태 아님
    }
}

 

3. AOP를 이용하는 방법

 OOP를 보완하기 위해 나온 개념으로 관점 지향 프로그래밍으로 불린다. 객체 지향의 프로그래밍을 했을 때 중복을 줄일 수 없는 부분을 줄이기 위해 종단면(관점)에서 바라보고 처리한다.

 

 주로 ‘로깅’, ‘트랜잭션’, ‘에러 처리’ 등 비지니스 단의 메서드에서 조금 더 세밀하게 조정하고 싶을 때 사용한다. Interceptor와 Filter와 달리 메서드 전, 후의 지점에 자유롭게 설정이 가능하므로 비지니스 로직을 처리할 때 사용된다. Interceptor와 Filter는 주소로 대상을 구분해서 걸러내야 하는 반면, AOP는 주소, 파라미터, 애노테이션 등 다양한 방법으로 대상을 지정할 수 있다.

 

 AOP의 Advice와 HandlerInterceptor의 가장 큰 차이파라미터의 차이다. Advice의 경우 JoinPoint나 ProceedingJoinPoint 등을 활용해서 호출한다. 반면 HandlerInterceptor는 Filter와 유사하게 HttpServletRequest, HttpServletResponse를 파라미터로 사용한다.

AOP의 포인트컷
@Before : 대상 메서드의 수행 전
@After : 대상 메서드의 수행 후
@After-returning : 대상 메서드의 정상적인 수행 후
@After-throwing : 예외 발생 후
@Around : 대상 메서드의 수행 전/후

 

Servlet Filter VS Spring Interceptor

출처 :&nbsp;https://mangkyu.tistory.com/173

 

 필터는 스프링 이전의 서블릿 영역에서 관리되지만, 인터셉터는 스프링 영역에서 관리되는 영역이기 때문에 필터는 스프링이 처리해주는 내용들을 적용받을 수 없습니다. 대표적인 예시로, 필터는 스프링에 의한 예외 처리가 되지 않는다는 점이 있습니다.

 

 글의 마지막 부분에 기존에 필터를 사용했을 때와, 인터셉터로 변경했을 때의 예외 처리를 하는 예시를 확인하실 수 있습니다.

Spring Interceptor vs Spring AOP

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

 

아이돔의 새로운 인증 방식 : Interceptor와 Argument Resolver

*Controller.java

// 인증 불필요
@PostMapping("/verify")
public ResponseEntity<SuccessResponse<Object>> sendAuthenticationEmail(
        @RequestBody @Valid EmailSendRequest request) {
    // 로직
}

// 회원 권한 필요
@Auth
@GetMapping("/logout")
public ResponseEntity<SuccessResponse<Object>> logout(
	@AuthInfo AuthResponse auth
) {
	// 로직
}

// 관리자 권한 필요
@Auth(role = RoleType.ADMIN)
@PostMapping("/official/calendar")
public ResponseEntity<SuccessResponse<Object>> update(
    @RequestBody @Valid OfficialCalendarUpdateRequest request
) {
  // 로직
}

 

 개선된 아이돔에서는 servletRequest의 헤더 내 토큰에 대한 유효성 검사를 Interceptor에서 처리합니다.

 

 기존에는 API 별로 인증 및 권한의 필요 여부를 Spring Security가 기본으로 제공하는 antMatchers()의 URI를 통한 리소스 권한 체크 여부를 진행하였습니다. 그러나 변경된 아이돔은 기존의 SecurityConfig를 사용하지 않기 때문에 다른 기술이 필요했습니다. 이는 Meta Annotation을 사용해 @Auth라는 커스텀 어노테이션으로 인증, 권한 체크 여부를 판단합니다.

 

 인증과 권한에 대한 검증은 기존과 동일하게 컨트롤러 진입 전 실행됩니다. 그러나 기존과 달리 컨트롤러에서 회원 정보가 필요하다면, 파라미터에서 검증한 회원 인증 정보를 전달받도록 수정되었습니다. 이렇게 함으로써 검증된 회원에 대한 재검증이란 불필요한 책임을 컨트롤러에서 제거할 수 있습니다. 또한 어노테이션만 붙여주면 회원에 대한 필요한 정보를 가진 객체를 전달받을 수 있으므로 간단하게 구현할 수 있습니다.

 

컨트롤러 내 회원 인증 파라미터 바인딩을 구현하는 방법은 아래와 같습니다.

 

WebMvcConfig.java

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

	private final AuthInterceptor authInterceptor;
	private final JwtTokenUseCase jwtTokenUseCase;

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(authInterceptor)
			.addPathPatterns("/**")
			.excludePathPatterns("/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**")
			.excludePathPatterns("/api/v1/email/**", "/api/v1/signup", "/api/v1/signin", "/api/v1/members/me/password")
			.excludePathPatterns("/api/error", "/test/**");
	}

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

 

 WebMvcConfigurer은 Spring framework에서 제공하는 인터페이스로, 특정 스프링 클래스 구현 혹은 상속 없이 MVC 구성 정보를 제어할 수 있게 해 줍니다. WebMvcConfig 클래스에서 커스텀 Interceptor와 Argument Resolver를 등록합니다.

 

 Spring ArgumentResolver는 어떠한 요청이 컨트롤러에 들어왔을 때, 요청에 들어온 값으로부터 원하는 객체를 만들어내는 일을 간접적으로 해줄 수 있습니다. 컨트롤러 메서드의 파라미터 중 회원 인증 정보가 필요한 경우, 파라미터 바인딩을 위해 등록합니다.

 

AuthenticationPrincipalArgumentResolver.java

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

	private final JwtTokenUseCase jwtTokenUseCase;

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

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

		HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
		String token = AuthorizationExtractor.extractAccessToken(Objects.requireNonNull(request));
		if (token == null) {
			return new AuthResponse(null, null, null);
		}
		return jwtTokenUseCase.getParsedClaims(token);
	}
}

 

 Argument Resolver는 HandlerMethodArgumentResolver를 구현합니다.

 

boolean supportsParameter(MethodParameter parameter);

@Nullable
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                    NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;

 

 HandlerMethodArgumentResolver 인터페이스는 두 메서드를 구현하도록 명시하고 있습니다. ArgumentResolver가 실행되기를 원하는 Parameter 앞에 특정 어노테이션을 생성해 붙입니다.

  • supportsParameter : 요청받은 메서드의 인자에 원하는 어노테이션이 붙어있는지를 확인하며, 포함한다면 true를 반환합니다.
  • resolveArgument : supportsParameter에서 true를 받은 경우, 즉 특정 어노테이션이 붙어있는 어느 메서드가 있는 경우 parameter가 원하는 형태로 정보를 바인딩하여 반환하는 메서드입니다.

 

AuthInterceptor.java

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

		if (!(handler instanceof HandlerMethod)) {
			return true;
		}
		HandlerMethod handlerMethod = (HandlerMethod)handler;
		Auth auth = handlerMethod.getMethodAnnotation(Auth.class);
		if (auth == null) {
			return true;
		}

		if (notExistHeader(request)) {
			throw new UnAuthorizedAccessTokenException();
		}

		String token = AuthorizationExtractor.extractAccessToken(request);

		if (isInvalidToken(token)) {
			throw new UnAuthorizedAccessTokenException();
		}

		AuthResponse authInfo = jwtTokenUseCase.getParsedClaims(token);
		if (isAdminOnly(auth)) {
			if (isNotAdmin(authInfo.getRole())) {
				throw new AccessDeniedAdminException();
			}
		}
		return true;
	}

 

 인터셉터에서는 컨트롤러 진입 전에 헤더 내 토큰의 유효성을 검사하고 권한을 체크합니다.

 

 인터셉터의 preHandle 또는 postHandle에서 예외가 발생한다면, @ExceptionHandler로 핸들링할 수 있기 때문에 예외가 서블릿까지 전달되지 않고 처리될 수 있습니다. afterCompletion의 경우에는 @ExceptionHandler로 핸들링 할 수 없습니다.

 

 반면에 기존에 필터로 구현했을 때는 @ExceptionHandler로 예외를 핸들링할 수 없었습니다. 필터는 스프링 앞의 서블릿 영역에서 관리되기 때문에 스프링의 지원을 받을 수 없습니다. 따라서 필터에서 예외를 던지더라도, 처리되지 않고 서블릿까지 전달되게 됩니다. 서블릿은 예외가 핸들링 되기를 기대했지만, 예외가 그대로 올라왔기 때문에 예상치 못한 Exception을 마주한 상황으로 내부에 문제가 있다고 판단하여 500 응답을 반환합니다. 

 

CustomJwtAuthenticationFilter.java

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

    try {
        String token = jwtTokenProvider.resolveToken(request);

        // 토큰에 대한 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
    } catch (IllegalArgumentException e) {
        this.setExceptionResponse(response, UNAUTHORIZED_MEMBER);
    } catch (UsernameNotFoundException e) {
        this.setExceptionResponse(response, MEMBER_NOT_FOUND);
    }

    filterChain.doFilter(request, response);
}

private void setExceptionResponse(
        HttpServletResponse response,
        ExceptionCode exceptionCode
){
    ObjectMapper mapper = new ObjectMapper();
    response.setStatus(exceptionCode.getHttpStatus().value());
    response.setContentType("application/json");
    response.setCharacterEncoding("utf-8");

    ErrorResponse errorResponse = new ErrorResponse(exceptionCode.toString(), exceptionCode.getMessage());
    try {
        OutputStream os = response.getOutputStream();
        mapper.writeValue(os, errorResponse);
        os.flush();
    } catch (IOException e) {
        log.error("[THROWING] CustomJwtAuthenticationFilter | setExceptionResponse | throwing = {}", e.getMessage());
    }
}

@Data
private static class ErrorResponse{
    private final String responseCode;
    private final String responseMessage;
}

 

 따라서 Filter에서 발생하는 예외를 핸들링하기 위해서는, 예외 발생이 예상되는 Filter의 상위에 예외를 핸들링하는 Filter를 만들어서 Filter Chain에 추가해주는 방법이 있습니다. 위의 경우에는 해당 필터에서 응답 객체에 예외 처리를 해주는 방향으로 구현되어있습니다. 

 

 

참고 레퍼런스

https://mangkyu.tistory.com/173

https://willseungh0.tistory.com/84

https://goddaehee.tistory.com/154

https://memodayoungee.tistory.com/114

https://tecoble.techcourse.co.kr/post/2021-05-24-spring-interceptor/