Spring Security 인증 아키텍처 심화

Spring Security 인증 아키텍처

Spring Security의 인증(Authentication)은 여러 컴포넌트가 체인으로 연결된 구조입니다. HTTP 요청이 들어오면 FilterAuthenticationManagerAuthenticationProviderUserDetailsService 순서로 처리됩니다. 각 컴포넌트의 역할과 확장 포인트를 이해하면 JWT, OAuth2, 다중 인증 등 복잡한 요구사항을 구현할 수 있습니다.

이 글에서는 인증 처리 흐름의 전체 아키텍처, 각 컴포넌트의 내부 동작, 커스텀 AuthenticationProvider 구현, 다중 인증 전략, 그리고 SecurityContext 전파까지 심층적으로 다룹니다. Spring Security Filter Chain 가이드와 함께 읽으면 Security 전체 구조를 파악할 수 있습니다.

인증 처리 흐름

HTTP Request
    │
    ▼
┌──────────────────────────┐
│ AbstractAuthenticationFilter │  ← UsernamePasswordAuthenticationFilter
│ (요청에서 credentials 추출)   │     BearerTokenAuthenticationFilter 등
└────────────┬─────────────┘
             │ Authentication(미인증)
             ▼
┌──────────────────────────┐
│ AuthenticationManager     │  ← 인터페이스 (authenticate 메서드)
│ (= ProviderManager)      │
└────────────┬─────────────┘
             │ 적합한 Provider 선택
             ▼
┌──────────────────────────┐
│ AuthenticationProvider    │  ← DaoAuthenticationProvider
│ (실제 인증 로직 수행)       │     JwtAuthenticationProvider 등
└────────────┬─────────────┘
             │ UserDetails 조회
             ▼
┌──────────────────────────┐
│ UserDetailsService        │  ← DB, LDAP, 외부 API 등에서 사용자 조회
└────────────┬─────────────┘
             │ Authentication(인증됨)
             ▼
┌──────────────────────────┐
│ SecurityContextHolder     │  ← ThreadLocal에 인증 정보 저장
│ .getContext()             │
│ .setAuthentication(auth)  │
└──────────────────────────┘

핵심 컴포넌트 상세

Authentication 객체

// Authentication 인터페이스의 두 가지 상태
public interface Authentication extends Principal {
    // 인증 전: credentials(비밀번호 등) 포함, authenticated=false
    // 인증 후: principal(사용자 정보) 포함, authenticated=true
    
    Object getPrincipal();      // 인증 전: username, 인증 후: UserDetails
    Object getCredentials();    // 비밀번호 (인증 후 지워짐)
    Collection<? extends GrantedAuthority> getAuthorities(); // 권한
    boolean isAuthenticated();  // 인증 완료 여부
}

// 대표 구현체
// UsernamePasswordAuthenticationToken: 폼 로그인
// BearerTokenAuthenticationToken: JWT/OAuth2
// PreAuthenticatedAuthenticationToken: 외부 인증 연동

ProviderManager (AuthenticationManager 구현체)

// ProviderManager = AuthenticationProvider 목록을 순회하며 인증 시도
public class ProviderManager implements AuthenticationManager {
    private List<AuthenticationProvider> providers;
    private AuthenticationManager parent; // 부모 Manager (위임 가능)

    public Authentication authenticate(Authentication auth) {
        for (AuthenticationProvider provider : providers) {
            // 1. 이 Provider가 해당 Authentication 타입을 처리할 수 있는지 확인
            if (!provider.supports(auth.getClass())) {
                continue;
            }
            
            // 2. 인증 시도
            try {
                Authentication result = provider.authenticate(auth);
                if (result != null) {
                    // 인증 성공 → credentials 지우고 반환
                    ((AbstractAuthenticationToken) result)
                        .eraseCredentials();
                    return result;
                }
            } catch (AuthenticationException e) {
                lastException = e;
            }
        }
        
        // 3. 모든 Provider 실패 → 부모에게 위임
        if (parent != null) {
            return parent.authenticate(auth);
        }
        
        throw lastException;
    }
}

DaoAuthenticationProvider (기본 Provider)

// DaoAuthenticationProvider의 인증 과정
// 1. UserDetailsService.loadUserByUsername(username) 호출
// 2. PasswordEncoder.matches(rawPassword, encodedPassword) 비교
// 3. UserDetails의 계정 상태 확인 (만료, 잠금, 비활성화)
// 4. 성공 시 UsernamePasswordAuthenticationToken 반환

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authenticationProvider(daoAuthProvider())
            .build();
    }

    @Bean
    public DaoAuthenticationProvider daoAuthProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService());
        provider.setPasswordEncoder(passwordEncoder());
        // 사용자 없을 때도 같은 시간이 걸리도록 (타이밍 공격 방지)
        provider.setHideUserNotFoundExceptions(true);
        return provider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt가 기본, Argon2가 더 안전
        return new BCryptPasswordEncoder(12); // strength=12
        // 또는 Argon2:
        // return new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
    }
}

커스텀 AuthenticationProvider

// 예: API Key 인증 Provider
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
    private final String apiKey;
    
    // 인증 전
    public ApiKeyAuthenticationToken(String apiKey) {
        super(null);
        this.apiKey = apiKey;
        setAuthenticated(false);
    }
    
    // 인증 후
    public ApiKeyAuthenticationToken(String apiKey, 
            Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.apiKey = apiKey;
        setAuthenticated(true);
    }
    
    @Override
    public Object getCredentials() { return apiKey; }
    
    @Override
    public Object getPrincipal() { return apiKey; }
}

@Component
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private ApiKeyRepository apiKeyRepository;

    @Override
    public Authentication authenticate(Authentication auth) {
        String apiKey = (String) auth.getCredentials();
        
        ApiKeyEntity entity = apiKeyRepository.findByKey(apiKey)
            .orElseThrow(() -> new BadCredentialsException("Invalid API key"));
        
        if (entity.isExpired()) {
            throw new CredentialsExpiredException("API key expired");
        }
        
        List<GrantedAuthority> authorities = entity.getScopes().stream()
            .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
            .collect(Collectors.toList());
        
        return new ApiKeyAuthenticationToken(apiKey, authorities);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

// Filter에서 Authentication 객체 생성
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain) {
        
        String apiKey = request.getHeader("X-API-Key");
        
        if (apiKey != null) {
            ApiKeyAuthenticationToken token = 
                new ApiKeyAuthenticationToken(apiKey);
            
            Authentication result = authenticationManager.authenticate(token);
            SecurityContextHolder.getContext().setAuthentication(result);
        }
        
        chain.doFilter(request, response);
    }
}

다중 인증 전략

// JWT + API Key + 폼 로그인을 동시에 지원하는 구성
@Configuration
@EnableWebSecurity
public class MultiAuthConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/**").authenticated()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().permitAll()
            )
            // 여러 AuthenticationProvider 등록
            .authenticationProvider(daoAuthProvider())      // 폼 로그인
            .authenticationProvider(jwtAuthProvider())       // JWT
            .authenticationProvider(apiKeyAuthProvider())    // API Key
            // 커스텀 필터 추가
            .addFilterBefore(
                new ApiKeyAuthenticationFilter(authManager),
                UsernamePasswordAuthenticationFilter.class
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }

    // ProviderManager가 Authentication 타입별로 적합한 Provider를 자동 선택
    // UsernamePasswordAuthenticationToken → DaoAuthenticationProvider
    // BearerTokenAuthenticationToken → JwtAuthenticationProvider
    // ApiKeyAuthenticationToken → ApiKeyAuthenticationProvider
}

SecurityContext 전파

전파 전략 동작 사용 시점
MODE_THREADLOCAL 현재 스레드에만 (기본값) 일반 동기 처리
MODE_INHERITABLETHREADLOCAL 자식 스레드에 상속 @Async, CompletableFuture
MODE_GLOBAL 모든 스레드 공유 데스크톱 앱 (웹에서 사용 금지)
// @Async에서 SecurityContext 전파
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.initialize();
        // SecurityContext를 자식 스레드에 전파하는 데코레이터
        return new DelegatingSecurityContextAsyncTaskExecutor(executor);
    }
}

// Virtual Thread (Spring Boot 3.2+) 환경
// SecurityContextHolder 전략 설정
SecurityContextHolder.setStrategyName(
    SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
);

// 컨트롤러에서 인증 정보 접근
@GetMapping("/me")
public UserDto getCurrentUser(
        @AuthenticationPrincipal UserDetails userDetails) {
    // 방법 1: @AuthenticationPrincipal 어노테이션
    return UserDto.from(userDetails);
}

@GetMapping("/me2")
public UserDto getCurrentUser2() {
    // 방법 2: SecurityContextHolder 직접 접근
    Authentication auth = SecurityContextHolder.getContext()
        .getAuthentication();
    UserDetails user = (UserDetails) auth.getPrincipal();
    return UserDto.from(user);
}

인증 이벤트 활용

// Spring Security는 인증 성공/실패 시 이벤트를 발행
@Component
public class AuthenticationEventListener {

    @EventListener
    public void onSuccess(AuthenticationSuccessEvent event) {
        String username = event.getAuthentication().getName();
        log.info("로그인 성공: {}", username);
        // 로그인 이력 저장, 실패 카운터 초기화 등
    }

    @EventListener
    public void onFailure(AbstractAuthenticationFailureEvent event) {
        String username = event.getAuthentication().getName();
        log.warn("로그인 실패: {}, 원인: {}", 
            username, event.getException().getMessage());
        // 실패 횟수 증가, 계정 잠금 처리 등
    }
}

// 커스텀 이벤트 매핑
@Bean
public AuthenticationEventPublisher authEventPublisher(
        ApplicationEventPublisher publisher) {
    DefaultAuthenticationEventPublisher eventPublisher =
        new DefaultAuthenticationEventPublisher(publisher);
    // 특정 예외에 대한 커스텀 이벤트 매핑
    Map<Class<? extends AuthenticationException>, 
         Class<? extends AbstractAuthenticationFailureEvent>> mappings = 
        Map.of(LockedException.class, AuthenticationFailureLockdEvent.class);
    eventPublisher.setAdditionalExceptionMappings(mappings);
    return eventPublisher;
}

Spring Method Security 심화에서 인가(Authorization) 처리도 함께 확인하세요.

정리

Spring Security 인증 아키텍처의 핵심은 Filter → AuthenticationManager(ProviderManager) → AuthenticationProvider → UserDetailsService 체인입니다. ProviderManager가 Authentication 객체 타입에 따라 적합한 Provider를 자동 선택하므로, JWT·API Key·폼 로그인 등 다중 인증을 깔끔하게 구성할 수 있습니다. 커스텀 인증은 AuthenticationToken + AuthenticationProvider + Filter 세 가지를 구현하면 됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux