WIL/웹 개발

UserDetailsService.loadUserByUsername에 파라미터를 추가하고 싶을 때: 불가능한 이유와 대안

아크리미츠 2025. 10. 10. 16:20

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 경로를 우회(테스트/감사지표 일관성 고려 필요)