SecurityContextHolder 동작원리 이해하기

리리·2024년 9월 14일
0

글 작성 배경

나를 포함한 소마 15기 연수생 4명과 함께 6월부터 토비의 스프링 스터디를 진행하고 있다. 스프링 스터디이다보니 책 내용 이외에도 스프링과 관련한 이런 저런 이야기를 많이 나누게 된다. 얼마 전에는 스프링 시큐리티의 SecurityContextHolder의 동작 원리와 관련해 이야기를 나눌 기회가 있었는데, 관련 코드를 작성해본 경험이 있음에도 내부의 동작 방식에 무지했던 것이 부끄러워 뒤늦게라도 공부한 내용을 기록해보고자 한다.

기존에 가지고 있던 의문: SecurityContext의 전역적 사용

이전에 인증 로직을 구현하면서 SecurityContextHolder의 동작에 대해 가지고 있던 궁금증이 있다. jwt 토큰을 이용해 인증객체를 생성한 뒤 이 인증객체를 전역적으로 사용하기 위해 SecurityContextHolder의 컨텍스트에 인증객체를 저장하는데, 이때 전역적으로 사용한다는게 어떤 의미인지 잘 와닿지가 않았던 것이다.

아래 코드는 현재 진행중인 프로젝트의 코드로, jwt 토큰을 이용해 API 요청을 보낼 때마다 SecurityContextHolder에 인증객체를 저장하게끔 구현되어 있다. 이렇게 매 요청마다 인증객체를 저장할거라면, SecurityContextHolder가 왜 필요한걸까?

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtVerifier jwtVerifier;
    private final JwtService jwtService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        List<RequestMatcher> matchers = new ArrayList<>();
        matchers.add(new AntPathRequestMatcher("/customer/product/**"));
        matchers.add(new AntPathRequestMatcher("/login/**"));
        matchers.add(new AntPathRequestMatcher("/reissue"));

        return matchers.stream()
            .anyMatch((matcher -> matcher.matches(request)));
    }

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

        try {
            String accessToken = request.getHeader("Authorization");

            if (accessToken == null || !accessToken.startsWith("Bearer ")) {
                throw new JwtTokenException(JwtErrorCode.INVALID_TOKEN);
            }

            accessToken = accessToken.substring(7);
            if (jwtVerifier.isAccessTokenValid(accessToken)) {
                Authentication auth = jwtService.getAuthentication(accessToken);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch (ExpiredJwtException e) {
            throw new JwtTokenException(JwtErrorCode.EXPIRED_TOKEN);
        } catch (JwtException e) {
            throw new JwtException(e.getMessage(), e);
        }
        filterChain.doFilter(request, response);
    }
}

전역적으로 사용한다는 의미

SecurityContextHolder를 전역적으로 사용가능하다는 것은 하나의 API 요청에서 그렇다는 의미이다. 한 요청에서 여러 보안 체크를 하면서 시큐리티 필터 체인이 동작하게 되는데, 이때 SecurityContextHolder에 저장된 인증 정보를 전역적으로 참조한다는 의미인 것이다. 그리고 stateless한 jwt 인증 방식에서는 매 요청마다 토큰을 검증하고 인증객체를 생성하는 코드가 반복되는 것이 자연스럽다.

또 다른 의문: Spring Security는 사용자를 어떻게 구분할까?

SecurityContextHolder는 인증이 완료된 유저의 정보를 저장하는 저장소의 역할을 한다. 스프링 시큐리티는 이 저장소에 인증객체가 어떻게 저장되는지에는 관심이 없으며, 인증객체가 담겨져 있다면 인증된 사용자로 간주하고 사용한다.


사용자가 인증되었음을 나타내기 위해서 우리는 SecurityContextHolder를 직접 설정하게 된다. 이때 SecurityContextHolder는 new를 통해 매번 새로운 인스턴스가 생성되는 것이 아님을 확인할 수 있다.
그런데 그렇다면, 매번 다른 유저가 API 요청을 동시다발적으로 보내게 될 텐데 SecurityContextHolder에 저장된 인증객체도 매번 새로운 값으로 덮어씌워지는 건 아닐까? 하나의 유저 당 하나의 SecurityContextHolder를 가진다는 건 어떻게 보장할 수 있는걸까?

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        
			accessToken = accessToken.substring(7);
            if (jwtVerifier.isAccessTokenValid(accessToken)) {
                Authentication auth = jwtService.getAuthentication(accessToken);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
     }
}  

public class JwtService {
   public Authentication getAuthentication(String token) {
          String memberId = String.valueOf(jwtProvider.getMemberIdFromToken(token));
          CustomUserDetails userDetails = customUserDetailsService.loadUserByUsername(memberId);

          return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
      }
}      

ThreadLocal을 통한 동시성 보장

SecurityContextHolder의 내부 구현 코드를 확인해보자. initializeStrategy() 메소드를 보면 개발자가 특별하게 전략을 설정하지 않는 경우에는 기본적으로 MODE_THREADLOCAL이 설정됨을 알 수 있다. 즉, SecurityContextHolder가 내부적으로 사용하는 SecurityContext는 ThreadLocal에 저장된다.
따라서 여러 사용자가 동시다발적으로 API 요청을 보내더라도 각 요청마다 서로 다른 스레드가 할당되고 스레드마다 독립적인 SecurityContext를 갖게 되기 때문에 유저간 인증객체는 서로 간섭받지 않는다는 걸 보장할 수 있게 되는 것이다.

public class SecurityContextHolder {
    public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
    public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
    public static final String MODE_GLOBAL = "MODE_GLOBAL";
    private static final String MODE_PRE_INITIALIZED = "MODE_PRE_INITIALIZED";
    public static final String SYSTEM_PROPERTY = "spring.security.strategy";
    private static String strategyName = System.getProperty("spring.security.strategy");
    private static SecurityContextHolderStrategy strategy;
    private static int initializeCount = 0;

    public SecurityContextHolder() {
    }

    private static void initialize() {
        initializeStrategy();
        ++initializeCount;
    }

    private static void initializeStrategy() {
        if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
            Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
        } else {
            if (!StringUtils.hasText(strategyName)) {
                strategyName = "MODE_THREADLOCAL";
            }

            if (strategyName.equals("MODE_THREADLOCAL")) {
                strategy = new ThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
                strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_GLOBAL")) {
                strategy = new GlobalSecurityContextHolderStrategy();
            } else {
                try {
                    Class<?> clazz = Class.forName(strategyName);
                    Constructor<?> customStrategy = clazz.getConstructor();
                    strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
                } catch (Exception var2) {
                    Exception ex = var2;
                    ReflectionUtils.handleReflectionException(ex);
                }

            }
        }
    }
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
  private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal();
}

ThreadLocal이 아닌 다른 전략을 사용하는 경우는 언제일까?

기본적으로 ThreadLocal 전략이 설정되었기 때문에 사용자마다 독립적인 SecurityContext를 가지며, 실제로 그렇게 동작하는 것이 합리적이라는 생각이 든다. 그런데 위 코드를 살펴보다 보면 MODE_INHERITABLETHREADLOCAL, MODE_GLOBAL과 같은 다른 전략을 선택할 수도 있음을 확인할 수 있는데 이것들은 무엇이고 언제, 왜 사용하게 되는건지 궁금해졌다.

  1. MODE_INHERITABLETHREADLOCAL
  • 부모-자식 스레드 간의 SecurityContext를 상속할 필요가 있을 때 사용한다.
  • 기본적으로 ThreadLocal 전략에서는 부모-자식 스레드 간에 SecurityContext를 공유하지 않는다. 하지만 해당 전략을 사용하게 되면 자식 스레드가 부모 스레드의 SecurityContext를 상속받기 때문에 자식 스레드에서도 인증이 유지된다.
  • 구체적으로 어떤 경우에 이런 필요성이 대두되는지는 아직 파악하지 못했다.

  1. MODE_GLOBAL
  • 내가 처음 이해했던 것처럼 하나의 SecurityContext를 모든 스레드가 공유하게끔 설정할 때 해당 전략을 사용한다.
  • ThreadLocal 전략에서는 각 스레드마다 독립적인 SecurityContext를 관리하지만, 이 전략에서는 모든 스레드가 동일한 SecurityContext를 공유하도록 보장하므로 이름 그대로 전역적인 모드를 지원하는 것이다.
  • 실제 배포 환경에서 해당 전략을 사용할 가능성은 0에 수렴할 것 같지만 아마 특수한 테스트 환경 등에서 전역적인 사용이 필요한 경우도 있지 않을까 생각해본다.

ThreadLocal 사용 시 주의사항

*김영한님의 스프링 핵심 원리 - 고급편 참고

ThreadLocal을 스레드 풀과 함께 사용할 때 스레드 재사용과 관련해 주의해야 할 점이 있다고 한다. 이를 그림으로 이해해보자.

  1. 사용자 A의 요청

  2. 사용자 A의 요청 종료

    WAS는 사용이 끝난 ThreadA를 스레드 풀에 반환한다. 이는 스레드 생성 비용이 비싸기 때문에, 스레드를 나중에 재사용하기 위함이다. 따라서 ThreadA는 스레드 풀에 계속해서 살아있게 되고, ThreadA 전용 보관소에 userA에 대한 정보도 함꼐 살아있게 된다.

  3. 사용자 B의 요청

    만약 ThreadA가 재할당되면, ThreadA가 스레드 로컬 조회 시 사용자 A에 대한 저장소에서 사용자 A에 대한 정보가 반환되는 심각한 문제가 발생할 수 있다.


이런 문제를 해결하기 위해서 사용자 A의 요청이 끝나는 시점에 스레드 로컬의 값을 ThreadLocal.remove()를 통해 꼭 제거해주어야 한다고 한다.

내 코드에도 적용해야 할까?

내가 작성한 JwtAuthenticationFilter 클래스는 OncePerRequestFilter를 상속받고 있다. 매 요청마다 해당 필터가 동작하면서 인증객체를 생성하고, ThreadLocal 매커니즘으로 동작하는 SecurityContext에 이 인증객체를 저장하고 있다. 그럼 위에서 살펴본 ThreadLocal 사용 시 발생할 수 있는 문제점을 예방하기 위해 OncePerRequest 필터에서 스레드 로컬을 비워주는 코드를 작성해야 하는 건 아닐까?

하지만 스프링 시큐리티는 기본적으로 요청이 끝난 뒤에 SecurityContext를 자동으로 정리해준다고 한다. 따라서 명시적으로 코드를 작성할 필요는 없어보인다.

마무리

평소에 코드를 작성하면서 조금이라도 의문이 들거나 궁금한 점이 생기면 그걸 이해하고 넘어가려고 습관을 들여야겠다는 생각이 든다. 이번에 글을 작성하며 작은 의문 하나 하나를 해소하는 과정에서 생각지도 못했던 단어, 개념이 등장하고 그걸 공부하다보니 조금씩 지식이 확장되는 즐거운 경험을 했던 것 같다 ✨

0개의 댓글