로그인 버튼을 누르면, 입력한 아이디와 비밀번호는 내부에서 어떤 경로를 거쳐 검증될까요?

Spring Security의 인증은 단순히 ID/PW를 비교하는 것이 아닙니다. AuthenticationManagerProviderManagerAuthenticationProvider로 이어지는 체인 구조 로 동작하며, 이 구조를 이해하면 폼 로그인, OAuth2, API 키 인증 같은 다양한 방식을 유연하게 확장할 수 있습니다.

개념 정의

AuthenticationManager 는 인증 요청을 처리하는 최상위 인터페이스입니다. authenticate() 메서드 하나만 정의합니다.

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

인증 처리 흐름

PLAINTEXT
사용자 로그인 요청 (username + password)


AuthenticationFilter
         │ UsernamePasswordAuthenticationToken 생성

AuthenticationManager (인터페이스)


ProviderManager (구현체)
         │ AuthenticationProvider 목록 순회

AuthenticationProvider (적합한 Provider)
         │ supports() → true인 Provider가 처리
         ├── UserDetailsService.loadUserByUsername()
         ├── PasswordEncoder.matches()

         ├── 성공 → 인증된 Authentication 반환
         └── 실패 → AuthenticationException

이 흐름의 핵심은 각 단계가 역할을 분리 한다는 점입니다. Filter는 토큰을 만들고, Manager는 적합한 Provider를 찾고, Provider가 실제 검증을 수행합니다.

Authentication 객체의 변화

인증 전후로 Authentication 객체의 상태가 달라집니다.

인증 전 — 인증 필터가 사용자 입력으로 토큰을 생성합니다.

JAVA
UsernamePasswordAuthenticationToken token =
    new UsernamePasswordAuthenticationToken(username, password);
// principal = username (String)
// credentials = password (String)
// authenticated = false
// authorities = 빈 목록

** 인증 후** — AuthenticationProvider가 검증을 마치고 인증된 토큰을 반환합니다.

JAVA
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를 찾아 인증을 위임합니다.

JAVA
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만 자식에 두는 설계가 가능합니다.

JAVA
        if (parent != null) {
            return parent.authenticate(authentication);
        }
        throw new ProviderNotFoundException("적합한 AuthenticationProvider 없음");
    }
}

Provider 체인 구조를 시각화하면 다음과 같습니다.

PLAINTEXT
ProviderManager
├── DaoAuthenticationProvider       → 폼 로그인 (ID/PW)
├── JwtAuthenticationProvider       → JWT 토큰 인증
├── OAuth2LoginAuthenticationProvider → OAuth2 로그인
└── parent: ProviderManager (공통 Provider)
    └── AnonymousAuthenticationProvider → 익명 사용자

기본 Provider: DaoAuthenticationProvider

Spring Security가 제공하는 기본 Provider로, UserDetailsService로 사용자를 조회하고 PasswordEncoder로 비밀번호를 비교합니다.

JAVA
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()로 비교합니다.

JAVA
    @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 포함) 상태를 모두 표현합니다.

JAVA
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {

    private final String apiKey;
    private Object principal;

    // 인증 전
    public ApiKeyAuthenticationToken(String apiKey) {
        super(null);
        this.apiKey = apiKey;
        setAuthenticated(false);
    }
JAVA
    // 인증 후
    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()에서 실제 검증을 수행합니다.

JAVA
@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 키"));
JAVA
        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에 등록.

JAVA
@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가 모두 실패했을 때 부모에게 위임합니다. "어디서 인증이 성공하는지" 추적하려면 부모 체인까지 확인해야 합니다. 디버깅 시 ProviderManagerproviders 리스트와 parent 필드를 로그로 출력하면 흐름을 파악하기 쉽습니다.


정리

항목설명
AuthenticationManagerauthenticate() 하나의 메서드를 가진 인증 처리 인터페이스
ProviderManager여러 AuthenticationProvider를 순회하며 적합한 Provider에 위임
AuthenticationProvidersupports()로 처리 가능 여부 판단, authenticate()로 실제 인증 수행
Authentication 객체인증 후 Principal + Authorities 포함, Credentials는 보안상 제거
커스텀 인증AuthenticationToken + AuthenticationProvider를 구현하여 확장
부모 체인자식 ProviderManager의 Provider가 모두 실패하면 부모에게 위임
댓글 로딩 중...