

UserDetails란 Spring Security에서 사용자의 핵심 정보를 담는 인터페이스다.
사용자의 정보를 관리하는 User 엔티티가 UserDetails를 직접 구현할 수도 있다.
@Entity
@Getter
@NoArgsConstructor
@Table(name = "USERS")
public class User extends BaseEntity, UserDetails {
@Id @GeneratedValue
@Column(name = "USER_ID")
private Long id;
...
그러나 이 경우 User 엔티티가 Spring Security에 직접적으로 의존을 하게 되는 문제가 발생한다. 따라서 난 UserDetailsImpl을 통해 UserDetails를 구현했다.
public class UserDetailsImpl implements UserDetails {
private final User user;
private final String username;
private final String password;
/**
* 생성자, Spring Security에서 사용할 정보와 실제 User 엔티티를 연결
*/
public UserDetailsImpl(User user, String username, String password) {
this.user = user;
this.username = username;
this.password = password;
}
/**
* Enum으로 정의된 사용자의 권한을 Spring Security가 이해할 수 있는
GrantedAuthority 객체로 변환
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities(){
UserRole role = user.getUserRole();
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override
public String getUsername() {
return this.username;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return user.isEnabled();
}
/**
* 연관된 User 엔티티 객체를 반환
* UserDetailsImpl 외부에서 필요할 경우 원본 User 객체에 접근할 수 있음
*/
public User getUser() {
return user;
}
}

UserDetailsService는 사용자 정보를 가져오는 메서드를 정의하는 인터페이스다. 마찬가지로 UserDetailsServiceImpl를 통해 구현했다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String userLoginId) throws UsernameNotFoundException {
User user = userRepository.findByLoginId(userLoginId)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
return new UserDetailsImpl(user, user.getLoginId(), user.getPassword());
}
}
userRepository를 통해 조회된 User 객체를 기반으로 UserDetailsImpl 객체를 생성하여 반환한다.
사용자가 로그인할 때 입력한 로그인 ID를 기반으로 UserDetailsServiceImpl의 loadUserByUsername 메서드가 호출되어 사용자의 정보를 로드하게 된다.

JwtService는 JWT 토큰의 생성, 파싱, 검증과 관련된 모든 작업을 담당하는 서비스 계층의 클래스이다.
@Service
public class JwtService {
/**
* JWT 토큰 암호화 키
*/
@Value("${jwt.secret.key}")
private String secretKey;
/*
* JWT 토큰을 생성하는 핵심 메서드
* 사용자의 username(로그인 ID), 사용자 ID(DB의 PK), 권한을 토큰에 포함시킴
* 토큰의 발행 시간과 만료 시간(24시간 후)을 설정
* HS256 알고리즘을 사용하여 토큰에 서명
*/
public String generateToken(UserDetailsImpl user){
return Jwts.builder()
.setSubject(user.getUsername())
.claim("authorities", populateAuthorities(user.getAuthorities()))
.claim("userId", user.getUser().getId())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 86400000))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
/*
* 사용자의 권한 정보를 문자열로 변환
*/
private String populateAuthorities(Collection<? extends GrantedAuthority> authorities) {
Set<String> authoritiesSet = new HashSet<>();
for(GrantedAuthority authority: authorities) {
authoritiesSet.add(authority.getAuthority());
}
//MEMBER, ADMIN
return String.join(",", authoritiesSet);
}
/*
* JWT 서명에 사용할 키를 생성
*/
private Key getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
/*
* 주어진 토큰에서 사용자 이름(username)을 추출
*/
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
/*
* 토큰에서 사용자 ID를 추출
*/
public Long extractUserId(String token) {
return extractClaim(token, claims -> claims.get("userId", Long.class));
}
/*
* 토큰의 유효성을 검사
* 토큰에서 추출한 username과 UserDetails의 username이 일치하는지 확인
* 토큰이 만료되지 않았는지 확인
*/
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}
/*
* 토큰의 만료 여부를 확인
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
/*
* 토큰에서 만료 시간을 추출
*/
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
/*
* 토큰에서 특정 클레임을 추출하는 범용 메서드
*/
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
/*
* 토큰에서 모든 클레임을 추출
*/
private Claims extractAllClaims(String token) {
return Jwts
.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}

JwtAuthenticationFilter는 모든 HTTP 요청을 가로채어 JWT 토큰의 존재 여부를 확인한다. 토큰이 존재하면 유효성을 검증하고, 유효한 토큰이라면 해당 토큰의 정보를 이용해 Authentication 객체를 생성하여 SecurityContext에 설정한다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userLoginId;
if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
userLoginId = jwtService.extractUsername(jwt);
if (userLoginId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(userLoginId);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
1. 요청 가로채기
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
모든 요청의 "Authorization" 헤더를 확인한다.
2. 토큰 추출
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
"Bearer " 접두사를 확인하고 실제 토큰을 추출한다.
3. 사용자 ID 추출
javaCopyuserLoginId = jwtService.extractUsername(jwt);
JwtService를 사용하여 토큰에서 사용자 ID를 추출한다.
4. 토큰 유효성 검증
if (userLoginId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(userLoginId);
if (jwtService.isTokenValid(jwt, userDetails)) {
// ... (Authentication 객체 생성 및 설정)
}
}
추출한 사용자 ID로 UserDetails를 로드한 뒤 JwtService의 isTokenValid 메서드로 토큰의 유효성을 검증한다.
5. Authentication 객체 생성 및 설정
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, // Principal (UserDetails 객체)
null, // Credentials (JWT에서는 보통 null)
userDetails.getAuthorities() // Authorities
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
유효한 토큰이라면 UsernamePasswordAuthenticationToken 객체를 생성한다.
UsernamePasswordAuthenticationToken은 사용자의 인증 정보(principal, credentials, authorities)를 캡슐화하여 Spring Security의 인증 및 권한 부여 메커니즘에 사용되는 Authentication 객체를 제공한다.
이 객체를 SecurityContextHolder에 설정하여 현재 요청을 인증된 상태로 만든다. SecurityContextHolder는 Spring Security의 핵심 컴포넌트로, 현재 인증된 사용자의 보안 정보를 저장한다.
6. 필터 체인 계속 실행
javaCopyfilterChain.doFilter(request, response);
인증 과정이 완료되면 다음 필터로 요청을 전달한다.
SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
//CSRF(Cross-Site Request Forgery) 보호 기능 비활성화
.csrf(AbstractHttpConfigurer::disable)
//모든 HTTP 요청을 허용
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
// 세션 관리를 상태 없는(stateless) 방식으로 설정한다.
// 이는 서버가 클라이언트의 세션 정보를 저장하지 않음을 의미하며, JWT와 같은 토큰 기반 인증 메커니즘을 사용할 때 일반적으로 사용된다.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
/*
* 사용자 인증을 처리하기 위해 AuthenticationProvider를 등록한다.
* 이 객체는 사용자 인증 정보를 검증하고, 인증 객체를 생성하는 역할을 한다.
* 이 프로젝트에서는 JWT 기반 인증만을 사용하므로 AuthenticationProvider를 따로 구현하지 않았다.
* 따라서 추후 서술할 UserService의 authenticate 메서드가 해당 역할을 담당한다.
*/
.authenticationProvider(authenticationProvider)
//JWT 인증 필터를 추가하여 JWT 토큰을 기반으로 한 인증을 처리
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
public enum UserRole {
USER(Authority.USER), ADMIN(Authority.ADMIN);
private final String authority;
UserRole(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "USER";
public static final String ADMIN = "ADMIN";
}
}
@RestController
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@SecurityRequirement(name = "Bearer Authentication")
public class AdminController {
private final CollegeService collegeService;
/*
* [POST] 단과대학 추가
* */
@PostMapping("/api/admin/university/manage/college")
public ResponseEntity<SaveResponse> saveCollege(@RequestBody CollegeDto.saveRequest request){
return ResponseEntity.ok(collegeService.save(request));
}
/*
* [POST] 단과대학 목록 반환
* */
@GetMapping("/api/admin/university/manage/college")
public ResponseEntity<CollegeListResponse> getCollege(Authentication authentication){
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
Long userId = userDetails.getUser().getId();
return ResponseEntity.ok(collegeService.getCollegesByUserUniversity(userId));
}
}
@PreAuthorize : 메서드 호출 전에 권한을 확인하는 Spring Security의 메서드 보안 기능
getCollege(Authentication authentication) : 사용자가 특정 API 엔드포인트에 접근할 때, Spring Security는 해당 요청의 컨텍스트에서 Authentication 객체를 찾아 매개변수로 전달한다. 이를 통해 컨트롤러 메서드 내에서 현재 인증된 사용자 정보를 사용할 수 있게 된다.
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final UserDetailsServiceImpl userDetailsService;
public AuthenticationResponse authenticate(AuthenticationRequest request){
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUserId(),
request.getPassword()
)
);
UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(request.getUserId());
String jwtToken = jwtService.generateToken(userDetails);
return AuthenticationResponse.builder()
.accessToken(jwtToken)
.userId(userDetails.getUser().getId()).build();
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class AuthenticationRequest {
private String userId;
private String password;
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class AuthenticationResponse{
@JsonProperty("access_token")
private String accessToken;
private Long userId;
}
}
사용자 인증 프로세스
사용자가 제공한 로그인 ID와 비밀번호를 기반으로 인증을 수행합니다.

AuthenticationManager
AuthenticationManager는 Spring Security에서 사용자 인증을 처리하는 핵심 인터페이스다. 이 인터페이스는 다음과 같은 기능을 제공한다.
- 인증 요청 수행
- 사용자의 자격 증명 검증에 필요한 메커니즘 제공
사용자가 로그인할 때, AuthenticationManager는 해당 요청을 적절한 AuthenticationProvider에 위임하여 인증을 수행한다.
이 프로젝트에서는 AuthenticationProvider를 따로 구현하지 않았으므로 Spring Security가 기본적으로 제공하는 구현을 이용해 인증을 처리한다. 일반적으로 DaoAuthenticationProvider가 이 역할을 수행한다.
DaoAuthenticationProvider
DaoAuthenticationProvider는 다음과 같은 과정으로 인증을 수행한다.
