개인 공부용/ㅇㅅㅇ

Spring Security에서 JWT를 Stateless하게 사용하기

pon9 2025. 4. 20. 21:20

개요

Spring security와 jwt를 함께 사용할 때 항상 고민되는 주제가 '정말 무상태하게 구성하고 있는걸까' 인 거 같다.

단순히 토큰을 발급하고 필터에서 인증하는 걸로 끝나면 안되고, 좀 신경써야 할 부분이 있다고 생각한다.

 

우선 stateless하다는 건 서버가 클라이언트의 상태를 기억하지 않는다는 것이다.

로그인한 사용자의 정보를 세션에 담아두지 않고, 매 요청마다 인증 정보가 포함되어야 한다.

결국 이 구조를 위해 JWT를 사용하는거고.. 토큰 자체가 인증 정보를 담고 있어야 한다.

 

그런데 여기서 중요한 건, JWT를 단순히 사용한다 해서 자동으로 stateless해지는 게 아니라는 점이다.

예를 들어, 토큰 검증 후 유저 정보를 DB에서 조회해서 세션이나 컨텍스트에 올려버리면 그건 이미 상태를 갖는 구조다.

그래서 이걸 피하기 위해 최대한 JWT 하나만으로 인증 정보를 판단하도록 설계했다.

 

 

JWT 구조와 인증 흐름

1. 클라이언트는 로그인 요청으로 AccessToken과 RefreshToken(쿠키)을 발급받는다.

2. 이후 모든 요청은 Authorization 헤더에 Bearer 토큰을 담아 전송한다.

3. JWTfilter가 이 토큰을 파싱해서 유효성을 검증하고, SecurityContext에 인증 정보를 세팅한다.

4. 이 과정에서 절대 DB를 조회하지 않는다. 모든 인증 정보는 토큰안에 들어있는 Claim에서 나온다.

Claims claims = jwtUtil.extractClaims(token);
String[] data = claims.getSubject().split(":");
Long memberId = Long.valueOf(data[0]);
Role userRole = Role.of(data[1]);

JWT의 subject에 memberId:Role 이런식으로 정보를 담았기 떄문에, 따로 DB조회 없이도 AuthUser 객체를 생성할 수 있다.

즉, 매 요청마다 Stateless하게 인증이 끝난다.

 

 

Authentication 객체

JWT 인증 흐름을 타다 보면 SecurityContext 안에 넣어줄 인증 객체가 필요해진다.

근데 이걸 그냥 UsernamePasswordAuthenticationToken 같은거로 때우면, 불필요하게 context가 길어져서 좀 거슬린다.

그래서 아예 커스텀 인증 객체를 따로 정의해주자.

public record AuthUser(Long memberId, Collection<? extends GrantedAuthority> authorities)

AuthUser는 말 그대로 인증된 사용자의 정보만 담고있는 레코드 클래스다.

memberId는 내가 서비스에서 식별할 수 있는 유저 ID,

authorities는 security가 권한 체크할 때 사용할 값(ROLE_USER)이다.

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final AuthUser authUser;
    private final String token;

    public JwtAuthenticationToken(AuthUser authUser, String token) {
        super(authUser.authorities());
        this.authUser = authUser;
        this.token = token;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return token;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}

그리고 AuthUser객체는 여기 커스텀 인증 객체를 구성하게 된다.

AuthUser를 principal로, 실제 jwt문자열을 credentials로 넣는다.

여기서 중요한 건, 생성자에서 setAuthenticated(true)를 호출해줘서, 이게 없으면 security에서 인증되지 않은 사용자로 인식하도록 한다.

그래서 이 구조로 JWT -> Claim 파싱 -> AuthUser 생성 -> JwtAuthenticationToken 생성 -> Context등록 이 흐름이 딱 정돈된다.

 

 

예외 처리, JwtFilter

예외는 일반적인 @RestControllerAdvice로 처리할 수 없다. 

@Component
public class ErrorResponseHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    public void send(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        send(response, ErrorResponse.of(errorCode));
    }

    private void send(HttpServletResponse response, ErrorResponse error) throws IOException {
        response.setStatus(response.getStatus());
        if (error.code().equals("SW401")) { //웹소켓에러
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        objectMapper.writeValue(response.getOutputStream(), error);
        response.getOutputStream().flush();
    }
}

그래서 필터, 서블릿, 인터셉터 등등 모든 에러를 동일한 형태로 반환할 수 있는 컴포넌트를 만들었다.

response와 ErrorCode(커스텀 에러코드 enum)을 파라미터로 받아, response.getStatus()로 상태 코드를 반환한다.

웹소켓만 따로 다룬 이유는 담에 웹소켓 글에서..

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtSecurityProperties jwtSecurityProperties;
    private final BlackListService blackListService;
    private final JwtUtil jwtUtil;
    private final ErrorResponseHandler errorResponseHandler;

    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return jwtSecurityProperties.secret().whiteList().stream()
                .anyMatch(whitelist -> antPathMatcher.match(whitelist, uri));
    }

    @Override
    public void doFilterInternal(
            HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain chain
    ) throws IOException, ServletException {
        String bearerJwt = request.getHeader("Authorization");

        if (bearerJwt != null && bearerJwt.startsWith(jwtSecurityProperties.token().prefix())) {
            String token = jwtUtil.substringToken(bearerJwt);
            if (blackListService.isBlackList(token)) {
                errorResponseHandler.send(response, ErrorCode.TOKEN_UNAUTHORIZED);
                return;
            }
            try {
                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    setAuthentication(token);
                }
            } catch (ExpiredJwtException e) {
                log.error("만료된 JWT 토큰입니다.");
                errorResponseHandler.send(response, ErrorCode.TOKEN_UNAUTHORIZED);
                return;
            } catch (SecurityException | MalformedJwtException e) {
                log.error("유효하지 않은 JWT 서명입니다.");
                errorResponseHandler.send(response, ErrorCode.TOKEN_UNAUTHORIZED);
                return;
            } catch (UnsupportedJwtException e) {
                log.error("지원되지 않는 JWT 토큰입니다.");
                errorResponseHandler.send(response, ErrorCode.TOKEN_UNAUTHORIZED);
                return;
            } catch (IllegalArgumentException e) {
                log.error("잘못된 JWT 토큰 형식입니다.");
                errorResponseHandler.send(response, ErrorCode.TOKEN_UNAUTHORIZED);
                return;
            } catch (Exception e) {
                log.error("예기치 못한 오류.");
                errorResponseHandler.send(response, ErrorCode.TOKEN_UNAUTHORIZED);
                return;
            }
        }
        chain.doFilter(request, response);
    }

    /**
     * Context 에 인증정보 저장
     */
    private void setAuthentication(String token) {
        Claims claims = jwtUtil.extractClaims(token);
        String[] data = claims.getSubject().split(":");
        Long memberId = Long.valueOf(data[0]);
        Role userRole = Role.of(data[1]);

        AuthUser authUser = AuthUser.of(memberId, userRole);
        JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser, token);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }
}

JWT 오류는, 클라이언트에서는 어떤 오류인지 정확히 몰라도 된다고 생각해서 커스텀 에러코드 TOKEN_UNAUTHORIZED로 통일했다.

대신 개발 시엔 알아야하니 간단히 로깅만 남기도록 했다.

filter에서 토큰이 유효하다면, SecurityContextHolder.setAuthentication()로

JwtAuthenticationToken(인증 객체)를 Security Context에 삽입한다.

 

필터는 OncePerRequestFilter를 상속받아, 매 요청마다 한번 처리되고,

shouldNotFilter()로 화이트리스트를 정의할 수 있다.

화이트리스트 또는 어드민 리스트는 @ConfigurationProperties를 이용해, yml에서 전역 설정 가능하도록 구성해놨다.

 

 

SecurityFilterChain

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;
    private final WebSocketFilter webSocketFilter;
    private final JwtSecurityProperties jwtSecurityProperties;
    private final CustomAuthEntryPoint customAuthEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(webSocketFilter, SecurityContextHolderAwareRequestFilter.class)
                .addFilterAt(jwtFilter, SecurityContextHolderAwareRequestFilter.class)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .anonymous(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .rememberMe(AbstractHttpConfigurer::disable)
                .headers(headers -> headers
                    //이곳에 각종 헤더들
                                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(jwtSecurityProperties.secret().whiteList().toArray(new String[0])).permitAll()
                        .requestMatchers(jwtSecurityProperties.secret().adminList().toArray(new String[0])).hasAuthority(Role.Authority.ADMIN)
                        .anyRequest().hasAnyAuthority(Role.Authority.ADMIN, Role.Authority.USER)
                )
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(customAuthEntryPoint)
                        .accessDeniedHandler(customAccessDeniedHandler)
                );
        return http.build();
    }
}

마지막으로 SecurityFilterChain.. 상당히 긴데, 한줄씩 보자

1. csrf: csrf는 외부에서 내 사이트로 접근할 때, 사용자가 가지고 있는 상태를 기반으로 공격하는 건데 내 서비스는 stateless jwt라 disable해도 무방하다.

2. sessionManagement: 마찬가지로 STATELESS

3. addFilter: jwtFilter의 실행 순서를 정해주는건데, 대략 저기 하면 맞다.. 잘은 모르는데 UserPasswordFilter? 거기도 많이 쓰는거 같더라... 둘 중 위에 있는 건 웹소켓 요청 유효성 검증 필터인데 jwt 바로앞에 넣어줬다. (Header가 아니라 QueryParam으로 토큰을 받아서)

4. formLogin: 기본 로그인 폼. jwt니까 당연히 필요없음

5. httpBasic: 필요없음

6. anonymous: 이건 인증 안된 사용자한테도 SecurityContext 생성해주는 기능인데, 인증 안된건 그냥 401코드로 떨어지게 했다. 세션에서 익명 사용자 사용할때 쓰는거인듯

7. logout: 로그아웃도 jwt환경에선 의미없다

8. rememberMe: 쿠키 기반 자동로그인인데, jwt에선의미없다

9. headers: 보안 헤더 인터넷에서 그냥 찾아서 쓰면 된다. 보통 여기서 CORS같은거 설정한다

 

10. authorizeHttpRequests: url 별 권한별 접근제어 설정

화이트리스트는 인증 없이 통과(permitAll)

adminList는 admin 권한

그 외 다른 요청은 user + admin 둘 다 열어준다

@Getter
@RequiredArgsConstructor
public enum Role {

    USER(Authority.USER),
    ADMIN(Authority.ADMIN);

    private final String roleName;

    public static Role of(String role) {
        return Arrays.stream(Role.values())
                .filter(r -> r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 UserRole 입니다"));
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

role은 이렇게, security에서 인증 관련으로 사용할 때만 Authority(ROLE_USER, ROLE_ADMIN 처럼 securitycontext에 알맞은 이름으로)붙여서 사용하도록 구성했다

엔티티랑 securitycontext에 들어갈 이름 통일하면 나중에 다른 권한 생겼을 때 골치아파질거같다

 

11. exceptionHandling: 예외 핸들링 구간

인증이 안 된 경우는 AuthenticationEntryPoint

@Component
@RequiredArgsConstructor
public class CustomAuthEntryPoint implements AuthenticationEntryPoint {

    private final ErrorResponseHandler errorResponseHandler;

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    ) throws IOException {
        errorResponseHandler.send(
                response,
                ErrorCode.ACCESS_DENIED
        );
    }
}

권한이 모자란 경우는 AccessDeniedHandler

@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ErrorResponseHandler errorResponseHandler;

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException
    ) throws IOException {
        errorResponseHandler.send(
                response,
                ErrorCode.FORBIDDEN
        );
    }
}

로 각각 아까 만든 에러 핸들링 컴포넌트로 401, 403 뱉게 설정했다

 

 

회고

결과적으로 내가 신경쓴건 3가지다

1. jwt만으로 인증 흐름 끝내고, 서버에 상태 저장 하지않기

2. 인증 실패에 대한 명확한 에러 처리 구현하기. 서버의 모든 에러 반환형은 통일

 

stateless하다는건 그냥 세션 안쓴다가 아니라, 서버가 어떤 상태도 기억하지 않도록 설계하는거라 생각한다.

이걸 msa에 갖다놔도 그대로 사용할 수 있게..

사실 그래서 spring security를 jwt와 사용할 이유가 없다 생각했는데..(특히 최근에 django 맛보고 조금 더.. 얜 refreshToken까지 자동발급해주던데; 걍 argumentResolver 사용하는 게 가볍고 덜 머리아픈 거 같음)

모든 설계를 구조적으로 가져갈 수 있어서 약간 꼬랑내같은 매력이 있다

 

다음글에선 여기다 websocket을 달아보자