UserDetailsService — 사용자 정보는 어디에서 어떻게 가져올까
로그인할 때 입력한 사용자 이름으로 DB에서 사용자 정보를 가져오는 부분은 누가 담당할까요?
Spring Security가 인증을 수행하려면, "이 username에 해당하는 사용자가 누구인지"를 어딘가에서 가져와야 합니다. UserDetailsService 는 이 역할을 담당하는 인터페이스로, 사용자 데이터의 출처(DB, LDAP, 메모리 등)를 추상화합니다.
개념 정의
UserDetailsService 는 사용자 이름(username)으로 사용자 정보를 로드하는 단일 메서드 인터페이스입니다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
인증 흐름에서의 위치
AuthenticationFilter
→ AuthenticationManager
→ DaoAuthenticationProvider
→ UserDetailsService.loadUserByUsername() ← 여기
→ PasswordEncoder.matches()
DaoAuthenticationProvider가 사용자 정보를 필요로 할 때 UserDetailsService에 위임합니다. 즉 UserDetailsService는 "사용자를 어디서 어떻게 가져올지"만 책임지고, 비밀번호 비교는 PasswordEncoder가 담당합니다.
UserDetails 인터페이스
loadUserByUsername()이 반환하는 객체 타입입니다. 사용자의 인증 정보와 계정 상태를 담습니다.
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 클래스의 빌더를 사용합니다.
@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로 구현하면 인증 후 사용자 정보에 바로 접근할 수 있습니다.
@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의 메서드를 엔티티 필드에 위임합니다.
@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 의존성을 넣고 싶지 않을 때, 래퍼 클래스로 분리합니다.
@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()));
}
래퍼에서 도메인 엔티티의 추가 정보에도 접근할 수 있습니다.
@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 구현은 다음과 같습니다.
@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로 컨트롤러에서 바로 받을 수 있습니다.
@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가 비교할 수 있습니다.
@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()를 사용합니다. 구현하지 않으면 같은 사용자의 두 번째 로그인을 다른 사용자로 인식하여 동시 세션 제한이 무력화됩니다.
정리
| 항목 | 설명 |
|---|---|
| UserDetailsService | loadUserByUsername() 하나의 메서드로 사용자 조회를 추상화 |
| UserDetails | 인증 정보(이름, 비밀번호, 권한)와 계정 상태를 담는 인터페이스 |
| 구현 방식 | User.builder() / 엔티티 직접 구현 / 래퍼 클래스 |
| 인증 후 접근 | @AuthenticationPrincipal로 컨트롤러에서 직접 수신 |
| password 규칙 | 반드시 인코딩된 상태로 저장, DaoAuthenticationProvider가 비교 |
| equals/hashCode | 세션 기반 동시 접속 제어 시 구현 필수 |