로그인 폼에서 아이디와 비밀번호를 입력하고 버튼을 누르면, 스프링 시큐리티 내부에서는 어떤 일이 벌어질까요?

@AuthenticationPrincipal로 현재 사용자 정보를 꺼내 쓰고 있지만, 그 정보가 어떤 경로로 만들어지고 어디에 저장 되는지 모르면 인증 관련 버그를 디버깅하기 어렵습니다. 이 글에서는 세션 기반 인증(폼 로그인/HTTP Basic)을 기준으로, 요청이 들어와서 인증이 완료되기까지의 전체 흐름을 따라갑니다.

개념 정의

스프링 시큐리티의 인증 은 HTTP 요청이 필터 체인을 통과하면서 **자격 증명을 검증하고 **, 검증된 사용자 정보를 SecurityContext에 저장 하는 과정입니다.

전체 흐름 한눈에

이 흐름을 단계별로 살펴보겠습니다.

1단계 — 요청이 필터 체인에 진입한다

모든 HTTP 요청은 서블릿 컨테이너(톰캣)의 필터 체인을 거칩니다. 스프링 시큐리티는 이 체인 안에 FilterChainProxy 를 등록해서, 요청 URL에 맞는 SecurityFilterChain을 선택합니다.

JAVA
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .httpBasic(Customizer.withDefaults())   // BasicAuthenticationFilter 등록
        .formLogin(Customizer.withDefaults());  // UsernamePasswordAuthenticationFilter 등록
    return http.build();
}

이 설정에 따라 어떤 인증 필터가 체인에 추가되는지가 결정됩니다.

2단계 — 인증 필터가 요청을 가로챈다

체인에 등록된 인증 필터들은 각자 "이 요청이 내가 처리할 요청인가?"를 먼저 판단합니다.

필터인증 방식가로채는 조건
BasicAuthenticationFilterHTTP BasicAuthorization: Basic ... 헤더 존재
UsernamePasswordAuthenticationFilter폼 로그인POST /login 요청

조건에 맞으면 필터가 요청에서 자격 증명(아이디·비밀번호)을 추출하고, Authentication 객체 를 생성합니다.

3단계 — Authentication 객체가 만들어진다

Authentication은 "이 사용자가 누구인지"를 담는 스프링 시큐리티의 핵심 인터페이스입니다.

필드역할인증 전인증 후
principal사용자 식별입력한 usernameUserDetails 객체
credentials자격 증명입력한 passwordnull (보안상 삭제)
authorities권한 목록비어있음ROLE_USER 등
authenticated인증 여부falsetrue

이 시점에서 Authentication은 아직 ** 미인증 상태 **(authenticated = false)입니다. 실제 검증은 다음 단계에서 일어납니다.

4단계 — AuthenticationManager가 검증을 위임한다

필터는 생성한 Authentication을 AuthenticationManager 에게 전달합니다. AuthenticationManager는 직접 검증하지 않고, 등록된 AuthenticationProvider 중 해당 Authentication 타입을 처리할 수 있는 것을 찾아 위임합니다.

Provider처리하는 Authentication용도
DaoAuthenticationProviderUsernamePasswordAuthenticationToken아이디·비밀번호 검증
RememberMeAuthenticationProviderRememberMeAuthenticationToken자동 로그인
OAuth2LoginAuthenticationProviderOAuth2LoginAuthenticationToken소셜 로그인

폼 로그인과 HTTP Basic의 경우, DaoAuthenticationProvider 가 선택됩니다.

5단계 — 사용자 조회 + 비밀번호 검증

DaoAuthenticationProvider는 두 단계로 검증합니다.

  1. UserDetailsService.loadUserByUsername() — DB에서 사용자 조회. 없으면 UsernameNotFoundException
  2. PasswordEncoder.matches() — 입력 비밀번호와 저장된 해시 비교. 불일치 시 BadCredentialsException
JAVA
// 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 에 저장됩니다.

  1. SecurityContextHolder는 ThreadLocal 기반이라, 현재 요청을 처리하는 스레드에서만 접근 가능합니다.
  2. 응답이 끝나면 SecurityContextPersistenceFilter 가 SecurityContext를 HTTP 세션에 저장 합니다.
  3. 다음 요청에서 JSESSIONID 쿠키로 세션을 찾아 SecurityContext를 복원합니다.
JAVA
// 컨트롤러에서 인증 정보 사용 — 어노테이션 방식
@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 토큰을 무시합니다.

JAVA
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
//                    ↑ 순서가 중요

2. SecurityContext가 비동기 스레드로 전파되지 않는다

ThreadLocal 기반이라 @Async 메서드나 새 스레드에서는 SecurityContext가 비어있습니다.

JAVA
@Async
public void sendEmail() {
    // SecurityContextHolder.getContext().getAuthentication() → null!
}

해결: SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) 또는 DelegatingSecurityContextExecutor 사용.

3. 테스트에서 인증 정보가 없어서 403이 나온다

@WebMvcTest에서 보안이 걸린 API를 테스트하면 기본적으로 403입니다. @WithMockUser를 붙여야 인증된 상태로 테스트할 수 있습니다.

JAVA
@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 + 세션)에 저장합니다.

댓글 로딩 중...