Spring Security 인증 아키텍처
Spring Security의 인증(Authentication)은 AuthenticationManager → AuthenticationProvider → UserDetailsService 순서로 위임됩니다. 기본 제공 구현체만으로도 충분하지만, 실무에서는 LDAP 연동, 다중 인증 소스, OTP 검증 등 커스텀 인증 로직이 필요한 경우가 대부분입니다.
Spring Security Filter Chain에서 다룬 것처럼 요청은 FilterChain을 거쳐 UsernamePasswordAuthenticationFilter에 도달하고, 여기서 AuthenticationManager에 인증을 위임합니다.
AuthenticationProvider 커스터마이징
AuthenticationProvider는 실제 인증 로직을 수행하는 핵심 인터페이스입니다.
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final OtpService otpService;
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// 1. 사용자 조회
UserDetails user = userDetailsService.loadUserByUsername(username);
// 2. 비밀번호 검증
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("잘못된 비밀번호입니다.");
}
// 3. 계정 상태 확인
if (!user.isEnabled()) {
throw new DisabledException("비활성화된 계정입니다.");
}
// 4. 추가 검증 (OTP 등)
String otp = ((CustomAuthenticationToken) authentication).getOtp();
if (otp != null && !otpService.verify(username, otp)) {
throw new BadCredentialsException("OTP 검증 실패");
}
return new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return CustomAuthenticationToken.class
.isAssignableFrom(authentication);
}
}
supports() 메서드가 중요합니다. AuthenticationManager는 여러 Provider를 순회하며, supports()가 true를 반환하는 Provider에게 인증을 위임합니다.
커스텀 Authentication Token
OTP나 디바이스 정보 같은 추가 데이터를 전달하려면 커스텀 Token이 필요합니다.
public class CustomAuthenticationToken
extends UsernamePasswordAuthenticationToken {
private final String otp;
private final String deviceId;
// 인증 전 (credentials 포함)
public CustomAuthenticationToken(
String username, String password,
String otp, String deviceId) {
super(username, password);
this.otp = otp;
this.deviceId = deviceId;
}
// 인증 후 (authorities 포함)
public CustomAuthenticationToken(
Object principal,
Collection<? extends GrantedAuthority> authorities,
String deviceId) {
super(principal, null, authorities);
this.otp = null;
this.deviceId = deviceId;
}
public String getOtp() { return otp; }
public String getDeviceId() { return deviceId; }
}
UserDetailsService 구현
DB에서 사용자를 조회하는 UserDetailsService 구현체입니다.
@Service
@RequiredArgsConstructor
public class JpaUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional(readOnly = true)
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())
.authorities(member.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.toList())
.accountLocked(member.isLocked())
.disabled(!member.isActive())
.accountExpired(member.isExpired())
.credentialsExpired(member.isCredentialsExpired())
.build();
}
}
PasswordEncoder 전략
| 인코더 | 특징 | 권장 여부 |
|---|---|---|
BCryptPasswordEncoder |
기본값, strength 조절 가능 | ✅ 대부분의 경우 |
Argon2PasswordEncoder |
메모리 기반, GPU 공격 방어 | ✅ 높은 보안 요구 |
SCryptPasswordEncoder |
CPU+메모리 집약적 | ⚠️ 특수 환경 |
DelegatingPasswordEncoder |
다중 인코더 지원, 마이그레이션 | ✅ 레거시 전환 |
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// DelegatingPasswordEncoder: 신규는 bcrypt, 레거시 sha256도 지원
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("sha256", new StandardPasswordEncoder());
DelegatingPasswordEncoder delegate =
new DelegatingPasswordEncoder("bcrypt", encoders);
delegate.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder(12));
return delegate;
}
}
DelegatingPasswordEncoder는 저장된 비밀번호의 접두사({bcrypt}, {argon2})로 어떤 인코더를 사용할지 자동 판단합니다. 레거시 시스템에서 점진적으로 인코딩 방식을 전환할 때 필수입니다.
다중 AuthenticationProvider 구성
DB 인증과 LDAP 인증을 동시에 지원하는 구성입니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomAuthenticationProvider dbProvider;
private final LdapAuthenticationProvider ldapProvider;
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(List.of(
dbProvider, // DB 인증 먼저 시도
ldapProvider // 실패 시 LDAP 시도
));
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authenticationManager(authenticationManager())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.build();
}
}
ProviderManager는 첫 번째 Provider가 AuthenticationException을 던지면 다음 Provider로 넘어갑니다. supports()가 false면 아예 건너뜁니다.
로그인 실패 제한(Account Lockout)
Spring Bucket4j Rate Limiting과 별도로, 인증 레벨에서 로그인 시도 횟수를 제한하는 패턴입니다.
@Component
@RequiredArgsConstructor
public class LoginAttemptService {
private final LoadingCache<String, Integer> attemptsCache =
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(15))
.build(key -> 0);
private static final int MAX_ATTEMPTS = 5;
public void loginFailed(String username) {
int attempts = attemptsCache.get(username, k -> 0) + 1;
attemptsCache.put(username, attempts);
}
public void loginSucceeded(String username) {
attemptsCache.invalidate(username);
}
public boolean isBlocked(String username) {
return attemptsCache.get(username, k -> 0) >= MAX_ATTEMPTS;
}
}
// AuthenticationProvider에서 활용
@Override
public Authentication authenticate(Authentication auth) {
String username = auth.getName();
if (loginAttemptService.isBlocked(username)) {
throw new LockedException("로그인 시도 초과. 15분 후 재시도하세요.");
}
try {
// ... 인증 로직
loginAttemptService.loginSucceeded(username);
return result;
} catch (BadCredentialsException e) {
loginAttemptService.loginFailed(username);
throw e;
}
}
정리
Spring Security 인증 커스터마이징의 핵심은 AuthenticationProvider, UserDetailsService, PasswordEncoder 세 계층을 용도에 맞게 구현하는 것입니다. 커스텀 Token으로 추가 인증 데이터를 전달하고, DelegatingPasswordEncoder로 레거시를 점진 전환하며, 다중 Provider로 여러 인증 소스를 지원할 수 있습니다. 실무에서는 반드시 로그인 실패 제한과 계정 잠금 로직을 함께 구현하세요.