Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해

2025. 4. 30. 21:12·서버
※ 시리즈 글
Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해 [현재 글]
Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리
Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL

도입: 왜 직접 구현했는가?

안녕하세요! 제 글을 찾아주셔서 감사합니다.

 

저는 프로젝트를 진행할 때, 인증과 인가를 직접 구현하는 것을 선호해왔습니다. 주로 작은 규모의 프로젝트였기에 간단하게 Interceptor를 사용해서 처리했죠. 그러던 중, Spring Security는 인증과 인가를 Filter를 통해 처리한다는 점을 알게 되었고, 처음에는 사용법만 간단하게 익혀보려 했습니다. 하지만 이 프레임워크의 정교한 아키텍처에 매력을 느끼게 되었습니다.

  • 요청은 FilterChainProxy를 거치고, 해당되는 SecurityFilterChain의 필터들이 순차적으로 실행된다.
  • SecurityContextHolder는 인증 정보를 ThreadLocal로 관리해 각 요청마다 독립적인 인증 정보를 보장한다.
  • Lambda DSL을 사용해 보안 설정을 선언적으로 구성할 수 있다.

이 밖에도 여러가지 이유들로, Spring Security의 아키텍처에 흥미를 느끼게 되었고, 직접 구현해보면서 이해해보았습니다.

 

이 시리즈는 Spring Security의 아키텍처를 간단히 이해해보고, 아래 레포지토리의 브랜치들을 순서대로 따라가며 글과 함께 구현 과정을 설명합니다.

 

GitHub - alstn113/spring-security-impl: Spring Security, 직접 만들면서 이해해보자!

Spring Security, 직접 만들면서 이해해보자! Contribute to alstn113/spring-security-impl development by creating an account on GitHub.

github.com

Spring Security의 개요 및 구조

Spring Security를 직접 구현하기에 앞서, Spring Security를 간단하게 알아보겠습니다. Spring Security는 인증, 인가 및 일반적인 공격으로부터 보호 기능을 제공하는 프레임워크입니다. Servlet과 Reactive 애플리케이션에 대해서 모두 지원하고, 이 글에서는 Servlet 애플리케이션에 대해서 다루겠습니다.

인증과 인가

웹 애플리케이션 보안에서 가장 기본적인 개념은 인증(Authenticatino)과 인가(Authorization)입니다.

인증은 누구인지 확인하는 과정입니다. 예를 들어, 사용자가 로그인할 때 입력하는 아이디와 비밀번호가 실제로 그 사용자의 것인지, 혹은 요청에 포함된 JWT 토큰이 유효한지를 검증하는 것이 인증입니다. 

인가는 인증이 완료된 사용자에게 특정 자원에 대한 접근 권한이 있는지를 판단하는 과정입니다. 즉, 인증이 "누구인지"를 확인하는 것이라면, 인가는 "무엇을 할 수 있는지"를 결정하는 단계입니다. 예를 들어, 어떤 사용자가 관리자인지, 일반 유저인지에 따라 특정 페이지에 접근할 수 있는지 여부가 달라집니다.

왜 Filter 기반인가?

Spring Security는 위에서 언급했던 것처럼 Filter 기반으로 작동합니다. 잠깐 요청과 응답의 과정을 확인해보겠습니다.

요청과 응답 과정

Filter는 Servlet Container에서 가장 먼저 요청을 가로챌 수 있기 때문에, 인증되지 않거나 인가되지 않은 요청을 애초에 애플리케이션 로직에 도달하기 전에 차단할 수 있습니다. 또한 Servlet API 레벨에서 구현되었기 때문에 Spring MVC가 아닌 환경에서도 동일하게 보안을 처리할 수 있습니다. 반면에, Interceptor에서 보안을 처리하는 경우 DispatcherServlet이 호출된 후에야 접근을 감지할 수 있기 때문에, 불필요한 리소스 낭비가 발생할 수 있습니다.

FilterChainProxy

Spring Security에는 FilterChainProxy라는 핵심 컴포넌트가 있습니다. 이것은 모든 HTTP 요청이 거치는 보안 필터 체인의 진입점 역할을 합니다. Spring Security는 인증, 인가, 예외 처리 등 다양한 보안 로직을 각각의 Servlet Filter로 분리하여 처리합니다. 이 필터들을 한데 모아 효과적으로 관리하고 실행 순서를 제어하는 것이 FilterChainProxy의 역할입니다. 

https://docs.spring.io/spring-security/reference/servlet/architecture.html

위의 그림과 같이, FilterChainProxy는 내부에 여러 개의 SecurityFilterChain을 가질 수 있으며, 요청 URL에 따라 적절한 SecurityFilterChain을 선택하여, 해당 요청에 필요한 보안 필터만 순차적으로 실행합니다. 예를 들어, "/api/**" 경로에는 JWT 인증 필터를 사용하고, "/admin/**" 경로에는 JWT 인증 필터 + IP 제한 필터를 사용할 수 있습니다.

핵심 필터 소개 및 동작 흐름

Spring Securiy에는 다양한 필터들이 존재합니다. 이 중 핵심 필터인 AbstractAuthenticationProcessingFilter, AuthorizaitonFilter, ExceptionTranslationFilter, SecurityContextHolderFilter가 있습니다. 이것들을 알아보기에 앞서 SecurityContextHolder에 대해서 알아보겠습니다.

SecurityContextHolder

Spring Security에서 인증(Authentication) 정보를 요청 흐름 속에서 안전하게 보관하고 공유하기 위해서 사용하는 핵심적인 클래스가 SecurityContextHolder입니다. 이 클래스는 인증 정보를 담고 있는 SecurityContext 객체를 내부적으로 관리합니다. 그리고 이 SecurityConext 안에는 현재 사용자의 인증 상태를 나타내는 Authentication 객체가 들어 있습니다. 

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

SecurityContextHolder는 기본적으로 ThreadLocal을 사용해 인증 정보를 저장합니다. 서블릿 기반 웹 애플리케이션에서는 하나의 HTTP 요청마다 하나의 스레드가 할당되고, 요청이 처리되는 동안 해당 스레드는 유지됩니다. 요청이 끝나면 스레드는 스레드 풀로 반환됩니다. 이러한 구조 덕분에 ThreadLocal을 통해 관리되는 SecurityContext는 각 요청에 대해 안전하고 인증 정보를 유지하고 공유할 수 있습니다. 

Authentication에서 Principal은 "누구인가"에 대한 정보입니다. 보통 사용자 객체 또는 사용자 식별자 값이 들어갑니다. Credentials는 "증명 수단"입니다. 즉, 비밀번호와 같은 민감한 인증 정보입니다. 인증 전에는 입력한 비밀번호가 들어가고, 인증 후에는 보안상의 이유로 null 처리됩니다. Authorities는 "무엇을 할 수 있는가"에 대한 정보입니다. 즉, 사용자가 가진 권한(Role, Permission) 목록이 들어갑니다.

AbstractAuthenticationProcessingFilter

Spring Security에서 인증(Authentication) 처리는 주로 추상 클래스인 AbstractAuthenticationProcessingFilter가 담당합니다. 이 클래스는 다양한 인증 방식을 처리할 수 있도록 설계된 필터들의 기반 클래스 역할을 합니다. 

Form Login을 설정하면, Spring Security는 자동으로 AbstractAuthenticationProcessingFilter의 구현체인 UsernamePasswordAuthenticationFilter등을 등록하며, 이 필터는 사용자 이름과 비밀번호를 이용한 인증을 처리합니다. 인증 흐름에 따라 구체적인 과정을 설명하겠습니다.

인증 과정

  1. 클라이언트가 POST "/login"로 서버에 요청을 한다.
  2. UsernamePasswordAuthenticationFilter는 POST "/login"으로 매칭되는 것을 확인하고 필터 흐름을 진행한다.
  3. attemptAuthentication 메서드를 실행하고, Authentication 인터페이스를 구현한 UsernamePasswordAuthenticationToken에 사용자 이름과 비밀번호를 담는다. 이 때 Authentication은 아직 unauthenticated 상태(isAuthenticated() = false)이다.
  4. 3번에서 생성된 Authentication을 AuthenticationManager(인터페이스)의 authenticate() 메서드를 전달하여 인증을 수행한다.
  5. AuthenticationManager를 구현한 ProviderManager는 다양한 AuthenticationProvider(인터페이스)들을 주입받는다. AuthenticationProvider는 supoorts와 authenticate 메서드를 가지고 있습니다. supports는 해당 AuthenticationProvider가 Authention을 처리할 수 있는지 여부이다.
  6. ProviderManager에서 반복문을 돌며 UsernamePasswordAuthenticationToken을 지원(supports)하는 AuthenticationProvider를 찾는다. DaoAuthenticationProvider가 해당되고, authenticate() 메서드가 실행된다.
  7. DaoAuthenticationProvider 내부에서 UserDetailsService(인터페이스)의 loadUserByUsername()를 실행한다. 여기서는 사용자 이름과 비밀번호가 일치하는지를 확인한다.
  8. 결과로 얻은 UserDetails를 Authentication에 저장한다. 이 때, Authentication은 authenticated 상태(isAuthenticated() = true)이다.
  9. AbstractAuthenticationProcessingFilter로 돌아가서, 인증이 성공할 경우 SecurityContext에 Authentication을 저장한다.

위의 예제는 사용자 이름과 비밀번호로 로그인하는 과정이었습니다. Authentication과 그것을 지원하는 AuthenticationProvider를 직접 구현하여, 다양한 방식의 인증을 쉽게 확장할 수 있습니다. 또는 Spring Security에서 제공하는 기본 구현체들을 활용할 수도 있습니다.

AuthorizationFilter

AuthorizationFilter는 Spring Security의 인가(Authorization)를 담당하는 필터입니다. 이 필터는 사용자의 인증(Authentication)이 완료된 후, 요청한 자원에 접근할 권한이 있는지를 검사합니다. 헷갈리기 쉬운 부분은 "인증 여부"를 검사하는 곳은 인증 관련 필터가 아니라 AuthorizationFilter라는 것입니다. 즉, permitAll, authenticated, hasAuthority 등과 같은 접근 제어 조건은 모두 이 필터를 통해 판단됩니다. Spring Security 사용자는 각 경로에 대해 다음과 같은 인가 정책을 설정할 수 있습니다.

경로라고 적었지만 HttpServletRequest로 할 수 있는 것은 다 가능하다. 예를 들어, Header로 매칭하는 것이 가능하다.
  • permitAll: 누구나 접근 허용(인증 불필요)
  • authenticated: 인증된 사용자만 접근 허용
  • hasAuthority, hasRole: 특정 권한 또는 역할을 가진 사용자만 접근 허용

다음 조건 하에서, 어떻게 AuthorizationFilter가 동작하는지 알아보겠습니다.

  • 현재 사용자는 "ADMIN" 권한(Authority)를 가지고 있다.
  • FilterChainProxy에는 하나의 SecurityFilterChain이 있다. 기본 값은 "/**"이고 전체 경로를 의미한다.
    • "/api/**" 경로는 인증된 사용자만 접근 허용된다.
    • "/admin/**" 경로는 "ADMIN" 권한이 있는 사용자만 접근 허용된다.
    • "/**" 경로는 누구나 접근 허용한다.
  • 사용자는 "/admin/posts" 경로로 요청한다.

AuthorizationFilter가 동작하는 흐름을 알아보겠습니다.

인가 과정

  1. 클라이언트가 "/admin/posts"로 서버에 요청한다.
  2. AuthorizationFilter는 AuthorizationManager(인터페이스)를 주입받고 있다. SecurityContextHolder에서 꺼낸 Authentication을 authorize 메서드에 담아 실행한다. Authentication에는 "ADMIN" 권한이 있다.
  3. AuthorizationManager의 구현체인 RequestMatcherDelegatingAuthorizationManager에는 Spring Security의 사용자가 RequestMatcher와 AuthorizationManager를 포함하는 RequestMatcherEntry들을 추가할 수 있다. "/admin/posts"는 "/admin/**"와 매칭된다.
  4. 3번에서 매칭된 것의 AuthoriztionManager의 authorize 메서드를 실행한다. 
  5. 권한을 통해 접근 제거을 하는 클래스는 AuthorizationManager를 구현한 AuthorityAuthorizationManager이다. Authentication이 필요로하는 권한을 가지고 있는지 확인한다. 결과로 AuthorizationResult(인터페이스)를 구현한 AuthorizationDecision을 반환한다. 
  6. AuthorizationFilter에서 결과에 따라 다음 흐름을 진행하거나, AccessDeniedException 예외를 반환한다. 
참고로, 이전의 FilterSecurityInterceptor가 현재의 AuthorizationFilter이다.

ExceptionTranslationFilter

Spring Security 필터는 Servlet Filter 레벨에서 동작하며, DispatcherServlet보다 앞서 실행됩니다. 즉, 각각의 필터는 Spring MVC의 흐름 바깥에 있습니다. 예외가 여기서 터지면, 아직 DispatcherServlet까지 도달하지 않았기 때문에, ControllerAdvice, ExceptionHandler 같은 MVC 예외 처리 메커니즘은 작동하지 않습니다. 

 

Spring Security는 이러한 상황을 해결하기 위해서 ExceptionTranslationFilter를 사용합니다.

  • AccessDeniedException은 AccessDeniedHandler가 처리하고 보통 403을 응답합니다.
  • AuthenticationException은 AuthenticationEntryPoint가 처리하고 401을 응답하거나 로그인 리다이렉션을 합니다.
API Server의 경우 AuthenticationEntryPoint가 401 예외를 반환하지만, 기본적으로 로그인 화면으로 리다이렉션하는데 사용된다.

위에서 설명했듯이, AuthorizationFilter는 AccessDeniedException을 반환한다고 했습니다. 그러면 인증 정보가 없는 경우, AuthenticationEntryPoint가 처리하지 않고, AccessDeniedHandler가 처리하게 됩니다. 쉽게 설명해서, 401이 아닌 403을 반환하게 됩니다. 

Spring Security의 ExceptionTranslationFilter는 AccessDeniedException이 발생했을 때, 현재 사용자가 익명 사용자(비인증 사용자)인 경우 AuthenticationEntryPoint를 통해 예외를 처리합니다. 익명 사용자는 SecurityContext를 단순히 null로 두는 대신, 인증되지 않은 상태를 명확하게 나타내기 위해 사용하는 객체입니다. 이 객체는 AnonymousAuthenticationFilter에 의해 생성되며, 인증 정보가 없는 요청일 경우 SecurityContext를 익명 사용자로 채웁니다.

 

ExceptionTranslationFilter는 인증 관련 필터 뒤, AuthorizationFilter 앞에 있습니다. 필터는 뒤에 있는 필터에서 발생한 예외만 잡을 수 있습니다. 즉, Authentication 관련 필터 -> ExceptionTranslationFilter -> AuthorizationFilter 순으로 처리되고, ExceptionTranslationFilter는 AuthorizationFilter에서 발생한 예외만 잡을 수 있습니다.

  • Authentication 관련 필터는 일반적으로 ExceptionTranslationFilter보다 앞에 위치합니다. 공식 문서에 명확한 이유가 제시되지는 않지만, 개인적인 해석으로는 인증 과정에서 발생한 예외는 굳이 이후로 전달하여 처리할 필요 없이, 곧바로 AuthenticationEntryPoint를 통해 처리되는 것이 효율적이기 때문으로 보입니다.
  • AuthorizationFilter가 ExceptionTranslationFilter 뒤에 있는 이유: 위에서 설명했듯이, 인증이 되지 않았거나, 권한이 부족한 요청에 대한 예외를 잡기 위해서는 해당 필터 앞에 위치해야 합니다.

SecurityContextHolderFilter

SecurityContextHolderFilter는 인증/인가 필터보다 앞에 위치하며, 요청 시작 시 Http Session에서 SecurityContext를 로드하고 요청 종료 시 SecurityContext를 정리하는 역할을 합니다. AbstractAuthenticationProcessingFilter 부분에서 Form Login 과정 기억 나시나요? 로그인 이후 Session에 SecurityContext를 저장하고, 이후 요청이 들어올 때마다 SecurityContextHolderFilter는 Session에서 해당 정보를 로드하여 요청에 필요한 인증 정보를 제공합니다. 

 

SecurityContext는 ThreadLocal에 저장되기 때문에, 요청 종료 시 값을 정리해야 합니다. ThreadLocal은 스레드가 종료되더라도 자동으로 값을 정리하지 않기 때문에, 다음 요청에서 스레드가 이전 요청의 데이터를 갖게 될 수 있습니다. 요청이 종료되는 경우, ThreadLocal에 저장된 인증 정보를 명시적으로 정리하는 것이 중요합니다.

기존의 SecurityContextPersistenceFilter는 SecurityContextHolderFilter로 대체되었습니다.

Spring Security Lambda DSL

Spring Security는 보안 설정을 간결하고 직관적으로 정의할 수 있게 Lambda DSL(Domain-Specific Language)을 지원합니다. 

Lambda DSL 예시

HttpSecurity를 통해 설정된 내용은 SecurityFilterChain으로 생성되며, 이는 필터 기반의 보안 설정을 정의하는 핵심 구성 요소입니다. 여러 개의 보안 체인을 구성하고 싶다면, SecurityFilterChain을 Bean으로 추가로 등록하면 됩니다.
각 SecurityFilterChain에는 securityMatcher를 통해 적용할 경로를 지정할 수 있으며, authorizeHttpRequests 내의 requestMatchers를 사용해 접근 제어를 설정합니다. 접근 제어 방식으로는 permitAll, authenticated, hasAuthority 등을 사용할 수 있습니다.

직접 구현할 부분

지금까지 Spring Security의 기본 구조에 대해 살펴보았습니다. 이제부터는 직접 구현을 통해 각 구성 요소를 더 깊이 이해해보겠습니다.최근 서버는 주로 API 서버로 사용되며, 인증 방식으로는 JWT가 널리 활용되고 있습니다. 이에 따라 JWT 기반의 인증 흐름을 직접 구현해보려고 합니다. 시작 코드를 Clone하거나 Fork하시고, 각 브랜치들을 순서대로 따라가며 글과 함께 이해하시는 걸 추천합니다.

 

현재 코드에는 사용자 이름과 비밀번호를 이용한 회원가입 및 로그인이 이미 구현되어 있으며, 로그인 시 JWT를 생성하여 쿠키에 저장하고 있습니다. 이제 이 구조를 바탕으로, 인증, 인가, 예외 처리, Lambda DSL을 적용해보면서 점진적으로 발전시켜보겠습니다. 

 

다음 글인 2편: 인증, 인가, 예외 처리에서 뵙겠습니다. 읽어주셔서 감사합니다!

참고

  • Spring Security's High-Level Architecture
  • Spring Security's Authentication Architecture
  • Spring Security's Authorization Architecture

'서버' 카테고리의 다른 글

Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL  (0) 2025.05.02
Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리  (0) 2025.05.02
로컬 환경과 CI 환경의 시간 정밀도 차이로 인한 테스트 실패 해결  (0) 2025.04.01
Presigned URL과 CDN으로 이미지 업로드 & 조회 개선  (0) 2025.03.07
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장  (0) 2025.03.03
'서버' 카테고리의 다른 글
  • Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL
  • Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리
  • 로컬 환경과 CI 환경의 시간 정밀도 차이로 인한 테스트 실패 해결
  • Presigned URL과 CDN으로 이미지 업로드 & 조회 개선
alstn113
alstn113
웹 프론트엔드, 서버 개발에 관한 이야기를 다룹니다 :D
  • alstn113
    alstn113's devlog
    alstn113
  • 전체
    오늘
    어제
    • 분류 전체보기 (50)
      • 서버 (21)
      • 웹 프론트엔드 (5)
      • 협업 (2)
      • 우아한테크코스 6기 백엔드 (12)
      • 책, 영상, 블로그 정리 (8)
      • 회고 (1)
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    플러피
    우아한테크코스
    글쓰기
    회고
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
alstn113
Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해
상단으로

티스토리툴바