Spring Security를 활용한 JWT 인증 | Stateless하게 제대로 적용하기!

2025. 3. 21. 10:48·TIL (Today I Learned)

이전에 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)

https://github.com/mannaKim/spring-advanced/commit/e90608bf7e3ff24bad9c80c53fe1c2c6c4fcff0b#diff-fe3d1e1e19cdc76fe965f45c475c82d06d64c4607d27494db399bf0c14cddc9e

 

feat(lv5): Spring Security JWT 적용 · mannaKim/spring-advanced@e90608b

- 기존 필터 등록 방식에서 Spring Security로 전환 - SecurityFilterChain을 활용한 인증 설정 적용 - SecurityContextHolder 활용하여 인증 정보 저장 - JWT 토큰 검증 및 사용자 인증 로직을 Spring Security에 맞게 리

github.com

 

 

✅ After: 변경된 방식 (Stateless Spring Security JWT)

https://github.com/mannaKim/spring-advanced/commit/dbed3e815b731ced43ddd1339e8eb6aad8bab96c#diff-05b38d5474c0ca48e0707e9047e891a92d9911be127d985169736778449a28b7

 

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

저작자표시 비영리 변경금지 (새창열림)

'TIL (Today I Learned)' 카테고리의 다른 글

Windows 개발 환경에서 AWS 활용 과제 | Spring Boot v3.3.3 & Gradle 8.12  (0) 2025.03.21
SPRING PLUS 과제 | 회고와 트러블슈팅⛹️‍♀️  (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
스노우플레이크 방식으로 주문번호 생성하기  (0) 2025.03.10
'TIL (Today I Learned)' 카테고리의 다른 글
  • Windows 개발 환경에서 AWS 활용 과제 | Spring Boot v3.3.3 & Gradle 8.12
  • SPRING PLUS 과제 | 회고와 트러블슈팅⛹️‍♀️
  • H2 콘솔 접속 시 Whitelabel Error Page (400 Bad Request) 해결 과정 + spring boot에서 active profile 선택하기 (IntelliJ)
  • H2 데이터베이스 설치 방법 | Server / In-memory / Embedded Mode❔
기만나🐸
기만나🐸
공부한 내용을 기록합시다 🔥🔥🔥
  • 기만나🐸
    기만나의 공부 기록 🤓
    기만나🐸
  • 전체
    오늘
    어제
    • ALL (147) N
      • TIL (Today I Learned) (56) N
      • Dev Projects (15)
      • Algorithm Solving (67)
        • Java (52)
        • SQL (15)
      • Certifications (8)
        • 정보처리기사 실기 (8)
  • 인기 글

  • 태그

    dp
    CSS
    java
    HTML
    프로그래머스
    mysql
    sql
    다이나믹프로그래밍
    그리디
    greedy
    시뮬레이션
    자료구조
    Google Fonts
    GROUP BY
    jQuery
    백트래킹
    websocket
    BOJ
    BFS
    join
    jpa
    jwt
    완전탐색
    백준
    bootstrap
    DFS
    Firebase
    programmers
    javascript
    Subquery
  • 최근 글

  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
기만나🐸
Spring Security를 활용한 JWT 인증 | Stateless하게 제대로 적용하기!
상단으로

티스토리툴바