Spring Security 심화 — FilterChain, 인증 흐름, OAuth2
Spring Security는 요청 하나를 어떤 과정으로 인증하고 인가할까요?
HTTP 요청이 컨트롤러에 도달하기까지, Spring Security의 필터 체인 안에서 인증과 인가가 순서대로 처리됩니다. 이 흐름을 이해하면 "왜 401이 나오는지", "왜 403이 나오는지"를 필터 레벨에서 추적할 수 있습니다.
필터 체인의 전체 구조
Spring Security는 서블릿 필터 기반으로 동작합니다. 핵심 컴포넌트 세 가지가 요청을 처리합니다.
DelegatingFilterProxy
서블릿 컨테이너는 Spring Bean을 직접 인식하지 못합니다. DelegatingFilterProxy는 서블릿 필터로 등록되어, 실제 처리를 Spring 컨텍스트의 Bean에 위임 합니다.
[서블릿 컨테이너 필터 체인]
├─ ... (다른 서블릿 필터들)
├─ DelegatingFilterProxy ──위임──▶ FilterChainProxy (Spring Bean)
└─ ...
서블릿 세계와 Spring 세계를 이어주는 다리 역할입니다.
FilterChainProxy
DelegatingFilterProxy가 위임하는 대상입니다. springSecurityFilterChain이라는 이름으로 등록된 Spring Bean이며, 내부에 여러 SecurityFilterChain을 가지고 있습니다.
요청이 들어오면 URL 패턴에 맞는 SecurityFilterChain ** 하나 **를 선택해서 실행합니다.
// FilterChainProxy 내부 (의사 코드)
for (SecurityFilterChain chain : filterChains) {
if (chain.matches(request)) {
chain.getFilters().forEach(filter -> filter.doFilter(request, response));
return; // 첫 번째 매칭된 체인만 실행
}
}
SecurityFilterChain
Spring Security 6.x에서는 SecurityFilterChain을 ** 빈으로 직접 등록 **합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
}
여러 SecurityFilterChain 빈을 등록하면 @Order로 우선순위를 지정할 수 있습니다. API용 체인과 웹 페이지용 체인을 분리할 때 유용합니다.
@Bean @Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean @Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
주요 필터 순서
SecurityFilterChain 안에는 수십 개의 필터가 정해진 순서로 배치됩니다.
| 순서 | 필터 | 역할 |
|---|---|---|
| 1 | SecurityContextHolderFilter | SecurityContext를 로드/저장 |
| 2 | UsernamePasswordAuthenticationFilter | 폼 로그인 처리 (POST /login) |
| 3 | ExceptionTranslationFilter | 인증/인가 예외를 HTTP 응답으로 변환 |
| 4 | AuthorizationFilter | URL 기반 인가 처리 |
Spring Security 5.x의
SecurityContextPersistenceFilter와FilterSecurityInterceptor는 6.x에서 각각SecurityContextHolderFilter와AuthorizationFilter로 대체되었습니다.
SecurityContextHolderFilter 는 요청 시작 시 SecurityContextRepository에서 기존 SecurityContext를 꺼내 SecurityContextHolder에 설정하고, 응답 시 다시 저장합니다.
ExceptionTranslationFilter 는 이후 필터에서 터진 예외를 잡아 적절한 HTTP 응답으로 변환합니다.
AuthenticationException→ 401 +AuthenticationEntryPoint호출AccessDeniedException→ 403 +AccessDeniedHandler호출
인증(Authentication) 흐름
폼 로그인 기준으로 인증 흐름 전체를 따라가면 다음과 같습니다.
[사용자] ──POST /login──▶ UsernamePasswordAuthenticationFilter
│
UsernamePasswordAuthenticationToken 생성
│
▼
AuthenticationManager (인터페이스)
│
▼
ProviderManager (구현체)
│
등록된 AuthenticationProvider 순회
│
▼
DaoAuthenticationProvider
│
UserDetailsService.loadUserByUsername()
│
UserDetails 반환 + PasswordEncoder.matches()
│
인증 성공 → Authentication 반환
│
SecurityContextHolder에 Authentication 저장
각 컴포넌트의 역할을 코드로 확인합니다.
AuthenticationManager — 인증의 진입점 인터페이스입니다.
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
AuthenticationProvider — 실제 인증 로직을 수행합니다. supports()로 처리 가능한 토큰 타입을 선언합니다.
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
UserDetailsService — DB에서 사용자를 조회하여 UserDetails로 반환합니다.
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
public CustomUserDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException(
"사용자를 찾을 수 없습니다: " + username));
return User.builder()
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRole().name())
.build();
}
}
SecurityContext / SecurityContextHolder
인증 성공 후 Authentication 객체는 SecurityContext에 저장되고, 이를 SecurityContextHolder가 관리합니다.
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
기본 전략은 MODE_THREADLOCAL입니다. 같은 요청을 처리하는 동안 ** 같은 스레드 **가 사용되기 때문에, 컨트롤러/서비스/리포지토리 어디서든 SecurityContextHolder.getContext()로 인증 정보에 접근할 수 있습니다.
@Async 같은 비동기 처리에서는 새 스레드가 생기기 때문에 SecurityContext가 전파되지 않습니다. DelegatingSecurityContextAsyncTaskExecutor를 사용하여 해결합니다.
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
인가(Authorization)
URL 기반 인가
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/posts").authenticated()
.anyRequest().denyAll()
);
메서드 기반 인가
@EnableMethodSecurity를 활성화하면 메서드 레벨에서도 인가를 설정할 수 있습니다.
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) { ... }
@PreAuthorize("#userId == authentication.principal.id")
public UserDto getUser(Long userId) { ... }
주의할 점
여러 SecurityFilterChain에서 @Order를 빠뜨리면 충돌한다
여러 SecurityFilterChain 빈을 등록할 때 @Order를 지정하지 않으면, 어떤 체인이 먼저 매칭되는지 예측할 수 없습니다. 반드시 우선순위를 명시하고, 좁은 범위(예: /api/**)의 체인을 먼저(낮은 Order 값) 배치해야 합니다.
anyRequest().denyAll()을 빠뜨리면 보안 구멍이 생긴다
authorizeHttpRequests()에서 명시하지 않은 경로는 기본적으로 허용될 수 있습니다. anyRequest().denyAll() 또는 anyRequest().authenticated()를 마지막에 두어, 설정에서 누락된 경로를 차단해야 합니다.
Spring Security 6.x 마이그레이션 시 API 변경
antMatchers(), mvcMatchers()가 모두 requestMatchers()로 통합되었습니다. classpath에 Spring MVC가 있으면 자동으로 MvcRequestMatcher를 사용합니다. 5.x 코드를 그대로 가져오면 컴파일 에러가 발생합니다.
정리
| 항목 | 설명 |
|---|---|
| DelegatingFilterProxy | 서블릿 컨테이너 ↔ Spring Bean 연결 다리 |
| FilterChainProxy | URL 패턴에 맞는 SecurityFilterChain 선택 |
| SecurityFilterChain | 인증·인가·보안 필터들의 체인, 빈으로 등록 |
| 인증 흐름 | Filter → Manager → Provider → UserDetailsService |
| SecurityContext | ThreadLocal 기반, 같은 스레드 내에서 인증 정보 공유 |
| 인가 | URL 기반(authorizeHttpRequests) + 메서드 기반(@PreAuthorize) |