로그인할 때 입력한 사용자 이름으로 DB에서 사용자 정보를 가져오는 부분은 누가 담당할까요?

Spring Security가 인증을 수행하려면, "이 username에 해당하는 사용자가 누구인지"를 어딘가에서 가져와야 합니다. UserDetailsService 는 이 역할을 담당하는 인터페이스로, 사용자 데이터의 출처(DB, LDAP, 메모리 등)를 추상화합니다.

개념 정의

UserDetailsService 는 사용자 이름(username)으로 사용자 정보를 로드하는 단일 메서드 인터페이스입니다.

JAVA
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

인증 흐름에서의 위치

PLAINTEXT
AuthenticationFilter
    → AuthenticationManager
        → DaoAuthenticationProvider
            → UserDetailsService.loadUserByUsername()  ← 여기
            → PasswordEncoder.matches()

DaoAuthenticationProvider가 사용자 정보를 필요로 할 때 UserDetailsService에 위임합니다. 즉 UserDetailsService는 "사용자를 어디서 어떻게 가져올지"만 책임지고, 비밀번호 비교는 PasswordEncoder가 담당합니다.

UserDetails 인터페이스

loadUserByUsername()이 반환하는 객체 타입입니다. 사용자의 인증 정보와 계정 상태를 담습니다.

JAVA
public interface UserDetails extends Serializable {
    String getUsername();
    String getPassword();                    // 인코딩된 비밀번호
    Collection<? extends GrantedAuthority> getAuthorities();
    boolean isAccountNonExpired();           // 계정 만료 여부
    boolean isAccountNonLocked();            // 계정 잠금 여부
    boolean isCredentialsNonExpired();       // 비밀번호 만료 여부
    boolean isEnabled();                     // 계정 활성화 여부
}

네 가지 상태 메서드(isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired, isEnabled) 중 하나라도 false를 반환하면, DaoAuthenticationProvider가 인증을 거부합니다.

커스텀 UserDetails 구현

방법 1: User.builder() 사용

가장 간단한 방법입니다. Spring Security가 제공하는 User 클래스의 빌더를 사용합니다.

JAVA
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    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())
            .roles(member.getRole().name())  // "USER" → "ROLE_USER"로 자동 변환
            .accountLocked(!member.isActive())
            .build();
    }
}

roles()는 내부적으로 ROLE_ 접두사를 자동으로 붙입니다. "USER"를 전달하면 ROLE_USER로 변환됩니다.

방법 2: 엔티티에 UserDetails 직접 구현

엔티티 자체를 UserDetails로 구현하면 인증 후 사용자 정보에 바로 접근할 수 있습니다.

JAVA
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member implements UserDetails {

    @Id @GeneratedValue
    private Long id;
    private String email;
    private String password;
    private String nickname;
    @Enumerated(EnumType.STRING)
    private Role role;
    private boolean active = true;

UserDetails의 메서드를 엔티티 필드에 위임합니다.

JAVA
    @Override
    public String getUsername() { return email; }
    @Override
    public String getPassword() { return password; }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }
    @Override
    public boolean isAccountNonExpired() { return true; }
    @Override
    public boolean isAccountNonLocked() { return active; }
    @Override
    public boolean isCredentialsNonExpired() { return true; }
    @Override
    public boolean isEnabled() { return active; }
}

방법 3: 래퍼 클래스

엔티티에 Spring Security 의존성을 넣고 싶지 않을 때, 래퍼 클래스로 분리합니다.

JAVA
@Getter
public class CustomUserDetails implements UserDetails {

    private final Member member;

    public CustomUserDetails(Member member) {
        this.member = member;
    }

    @Override
    public String getUsername() { return member.getEmail(); }
    @Override
    public String getPassword() { return member.getPassword(); }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name()));
    }

래퍼에서 도메인 엔티티의 추가 정보에도 접근할 수 있습니다.

JAVA
    @Override
    public boolean isAccountNonExpired() { return true; }
    @Override
    public boolean isAccountNonLocked() { return member.isActive(); }
    @Override
    public boolean isCredentialsNonExpired() { return true; }
    @Override
    public boolean isEnabled() { return member.isActive(); }

    public Long getMemberId() { return member.getId(); }
    public String getNickname() { return member.getNickname(); }
}

이 래퍼를 반환하는 UserDetailsService 구현은 다음과 같습니다.

JAVA
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "사용자를 찾을 수 없습니다: " + username));
        return new CustomUserDetails(member);
    }
}

인증 후 사용자 정보 접근

@AuthenticationPrincipal로 컨트롤러에서 바로 받을 수 있습니다.

JAVA
@GetMapping("/api/me")
public MemberDto getCurrentUser(
        @AuthenticationPrincipal CustomUserDetails userDetails) {
    return new MemberDto(
        userDetails.getMemberId(),
        userDetails.getUsername(),
        userDetails.getNickname()
    );
}

SecurityContextHolder에서 직접 꺼내는 방법도 있지만, @AuthenticationPrincipal이 더 간결하고 테스트하기 쉽습니다.

비밀번호 인코딩 연동

UserDetailsService가 반환하는 UserDetails의 password는 반드시 인코딩된 상태 여야 합니다. 회원가입 시 PasswordEncoder.encode()로 해싱해서 저장해야 DaoAuthenticationProvider가 비교할 수 있습니다.

JAVA
@Service
@RequiredArgsConstructor
public class MemberService {

    private final PasswordEncoder passwordEncoder;
    private final MemberRepository memberRepository;

    public void register(RegisterRequest request) {
        Member member = Member.builder()
            .email(request.getEmail())
            .password(passwordEncoder.encode(request.getPassword()))
            .role(Role.USER)
            .build();
        memberRepository.save(member);
    }
}

주의할 점

loadUserByUsername에서 null을 반환하면 안 된다

사용자를 찾지 못하면 반드시 UsernameNotFoundException을 던져야 합니다. null을 반환하면 NullPointerException이 발생하고, 에러 메시지가 모호해져 디버깅이 어려워집니다.

UserDetails 구현체에 equals/hashCode를 구현하지 않으면 세션 관리에 문제가 생긴다

세션 기반 인증에서 SessionRegistry가 동시 세션을 관리할 때 UserDetails의 equals()를 사용합니다. 구현하지 않으면 같은 사용자의 두 번째 로그인을 다른 사용자로 인식하여 동시 세션 제한이 무력화됩니다.


정리

항목설명
UserDetailsServiceloadUserByUsername() 하나의 메서드로 사용자 조회를 추상화
UserDetails인증 정보(이름, 비밀번호, 권한)와 계정 상태를 담는 인터페이스
구현 방식User.builder() / 엔티티 직접 구현 / 래퍼 클래스
인증 후 접근@AuthenticationPrincipal로 컨트롤러에서 직접 수신
password 규칙반드시 인코딩된 상태로 저장, DaoAuthenticationProvider가 비교
equals/hashCode세션 기반 동시 접속 제어 시 구현 필수
댓글 로딩 중...