Spring Security는 요청 하나를 어떤 과정으로 인증하고 인가할까요?

HTTP 요청이 컨트롤러에 도달하기까지, Spring Security의 필터 체인 안에서 인증과 인가가 순서대로 처리됩니다. 이 흐름을 이해하면 "왜 401이 나오는지", "왜 403이 나오는지"를 필터 레벨에서 추적할 수 있습니다.

필터 체인의 전체 구조

Spring Security는 서블릿 필터 기반으로 동작합니다. 핵심 컴포넌트 세 가지가 요청을 처리합니다.

DelegatingFilterProxy

서블릿 컨테이너는 Spring Bean을 직접 인식하지 못합니다. DelegatingFilterProxy는 서블릿 필터로 등록되어, 실제 처리를 Spring 컨텍스트의 Bean에 위임 합니다.

PLAINTEXT
[서블릿 컨테이너 필터 체인]
    ├─ ... (다른 서블릿 필터들)
    ├─ DelegatingFilterProxy ──위임──▶ FilterChainProxy (Spring Bean)
    └─ ...

서블릿 세계와 Spring 세계를 이어주는 다리 역할입니다.

FilterChainProxy

DelegatingFilterProxy가 위임하는 대상입니다. springSecurityFilterChain이라는 이름으로 등록된 Spring Bean이며, 내부에 여러 SecurityFilterChain을 가지고 있습니다.

요청이 들어오면 URL 패턴에 맞는 SecurityFilterChain ** 하나 **를 선택해서 실행합니다.

JAVA
// FilterChainProxy 내부 (의사 코드)
for (SecurityFilterChain chain : filterChains) {
    if (chain.matches(request)) {
        chain.getFilters().forEach(filter -> filter.doFilter(request, response));
        return;  // 첫 번째 매칭된 체인만 실행
    }
}

SecurityFilterChain

Spring Security 6.x에서는 SecurityFilterChain을 ** 빈으로 직접 등록 **합니다.

JAVA
@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용 체인과 웹 페이지용 체인을 분리할 때 유용합니다.

JAVA
@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 안에는 수십 개의 필터가 정해진 순서로 배치됩니다.

순서필터역할
1SecurityContextHolderFilterSecurityContext를 로드/저장
2UsernamePasswordAuthenticationFilter폼 로그인 처리 (POST /login)
3ExceptionTranslationFilter인증/인가 예외를 HTTP 응답으로 변환
4AuthorizationFilterURL 기반 인가 처리

Spring Security 5.x의 SecurityContextPersistenceFilterFilterSecurityInterceptor는 6.x에서 각각 SecurityContextHolderFilterAuthorizationFilter로 대체되었습니다.

SecurityContextHolderFilter 는 요청 시작 시 SecurityContextRepository에서 기존 SecurityContext를 꺼내 SecurityContextHolder에 설정하고, 응답 시 다시 저장합니다.

ExceptionTranslationFilter 는 이후 필터에서 터진 예외를 잡아 적절한 HTTP 응답으로 변환합니다.

  • AuthenticationException → 401 + AuthenticationEntryPoint 호출
  • AccessDeniedException → 403 + AccessDeniedHandler 호출

인증(Authentication) 흐름

폼 로그인 기준으로 인증 흐름 전체를 따라가면 다음과 같습니다.

PLAINTEXT
[사용자] ──POST /login──▶ UsernamePasswordAuthenticationFilter

                      UsernamePasswordAuthenticationToken 생성


                          AuthenticationManager (인터페이스)


                           ProviderManager (구현체)

                      등록된 AuthenticationProvider 순회


                      DaoAuthenticationProvider

                    UserDetailsService.loadUserByUsername()

                    UserDetails 반환 + PasswordEncoder.matches()

                          인증 성공 → Authentication 반환

                    SecurityContextHolder에 Authentication 저장

각 컴포넌트의 역할을 코드로 확인합니다.

AuthenticationManager — 인증의 진입점 인터페이스입니다.

JAVA
public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication)
        throws AuthenticationException;
}

AuthenticationProvider — 실제 인증 로직을 수행합니다. supports()로 처리 가능한 토큰 타입을 선언합니다.

JAVA
public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication)
        throws AuthenticationException;
    boolean supports(Class<?> authentication);
}

UserDetailsService — DB에서 사용자를 조회하여 UserDetails로 반환합니다.

JAVA
@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가 관리합니다.

JAVA
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();

기본 전략은 MODE_THREADLOCAL입니다. 같은 요청을 처리하는 동안 ** 같은 스레드 **가 사용되기 때문에, 컨트롤러/서비스/리포지토리 어디서든 SecurityContextHolder.getContext()로 인증 정보에 접근할 수 있습니다.

@Async 같은 비동기 처리에서는 새 스레드가 생기기 때문에 SecurityContext가 전파되지 않습니다. DelegatingSecurityContextAsyncTaskExecutor를 사용하여 해결합니다.

JAVA
@Bean
public Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.initialize();
    return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}

인가(Authorization)

URL 기반 인가

JAVA
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를 활성화하면 메서드 레벨에서도 인가를 설정할 수 있습니다.

JAVA
@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 연결 다리
FilterChainProxyURL 패턴에 맞는 SecurityFilterChain 선택
SecurityFilterChain인증·인가·보안 필터들의 체인, 빈으로 등록
인증 흐름Filter → Manager → Provider → UserDetailsService
SecurityContextThreadLocal 기반, 같은 스레드 내에서 인증 정보 공유
인가URL 기반(authorizeHttpRequests) + 메서드 기반(@PreAuthorize)
댓글 로딩 중...