
회원가입/로그인 기능 구현하고 보니 현재 진행 중인 주문 플랫폼 서비스에서는 모든 도메인에서 인증된 사용자를 체크해야 했습니다. 하지만 모든 도메인의 컨트롤러에서 토큰을 파싱하여 권한 체크를 하면 반복되기도 하고 비즈니스 로직보다 권한 체크 로직이 더 커질 수도 있다고 생각했습니다.
그래서 Spring Security의 사용자 인증 구조를 도입하기로 했습니다.
이번 프로젝트에서 구현한 구조를 기반으로 Spring Security의 인증 절차가 이루어지는지 설명하도록 할 것입니다.
1. HTTP 요청 수신
JwtAuthenticationFilter가 이 요청을 가장 먼저 가로챕니다.2. 사용자 정보 로드(UserDetailsService)
JwtAuthenticationFilter에서 사용자 ID를 꺼내서 UserDetailService의 loadUserByUsername()을 호출하여 DB에서 사용자 정보를 받아옵니다.3. 사용자 데이터 조회 및 UserDetails 생성
UserDetailService는 DB에서 찾은 사용자를 UserDetails 객체로 반환합니다.UserDetilsImpl이 사용됩니다.4. 인증 객체(Authentication) 생성
UserDetails로 인증된 사용자 정보를 담아 Authentication 인증 객체를 생성합니다.5. 인증 완료 및 저장
SecurityContextHolder라는 저장소에 보관됩니다.위의 과정에서 두 가지 클래스를 구현하였습니다.
UserDetailsImpl: UserDetails의 구현체UserDetailsServiceImpl: UserDetailsService의 구현체UserDetailsImpl은 Spring Security의 인터페이스인 UserDetails를 구현한 클래스입니다. 이 클래스는 User엔티티와 Spring Security 사이의 연결고리 역할을 합니다.
Spring Security는 우리 프로젝트에 User라는 엔티티가 있는지, 그 안에 어떤 필드가 있는지 알지 못합니다. Spring Security는 그저 정해진 규격(UserDetails)에 맞춰서 사용자 정보를 받기를 원합니다.
따라서 우리 도메인 객체인 User를 UserDetails라는 규격으로 포장해주는 어댑터(Adapter)가 필요한데, 그것이 바로 UserDetailsImpl입니다.
public class UserDetailsImpl implements UserDetails {
private final User user; // 우리 도메인인 User엔티티를 필드로 가짐
public UserDetailsImpl(User user) {
this.user = user;
}
// 서비스 로직에서 유저 객체가 필요할 때 꺼내 쓰기 위한 메서드
public User getUser() {
return user;
}
// Spring Security가 인증에 사용할 비밀번호
@Override
public String getPassword() {
return user.getPassword();
}
// Spring Security가 인증에 사용할 식별자(ID)
@Override
public String getUsername() {
return user.getUsername();
}
// 권한 설정 (UserRole을 GrantedAuthority로 변환)
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRole role = user.getRole();
String authority = role.getAuthority(); // 이전에 만든 "ROLE_CUSTOMER" 등 반환 메서드
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
// 계정 만료, 잠금, 패스워드 만료, 활성화 여부
// 현재 프로젝트에서는 별도의 로직이 없으므로 모두 true(정상)를 반환하도록 설정
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}
관심사의 분리: UserDetailsImpl이 보안에 필요한 부가적인 기능을 담당하게 되어서 User엔티티는 비즈니스 데이터와 로직만 신경쓰면 됩니다.
편의성: Controller에서 @AuthenticationPrincipal UserDetailsImpl userDetails와 같은 방식으로 현재 로그인한 사용자의 정보를 쉽게 꺼낼 수 있습니다.
UserDetailsServiceImpl은 Spring Security의 UserDetailsService 인터페이스를 구현한 클래스입니다. 이 클래스의 목적은 "전달받은 사용자 식별자(ID)를 가지고 실제 사용자 정보를 찾아와서 보안 규격에 맞게 반환하는 것"입니다.
Spring Security는 인증 과정에서 전달받은 사용자 식별자(ID)를 가진 유저가 있는지 검증합니다. 이때 구현체에 구현해둔 loadUserByUsername() 메서드가 실행됩니다. 즉, Spring Security와 Repository를 잇는 역할을 수행합니다.
@Service // Bean으로 등록하여 Spring Security가 사용할 수 있도록 함.
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
// username을 통해 DB에서 사용자 정보 조회
// 조회된 정보를 앞서 만든 UserDetailsImpl에 담아 반환
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// UserRepository를 통해 DB에서 유저 조회
User user = userRepository.findById(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
// 조회된 유저 엔티티를 UserDetailsImpl로 감싸서 반환
return new UserDetailsImpl(user);
}
}
1. "보안"과 "비즈니스 로직"의 완전한 분리
Code Shadowing 과제에서는 컨트롤러에서 비즈니스 로직을 분리해보았다면 이번에는 보안 체크 로직과 비즈니스 로직을 분리해본 경험이었습니다.
2. 확장성있는 설계
나중에 소셜 로그인이나 외부 인증 서버 방식으로 변경하더라도 도메인쪽 로직을 수정할 필요없이 Security 설정과 UserDetailsImpl만 수정하면 된다는 점에서 유연한 설계의 강점을 실감했습니다.
추후 MSA프로젝트를 진행할 때도 사용할 수 있을 것 같다는 생각이 들었습니다. UserDetailsServiceImpl클래스가 DB에서 사용자 정보를 받아온 것을 인증 서비스로 요청하도록 수정하여 사용할 수 있을 것 같다고 생각했습니다.