이전에 Spring Security를 활용한 JWT 글을 작성한 적이 있습니다.
2025.02.27 - [Java Study/Frameworks] - Spring Security를 활용한 JWT 인증 적용해보기 🖥️🔑
Spring Security를 활용한 JWT 인증 적용해보기 🖥️🔑
필터(JwtFilter)를 직접 등록하고, HttpServletRequest에서 토큰을 추출하는 방식으로 구현되어있던 코드를 Spring Security를 적용하면서 더 안전하고 유지보수가 쉬운 구조로 변경해보았다.이번 포스팅에
mannakingdom.tistory.com
이때는 JWT의 Stateless라는 특징은 고려하지 않고 구현한 방법이라 튜터님께 피드백도 받았었는데,
이제야 수정했습니다! 하하
어떤식으로 수정할지 고민이었는데 Spring Security 세션을 듣고 튜터님이 제공해주신 코드를 보며 명료하게 수정할 수 있었습니다.
참고: https://github.com/Nhahan/stateless-spring-security
GitHub - Nhahan/stateless-spring-security: stateless-spring-security
stateless-spring-security. Contribute to Nhahan/stateless-spring-security development by creating an account on GitHub.
github.com
1. 기존 인증 방식과 문제점
기존 `JwtAuthenticationFilter`에서는 JWT를 검증한 후, 해당 유저 정보를 DB에서 조회하여 `SecurityContextHolder`에 저장하는 방식이었습니다.
기존 JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String url = request.getRequestURI();
if (url.startsWith("/auth")) {
filterChain.doFilter(request, response);
return;
}
String bearerJwt = request.getHeader("Authorization");
if (bearerJwt == null) {
// 토큰이 없는 경우 400을 반환합니다.
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt);
try {
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
String email = claims.get("email", String.class);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
log.error("만료된 JWT 토큰입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
return;
} catch (JwtException | IllegalArgumentException e) {
log.error("유효하지 않은 JWT 토큰입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 JWT 토큰입니다.");
return;
}
filterChain.doFilter(request, response);
}
}
기존 JwtUtil - createToken
public String createToken(UserDetails userDetails) {
CustomUserDetails customUserDetails = (CustomUserDetails) userDetails;
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(customUserDetails.getId()))
.claim("email", customUserDetails.getUsername())
.claim("userRole", customUserDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()))
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm); // 암호화 알고리즘
}
문제점
- 불필요한 DB 조회
JWT 내부에 이미 사용자 정보가 포함되어 있음에도, 매번 DB에서 UserDetails를 조회하는
비효율적인 과정이 존재함. - Stateful한 방식
SecurityContext를 유지하려면 세션을 사용해야 하므로(Spring Security는 세션 방식 기반이기 때문)
Stateless하지 않다.
2. Stateless 인증 방식으로 변경
`loadUserByUsername`을 제거하고, JWT에서 추출한 정보를 직접 AuthUser 객체로 변환하여 SecurityContextHolder에 저장하는 방식으로 개선했습니다.
CustomUserDetailsService 및 CustomUserDetails 제거
이제 JWT 내부 정보를 직접 사용하기 때문에 CustomUserDetails와 CustomUserDetailsService를 삭제하였습니다.
// CustomUserDetails.java
public class CustomUserDetails implements UserDetails {
private final String email;
private final UserRole userRole;
...
}
// CustomUserDetailsService.java
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String email) {
return new CustomUserDetails(email, userRole);
}
}
기존: UserDetailsService에서 UserDetails 조회
변경: JWT에서 AuthUser를 직접 생성하여 SecurityContextHolder에 저장
변경된 JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String jwt = jwtUtil.substringToken(authorizationHeader);
try {
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
// SecurityContext에 인증 정보 설정
if (SecurityContextHolder.getContext().getAuthentication() == null) {
setAuthentication(claims);
}
} catch (SecurityException | MalformedJwtException e) {
String message = "유효하지 않은 JWT 토큰입니다.";
log.error(message, e);
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, message);
return;
} catch (ExpiredJwtException e) {
String message = "만료된 JWT 토큰입니다.";
log.error(message, e);
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, message);
return;
} catch (UnsupportedJwtException e) {
String message = "Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.";
log.error(message, e);
sendErrorResponse(response, HttpStatus.BAD_REQUEST, message);
return;
} catch (Exception e) {
String message = "Internal server error";
log.error(message, e);
sendErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, message);
return;
}
}
filterChain.doFilter(request, response);
}
private void setAuthentication(Claims claims) {
Long userId = Long.valueOf(claims.getSubject());
String email = claims.get("email", String.class);
UserRole userRole = UserRole.of(claims.get("userRole", String.class));
AuthUser authUser = new AuthUser(userId, email, userRole);
JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
변경된 JwtUtil - createToken
public String createToken(Long userId, String email, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole.getUserRole())
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm); // 암호화 알고리즘
}
JwtAuthenticationToken
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final AuthUser authUser;
public JwtAuthenticationToken(AuthUser authUser) {
super(authUser.getAuthorities());
this.authUser = authUser;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return authUser;
}
}
변경 사항
- `loadUserByUsername` 제거 → DB 조회 없이 JWT 내부 정보로 `AuthUser` 생성
- `setAuthentication` 메서드를 추가하여 JWT에서 추출한 정보를 기반으로 `SecurityContextHolder`에 인증 정보 저장
- Stateless 방식으로 변경하여 인증 상태를 서버에서 관리하지 않음
3. 권한 검증 방식 개선 (LoggingInterceptor 제거)
Before
기존에는 `LoggingInterceptor`에서 사용자 권한을 검증 했습니다.
After
`LoggingInterceptor`를 제거하고, `@Secured` 어노테이션을 활용하는 방식으로 변경하였습니다.
(1) `UserRole` 변경
`@Secured` 어노테이션은 Enum 타입을 직접 인자로 받을 수 없고, 문자열(String)만 허용하기 때문에 `UserRole` Enum을 다음과 같이 변경했습니다.
(2) `@Secured` 적용
검증을 적용할 컨트롤러단 메서드에 아래와 같이 `@Secured(UserRole.Authority.ADMIN)` 방식을 사용합니다.
// CommentAdminController.java
@Secured(UserRole.Authority.ADMIN)
@DeleteMapping("/admin/comments/{commentId}")
public void deleteComment(@PathVariable long commentId) {
commentAdminService.deleteComment(commentId);
}
// UserAdminController.java
@Secured(UserRole.Authority.ADMIN)
@PatchMapping("/admin/users/{userId}")
public void changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
userAdminService.changeUserRole(userId, userRoleChangeRequest);
}
- 인터셉터 방식 대신 `@Secured` 어노테이션을 사용
- 권한 검증을 컨트롤러에서 직접 수행하도록 변경
(3) 예외 처리 추가
`@Secured` 예외 처리를 `GlobalExceptionHandler`에 추가했습니다.
- 권한 예외 발생 시 403 FORBIDDEN 응답을 반환하도록 변경
4. @Auth 제거 및 @AuthenticationPrincipal 사용
Before
기존에는 `@Auth` 어노테이션과 `AuthUserArgumentResolver`를 사용하여 사용자 정보를 주입했습니다.
@PostMapping("/todos/{todoId}/comments")
public ResponseEntity<CommentSaveResponse> saveComment(@Auth AuthUser authUser, ...) { ... }
After
Spring Security에서 제공하는 `@AuthenticationPrincipal`을 사용하도록 변경하였습니다.
(1) `AuthUser` 변경 - `GrantedAuthority` 사용
Spring Security의 `@AuthenticationPrincipal`을 사용하기 위해 `AuthUser`를 변경했습니다.
- `AuthUser`에서 `UserRole` 대신 `Collection<? extends GrantedAuthority>` 필드를 사용하도록 변경
- Spring Security에서 권한을 인식할 수 있도록 `SimpleGrantedAuthority`를 사용해 `UserRole`을 `GrantedAuthority`로 변환
`User` 엔티티에서 `AuthUser` 정보를 가져올 때, `getAuthorities()`를 통해 첫 번째 권한을 가져와 `UserRole`로 변환하도록 수정했습니다.
(2) `@AuthenticationPrincipal` 적용
@PostMapping("/todos/{todoId}/comments")
public ResponseEntity<CommentSaveResponse> saveComment(@AuthenticationPrincipal AuthUser authUser, ...) { ... }
- `@AuthenticationPrincipal AuthUser authUser`를 통해 바로 사용자 정보 및 권한을 가져옴
정리
이번 변경을 통해 Spring Security JWT 인증을 Stateless한 방식으로 전환하였습니다.
불필요한 DB 조회 제거
- 기존 `loadUserByUsername`을 통한 DB 조회 제거
- JWT에서 사용자 정보를 직접 추출하여 사용
SecurityContextHolder에 직접 인증 정보 저장
- `SecurityContextHolder`에 `AuthUser` 객체를 바로 저장
- `JwtAuthenticationToken`을 사용하여 인증 정보 관리
@Secured 및 @AuthenticationPrincipal 적용하여 코드 개선
- `LoggingInterceptor` 제거 후 `@Secured`로 권한 검증
- `@Auth` 제거 후 `@AuthenticationPrincipal` 사용하여 유저 정보 주입
이제 JWT의 Stateless한 특징을 유지하면서 Spring Security의 기능을 활용할 수 있습니다!
⬇️ GitHub 링크
❌ Before: 기존 방식 (Stateful Spring Security JWT)
feat(lv5): Spring Security JWT 적용 · mannaKim/spring-advanced@e90608b
- 기존 필터 등록 방식에서 Spring Security로 전환 - SecurityFilterChain을 활용한 인증 설정 적용 - SecurityContextHolder 활용하여 인증 정보 저장 - JWT 토큰 검증 및 사용자 인증 로직을 Spring Security에 맞게 리
github.com
✅ After: 변경된 방식 (Stateless Spring Security JWT)
fix(security): Spring Security JWT를 Stateless하게 사용할 수 있도록 수정 · mannaKim/spring-advanced@dbed3e8
- JwtAuthenticationFilter에서 로그인한 유저 정보를 DB에서 조회(loadUserByUsername)하지 않고, JwtAuthenticationToken을 생성하여 AuthUser 객체로 저장하도록 변경 - LoggingInterceptor에서 수행하던 권한 검증을 @Secur
github.com
📌 전체 코드
https://github.com/mannaKim/spring-advanced
GitHub - mannaKim/spring-advanced: 코드 개선과 테스트 코드 작성 과제
코드 개선과 테스트 코드 작성 과제. Contribute to mannaKim/spring-advanced development by creating an account on GitHub.
github.com
'Java Study > Frameworks' 카테고리의 다른 글
동시성 제어는 어떻게 할까❓ (0) | 2025.03.26 |
---|---|
Windows 개발 환경에서 AWS 활용 과제 | Spring Boot v3.3.3 & Gradle 8.12 (0) | 2025.03.21 |
H2 콘솔 접속 시 Whitelabel Error Page (400 Bad Request) 해결 과정 + spring boot에서 active profile 선택하기 (IntelliJ) (0) | 2025.03.12 |
H2 데이터베이스 설치 방법 | Server / In-memory / Embedded Mode❔ (0) | 2025.03.10 |
Spring Security를 활용한 JWT 인증 적용해보기 🖥️🔑 (0) | 2025.02.27 |