본문 바로가기
개발

Spring Boot(Kotlin) — 6편. JWT TokenProvider 이해하기 (토큰 생성·검증·구조 설계까지)

by 새싹 아빠 2025. 11. 25.

📚 Spring Boot(Kotlin) 서버 기본 셋팅 — 시리즈 안내

  1. 왜 멀티 모듈 구조인가? (아키텍처 철학 & 전체 설계 편)
  2. API Response 포맷 설계
  3. 글로벌 예외 처리(GlobalExceptionHandler)
  4. Swagger(OpenAPI) 설정
  5. Security(JWT) 기본 골격
  6. JWT TokenProvider ← 현재 글
  7. Redis 설정
  8. Validation 설정
  9. Logging + MDC(traceId)
  10. application.yml 프로필 분리 (local/dev/prod)
  11. 멀티모듈 + JPA 기본 구조 정리
  12. 완성된 프로젝트 템플릿 git 공유

📌 JWT는 뭐고, 왜 사용하는가?

인증을 구현할 때 가장 먼저 고민하는 것이 세션 기반 인증 vs 토큰 기반 인증(JWT)입니다.

✔ 세션 기반 인증: 서버가 세션을 메모리/DB에 저장하고, 클라이언트는 세션 ID만 쿠키로 보냄
✔ JWT 기반 인증: 서버가 서명된 토큰을 발급하고, 클라이언트가 매 요청마다 토큰을 전송

이번 시리즈에서는 JWT 기반 인증을 사용하는 구조를 선택했습니다. JWT(JSON Web Token)는 간단히 말하면 서버가 서명한 “검증 가능한 신분증”입니다.

✔ 서버는 JWT를 발급(서명 포함)하고
✔ 클라이언트는 이 토큰을 Authorization 헤더에 담아 API 호출 시마다 전송
✔ 서버는 토큰 서명과 만료 시간을 확인하여 사용자를 식별

이 글에서는 JWT 자체 개념보다는, 실제 서비스 코드에서 JWT를 어떻게 만들고 검증할지에 초점을 맞춥니다.
즉, AccessTokenProvider + RefreshTokenRepository(Redis) + JwtAuthenticationFilter를 조합해 JWT 인증의 핵심 뼈대를 구성합니다.

📌 Access Token + Refresh Token

JWT를 사용할 때 보통 두 가지 토큰을 같이 사용합니다.

✅ Access Token
- 비교적 짧은 유효기간 (예: 15분)
- 매 요청마다 Authorization 헤더에 실어서 전달
- 탈취되면 그 시간 동안 악용 가능 → 너무 길게 가져가면 위험

✅ Refresh Token
- 더 긴 유효기간 (예: 14일)
- 서버(Redis/DB)에 저장하여 서버가 유효성 판단 주체가 됨
- Access Token이 만료되었을 때 “새 Access Token을 발급받기 위한 용도”로 사용

이번 설계에서는 Access Token에는 userId만을 넣고, 복잡한 정보는 넣지 않습니다.
권한, 닉네임, 프로필 등은 모두 서버에서 다시 조회하는 방식으로 JWT에는 최소한의 정보만 담는 방향을 선택했습니다.

📌 모듈 구조에서 JWT 관련 코드 배치

멀티 모듈 구조에서 JWT 관련 코드는 다음처럼 나누었습니다.

✅ API 모듈
- HTTP 요청/응답 레이어
- Spring Security 설정(SecurityConfig)
- JwtAuthenticationFilter (요청에서 토큰을 꺼내고 검증하는 필터)
- 인증 실패/인가 실패 핸들러 (401/403 응답)

✅ Application 모듈
- 도메인/비즈니스 로직 계층
- AccessTokenProvider (Access Token 생성/검증/파싱 로직)
- RefreshTokenRepository (Redis에 Refresh Token 저장/조회/삭제)
- 이후 AuthService, UserService 등과 함께 동작

즉, API 모듈은 “토큰을 꺼내서 검증하고 SecurityContext에 넣는 쪽”,
Application 모듈은 “토큰을 발급하고(로그인) 관리하는 쪽”으로 역할을 나눈 구조입니다.

📌 JwtAuthenticationFilter (API 모듈)

API 모듈에서 동작하는 JWT 필터입니다. 이 필터는 요청 헤더에서 Access Token을 추출하고 검증한 뒤, 인증 정보를 SecurityContext에 저장합니다.

package com.example.api.security

import com.example.application.security.TokenProvider
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import org.slf4j.MDC

@Component
class JwtAuthenticationFilter(
    private val accessTokenProvider: AccessTokenProvider
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val token = resolveToken(request)

        if (token != null && accessTokenProvider.validateAccessToken(token)) {

            val auth = accessTokenProvider.getAuthentication(token)
            SecurityContextHolder.getContext().authentication = auth

            accessTokenProvider.getUserId(token)?.let { userId ->
                MDC.put("userId", userId.toString())
            }
        }

        filterChain.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        val bearer = request.getHeader("Authorization")
        return if (bearer != null && bearer.startsWith("Bearer ")) {
            bearer.substring(7)
        } else null
    }
}
✔ JWT 필터는 요청마다 실행되며, 토큰이 있으면 인증 정보를 세팅
✔ 토큰이 없거나 유효하지 않으면 인증 없이 다음 필터로 전달
✔ 실제 차단(401/403)은 Spring Security가 담당

📌 TokenProvider (Application 모듈)

이제 Access Token을 생성하고 검증하는 핵심 클래스입니다. 이 글에서는 Access Token 전용 Provider 기준으로 설명합니다.

package com.example.application.security

import io.jsonwebtoken.*
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.stereotype.Component
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import java.util.*

@Component
class AccessTokenProvider(
    @Value("\${jwt.secret}") secretKey: String
) {

    companion object {
        private const val ACCESS_TOKEN_VALIDITY =
            1000L * 60 * 15   // 15분
    }

    private val key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey))

    fun createAccessToken(userId: Long): String {
        val now = Date()

        return Jwts.builder()
            .setSubject(userId.toString())
            .setIssuedAt(now)
            .setExpiration(Date(now.time + ACCESS_TOKEN_VALIDITY))
            .signWith(key, SignatureAlgorithm.HS512)
            .compact()
    }

    fun validateAccessToken(token: String): Boolean {
        return try {
            Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
            true
        } catch (e: JwtException) {
            false
        } catch (e: IllegalArgumentException) {
            false
        }
    }

    fun parseClaims(token: String): Claims {
        return Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .body
    }

    fun parseClaimsAllowExpired(token: String): Claims? {
        return try {
            Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .body
        } catch (e: ExpiredJwtException) {
            e.claims
        } catch (e: JwtException) {
            null
        }
    }

    fun getUserId(token: String): Long? {
        val claims = parseClaimsAllowExpired(token) ?: return null
        return claims.subject?.toLongOrNull()
    }

    fun getAuthentication(token: String): Authentication {
        val claims = parseClaims(token)
        val userId = claims.subject

        return UsernamePasswordAuthenticationToken(
            userId,
            null,
            null
        )
    }
}

📌 전체 JWT 인증 흐름 정리

1️⃣ 로그인 성공 시
- AuthService에서 사용자 인증 완료
- AccessTokenProvider.createAccessToken(userId) 호출
- 서버에서 Refresh Token(랜덤 문자열) 생성 후 Redis에 저장
- 클라이언트에게 Access Token + Refresh Token 반환

2️⃣ 인증이 필요한 API 호출 시
- Authorization: Bearer <AccessToken>
- JwtAuthenticationFilter가 토큰 검증
- 인증 성공 시 SecurityContext에 Authentication 저장

3️⃣ Access Token 만료 시
- Refresh Token을 이용해 재발급 API 호출
- Redis에 저장된 Refresh Token 검증 후 새로운 Access Token 발급

📌 결론

📌 JWT 인증의 핵심은 “토큰을 언제 만들고, 언제 검증하느냐”를 명확히 나누는 것
📌 Access Token은 로그인 시 발급, 요청마다 검증
📌 Refresh Token은 서버가 관리하는 장기 인증 수단
📌 이 구조를 잡아두면 이후 인증 정책 변경에도 유연하게 대응 가능

결국 API 모듈은 “HTTP + Security 필터 담당”,
Application 모듈은 “인증 규칙과 토큰 정책 담당”이라는 역할 분리가 핵심입니다.

 

📌 다음편 예고

https://jaemoi8.tistory.com/40

 

Spring Boot(Kotlin) — 7편. 왜 Redis인가? Spring Boot 애플리케이션에서 Redis를 사용하는 이유와 설정

📚 Spring Boot(Kotlin) 서버 기본 셋팅 — 시리즈 안내왜 멀티 모듈 구조인가? (아키텍처 철학 & 전체 설계 편)API Response 포맷 설계글로벌 예외 처리(GlobalExceptionHandler)Swagger(OpenAPI) 설정Security(JWT) 기본

jaemoi8.tistory.com