본문 바로가기

백엔드 스터디

스프링 OAuth2 로그인 구현하기 4 - JWT

Spring Security + JWT로 로그인 구현하기 (2) - DB 연동 및 회원 검증

지난 포스팅에서는 로그인 요청을 가로채는 LoginFilter의 프레임 만들었습니다. 이번에는 AuthenticationManager가 실제로 DB에 저장된 회원 정보와 사용자가 제출한 정보를 비교하여 인증을 수행하도록 하는 내부 로직을 구현해 보겠습니다.

이 과정에서는 Spring Security의 핵심 인터페이스인 UserDetailsServiceUserDetails를 우리 시스템에 맞게 커스텀해보겠습니다.

  • UserDetailsService: username을 받아 DB에서 사용자 정보를 조회하는 회원 탐색 역할
  • UserDetails: 조회된 사용자 정보를 Spring Security가 이해할 수 있는 형태로 감싸주는 시큐리티용 회원 증명서 역할

1단계: UserRepository에 회원 조회 메소드 추가

가장 먼저, 데이터베이스에서 username을 기준으로 회원을 조회할 수 있는 기능이 필요합니다. Spring Data JPA를 사용하므로, 정해진 규칙에 따라 메소드를 선언하기만 하면 됩니다.

UserRepository.java

public interface UserRepository extends JpaRepository<UserEntity, Integer> {

    // username을 받아 DB 테이블에서 회원을 조회하는 메소드
    UserEntity findByUsername(String username);

    Boolean existsByUsername(String username);
}

2단계: UserDetailsService 커스텀 구현

이제 AuthenticationManager가 인증을 처리할 때 호출할 UserDetailsService를 직접 구현할 차례입니다.

CustomUserDetailsService.java

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
				
		// 1. DB에서 username으로 회원 조회
        UserEntity userData = userRepository.findByUsername(username);

        if (userData != null) {
			
			// 2. 조회된 회원 정보를 CustomUserDetails 객체로 감싸서 반환
            //    이 객체를 반환하면 AuthenticationManager가 비밀번호 검증을 수행함
            return new CustomUserDetails(userData);
        }

        // 조회된 회원이 없을 경우 null 또는 예외 처리
        return null;
    }
}

AuthenticationManager는 loadUserByUsername(username)을 호출하여 DB로부터 회원 정보를 가져오도록 요청합니다. UserRepository를 통해 회원을 조회한 뒤에 다음 단계에서 만들 CustomUserDetails 객체로 감싸서 반환하면 됩니다.


3단계: UserDetails 커스텀 구현

UserDetails는 Spring Security가 사용자의 인증 정보를 인식하는 표준 형식입니다. 우리의 회원 정보 객체(UserEntity)를 UserDetails 형식으로 변환해 주는 어댑터 클래스를 만듭니다.

CustomUserDetails.java

public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

    // 사용자의 권한(Role)을 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userEntity.getRole();
            }
        });
        return collection;
    }

    // 사용자의 비밀번호를 반환
    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    // 사용자의 아이디(username)를 반환
    @Override
    public String getUsername() {
        return userEntity.getUsername();
    }

    // 아래 4개 메소드는 계정의 상태를 관리
    @Override
    public boolean isAccountNonExpired() {
        return true; // 계정 만료 여부
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // 계정 잠금 여부
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true; // 비밀번호 만료 여부
    }

    @Override
    public boolean isEnabled() {
        return true; // 계정 활성화 여부
    }
}

이제 전체 로그인 흐름을 다시 정리해보자면

전체 로그인 검증 흐름 

  1. 사용자가 ID/PW로 /login 요청을 보냅니다.
  2. LoginFilter가 요청을 가로채 UsernamePasswordAuthenticationToken을 생성합니다.
  3. 생성된 Token을 AuthenticationManager에게 전달하여 인증을 위임합니다.
  4. AuthenticationManager는 우리가 등록한 CustomUserDetailsService의 loadUserByUsername 메소드를 호출합니다.
  5. CustomUserDetailsService는 UserRepository를 통해 DB에서 회원을 조회하고, CustomUserDetails로 감싸서 반환합니다.
  6. AuthenticationManager는 CustomUserDetails에 담긴 비밀번호와 사용자가 요청한 비밀번호가 일치하는지 검증합니다.
  7. 검증에 성공하면, LoginFilter의 successfulAuthentication() 메소드가 호출됩니다.

이제 DB를 통한 인증까지 구현되었습니다.