스프링 시큐리티는 인증과 인가를 도와주는 프레임워크로 많은 사람들이 사용하고 있다.
하지만 시큐리티를 잘 알고 능동적으로 사용하는 사람이 있는 반면 잘 모르고 그냥 사람들이 사용하기 때문에 사용하는 사람들도 있을 것이다. (내가 그런 사람이었기 때문) 그렇다면 내가 왜 이 글을 쓰게 되었는지에 대해 말해보겠다.
이 글을 쓰게 된 계기
나는 프로젝트를 개발할 때마다 인증과 인가가 필요하면 일단 몸이 스프링 시큐리티를 사용하고 있었다. 하지만 이번 식견이라는 프로젝트를 할 때 같은 백엔드 팀원 한 명이 시큐리티 없이 인증과 인가를 구현하자고 하여 팀원이 시큐리티 없이 구현을 해주었다. 그 당시까지만 해도 나는 시큐리티 없이 인증과 인가를 구현하는 방법은 찾아보았지만 사용하는 이유를 생각하지는 못했다. 하지만 이번에 팀원의 블로그를 보고 난 뒤 나는 스프링 시큐리티에 대해 다시 생각해 보고 고민하게 되는 계기가 되었고 이 글을 쓰게 된 계기가 되었기도 한다. 이제 내가 처음 한 프로젝트인 쪼잉에 시큐리티 없이 인증과 인가를 직접 구현하면서 겪은 과정들과 여러 가지 어떤 방법을 사용하였는지에 대한 근거도 함께 풀어보겠다.
스프링 시큐리티를 쓰지 않아도 이유는 무엇일까?
먼저 스프링 시큐리티의 기능들을 보면 아래와 같이 다양한 기능을 제공한다.
우리는 여기서 시큐리티를 적용하면 많은 보안 관련 기능들을 쉽게 사용할 수 있고 비즈니스 로직에 집중할 수 있다.
하지만 쪼잉에서의 현재 상황을 정리하면 이렇다.
- JWT를 사용해 인증과 인가를 처리하기 때문에 세션 관리, CSRF 방어 등 세션 관련 보안 기능 등을 사용하지 않는다.
- JWT를 처리하기 위한 별도의 필터 구현
위처럼 인증과 인가를 편하게 관리하기 위해 시큐리티를 사용했는데 별도의 로직을 구현해야 하고, 또 시큐리티는 세션 기반이기 때문에 몇몇의 시큐리티의 기능들이 사용되지 않아 오버 엔지니어링이라고 생각한다. 그래서 나는 쪼잉에서 시큐리티를 없애고 인증과 인가를 구현하기로 했다. 적용하는 부분에서도 사용하지 않은 근거가 나올 것이니 끝까지 읽어주길 바란다.
시큐리티 없이 인증과 인가 구현하기
먼저 이번 쪼잉에서 인증과 인가를 구현하기 위해 해야 할 것들이다.
- AuthInterceptor 만들기
- SecurityContextHolder를 대신할 클래스 만들기
- Path 처리를 대신할 어노테이션 만들기
여기서 스프링 시큐리티와 다른 점은 인증과 인가를 처리하는 위치가 다르다.
위의 사진을 보면 시큐리티는 Filter에서 인증과 인증을 처리하지만 나는 Interceptor에서 처리할 것이다. 그렇다면 시큐리티는 왜 Filter에서 처리하고 나는 왜 Interceptor에서 처리할까??
시큐리티에서 Filter를 사용하는 이유
- Servlet 이전에 실행되기 때문에 Spring으로 넘어가기 전에 잘못된 요청을 차단하여 안정성이 증가한다.
- 다른 Spring과는 분리된 독립적인 프레임워크를 지향하기 때문에 Spring MVC에 속해있는 Intercepter를 사용할 수 없다.
내가 Interceptor를 사용하는 이유
- 원하는 곳에서만 작동할 수 있게 설정할 수 있어서 엔드포인트 관리가 편하다.
- Servlet 이후에 실행되기 때문에 더 상세하게 요청의 정보를 확인할 수 있다. (고수준의 인증과 인가 가능)
- Spring에 종속적이기 때문에 추후의 기능확장에 유리하고 관리하기가 편하다.
이런 이유로 나는 Interceptor에서 인증과 인가를 처리하기로 했다.
구현하면서 고민들이 생겼지만 먼저 완성한 코드를 보여주겠다.
@Configuration
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final JwtParser jwtParser;
private final AuthUpdater authUpdater;
private final AuthReader authReader;
private final UserReader userReader;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (handler instanceof HandlerMethod hm) {
if (hm.hasMethodAnnotation(LoginOrNot.class)) {
String bearer = request.getHeader(AUTHORIZATION);
if (!(bearer == null)) {
String jwt = BearerTokenExtractor.extract(bearer);
Long userId = jwtParser.getIdFromJwt(jwt);
User user = userReader.readUser(userId);
authUpdater.updateCurrentUser(user);
}
}
if (hm.hasMethodAnnotation(LoginRequired.class)) {
if (authReader.getCurrentUser() == null) {
throw new TokenNotExistException();
}
}
if (hm.hasMethodAnnotation(AdminOnly.class)) {
User currentUser = authReader.getCurrentUser();
shouldUserAdmin(currentUser);
}
}
return true;
}
private static void shouldUserAdmin(User currentUser) {
if (currentUser.getAuthority() != Authority.ADMIN) {
throw new UserIsNotAdminException();
}
}
}
이렇게 하면 나중에 Controller에서 어노테이션으로 Path를 관리할 수 있다. 아래는 Security Context Holder의 역할을 대신하는 AuthRepository이다.
@Repository
@RequestScope
public class AuthRepository {
private User currentUser = null;
public User getCurrentUser() {
if (currentUser == null) {
throw new UserNotLoginException();
}
return currentUser;
}
public User getNullableCurrentUser() {
return currentUser;
}
public void updateCurrentUser(User currentUser) {
this.currentUser = currentUser;
}
}
Spring을 멀티 쓰레드를 기반으로 하고 싱글턴으로 빈을 등록하기 때문에 여러 요청에서 존재할 수 있고 이전 요청의 남아 있을 수 있어 처리를 해줘야 하는데 이를 @RequestScope으로 해결할 수 있다. @ReqeustScope는 HTTP 요청을 단위로 빈을 자동으로 관리해 준다. 마지막으로 컨트롤러에서 어노테이션으로 Path를 설정해 주면 된다.
이렇게 시큐리티 없이 인증과 인가를 구현해 보았다. 이제는 내가 적용하면서 있었던 고민들과 선택을 얘기해볼까 한다.
첫 번째 고민과 선택
첫 번째 고민은 Security Context Holder를 대신할 클래스에서 사용할 기술을 선택할 때 @RequestScope를 사용할 것인지 자바의 LocalThread를 사용할 것인지 고민이 되었다.
먼저 둘 다 요청을 개별적으로 처리하게 도와준다. 하지만 LocalThread는 자바에서 제공하는 것이고 @RequestScope는 스프링에서 제공하는 것이다. 두 개의 특징을 정리하면 이렇다.
- @RequestScope는 스프링 빈으로 등록해야 하는 과정이 필요해 LocalThread보다 성능이 느릴 수 있다.
- @RequestScope는 요청이 끝나면 자동으로 스코프에 있는 빈이 정리되지만 LocalThread는 요청이 아닌 쓰레드를 단위로 하기 때문에 요청이 끝나도 메모리가 정리되지 않아 직접 정리해줘야 한다.
스프링 시큐리티는 다른 스프링의 기술을 사용하지 않는 독립적인 프레임워크를 지향하고 성능의 이점을 잡기 위해 LocalThread를 사용하였다. 나는 스프링에서 자동으로 관리해 주고 스프링에 맞는 라이프 사이클을 가진 @RequestScope를 사용하려 했지만 성능에서 차이가 많이 날까 생각이 들어서 둘 다 사용해 보고 결정하기로 하였다. (위에서 @RequestScope 코드를 보았으니 LocalThread 코드만 보여주겠다)
public class AuthContextHolder {
private static final ThreadLocal<User> context = new ThreadLocal<>();
public static User getCurrentUser() {
return context.get();
}
public static void addCurrentUser(User user) {
context.set(user);
}
public static void clear() {
context.remove();
}
}
이렇게 ThreadLocal을 선언하고 AuthReader를 통해 사용하면 된다. 하지만 ThreadLocal은 요청이 아닌 쓰레드를 라이프사이클로 갖기 때문에 요청이 끝났을 때 메모리를 직접 정리해주어야 한다. 이는 AuthInterceptor에서 postHandle라는 요청을 처리하고 난 후 작동하는 메소드로 처리 가능하다.
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) {
AuthContextHolder.clear();
}
두 방법의 코드를 작성한 후 포스트맨으로 테스트해 보았을 때 결과는 아래와 같다.
보다 정확하게 확인하기 위해 여러 번 반복해서 요청을 보내 평균을 비교했을 때도 눈에 띌 정도로 차이가 나지 않아서 스프링에 종속적이라 관리하기 편한 @ReqeustScope를 사용하기로 선택했다.
두 번째 고민과 선택
두 번째 고민은 로그인한 유저의 정보를 가져오는 방법에서 발생했다. 첫 번째는 @RequestScope를 통해 관리되고 있는 유저를 AuthReader를 통해 로직에서 받아오는 방법이 있고, ArgumentResolver의 구현체를 만들어서 Controller의 메소드의 파라미터에서 받아오는 방법이 있었다. ArgumentResolver는 Controller의 실행 직전에 실행되어 요청의 파라미터 값들을 우리가 원하는 값으로 바인딩해 주는 역할을 한다. 우리가 자주 사용하는 @RequestBody, @RequestParam 등이 있다. ArguementResolver를 사용하려는 이유는 로직에서 유저를 불러오는 코드가 Controller에서 반복되기 때문에 이를 없애기 위해서다. 또 ArgumentResolver를 사용하면 Interceptor에서는 토큰 검증만 해주면 된다. 구현을 해보면 아래와 같다. ( 마찬가지로 @RequestScope는 위에서 보았으니 ArgumentResolver 코드만 보겠다)
@Component
@RequiredArgsConstructor
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {
private final JwtParser jwtParser;
private final UserReader userReader;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return (parameter.hasParameterAnnotation(Auth.class) || parameter.hasParameterAnnotation(OptionalAuth.class))
&& parameter.getParameterType().equals(User.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
String authorization = webRequest.getNativeRequest(HttpServletRequest.class).getHeader(AUTHORIZATION);
if (parameter.hasParameterAnnotation(OptionalAuth.class)) {
if (authorization == null) {
return null;
}
return getUser(authorization);
}
return getUser(authorization);
}
private User getUser(String authorization) {
String bearer = BearerTokenExtractor.extract(authorization);
Long userId = jwtParser.getIdFromJwt(bearer);
return userReader.readUser(userId);
}
}
이렇게 하면 Controller가 실행되기 전에 ArgumentResolver가 Header로 들어온 Token을 User 객체에 바인딩 해주어 파라미터에서 User를 받을 수 있게 된다.
이렇게 Controller에서도 잘 받아서 사용할 수 있다. 하지만 이렇게 만들고 보니 User 도메인이 프레젠테이션 레이어에 노출되는 현상이 발생했다. 이는 바인딩할 객체를 DTO로 만들어 Id만 받아서 조회하도록 만들면 해결할 수 있는데 그러면 로직에서 User를 찾는 중복 코드가 또 발생하게 된다. 즉 우리가 해결하려 한 문제를 해결하지 못한 셈이다. 또 한 가지 문제는 클라이언트 입장에서 봤을 때 PostRequest만 넘기는 것이 아닌 User 객체도 같이 넘겨야 하나?라는 오해가 생길 수 있기 때문에 나는 @RequestScope를 사용한 방법으로 하기로 선택했다. 전체 코드를 보고 싶으면 아래 깃허브에서 확인할 수 있다.
https://github.com/WOONGEYA/JJoing-Backend
이번에 하면서 느낀 점
이번에 스프링 시큐리티 없이 직접 인증과 인가를 구현해 보면서 시큐리티의 기능과 시큐리티가 인증과 인가를 어떻게 처리하는지에도 알게 되었고, 무엇보다 구현하면서 발생한 문제들을 해결하면서 얻은 게 많다고 생각한다. 또 여러 개의 방법이 존재할 때 글을 찾아서 장단점을 비교해 내 상황에 맞는 방법을 선택하는 것도 좋지만 빠르게 있는 방법들을 직접 코드에 적용해 보면서 장단점들을 찾아 어떤 게 현재 상황에 맞는지 고민하고 선택하는 것이 성장에 더 많은 도움이 되는 것 같다. 또 무엇보다 이렇게 생각할 수 있게 도와주고 마인드를 고쳐준 식견 팀원이자 나의 친구에게 너무나도 고맙다. 앞으로도 다양한 기술과 방법들을 적용해 보면서 내 프로젝트에 맞는 기술들을 적용할 예정이다. 이 글을 읽고 의문이 든 부분이 있거나 의견이 있다면 부담 갖지 말고 댓글을 써주길 바란다.