AuthenticationManager — 인증 요청은 내부에서 어떻게 처리될까
로그인 버튼을 누르면, 입력한 아이디와 비밀번호는 내부에서 어떤 경로를 거쳐 검증될까요?
Spring Security의 인증은 단순히 ID/PW를 비교하는 것이 아닙니다. AuthenticationManager → ProviderManager → AuthenticationProvider로 이어지는 체인 구조 로 동작하며, 이 구조를 이해하면 폼 로그인, OAuth2, API 키 인증 같은 다양한 방식을 유연하게 확장할 수 있습니다.
개념 정의
AuthenticationManager 는 인증 요청을 처리하는 최상위 인터페이스입니다. authenticate() 메서드 하나만 정의합니다.
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
인증 처리 흐름
사용자 로그인 요청 (username + password)
│
▼
AuthenticationFilter
│ UsernamePasswordAuthenticationToken 생성
▼
AuthenticationManager (인터페이스)
│
▼
ProviderManager (구현체)
│ AuthenticationProvider 목록 순회
▼
AuthenticationProvider (적합한 Provider)
│ supports() → true인 Provider가 처리
├── UserDetailsService.loadUserByUsername()
├── PasswordEncoder.matches()
│
├── 성공 → 인증된 Authentication 반환
└── 실패 → AuthenticationException
이 흐름의 핵심은 각 단계가 역할을 분리 한다는 점입니다. Filter는 토큰을 만들고, Manager는 적합한 Provider를 찾고, Provider가 실제 검증을 수행합니다.
Authentication 객체의 변화
인증 전후로 Authentication 객체의 상태가 달라집니다.
인증 전 — 인증 필터가 사용자 입력으로 토큰을 생성합니다.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(username, password);
// principal = username (String)
// credentials = password (String)
// authenticated = false
// authorities = 빈 목록
** 인증 후** — AuthenticationProvider가 검증을 마치고 인증된 토큰을 반환합니다.
UsernamePasswordAuthenticationToken authenticated =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
// principal = UserDetails (사용자 정보 객체)
// credentials = null (보안상 제거)
// authenticated = true
// authorities = [ROLE_USER, ROLE_ADMIN]
credentials가 null로 바뀌는 이유는, 인증이 끝난 후 비밀번호가 메모리에 남아있으면 보안 위험이 되기 때문입니다.
ProviderManager
AuthenticationManager의 기본 구현체입니다. 여러 AuthenticationProvider를 리스트로 가지고, supports()가 true를 반환하는 Provider를 찾아 인증을 위임합니다.
public class ProviderManager implements AuthenticationManager {
private List<AuthenticationProvider> providers;
private AuthenticationManager parent;
@Override
public Authentication authenticate(Authentication authentication) {
for (AuthenticationProvider provider : providers) {
if (!provider.supports(authentication.getClass())) {
continue;
}
Authentication result = provider.authenticate(authentication);
if (result != null) {
return result;
}
}
적합한 Provider가 없으면 ** 부모 ProviderManager**에 위임합니다. 이 계층 구조 덕분에 공통 Provider를 부모에 두고, 특화된 Provider만 자식에 두는 설계가 가능합니다.
if (parent != null) {
return parent.authenticate(authentication);
}
throw new ProviderNotFoundException("적합한 AuthenticationProvider 없음");
}
}
Provider 체인 구조를 시각화하면 다음과 같습니다.
ProviderManager
├── DaoAuthenticationProvider → 폼 로그인 (ID/PW)
├── JwtAuthenticationProvider → JWT 토큰 인증
├── OAuth2LoginAuthenticationProvider → OAuth2 로그인
└── parent: ProviderManager (공통 Provider)
└── AnonymousAuthenticationProvider → 익명 사용자
기본 Provider: DaoAuthenticationProvider
Spring Security가 제공하는 기본 Provider로, UserDetailsService로 사용자를 조회하고 PasswordEncoder로 비밀번호를 비교합니다.
public class DaoAuthenticationProvider
extends AbstractUserDetailsAuthenticationProvider {
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
@Override
protected UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken auth) {
return userDetailsService.loadUserByUsername(username);
}
조회된 UserDetails의 비밀번호와 사용자가 입력한 비밀번호를 PasswordEncoder.matches()로 비교합니다.
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken auth) {
String presentedPassword = auth.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다");
}
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
supports()가 UsernamePasswordAuthenticationToken만 처리한다고 선언하기 때문에, JWT 토큰이나 OAuth2 토큰이 들어오면 이 Provider는 건너뛰어집니다.
커스텀 Provider 구현 — API 키 인증
API 키 기반 인증을 예로 커스텀 Provider를 구현합니다.
1단계 — 커스텀 Authentication 토큰 정의. 인증 전(API 키만 보유)과 인증 후(principal + authorities 포함) 상태를 모두 표현합니다.
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final String apiKey;
private Object principal;
// 인증 전
public ApiKeyAuthenticationToken(String apiKey) {
super(null);
this.apiKey = apiKey;
setAuthenticated(false);
}
// 인증 후
public ApiKeyAuthenticationToken(Object principal, String apiKey,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.apiKey = apiKey;
setAuthenticated(true);
}
@Override
public Object getCredentials() { return apiKey; }
@Override
public Object getPrincipal() { return principal; }
}
2단계 — AuthenticationProvider 구현. supports()로 처리 가능한 토큰 타입을 명시하고, authenticate()에서 실제 검증을 수행합니다.
@Component
@RequiredArgsConstructor
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {
private final ApiKeyRepository apiKeyRepository;
@Override
public Authentication authenticate(Authentication authentication) {
String apiKey = (String) authentication.getCredentials();
ApiKeyEntity keyEntity = apiKeyRepository.findByKey(apiKey)
.orElseThrow(() -> new BadCredentialsException("유효하지 않은 API 키"));
if (keyEntity.isExpired()) {
throw new BadCredentialsException("만료된 API 키");
}
List<GrantedAuthority> authorities = keyEntity.getAuthorities().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new ApiKeyAuthenticationToken(keyEntity.getOwner(), apiKey, authorities);
}
@Override
public boolean supports(Class<?> authentication) {
return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
}
}
3단계 — SecurityConfig에 등록.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final ApiKeyAuthenticationProvider apiKeyProvider;
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder builder =
http.getSharedObject(AuthenticationManagerBuilder.class);
builder.authenticationProvider(apiKeyProvider);
return builder.build();
}
}
인증 실패 시 예외 종류
| 예외 | 원인 |
|---|---|
| BadCredentialsException | 비밀번호 불일치 |
| UsernameNotFoundException | 사용자를 찾을 수 없음 |
| DisabledException | 계정 비활성화 |
| LockedException | 계정 잠금 |
| AccountExpiredException | 계정 만료 |
| CredentialsExpiredException | 비밀번호 만료 |
주의할 점
supports()가 false를 반환하면 조용히 무시된다
커스텀 Provider를 만들었는데 supports()가 해당 Authentication 타입을 지원하지 않으면, 예외 없이 다음 Provider로 넘어갑니다. 디버깅할 때 supports()의 반환값을 먼저 확인해야 합니다.
ProviderManager의 부모 체인을 모르면 추적이 어렵다
ProviderManager에 parent가 설정되어 있으면, 자신의 Provider가 모두 실패했을 때 부모에게 위임합니다. "어디서 인증이 성공하는지" 추적하려면 부모 체인까지 확인해야 합니다. 디버깅 시 ProviderManager의 providers 리스트와 parent 필드를 로그로 출력하면 흐름을 파악하기 쉽습니다.
정리
| 항목 | 설명 |
|---|---|
| AuthenticationManager | authenticate() 하나의 메서드를 가진 인증 처리 인터페이스 |
| ProviderManager | 여러 AuthenticationProvider를 순회하며 적합한 Provider에 위임 |
| AuthenticationProvider | supports()로 처리 가능 여부 판단, authenticate()로 실제 인증 수행 |
| Authentication 객체 | 인증 후 Principal + Authorities 포함, Credentials는 보안상 제거 |
| 커스텀 인증 | AuthenticationToken + AuthenticationProvider를 구현하여 확장 |
| 부모 체인 | 자식 ProviderManager의 Provider가 모두 실패하면 부모에게 위임 |