Spring Security + JWT 정리: Provider 인증 흐름, CustomUserDetails 설계

오병택·2026년 2월 3일
post-thumbnail

전체 그림: “로그인”과 “JWT 인증”은 서로 다른 길을 탄다

로그인(아이디/비밀번호)

AuthenticationManagerAuthenticationProvider(주로 DaoAuthenticationProvider)가 처리
→ 내부에서 UserDetailsService.loadUserByUsername() 호출 + PasswordEncoder.matches()로 비밀번호 검증

로그인 이후 요청(JWT)

→ JWT 필터/TokenProvider에서 토큰 검증(서명/만료 등)
→ 토큰에서 userId 꺼내 loadUserById()로 사용자 로딩
SecurityContextHolderAuthentication 직접 넣음

즉, 로그인은 Provider 구조를 타고, JWT는 토큰 검증 후 인증 객체를 만들어 넣는 구조가 보통이다.

1) CustomUserDetailsService extends UserDetailsService를 인터페이스로 만드는 이유

Spring Security 표준 계약은 UserDetailsService이고, 거기엔 기본적으로:

loadUserByUsername(String username)

만 존재한다.

하지만 JWT 인증에서는 토큰에서 userId를 꺼내 조회하는 경우가 많아서:

loadUserById(Long userId)

가 필요해진다.

그래서 확장 인터페이스로 “우리 앱의 계약”을 만든다:

public interface CustomUserDetailsService extends UserDetailsService {
    UserDetails loadUserById(Long userId);
}

장점

  • Spring Security 호환(Username 기반 메서드 유지)

  • JWT용 ID 기반 조회 계약을 타입으로 명확히 보장

  • 구현체가 한 곳에서 두 방식을 모두 책임

2) 로그인에서 “username으로 로딩만 하면 끝인가?” → 아니면 검증은 어디서?

로그인 흐름에서 보통 여기까지만 한다:

  • loadUserByUsername(email)에서 유저 조회 후 UserDetails 반환

비밀번호 검증은 보통 Provider가 내부에서 한다:

  • DaoAuthenticationProviderPasswordEncoder.matches(raw, encoded)로 비교

즉 비밀번호 비교 로직을 직접 짤 필요가 없다(기본 흐름에서는).

3) AuthenticationManager를 이렇게 등록하면 Provider가 자동 구성되나?

설정:

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
    return configuration.getAuthenticationManager();
}

이 방식은 “시큐리티가 구성한 AuthenticationManager를 가져오는 방식”이다.
대부분의 기본 구성에서, UserDetailsServicePasswordEncoder가 준비되어 있으면
내부적으로 DaoAuthenticationProvider 기반 매니저가 구성되어 로그인 인증을 처리한다.

다만 “자동”에 의존할수록 눈에 보이지 않아 불안할 수 있으니,
확인하려면 ProviderManager의 providers를 로그로 찍어서 검증할 수 있다.

4) UserDetails를 “기본 User”로 쓸지, CustomUserDetails를 만들지

Spring Security 기본 User(구현체) 사용

  • username/password + authorities 정도면 충분할 때 간단

CustomUserDetails 사용

  • JWT에서 userId가 필요함

  • 컨트롤러에서 @AuthenticationPrincipal로 id 같은 추가 정보가 필요함

  • 계정 상태 정책(삭제/정지/휴면 등)을 Security 상태로 매핑하고 싶음

추천

  • 엔티티(User)를 통째로 principal에 들고 다니기보단, 필요한 값만 CustomUserDetails에 담는 방식(가볍고 안전).

5) isEnabled()를 deletedAt 기반으로 써도 되나? + 도메인 캡슐화 vs 직접 계산

deletedAt 기반 enabled → OK

@Override
public boolean isEnabled() {
    return deletedAt == null;
}

다만 정책이 “삭제” 외에도 있으면 함께 반영하는 게 보통 더 현실적:

  • 정지/휴면/미인증 등

직접 계산

이 방식은 User 도메인에 따로 메서드를 만들어주지 않고 사용되는 메서드에 직접 사용하는 방식

도메인 캡슐화가 보통 더 좋다

규칙이 여기저기 흩어지지 않게, 도메인 언어로 캡슐화:

// User 도메인
public boolean isActive() {
    return deletedAt == null && status == ACTIVE;
}

그리고 Security에서 매핑:

@Override
public boolean isEnabled() {
    return user.isActive();
}

주의: 도메인에 Security 용어(isEnabled)를 박는 것보다 canLogin()/isActive() 같은 도메인 용어가 결합도가 낮다.

6) @NullMarked 관련 경고: “Not annotated method overrides method annotated with @NullMarked”

이 경고는 상위 메서드/패키지가 @NullMarked(기본 non-null)인데,
오버라이드 쪽이 null 계약을 명확히 표현하지 않을 때 분석기가 띄우는 케이스가 많다.

7) JWT에서 Authentication을 왜 UsernamePasswordAuthenticationToken으로 만들까?

이 클래스는 로그인 전용이 아니라,
Spring Security에서 “인증 결과(Principal + Authorities)”를 담는 가장 흔한 Authentication 구현체다.

JWT에서는 비밀번호(credential)가 없으니 null로 두는 게 일반적:

return new UsernamePasswordAuthenticationToken(
    userDetails, null, userDetails.getAuthorities()
);

8) principal에 password(해시)가 남는 것도 양보 못한다면

Spring Security도 그걸 고려해서 인증 후 자격증명 지우기(erase)를 함

핵심 포인트

  • ProviderManager는 기본적으로 eraseCredentialsAfterAuthentication = true 라서
    인증 성공 후 credentials를 지움

  • 여기서 principal(UserDetails)까지 지우고 싶으면, CustomUserDetailsCredentialsContainer를 구현하면 돼.

예:

public class CustomUserDetails implements UserDetails, CredentialsContainer {
    private String password; // encoded

    @Override
    public void eraseCredentials() {
        this.password = null;
    }
}

이렇게 해두면 로그인 성공 후에 principal 내부의 password 해시도 null로 지워질 수 있어.
(“로그인” 흐름에서 Provider를 태울 때 특히 효과적)

profile
걱정하지 말고 일단 해봐!

0개의 댓글