본문 바로가기

프로젝트

[Spring] 어노테이션 기반 커스텀 인증 시스템 구현

서론

 소프티어 부트캠프에서 진행된 프로젝트에서는 Spring Security를 사용할 수 없어 직접 인증 시스템을 구현해야 했다. 인증 시스템의 본질은 사용자가 특정 자원에 접근하려고 할 때 해당 사용자가 접근 권한이 있는지 검사하는 것이다. "사전"에 검사한다는 측면에서, 스프링은 아래와 같은 다양한 방식으로 인증을 구현할 수 있다.

  1. Spring Interceptor
  2. Spring AOP
  3. Servlet Filter

 요구사항이 없다면 어떤 방식으로 구현하더라도 상관 없지만, 현재 팀 프로젝트는 ControllerAdvice을 이용해 서버에서 발생하는 모든 예외를 잡아 동일한 규격의 메시지를 반환하도록 설계되어 있으며, 인증 시스템 역시 ControllerAdvice의 예외처리에 의해 관리되게 구성하고 싶었기에 스프링에 의해 관리되지 않는 Servlet Filter 사용은 제외했다.

한편, 현재 ControllerAdvice 설정은 다음과 같다.

@RestControllerAdvice
public class GlobalExceptionHandler {
    @Autowired
    private MessageSource messageSource;

    @ExceptionHandler(MethodArgumentNotValidException.class) // 요청의 유효성 검사 실패 시
    @ResponseStatus(HttpStatus.BAD_REQUEST) // 400 Bad Request로 응답 반환
    public ResponseEntity<Map<String, String>> handleInValidRequestException(MethodArgumentNotValidException e) {
        // 에러가 발생한 객체 내 필드와 대응하는 에러 메시지를 map에 저장하여 반환
        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> {
            String fieldName = error.getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return ResponseEntity.badRequest().body(errors);
    }

    // TODO: messages.properties에 예외 메시지 커스터마이징할 수 있게 방법 찾아보기
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String,String> handleInValidRequestException(MethodArgumentTypeMismatchException e) {
        String code = e.getErrorCode();
        String fieldName = e.getName();
        Locale locale = LocaleContextHolder.getLocale(); // 현재 스레드의 로케일 정보를 가져온다.
        String errorMessage = messageSource.getMessage(code, null, locale); // 국제화 된 메시지를 가져온다.

        Map<String, String> error = new HashMap<>();
        error.put(fieldName, errorMessage);
        return error;
    }

    @ExceptionHandler({BaseException.class})
    public ResponseEntity<ErrorResponse> handleAllBaseException(BaseException e) {
        return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(ErrorResponse.from(e.getErrorCode()));
    }
}

 서버 측에서 발생하는 모든 예외는 BaseException 클래스를 상속하도록 구현하고 있다. BaseException 예외가 발생하면 Exception Handler에서 잡아 ErrorResponse 객체를 만들어 반환하게 된다.

Servlet Filter이 나쁘다는 것은 아니다. Servlet Filter은 Spring과 독립적인 기술이며, request / response을 조작할 수 있다는 장점이 있다. 만약 request / response 자체를 조작하거나 래핑해야 하는 경우가 있다면 Servlet Filter 기술이 가장 적합하다.

내가 Servlet Filter을 선택하지 않은 이유는 단지 예외 처리 방식을 ControllerAdvice로 일원화하기 위해서이지, Servlet Filter이 나쁘기 때문이 아니다. 인증 관련 예외 메시지 처리를 Filter 단에서 처리하게 만들면 기능을 동일하게 구현할 수 있다.

기술 선택 중 고려한 점

 Servlet Filter은 스프링 컨테이너보다는 서블릿 컨테이너에 의해 관리되는 객체다. Filter에서 발생하는 예외는 스프링 컨테이너에 의해 관리되는 ControllerAdvice을 거치지 않으므로 (정확히 말하면 스프링 컨테이너에 도달하기도 전에 예외가 발생해서 ControllerAdvice에 도달하지 못함 ) , 인증 기능을 Filter 단에 구현하면 ControllerAdvice에서의 예외 객체 구현 방식에 의존하고 있는 기존 시스템과 호환되지 않는다.

 정말 그럴까? 내가 알고 있는 사실과는 달리 @ControllerAdvice을 통해 예외를 핸들링할 수 있을지도 모른다는 의심을 걷어내기 위해 실제로 필터 수준에서 예외를 던질 때 ErrorResponse 객체로 변하지 않는지 테스트해봤다.

public class ThrowFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        throw new InternalServerException();
    }
}

 필터 정의는 반드시 예외를 던지는, 아주 간단한 로직으로 구현된다. InternalServerException은 BaseException을 상속하는 예외 타입으로 인터셉터를 이용하면 json 형식의 예외 메시지를 반환한다. 위 필터를 3가지 방식으로 등록해 테스트해보기로 했다.

  1. @Bean - FilterRegistrationBean으로 등록
  2. @Component + @ComponentScan으로 등록 ( 스프링에서 관리 )
  3. @WebFilter + @ServletComponentScan으로 등록 ( 서블릿 컨테이너에서 관리 )

1번 방식

    @Bean
    public FilterRegistrationBean<Filter> filterRegistrationBean() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new ThrowFilter());
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }

@ControllerAdvice에 의한 예외 변환을 거치지 않는다.

2번 방식

@Component
public class ThrowFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        throw new InternalServerException();
    }
}

동일하다.

3번 방식

@WebFilter
public class ThrowFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        throw new InternalServerException();
    }
}

 전혀 다르지 않은 결과가 나온다. 위 3가지 필터 등록 방식을 보면 알 수 있듯이, 어떤 방식을 사용해도 Filter에서 발생한 예외는 ControllerAdvice에 도달하지 않는다. 이런 동작은 당연한데, 필터는 컨트롤러에 요청을 전달하기 전의 위치에 있기 때문에 Spring 관련 기능과 하등 관계가 없다.

 이와 같은 측면에서 예외를 @ControllerAdvice에 의해 일관되게 처리하는 구조를 가져가기 위해서는 Spring에 의해 관리되는 기술인 Spring Interceptor 또는 Spring AOP를 이용해야 한다고 생각했다. 현재 나는 두 기술 중 Spring Interceptor을 선택해서 구현한 상태다.

 Spring Interceptor을 선택한 이유는 크게 2가지 정도가 있다.

  1. 인터셉터의 preHandle은 boolean 값을 리턴하여 다음으로 작업을 넘길지, 혹은 넘기지 않을지를 결정할 수 있다. 작업을 넘기면 true, 아니면 false을 반환하는 동작이 인증 시스템에서 인증 여부를 boolean 값으로 반환하는 구조와 멘탈 모델 측면에서 쉽게 매칭되는 것이 장점이라고 생각했다.
  2. 구현이 쉽다. 동일한 기능을 구현한다고 했을 때 Spring AOP에 비해 더 쉽게 구현할 수 있다. 동일한 기능을 더 쉽게 구현할 수 있다면, 추후 유지보수하기도 더 쉬울 것이라고 판단했다.

물론, 앞에서 언급했듯이 3가지 방법 중 어떤 것을 사용하더라도 전혀 상관없다. 각자의 프로젝트에서 각자 납득할만한 이유만 있으면 된다. 

인증 시스템 구현 방법 선택

인증 시스템에도 여러 구현 방법이 존재할 수 있다. 내가 생각했던 구현 방식은 대략 2가지다.

  1. 최신 Spring Security처럼 단일 위치에서 url 목록을 정의하고, 해당 url에 대한 인증 구현하기
  2. 인증 어노테이션을 만들고, 인증이 필요한 메서드 / 클래스 위에 붙여 인증 정보 검사하게 구현하기

 난 개인적으로 간단한 프로젝트에서 Spring Security를 사용할 때 컨트롤러 수준에서 각 API에 인증 필요 여부를 바로 알 수 없다는 점이 불편하게 느껴졌다.

 인증 경로를 한눈에 알아보기 쉽고 API와 인증 시스템이 독립적이라는 장점이 있기는 하나, 컨트롤러를 개발할 때 API에 대한 인증 정보가 직관적으로 표현되지 않는다는 단점도 존재한다. @RequestMapping으로 컨트롤러 수준에서 API 경로를 명시하는 것처럼, @Auth 어노테이션을 붙여 현재 경로에 인증 여부를 표현함으로써 개발 및 유지보수 단계에서 각 API 에 대한 인증을 직관적으로 받아들일 수 있도록 어노테이션 기반 인증 시스템을 채택했다. 

인증 시스템 구현

 java의 어노테이션은 단순히 메타데이터를 제공할 뿐, 어떤 추가적인 기능도 수행하지 않는다. 따라서 어노테이션을 붙인 대상에 대해 추가적인 작업이 필요하다면, 어노테이션을 인식하고 작업을 수행할 수 있는 별도의 객체가 요구된다. 이 작업을 Spring Interceptor에서 수행한다.

 Spring Interceptor은 preHandle에서 현재 요청을 처리하고 있는 핸들러를 handler 변수로 제공한다.

public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return true;
    }
}

handler은 정적 경로나 외부로의 redirect를 요청하는 등의 케이스를 제외하면 Controller에서 해당 경로를 다루는 메서드로, HandlerMethod 타입을 가진다. handler의 타입이 HandlerMethod인 경우 메서드 및 클래스 수준에 적용되어 있는 어노테이션을 확인, Auth를 찾는다. 이후부터는 Auth에 존재하는 정보를 활용하여 인증을 진행하면 된다.

if (!(handler instanceof HandlerMethod handlerMethod)) return true;

 우선 현재 요청이 API를 호출한 것인지 확인한다. Static 경로나 외부 URL Redirect 등의 상황에서는 HandlerMethod 대신 다른 handler 타입을 이용하는데, 이 경우는 검사하지 않아도 된다.

Auth authAnnotation = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Auth.class);
if (authAnnotation == null) {
    authAnnotation = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Auth.class);
}
// 어노테이션 없으면 인증 필요 없음
if (authAnnotation == null) return true;

인증 어노테이션이 적용되어 있는지 확인하고, 어노테이션을 가져온다. AnnotationUtils.findAnnotation을 이용하면 찾고 있는 어노테이션이 다른 어노테이션에 붙어 있는 경우에도 찾아온다. 이 방식을 선택한 이유는 아래에서 설명한다.

만약 Auth가 적용되지 않았다면 인증이 필요하지 않은 경로다.

Set<String> authRoleStringSet = new HashSet<>();

for (AuthRole role : authAnnotation.value()) {
    authRoleStringSet.add(role.name());
}

Auth 어노테이션으로부터 현재 경로에 적용된 Role 목록을 가져온다. 추후 사용자가 지정된 Role 중 하나를 가지고 있는지 확인하기 위한 목적이다. 

// 헤더 분석 과정
String authorizationHeader = request.getHeader("Authorization");

// 헤더가 없는 경우 => 인증 안됨
if (authorizationHeader == null) throw new AuthException(ErrorCode.UNAUTHORIZED);
String[] tokenInfo = authorizationHeader.split("\\s+");

// Bearer token 형식이 아님 => 인증 안됨
if (tokenInfo.length < 2 || !tokenInfo[0].equalsIgnoreCase("bearer"))
    throw new AuthException(ErrorCode.UNAUTHORIZED);

String token = tokenInfo[1];

현재 프로젝트에서는 JWT 토큰을 이용하고 있으므로, 헤더에서 토큰 정보를 가져온다.

try {
    var parsedToken = jwtManager.parseToken(token);
    // 현재 토큰의 역할이 Auth에 정의되어 있는지 검사
    String role = parsedToken.getPayload().get(JWTConst.ROLE, String.class);
    if (!authRoleStringSet.contains(role)) throw new AuthException(ErrorCode.UNAUTHORIZED);

    request.setAttribute(JWTConst.Token, parsedToken);
} catch (Exception e) {
    throw new AuthException(ErrorCode.UNAUTHORIZED);
}

토큰 정보를 파싱하고 사용자에게 적절한 Role이 있는지 검사한다. 파싱된 토큰 정보는 request 객체에 담아 차후 시스템에서 사용할 수 있게 한다.


코드 상에서 Auth 어노테이션에서 AuthRole을 꺼내오는 부분이 존재한다. 장기적으로 API에 대한 사용자 권한을 동적으로 지정하거나 확장할 수 있도록 권한을 추상화하여 AuthRole로 분리했다.  

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Auth {
    AuthRole[] value() default {};
}
public enum AuthRole {
    admin,
    event_user
}

장기적으로 확장되면 유저가 가지고 있는 역할이 다양해질 수 있다고 생각하여 각 인증 경로에 동적으로 Role을 지정할 수 있게 했다. 이를 통해 관리자 인증 / 이벤트 유저 인증 모두 동일한 @Auth 어노테이션으로 처리할 수 있다.


AnnotationUtils

Auth authAnnotation = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Auth.class);
if (authAnnotation == null) {
    authAnnotation = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Auth.class);
}
// 어노테이션 없으면 인증 필요 없음
if (authAnnotation == null) return true;

AnnotationUtils는 Spring이 제공하는 유틸 기능 중 하나로, 어노테이션 관련 작업을 편하게 처리할 수 있게 돕는 다양한 유틸 기능을 가지고 있다. 

findAnnotation 메서드는 대상 오브젝트에 적용된 어노테이션 및 메타 어노테이션을 탐색하여 내가 찾는 어노테이션이 있다면 반환한다. 현재 프로젝트에서는 메타 어노테이션(어노테이션에 붙어 있는 어노테이션)까지 탐색한다는 특성을 이용하기 위해 사용했다.

Swagger에서는 인증이 필요한 경로를 하나씩 지정할 때 SecurityRequirement 어노테이션을 이용한다.

@Target({ElementType.METHOD, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(SecurityRequirements.class)
@Inherited
public @interface SecurityRequirement {
    String name();

    String[] scopes() default {};
}

name 속성을 Swagger 설정의 addSecuritySchemes의 키값과 동일하게 지정하면 해당 키에 대한 인증으로 처리한다. 이를 통해 swagger 문서 상에서 다양한 키(어드민, 이벤트 유저)에 대한 인증을 활성화할 수 있다.

@Bean
public OpenAPI openAPI() {
    return new OpenAPI()
            .components(
                    new Components()
                            // 일반화 할 수 있는 방법?
                    .addSecuritySchemes(AuthConst.ADMIN_AUTH, createAPIKeyScheme())
                    .addSecuritySchemes(AuthConst.EVENT_USER_AUTH, createAPIKeyScheme())
            )
            .info(apiInfo());
}

Admin / Event User에 대한 인증을 다르게 처리 가능

이때 @Auth가 붙으면 인증이 필요하다는 의미이므로, 당연히 @SecurityRequirement을 함께 붙여야 한다. 인증이 필요한 모든 경로에 둘을 함께 붙여야 한다면 여러 부작용이 있을 수 있다.

@RestController
@SecurityRequirement(name = AuthConst.ADMIN_AUTH) @Auth({AuthRole.admin})
public class AdminCommentController {
	private final CommentService commentService;
}
  1.  개발자의 실수를 유발한다. 실수로 한 어노테이션을 누락하거나, 속성 값을 다르게 설정하여 swagger 인증이 제대로 처리되지 않는 등 휴먼 에러가 발생할 여지가 있다.
  2. 인증 API 전 영역에서 어노테이션과 관련된 코드의 중복이 발생한다. 이는 추후 속성 이름처럼 간단한 변경이 코드 상의 수많은 API 경로 상의 변경으로 전파되는 악영향을 줄 수 있다.

이러한 문제들은 두 인증 관련 어노테이션들을 하나로 묶어 관리할 수 있는 방법이 있다면 해결된다. 코드의 중복을 없애고 인증 관련 어노테이션을 보다 직관적으로 관리할 수 있도록 두 어노테이션을 인증 별로 묶어 관리하기로 했다.

@SecurityRequirement(name = AuthConst.ADMIN_AUTH)
@Auth({AuthRole.admin})
public @interface AdminAuth {
}

@SecurityRequirement(name = AuthConst.EVENT_USER_AUTH)
@Auth({AuthRole.event_user})
public @interface EventUserAuth {
}

이 경우 내가 만든 @Auth 어노테이션이 AdminAuth, EventUserAuth의 메타 어노테이션이 되므로, @Auth가 메타 어노테이션으로 존재하는지 탐색하는 로직을 AuthInterceptor에 포함해야 한다. 초기에는 해당 로직을 직접 구현하려고 했지만, Swagger이나 Spring은 이미 메타 어노테이션을 이용하고 있다는 점에서 내가 원하는 기능을 제공하고 있지 않는지 검색해봤다.

Swagger, Spring 은 거의 동일한 로직의 어노테이션 처리 로직을 제공하고 있었다. 나는 AuthInterceptor은 결국 스프링 관련 기술이므로, Spring이 제공하는 AnnotationUtils을 이용하여 구현했다.

이를 통해 매번 2개의 어노테이션을 모두 붙이는 대신, 단일 어노테이션으로 인증을 관리할 수 있었다.

@RestController
@AdminAuth
public class AdminCommentController {
	private final CommentService commentService;
}