스프링 시큐리티: 인증 흐름과 동작 구조
로그인 폼에서 아이디와 비밀번호를 입력하고 버튼을 누르면, 스프링 시큐리티 내부에서는 어떤 일이 벌어질까요?
@AuthenticationPrincipal로 현재 사용자 정보를 꺼내 쓰고 있지만, 그 정보가 어떤 경로로 만들어지고 어디에 저장 되는지 모르면 인증 관련 버그를 디버깅하기 어렵습니다. 이 글에서는 세션 기반 인증(폼 로그인/HTTP Basic)을 기준으로, 요청이 들어와서 인증이 완료되기까지의 전체 흐름을 따라갑니다.
개념 정의
스프링 시큐리티의 인증 은 HTTP 요청이 필터 체인을 통과하면서 **자격 증명을 검증하고 **, 검증된 사용자 정보를 SecurityContext에 저장 하는 과정입니다.
전체 흐름 한눈에
이 흐름을 단계별로 살펴보겠습니다.
1단계 — 요청이 필터 체인에 진입한다
모든 HTTP 요청은 서블릿 컨테이너(톰캣)의 필터 체인을 거칩니다. 스프링 시큐리티는 이 체인 안에 FilterChainProxy 를 등록해서, 요청 URL에 맞는 SecurityFilterChain을 선택합니다.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(Customizer.withDefaults()) // BasicAuthenticationFilter 등록
.formLogin(Customizer.withDefaults()); // UsernamePasswordAuthenticationFilter 등록
return http.build();
}
이 설정에 따라 어떤 인증 필터가 체인에 추가되는지가 결정됩니다.
2단계 — 인증 필터가 요청을 가로챈다
체인에 등록된 인증 필터들은 각자 "이 요청이 내가 처리할 요청인가?"를 먼저 판단합니다.
| 필터 | 인증 방식 | 가로채는 조건 |
|---|---|---|
| BasicAuthenticationFilter | HTTP Basic | Authorization: Basic ... 헤더 존재 |
| UsernamePasswordAuthenticationFilter | 폼 로그인 | POST /login 요청 |
조건에 맞으면 필터가 요청에서 자격 증명(아이디·비밀번호)을 추출하고, Authentication 객체 를 생성합니다.
3단계 — Authentication 객체가 만들어진다
Authentication은 "이 사용자가 누구인지"를 담는 스프링 시큐리티의 핵심 인터페이스입니다.
| 필드 | 역할 | 인증 전 | 인증 후 |
|---|---|---|---|
| principal | 사용자 식별 | 입력한 username | UserDetails 객체 |
| credentials | 자격 증명 | 입력한 password | null (보안상 삭제) |
| authorities | 권한 목록 | 비어있음 | ROLE_USER 등 |
| authenticated | 인증 여부 | false | true |
이 시점에서 Authentication은 아직 ** 미인증 상태 **(authenticated = false)입니다. 실제 검증은 다음 단계에서 일어납니다.
4단계 — AuthenticationManager가 검증을 위임한다
필터는 생성한 Authentication을 AuthenticationManager 에게 전달합니다. AuthenticationManager는 직접 검증하지 않고, 등록된 AuthenticationProvider 중 해당 Authentication 타입을 처리할 수 있는 것을 찾아 위임합니다.
| Provider | 처리하는 Authentication | 용도 |
|---|---|---|
| DaoAuthenticationProvider | UsernamePasswordAuthenticationToken | 아이디·비밀번호 검증 |
| RememberMeAuthenticationProvider | RememberMeAuthenticationToken | 자동 로그인 |
| OAuth2LoginAuthenticationProvider | OAuth2LoginAuthenticationToken | 소셜 로그인 |
폼 로그인과 HTTP Basic의 경우, DaoAuthenticationProvider 가 선택됩니다.
5단계 — 사용자 조회 + 비밀번호 검증
DaoAuthenticationProvider는 두 단계로 검증합니다.
- UserDetailsService.loadUserByUsername() — DB에서 사용자 조회. 없으면
UsernameNotFoundException - PasswordEncoder.matches() — 입력 비밀번호와 저장된 해시 비교. 불일치 시
BadCredentialsException
// UserDetailsService — 사용자 조회
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
// PasswordEncoder — 비밀번호 검증
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}
두 검증을 모두 통과하면, authenticated = true 인 새 Authentication 객체를 생성해서 반환합니다.
6단계 — SecurityContext에 저장된다
인증이 완료된 Authentication은 SecurityContextHolder 에 저장됩니다.
- SecurityContextHolder는 ThreadLocal 기반이라, 현재 요청을 처리하는 스레드에서만 접근 가능합니다.
- 응답이 끝나면 SecurityContextPersistenceFilter 가 SecurityContext를 HTTP 세션에 저장 합니다.
- 다음 요청에서 JSESSIONID 쿠키로 세션을 찾아 SecurityContext를 복원합니다.
// 컨트롤러에서 인증 정보 사용 — 어노테이션 방식
@GetMapping("/me")
public String me(@AuthenticationPrincipal UserDetails user) {
return "Hello, " + user.getUsername();
}
// 직접 꺼내는 방식
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
UserDetails user = (UserDetails) auth.getPrincipal();
함정 — 이걸 모르면 터진다
1. 커스텀 필터 순서를 잘못 놓으면 인증이 무시된다
JWT 필터를 직접 만들어서 추가할 때, UsernamePasswordAuthenticationFilter 앞에 놓아야 합니다. 뒤에 놓으면 폼 로그인 필터가 먼저 동작해서 JWT 토큰을 무시합니다.
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
// ↑ 순서가 중요
2. SecurityContext가 비동기 스레드로 전파되지 않는다
ThreadLocal 기반이라 @Async 메서드나 새 스레드에서는 SecurityContext가 비어있습니다.
@Async
public void sendEmail() {
// SecurityContextHolder.getContext().getAuthentication() → null!
}
해결: SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) 또는 DelegatingSecurityContextExecutor 사용.
3. 테스트에서 인증 정보가 없어서 403이 나온다
@WebMvcTest에서 보안이 걸린 API를 테스트하면 기본적으로 403입니다. @WithMockUser를 붙여야 인증된 상태로 테스트할 수 있습니다.
@Test
@WithMockUser(username = "testuser", roles = {"USER"})
void 인증된_사용자만_접근_가능한_API() throws Exception {
mockMvc.perform(get("/api/profile"))
.andExpect(status().isOk());
}
정리
| 단계 | 핵심 | 주체 |
|---|---|---|
| 1. 필터 체인 진입 | URL에 맞는 SecurityFilterChain 선택 | FilterChainProxy |
| 2. 인증 필터 | 요청에서 자격 증명 추출 + Authentication 생성 | BasicAuthenticationFilter 등 |
| 3. 검증 위임 | 적합한 Provider 선택 | AuthenticationManager |
| 4. 사용자 조회 | DB에서 UserDetails 로드 | UserDetailsService |
| 5. 비밀번호 검증 | 해시 비교 | PasswordEncoder |
| 6. 저장 | ThreadLocal + 세션에 SecurityContext 저장 | SecurityContextHolder |
전체 흐름을 한 문장으로: HTTP 요청이 필터 체인에 들어오면, 인증 필터가 자격 증명으로 Authentication을 만들고, AuthenticationManager → Provider → UserDetailsService → PasswordEncoder 순서로 검증한 뒤, 결과를 SecurityContext(ThreadLocal + 세션)에 저장합니다.