Engineering Note

[Error Handling] Spring Security 로그인 테스트에서 userParameter 설정 이슈 해결 본문

Error Handling

[Error Handling] Spring Security 로그인 테스트에서 userParameter 설정 이슈 해결

Software Engineer Kim 2025. 9. 12. 20:31

문제 상황

Spring Security를 사용한 로그인 기능 테스트 중 인증이 실패하는 문제가 발생했습니다. 테스트 실행 시 loadUserByUsername 메소드에 null 값이 전달되어 회원을 찾을 수 없다는 오류가 발생했다.

환경 설정

HTML 로그인 폼

<form role="form" method="post" action="/members/login">
    <div class="form-group">
        <label th:for="email">이메일주소</label>
        <input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요">
    </div>
    <div class="form-group">
        <label th:for="password">비밀번호</label>
        <input type="password" name="password" id="password" class="form-control" placeholder="비밀번호 입력">
    </div>
    <button class="btn btn-primary">로그인</button>
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>

Security 설정

.formLogin(form -> form
    .loginPage("/members/login")
    .defaultSuccessUrl("/")
    .usernameParameter("email")  // 중요: form의 name="email"과 매핑
    .failureUrl("/members/login/error")
)

발생한 문제

실패한 테스트 코드

@Test
@DisplayName("로그인 성공 테스트")
public void loginSuccessTest() throws Exception{
    String email = "test@email.com";
    String password = "12345678";
    this.createMember(email, password);

    mockMvc.perform(formLogin()
            .user(email)
            .password(password)
            .loginProcessingUrl("/members/login"))
        .andExpect(authenticated())
        .andExpect(status().is3xxRedirection())
        .andExpect(redirectedUrl("/"));
}

발생한 오류

  • loadUserByUsername 메소드에 빈 문자열 전달
  • "회원 데이터 없음" 로그 출력
  • 인증 실패로 /members/login/error로 리다이렉트

원인 분석

Spring Security의 파라미터 처리 방식

  1. 일반 Spring MVC 요청
    • form의 name 속성이 Controller의 DTO 필드와 자동 매핑
    • <input name="email"> → memberDto.email
  2. Spring Security 로그인 요청
    • Security Filter가 요청을 가로채서 직접 처리
    • DTO를 거치지 않고 바로 loadUserByUsername으로 전달
    • 기본적으로 name="username" 파라미터를 찾음

문제의 핵심

  • Security Config: .usernameParameter("email") 설정
  • HTML Form: name="email" 사용
  • 테스트 코드: userParameter 설정 누락

테스트에서 formLogin()을 사용할 때 별도 설정 없이 기본값(username)을 찾으려 했기 때문에, 실제 전달된 email 파라미터를 인식하지 못했다.

해결 방법

수정된 테스트 코드

@Test
@DisplayName("로그인 성공 테스트")
public void loginSuccessTest() throws Exception{
    String email = "test@email.com";
    String password = "12345678";
    this.createMember(email, password);

    mockMvc.perform(formLogin()
            .userParameter("email")  // 추가: Security Config와 동일하게 설정
            .user(email)
            .password(password)
            .loginProcessingUrl("/members/login"))
        .andExpect(authenticated())
        .andExpect(status().is3xxRedirection())
        .andExpect(redirectedUrl("/"));
}

핵심 포인트

1. 파라미터 매핑 이해하기

HTML Form name="email" 
    ↓
Security Config .usernameParameter("email")
    ↓  
Test Code .userParameter("email")
    ↓
loadUserByUsername(String email)

2. 설정의 일관성

  • Security 설정: .usernameParameter("email")
  • HTML Form: name="email"
  • 테스트 코드: .userParameter("email")

모든 설정이 동일한 파라미터 이름을 사용해야 합니다.

3. 메소드 파라미터 이름의 자유도

// 파라미터 이름은 자유롭게 설정 가능
UserDetails loadUserByUsername(String email)     // OK
UserDetails loadUserByUsername(String username)  // OK
UserDetails loadUserByUsername(String userId)    // OK

메소드의 파라미터 이름은 실제 동작에 영향을 주지 않으며, 가독성을 위해 의미에 맞게 명명하면 된다.

학습한 내용

  1. Spring Security의 요청 처리 방식: Security Filter가 로그인 요청을 가로채서 처리
  2. 파라미터 설정의 중요성: Security Config, HTML Form, Test Code 간의 일관성 필요
  3. 테스트 환경 설정: 실제 환경과 테스트 환경의 설정을 동일하게 맞춰야 함

이 경험을 통해 Spring Security의 내부 동작 방식과 테스트 설정의 중요성을 깊이 이해할 수 있었다.
 
 
그리고 중간 중간 로그를 설정한 과정이 문제를 해결하는 과정에서 많은 도움이 되었다. 이번 경우는 Security를 적용하기 위해 'UserDetailService'의 메서드를 오버라이딩 한 아래 코드에서 아래의 2개의 로그를 통해 버그의 원인을 찾을 수 있었다.

 @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        log.info("로그인 시도 =====> email: {}", email);
        Member member = memberRepository.findByEmail(email);
        if(member == null){
            log.info("===== 회원 데이터 없음 =====");
            throw new UsernameNotFoundException(email);
        }
        log.info("===== 로그인 회원 조회 성공 =====");

        return User.builder()
                .username(member.getEmail())
                .password(member.getPassword())
                .roles(member.getRole().toString())
                .build();
    }

 
 
추가로 Spring Security에서 중요한 개념을 정리하면 UserDetailService와 UserDetail가 있다.
 
UserDetailsService 인터페이스
- 데이터베이스에서 회원 정보를 가져오는 역할을 담당
- loadUserByUsername() 메서드가 존재하며, 회원 정보를 조회하여 사용자의 정보와 권한을 갖는 UserDetails 인터페이스를 반환
 
Spring Security에서는 UserDetailsService를 구현하고 있는 클래스를 통해 로그인 기능을 구현하다고 생각하면 된다.
 
UserDetail
- Spring Security에서 회원 정보를 담기 위해 사용하는 인터페이스
- 이 인터페이스를 직접 구현하거나 Spring Security에서 제공하는 User 클래스를 사용.
- User 클래스는 UserDetails 인터페이스를 구현하고 있는 클래스
- UserDetail는 DB에서 조회한 회원정보를 저장하는 DTO 역할
 
 
그리고 추가로 Spring Security를 테스트 하기 위해서는 Security Test 라이브러리를 추가로 의존성을 적용해야 한다.
 
maven Security Test 의존성

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>

 
maven Security 의존성

    <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

 
 
loadUserByUser()메서드로 전달 된 후의 과정
loadUserByUsername이 반환한 UserDetails는 Spring Security 내부에서 AuthenticationManagerAuthenticationProvider가 활용한다.

흐름

  1. 사용자가 로그인 폼 제출
    • POST /members/login
    • email + password 전송
  2. UsernamePasswordAuthenticationFilter가 요청 가로채기
    • 입력받은 email과 password를 추출
  3. AuthenticationManager가 인증 시도
    • 내부적으로 DaoAuthenticationProvider를 사용
    • 여기서 UserDetailsService.loadUserByUsername(email) 호출
    • DB에서 회원 정보 조회 → UserDetails 반환
  4. Password 검증
    • 사용자가 입력한 평문 비밀번호와 UserDetails.getPassword()(암호화된 비밀번호)를 비교
    • PasswordEncoder.matches(rawPassword, encodedPassword) 사용
  5. 인증 성공 / 실패
    • 성공 → Authentication 객체 생성 후 SecurityContext에 저장
    • 실패 → 로그인 실패 처리

즉, loadUserByUsername → 회원 정보 조회 후 Spring Security가 사용할 객체 반환Password 검증Authentication 객체 생성SecurityContext 저장
 
 
요약

  • loadUserByUsername → DB 조회 + UserDetails 반환
  • 반환된 UserDetails → AuthenticationProvider가 비밀번호 검증
  • 검증 통과 → SecurityContextHolder에 Authentication 저장 → Spring Security 권한 관리 시작
Comments