Spring Security 인증 아키텍처
Spring Security의 인증(Authentication)은 여러 컴포넌트가 체인으로 연결된 구조입니다. HTTP 요청이 들어오면 Filter → AuthenticationManager → AuthenticationProvider → UserDetailsService 순서로 처리됩니다. 각 컴포넌트의 역할과 확장 포인트를 이해하면 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 세 가지를 구현하면 됩니다.