TL; DR
loadUserByUsername(String)시그니처는 변경 불가: 스프링 시큐리티 표준 인터페이스라 메서드 시그니처를 바꿀 수 없다.- 대안 1 — username 인코딩:
email|type형태로 합쳐서loadUserByUsername내부에서 파싱 후 위임. - 대안 2 — 커스텀 AuthenticationProvider: 타입을 포함한 토큰/프로바이더를 만들어 검증.
- 대안 3 — authenticate 없이(수동 검증+JWT/소셜): 표준 Provider를 거치지 않고 직접 검증 또는 외부 검증 후 JWT 발급.
왜 시그니처를 바꿀 수 없나?
UserDetailsService는 스프링 시큐리티가 사용하는 계약(Contract) 이고,DaoAuthenticationProvider등 표준 컴포넌트가 그대로 호출한다.- 메서드 시그니처를 변경하면 표준 흐름과의 호환성이 깨진다. 따라서 다른 방법으로 타입 등 추가 정보를 제공해야 한다.
문제 상황: 이메일 단독이 유니크가 아닐 때
- 도메인 제약이
email + type유니크라면,String username하나로는 사용자를 특정할 수 없다. - 이럴 때는 아래 대안 중 하나로 “타입” 정보를 함께 전달·복원해야 한다.
UserDetailsService는 왜 쓰는가?
- 표준 인증 계약:
DaoAuthenticationProvider가 비밀번호 로그인 시UserDetailsService.loadUserByUsername를 호출해 사용자 정보를 로드하고, 비밀번호 검증/권한/계정 상태(활성/잠김/만료 등)를 확인한다. - 권한 모델 일원화:
UserDetails로 계정 상태와 권한을 한 곳에서 정의해, 폼 로그인/필터/테스트 전반에서 일관되게 사용 가능하다. - 확장 지점: AOP 로깅, 감사(audit), 이벤트(
AuthenticationEventPublisher) 등 스프링 시큐리티 생태계와 자연스럽게 연결된다. - 우리 프로젝트에서는 JWT 필터에서도
UserDetailsService를 통해email + type로 로딩하여, 토큰 검증 후SecurityContextHolder를 구성한다.
그럼 이걸 안 쓰면 스프링은 정상 작동하나?
결론적으로, 작동한다(조건부). 다만 표준 흐름을 우회하므로 당신이 책임져야 할 범위가 늘어난다.
- Stateless + JWT 중심 아키텍처에서는, 로그인 시점에 직접 사용자 조회/비밀번호 검증 후 JWT만 발급하고, 이후 요청에서 JWT 필터가 검증·컨텍스트 세팅을 수행하면 애플리케이션은 정상 동작한다. (
AuthenticationManager.authenticate/DaoAuthenticationProvider경로를 반드시 쓸 필요는 없음) - 대신 잃는 것:
- 표준 Provider 경로와의 호환성(계정 잠금/비활성/만료 정책, 실패 이벤트, 내장 예외 처리 등)을 스스로 구현/유지해야 함
- 비밀번호 인코딩/검증, 예외 매핑, 정책 일관성에 대한 책임 증가
- 팀/테스트 레벨에서 기대하는 시큐리티 이벤트/메트릭 미연계 가능성
- 소셜 로그인(OAuth)의 경우, 외부 제공자에서 이미 신원 검증이 끝났으므로
authenticate없이도 정상. 그래도 사용자 상태/권한 일관성을 위해UserDetailsService로 로드해 JWT를 발급하는 편이 안전하다.
요약: UserDetailsService는 “스프링 시큐리티 표준 인증 계약”을 구현하는 핵심 컴포넌트다. JWT 중심이라도 표준 흐름을 완전히 버리기보다, 필요한 최소한으로 활용하거나 명시적으로 대체하는 것이 유지보수에 유리하다.
대안 1) username 인코딩(간단)
loadUserByUsername에 그대로 들어오는 문자열에 타입을 인코딩해 전달하고, 내부에서 파싱해 실제 메서드로 위임한다.
예시
// 컨트롤러/서비스: 로그인 시 principal을 "email|type" 형태로 전달
String principal = email + "|" + type.name();
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(principal, rawPassword)
);
// UserDetailsService 구현
@Override
public UserDetails loadUserByUsername(String principal) throws UsernameNotFoundException {
String[] parts = principal.split("\\|", 2);
if (parts.length != 2) throw new UsernameNotFoundException(principal);
String email = parts[0];
MemberType type = MemberType.valueOf(parts[1]);
return loadUserByEmailAndType(email, type);
}
장단점
- 장점: 구현이 단순, 기존 Provider 재사용 가능
- 단점: 문자열 포맷 의존(양쪽 일치 필요), principal 노출 포맷에 타입이 포함됨
대안 2) 커스텀 AuthenticationProvider/Token(정석, 확장성 높음)
타입을 필드로 포함한 토큰과 이를 처리하는 Provider를 구현한다.
예시
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(email));
return new MemberDetails(member);
}
// 커스텀 loadUserByUsername 대체 메소드
public UserDetails loadUserByEmailAndType(String email, MemberType type) throws UsernameNotFoundException {
Member member = memberRepository.findByEmailAndType(email, type)
.orElseThrow(() -> new UsernameNotFoundException(email + "|" + type));
return new MemberDetails(member);
}
}
// 커스텀 토큰
public class CustomAuthenticationToken extends UsernamePasswordAuthenticationToken {
private final MemberType memberType;
public CustomAuthenticationToken(String email, String password, MemberType type) {
super(email, password);
this.memberType = type;
}
public MemberType getMemberType() { return memberType; }
}
// 커스텀 Provider
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final MemberDetailsService memberDetailsService;
private final PasswordEncoder passwordEncoder;
public CustomAuthenticationProvider(MemberDetailsService uds, PasswordEncoder pe) {
this.memberDetailsService = uds; this.passwordEncoder = pe;
}
@Override
public Authentication authenticate(Authentication authentication) {
CustomAuthenticationToken token = (CustomAuthenticationToken) authentication;
UserDetails user = memberDetailsService.loadUserByEmailAndType(
(String) token.getPrincipal(), token.getMemberType());
if (!passwordEncoder.matches((String) token.getCredentials(), user.getPassword())) {
throw new BadCredentialsException("Invalid credentials");
}
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return CustomAuthenticationToken.class.isAssignableFrom(authentication);
}
}
// 보안 설정 등록
@Bean
public AuthenticationManager authenticationManager(PasswordEncoder pe, MemberDetailsService uds) {
return new ProviderManager(List.of(new CustomAuthenticationProvider(uds, pe)));
}
장단점
- 장점: 타입 안전, 명확한 책임 분리, 확장 용이
- 단점: 컴포넌트 추가 필요(Provider/Token/설정)
대안 3) authenticate 없이: 수동 검증 + JWT / 소셜 로그인
authenticate 없이 서비스에서 직접 사용자 조회와 비밀번호 검증을 수행한 뒤 JWT를 발급한다. 애플리케이션이 stateless 라면 유효한 접근 방식이지만 Provider 경로를 우회하므로 테스트/감사지표 일관성 고려가 필요하다.
수동 검증 + JWT 예시
MemberDetails user = (MemberDetails) memberDetailsService.loadUserByEmailAndType(email, type);
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new BadCredentialsException("Invalid credentials");
}
String accessToken = jwtUtil.generateJwtToken(user, type);
String refreshToken = jwtUtil.generateRefreshToken(user);
소셜 로그인 예시
외부 제공자(카카오, 구글 등)에서 이미 신원 검증이 이루어지므로, 우리 서비스는 loadUserByEmailAndType 후 바로 JWT 발급만 수행한다. authenticate가 필요 없다.
MemberDetails user = (MemberDetails) memberDetailsService.loadUserByEmailAndType(email, type);
String accessToken = jwtUtil.generateJwtToken(user, type);
String refreshToken = jwtUtil.generateRefreshToken(user);
장단점
- 장점: 구현 단순, 현재 JWT 필터 기반 흐름과 자연스럽게 연결
- 단점: 표준 Provider 경로를 우회(테스트/감사지표 일관성 고려 필요)
'WIL > 웹 개발' 카테고리의 다른 글
| Spring Security 중심으로 보는 OAuth2 소셜 로그인 (0) | 2025.10.31 |
|---|---|
| Next.js + TypeScript + App Router 환경에서 tsconfig 타입 꼬임 (0) | 2025.03.27 |
| Windows에서 Docker로 Ubuntu 서버 만들기 2: Ubuntu 개발 환경 초기 설정 (0) | 2025.03.24 |
| Windows에서 Docker로 Ubuntu 서버 만들기 1 (0) | 2025.03.11 |
| 프로토타입과 클래스 (0) | 2024.12.11 |