JWT 기반 인증 시스템 내부 동작

권왈왈·2025년 11월 3일

JWT 동작 흐름



1. 전체 요청 흐름 요약

JWT 인증은 Spring Security FilterChain을 따라 순차적으로 수행된다.

클라이언트 → DispatcherServlet 전에 → Security Filter Chain 작동

요청 시 전체 흐름

[Client] → [JwtAuthenticationFilter] → [UsernamePasswordAuthenticationFilter]
          → [JwtAuthorizationFilter] → [SecurityContextHolder]
          → [Controller @RestController]

JWT를 쓴다는 건

  • 로그인 시점에는 인증(Authentication)
  • 로그인 후 요청 시에는 인가(Authorization)

각각의 필터에서 처리한다는 것


2. JWT 생성 방식

구분설명예시
Header토큰 유형, 서명 알고리즘{ "alg": "HS512", "typ": "JWT" }
Payload사용자 정보(Claims){ "sub": "jiyoung@example.com", "auth": "ROLE_USER" }
Signature서버 비밀키로 서명HMACSHA512(base64(header+payload), secretKey)


3. 인증(Authentication) 과정

Access / Refresh Token을 “발급”하는 단계

3-1. 로그인 요청 (POST /login)

  • 클라이언트가 이메일/비밀번호를 보냄
  • Security FilterChain 중 UsernamePasswordAuthenticationFilter 가 가로챈다.

3-2. UsernamePasswordAuthenticationFilter

  • 요청에서 ID, PW를 추출
  • AuthenticationManager에게 인증 위임
  • UserDetailsService 를 호출해 DB의 유저 정보와 비교
  • 성공하면 Authentication 객체를 생성하고 SecurityContextHolder 에 저장

예시:

Authentication authentication = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(username, password)
);

3-3. JwtAuthenticationFilter (커스텀)

인증 성공 시 JWT를 생성하고 응답 헤더나 바디에 담는다.

@Override
protected void successfulAuthentication(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain chain,
                                        Authentication authResult) {

    String email = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
    String token = jwtProvider.createAccessToken(email);

    response.addHeader("Authorization", "Bearer " + token);
}
  • 여기서 AccessToken, RefreshToken 발급
  • RefreshToken은 DB나 Redis에 저장 (재발급용)


4. 인가(Authorization) 과정

사용자가 API 요청할 때 Access Token 검증하는 과정

이때 작동하는 핵심이 바로 JwtAuthorizationFilter다.


4-1. 매 요청 시 Authorization 헤더 포함

GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...

4-2. FilterChain 내 동작 순서

  1. 요청이 들어오면 DispatcherServlet 전에 OncePerRequestFilter 계열 필터 작동
  2. JwtAuthorizationFilter 가 모든 요청을 검사
  3. Authorization 헤더 존재 여부 확인
  4. “Bearer ” 접두사 이후의 JWT 추출
  5. JwtProvider를 통해 서명 검증 및 Claims 파싱
  6. 유효하면 Authentication 객체를 생성 → SecurityContextHolder에 등록
  7. 다음 필터로 요청 전달

예시 코드:

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws ServletException, IOException {

    String token = jwtProvider.resolveToken(request); // "Bearer ..." 추출
    if (token != null && jwtProvider.validateToken(token)) {
        Authentication auth = jwtProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(auth);
    }

    chain.doFilter(request, response);
}

5. JwtProvider 내부 동작

5-1. 토큰 추출

public String resolveToken(HttpServletRequest request) {
    String bearer = request.getHeader("Authorization");
    if (bearer != null && bearer.startsWith("Bearer ")) {
        return bearer.substring(7);
    }
    return null;
}

5-2. 서명 검증 + 클레임 파싱

public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(SKEY).build().parseClaimsJws(token);
        return true;
    } catch (JwtException | IllegalArgumentException e) {
        return false;
    }
}

5-3. Authentication 객체 생성

JWT에서 사용자 정보를 읽어 SecurityContextHolder 에 저장할 Authentication 생성

public Authentication getAuthentication(String token) {
    Claims claims = Jwts.parserBuilder()
        .setSigningKey(SKEY)
        .build()
        .parseClaimsJws(token)
        .getBody();

    String email = claims.getSubject();
    List<GrantedAuthority> authorities =
        Arrays.stream(claims.get("auth").toString().split(","))
              .map(SimpleGrantedAuthority::new)
              .collect(Collectors.toList());

    UserDetails principal = new User(email, "", authorities);
    return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}

6. SecurityContextHolder의 역할

  • SecurityContextHolder는 현재 요청의 “인증 상태”를 저장하는 ThreadLocal 저장소다.
  • Controller@PreAuthorize가 인증 객체를 꺼낼 수 있게 해준다.

예시:

@GetMapping("/profile")
public ResponseEntity<?> getProfile() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    String email = auth.getName();
    ...
}

7. FilterChain 정리 도식

순서필터역할동작 시점
1UsernamePasswordAuthenticationFilter로그인 시도 감지로그인 요청 시
2JwtAuthenticationFilterJWT 생성 및 발급로그인 성공 시
3JwtAuthorizationFilterJWT 검증 및 Authentication 주입매 요청마다
4ExceptionTranslationFilter인증/인가 예외 처리전역
5FilterSecurityInterceptorURL 권한 체크컨트롤러 진입 전

8. Security FilterChain 설정 예시

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthorizationFilter jwtAuthorizationFilter;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/login", "/signup").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

✅ 핵심: addFilterBefore, addFilterAfter, addFilterAt 으로 필터 순서 조정 가능.

Authorization 필터는 항상 “요청 진입 초반”에 위치시켜야 함.


9. 인증/인가 이후 흐름

JWT 인증 성공 → Authentication 생성 → SecurityContextHolder 저장 → Controller 호출 시 @AuthenticationPrincipal 사용 가능

예시:

@GetMapping("/user/info")
public ResponseEntity<?> info(@AuthenticationPrincipal UserDetails user) {
    return ResponseEntity.ok(user.getUsername());
}

@AuthenticationPrincipal 은 SecurityContext 안에 담긴 Principal(UserDetails)을 바로 꺼내주는 어노테이션이다.


10. 로그아웃 & 강제 만료

  • JWT는 서버 세션이 없기 때문에 단순히 “삭제”로 무효화가 안 됨.
  • 해결 방법:
    1. Redis에 blacklist:{token} 형태로 등록
    2. JwtAuthorizationFilter에서 매 요청 시 블랙리스트 여부 확인
    3. RefreshToken도 DB에서 삭제하여 재발급 차단

11. 전체 시퀀스 다이어그램

[Client] → POST /login(ID, PW)
    ↓
[JwtAuthenticationFilter]
    ↓ (인증 성공)
[JwtProvider] → Access + Refresh 발급
    ↓
[Client] ← 응답 (Authorization: Bearer ...)
    ↓
GET /api/profile (Authorization 헤더 포함)
    ↓
[JwtAuthorizationFilter] → validateToken() → setAuthentication()
    ↓
[SecurityContextHolder] → Controller
profile
slow but steady

0개의 댓글