이 글은 백엔드 개발 초보가 작성한 글로, 일부 잘못된 내용이 있을 수 있습니다.
이상하거나 잘못된 부분은 댓글을 통해 알려주세요!
Spring Boot를 처음 배울 때 함께 등장하는 Spring Security.
처음 느낀 인상은 "왜 이렇게 뭐가 복잡하지?"였다. Spring Security는 도대체 어떻게 돌아가는 걸까?
1. Java Servlet
Spring Security를 이해하려면 먼저 Servlet을 알아야 한다.
우리가 흔히 짜는 일반 자바 애플리케이션(main() 함수가 있는)은 개발자가 객체의 생성과 실행 흐름을 작성한다.
하지만 Servlet은 다르다.
- 일반 App: 내가 직접 new 하고 실행한다.
- Servlet: 나는 로직(service())만 짜두면, 생성하고 호출하고 지우는 건 Container(Tomcat 등)가 알아서 한다.
즉, 제어의 역전(IoC)이 일어나는 것이다.
수많은 사용자의 요청을 처리해야 하는데, 스레드 관리나 리소스 최적화를 개발자가 일일이 다 할 수 없기 때문이다.
중요한 점은 메모리 절약을 위해 서블릿은 '싱글톤'으로 관리된다는 점이다.
즉, 객체는 하나만 만들고 사용자 요청이 올 때마다 별도의 스레드(Thread) 를 할당해서 service() 메서드를 병렬로 실행한다.
(Node.js의 싱글 스레드 모델과는 반대되는 개념이다.)
2. Tomcat은 Spring Bean을 모른다
우리가 흔히 쓰는 Spring Security는 기본적으로 Servlet Filter 기반으로 동작한다. 여기서 한 가지 구조적 딜레마가 발생한다.
- Filter: 서블릿 컨테이너(Tomcat 등)가 생성하고 초기화하며 관리한다.
- Security Logic: 스프링 컨테이너가 관리하는 Bean으로 존재한다.
즉, 톰캣이 관리하는 필터 영역에서 스프링이 관리하는 보안 로직(Service, Component 등)을 가져다 써야 하는 상황인 것이다.
하지만 서블릿 컨테이너는 스프링 빈의 존재를 알지 못한다.
해결책: DelegatingFilterProxy
Spring은 이 문제를 해결하기 위해 DelegatingFilterProxy라는 녀석을 사용한다. 이름 그대로 '위임(Delegate)'하는 프록시 필터다.
서블릿 컨테이너에는 껍데기뿐인 DelegatingFilterProxy를 등록해두고, 실제 요청이 들어오면 내부적으로 Spring Context에서 springSecurityFilterChain 이라는 이름의 Bean을 찾아 모든 처리를 떠넘긴다.
덕분에 우리는 서블릿 필터의 위치(URL 요청의 가장 앞단)를 점유하면서도, Spring의 강력한 기능(DI, AOP 등)을 모두 활용할 수 있게 된 것이다.
3. 보안의 허브, FilterChainProxy
위임을 받은 springSecurityFilterChain의 실체는 바로 FilterChainProxy다.
Spring Security의 모든 보안 로직은 이 친구를 거쳐 간다고 봐도 무방하다.
단순한 라우팅을 넘어 메모리 누수 방지(SecurityContext 초기화) 나 HttpFirewall 같은 필수적인 보안 작업도 수행한다.
URL마다 다른 보안을 적용하고 싶다면?

FilterChainProxy의 강력한 점은 여러 개의 SecurityFilterChain을 가질 수 있다는 것이다.
예를 들어 /api/** 경로는 JWT 인증을 하고, /admin/** 경로는 세션 인증을 하고 싶을 수 있다.
이때 FilterChainProxy는 등록된 체인 목록을 순서대로 확인하며, 가장 먼저 매칭되는 첫 번째 체인 하나만 실행한다.
Tip: 정적 리소스(CSS, Image)에 대해 보안 검사를 아예 생략하고 싶다면, 필터가 0개인 SecurityFilterChain을 만들어 등록하면 된다.
4. 핵심 필터들은 어떤 순서로 실행될까?
SecurityFilterChain 안에는 수많은 필터가 들어있다. 이들의 순서는 매우 중요한데, 크게 보면 다음과 같은 흐름을 가진다.
[Context 로드] -> [CSRF 방어] -> [인증(Authentication)] -> [인가(Authorization)]
가장 중요한 4가지 단계를 구체적으로 뜯어보자.
① SecurityContextHolderFilter: 기억의 복원 (Persistence)
가장 먼저 실행되는 필터 중 하나다.
이전 요청에서 저장된 인증 정보(세션 등)가 있다면 불러와서 SecurityContextHolder 에 넣어준다.
여기서 SecurityContextHolder는 기본적으로 ThreadLocal 전략을 사용한다.
즉, 같은 스레드 내에서는 파라미터로 넘기지 않아도 언제든 인증 정보에 접근할 수 있다.
핵심 Flow는 다음과 같다.

SecurityContextRepository.loadContext(request)호출 (세션 등에서 조회)- 반환된
SecurityContext(없으면 빈 객체)를SecurityContextHolder에 세팅 filterChain.doFilter()실행 (다음 필터 실행)- 요청 처리가 끝나면
SecurityContextHolder.clearContext()를 호출하여 깔끔하게 비워준다.
(스레드 풀 재사용 시 데이터 오염 방지)
② Exploit Protection: CSRF
외부 위협을 차단하는 단계다.
CORS(Cross-Origin Resource Sharing) 처리를 위한 필터가 가장 앞에 오고, 그 뒤에 CSRF(Cross-Site Request Forgery) 필터가 온다. (CORS는 Preflight 처리를 해야 하므로)
CsrfFilter는 말 그대로 CSRF 공격을 방어하는 역할을 하는 필터이다.
CsrfTokenRepository는 CSRF 토큰을 어디에 저장하고 불러올 지(주로 세션이나 쿠키)를 결정하는 역할을 수행하고, CsrfTokenRequestHandler는 토큰을 어떻게 추출 및 검사할 것인지를 결정한다.
자세한 Flow는 아래와 같다.

- Deferred Loading (지연 로딩 준비)
- 요청이 들어오면
CsrfTokenRepository를 참조하여 나중에 토큰을 로드할 수 있도록DeferredCsrfToken을 준비한다.
- 요청이 들어오면
- Make Available (토큰 노출)
- 준비된
DeferredCsrfToken을Supplier<CsrfToken>형태로CsrfTokenRequestHandler<c/ode>에게 전달한다. - d이제 애플리케이션의 다른 곳에서도 필요하다면 이
Supplier를 통해 토큰을 얻을 수 있게 된다.
- 준비된
- Determine Requirement (보호 필요 여부 확인)
- GET과 같은 읽기 전용 요청이라면 검증을 건너뛰고, POST와 같이 상태를 변경하는 요청이라면 검증 단계로 넘어간다.
- Load Token (실제 토큰 로드)
- 검증이 필요하다고 판단되면, 그제야 아까 준비해둔
DeferredCsrfToken을 통해 실제 Persisted Token(세션이나 쿠키에 저장된 원본)을 로드한다.
- 검증이 필요하다고 판단되면, 그제야 아까 준비해둔
- Resolve & Compare (토큰 추출 및 비교)
- 클라이언트가 헤더(
X-CSRF-TOKEN)나 파라미터(_csrf)로 보낸 Actual Token을 추출한다. CsrfTokenRequestHandler가 원본 토큰과 사용자 토큰을 비교한다.- 핸들러의 기본값은
XorCsrfTokenRequestAttributeHandler로, 단순 문자열 비교가 아니라 XOR 연산된 난수를 복호화하여 비교하는 과정을 거친다.
- 클라이언트가 헤더(
- Handle Result (결과 처리)
- 토큰이 일치하면 다음 필터로 진행(
chain.doFilter)하고, 일치하지 않으면AccessDeniedException을 던져 요청을 차단한다.
- 토큰이 일치하면 다음 필터로 진행(
③ AuthenticationFilter: 인증 시도 (Authentication)
사용자의 자격 증명(ID/PW, 토큰 등)을 검증하는 필터이다.
이 필터가 직접 DB를 뒤져서 비밀번호를 확인하지는 않는다.AuthenticationManager 라는 관리자에게 "이 사람 진짜 맞는지 확인 좀 해줘"라고 위임한다.

보통 ProviderManager라는 구현체를 사용하는데, 이는 AuthenticationProvider라는 리스트를 가지고 있다. (DaoAuthenticationProvider, JwtAuthenticationProvider 등)
이들에게 순서대로 "너 이거 처리할 수 있어?"라고 물어보고, 처리할 수 있는 Provider가 인증을 수행한다.
만약 현재 ProviderManager이 해결 못 하면 부모에게 다시 부탁하는 계층 구조를 가진다.
이후 인증이 성공하면 보안을 위해 메모리에 있는 비밀번호 객체 지워버린다.
인증이 이뤄지는 Flow는 아래와 같다.

- Creation (토큰 생성)
- 사용자가 ID/PW를 제출하면 필터(
AbstractAuthenticationProcessingFilter를 상속받은 구현체)가 이를 가로채서UsernamePasswordAuthenticationToken(아직 인증 안 됨)을 만든다.
- 사용자가 ID/PW를 제출하면 필터(
- Delegation (위임)
- 만들어진 토큰을
AuthenticationManager에게 넘긴다.
- 만들어진 토큰을
- Success (성공)
- 인증된 정보(
Authentication객체)를SecurityContext에 저장한다. - 필요하다면 Remember-Me 토큰을 만들거나 로그인 성공 이벤트를 발행한다.
- 인증된 정보(
- Failure (실패)
SecurityContext를 초기화하고 실패 핸들러를 실행한다.
④ AuthorizationFilter: 권한 확인 (Authorization)
이제 각 페이지별 접근 권한을 확인하는 단계이다.
여기서도 지연 처리(Deferred Loading)를 활용한다.AuthorizationManager는 check() 메서드를 호출할 때, Authentication 객체를 통째로 넘기는 게 아니라 Supplier<Authentication> 을 넘긴다.
이를 통해 인증 정보 조회를 최대한 미뤄 DB 조회 등의 고부하 작업을 낭비하지 않도록 도와준다.
처리 Flow는 아래와 같다.
- Check Authorization (권한 확인)
AuthorizationFilter가 요청을 가로채면AuthorizationManager.check()를 호출한다.- 이때 URL 별로 설정된 매니저(
RequestMatcherDelegatingAuthorizationManager)가 적절한 구현체에게 위임한다.
- Inspect Authorities (권한 조회)
Supplier.get()이 호출되는 순간, 비로소 인증 객체를 로드하고 사용자의 권한(GrantedAuthority, 보통ROLE_로 시작)을 조회하여 조건과 비교한다.
- Decision & Handling (결정 및 처리)
- 통과:
AuthorizationDecision(true)가 반환되면 다음 필터(chain.doFilter)로 넘어간다. - 거절:
AuthorizationDecision(false)가 반환되면 즉시AccessDeniedException을 던져 403 에러 페이지를 보여준다.
- 통과:
5. 예외 처리와 캐싱
예외 처리를 담당하는 필터는 ExceptionTranslationFilter이다. 이 필터는 Exception을 HTTP 응답으로 변환하는 Bridge 역할을 수행한다.

ExceptionTranslationFilter는 try-catch로 filterChain.doFilter를 매핑, 예외 발생시 상황에 맞는 동작을 수행한다.
한편, 인증되지 않은 사용자가 보호된 리소스에 접근하면 로그인 성공 후 다시 원래 페이지로 보내줘야 할 수도 있다.
이 역할을 수행하는 것이 바로 Request Cache이다.
기본값으로 HttpSessionRequestCache가 사용되는데, 이는 원래 요청을 세션에 저장하는 방식으로 작동하며, 로그인 성공 시 RequestCacheAwareFilter가 이를 꺼내서 재실행(replay)해준다.
Stateless한 환경을 원한다면 NullRequestCache를 사용하도록 설정하면 된다.
6. 커스텀 필터 추가하기
Spring Security가 제공하는 기본 필터만으로는 부족할 때가 있다.
예를 들어, 특정 헤더 값을 검사하거나 Tenant ID를 확인하는 로직이 필요하다면 커스텀 필터를 만들어야 한다.
보통은 요청당 한 번만 실행되는 것을 보장하는 OncePerRequestFilter를 상속받아 구현한다.
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-Id");
// 간단한 권한 확인 로직
if (isValid(tenantId)) {
filterChain.doFilter(request, response); // 통과! 다음 필터로 이동
} else {
throw new AccessDeniedException("Access denied"); // 거부!
}
}
}
만든 필터는 SecurityFilterChain 설정에서 적절한 위치에 끼워 넣어야 한다.HttpSecurity는 addFilterBefore, addFilterAfter, addFilterAt과 같은 메서드를 제공한다.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... 기존 설정
// 인증 필터(AnonymousAuthenticationFilter) 뒤에 커스텀 필터 추가
.addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class);
return http.build();
}
앞서 언급했듯이, Filter들의 순서는 매우 매우 중요하다.
'보안 - 인증 - 인가'의 순서를 깨지 않는 선에서 커스텀 필터를 넣어주자.
필터 이중 호출 문제
필터를 Spring Bean(@Component 등)으로 등록하면, Spring Boot가 이를 자동으로 Servlet Container에 등록해버린다.
결과적으로 Spring Security Chain 안에서 한 번, Servlet Container에서 또 한 번, 총 두 번 실행되는 불상사가 발생한다.
이를 막으려면 FilterRegistrationBean을 사용하여 서블릿 컨테이너의 자동 등록을 비활성화해야 한다.
@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false); // 서블릿 컨테이너 자동 등록 OFF
return registration;
}
마치며
여러 내용이 많았지만, 한 줄 요약하자면 Spring Security는 "서블릿 필터(Filter)의 특성을 활용해 스프링 빈(Bean)으로 보안 로직을 위임(Delegate)하는 구조"라고 볼 수 있다.
본 내용은 Spring Security 공식 문서를 바탕으로 작성하였으므로, 해당 링크에서 더 많은 정보를 확인할 수 있다.