티스토리 뷰

반응형

스프링 시큐리티 완전 처음부터 4편 (Kotlin)

JWT 토큰 발급 + 인증 필터 만들기 (세션 없는 진짜 실무 구조)


여기까지 왔다면 솔직히 말해서
“시큐리티 하나도 몰라요” 라고 말하긴 좀 어렵다.

1편
→ 필터 체인, 기본 구조 감 잡고

2편
→ DB 기반 인증(UserDetailsService) 직접 붙이고

3편
→ formLogin 버리고 JSON 로그인 API까지 만들었다

이제 남은 마지막 퍼즐은 이거다.

“로그인 이후를 어떻게 유지할 것인가?”

  • 세션? ❌ (서버 확장성, 모바일/SPA에서 불편)
  • 쿠키? ❌ (CORS, CSRF, 도메인 이슈)
  • 👉 JWT(Token) ⭕

이번 글에서는 실무에서 가장 많이 쓰는 구조로 간다.


✔ 이번 글에서 최종적으로 만들 것

로그인 시

POST /api/login
→ username / password 인증
→ JWT Access Token 발급

이후 요청

GET /private/hello
Authorization: Bearer {JWT}

서버 내부 흐름

요청
 → JwtAuthenticationFilter
   → Authorization 헤더에서 토큰 추출
   → 토큰 검증
   → UserDetails 조회
   → SecurityContextHolder 저장
 → 컨트롤러

📌 세션 사용 안 함 (Stateless)
📌 서버 여러 대여도 문제 없음
📌 모바일 / SPA / 외부 API 연동에 최적


0. JWT 개념, 진짜 필요한 만큼만

JWT(Json Web Token)는 딱 이렇게 이해하면 된다.

“서명된 사용자 정보 묶음”

구조는 3덩어리다.

Header.Payload.Signature
  • Header → 알고리즘 정보
  • Payload → 사용자 정보 (username, role 등)
  • Signature → 위조 방지 서명

👉 서버는

  • 토큰을 발급만 하고
  • 이후 요청마다 검증만 한다
    👉 세션 저장 ❌

1. JWT 라이브러리 추가

반응형

가장 많이 쓰는 jjwt 라이브러리 사용한다.

build.gradle.kts

implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

2. JWT 유틸 클래스 만들기

토큰 생성 / 검증 / 정보 추출 전담 클래스다.


📄 JwtProvider.kt

package com.example.demo.security.jwt

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import org.springframework.stereotype.Component
import java.util.*

@Component
class JwtProvider {

    private val secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256)
    private val accessTokenValidity = 1000 * 60 * 30 // 30분

    fun generateToken(username: String): String {
        val now = Date()
        val expiryDate = Date(now.time + accessTokenValidity)

        return Jwts.builder()
            .setSubject(username)
            .setIssuedAt(now)
            .setExpiration(expiryDate)
            .signWith(secretKey)
            .compact()
    }

    fun getUsername(token: String): String {
        return parseClaims(token).subject
    }

    fun validateToken(token: String): Boolean {
        return try {
            parseClaims(token)
            true
        } catch (e: Exception) {
            false
        }
    }

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

📌 중요 포인트

  • secretKey → 실무에서는 환경변수로 관리
  • 토큰 만료(exp) 필수
  • Payload에는 최소 정보만 넣는다 (username 정도)

3. 로그인 성공 시 JWT 발급하도록 수정

3편에서 만든 CustomAuthenticationFilter 기억날 거다.
이제 로그인 성공 시 JWT를 내려주도록 수정한다.


📄 CustomAuthenticationFilter 수정

class CustomAuthenticationFilter(
    private val authenticationManager: AuthenticationManager,
    private val jwtProvider: JwtProvider
) : UsernamePasswordAuthenticationFilter() {

    init {
        setFilterProcessesUrl("/api/login")
    }

    override fun attemptAuthentication(
        request: HttpServletRequest,
        response: HttpServletResponse
    ): Authentication {

        val loginRequest = ObjectMapper().readValue(
            request.inputStream,
            LoginRequest::class.java
        )

        val authToken = UsernamePasswordAuthenticationToken(
            loginRequest.username,
            loginRequest.password
        )

        return authenticationManager.authenticate(authToken)
    }

    override fun successfulAuthentication(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
        authResult: Authentication
    ) {
        val token = jwtProvider.generateToken(authResult.name)

        response.contentType = "application/json"
        response.writer.write(
            """
            {
              "accessToken": "$token"
            }
            """.trimIndent()
        )
    }
}

이제 로그인 응답은 이렇게 바뀐다.

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9..."
}

4. JWT 인증 필터 만들기 (이게 핵심 중 핵심)

이 필터는 모든 요청마다 실행된다.

역할은 단 하나다.

“Authorization 헤더에 JWT 있으면 → 인증 처리”


📄 JwtAuthenticationFilter.kt

package com.example.demo.security.filter

import com.example.demo.security.CustomUserDetailsService
import com.example.demo.security.jwt.JwtProvider
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter

class JwtAuthenticationFilter(
    private val jwtProvider: JwtProvider,
    private val userDetailsService: CustomUserDetailsService
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val authHeader = request.getHeader("Authorization")

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            val token = authHeader.substring(7)

            if (jwtProvider.validateToken(token)) {
                val username = jwtProvider.getUsername(token)
                val userDetails = userDetailsService.loadUserByUsername(username)

                val authentication = UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.authorities
                )

                authentication.details =
                    WebAuthenticationDetailsSource().buildDetails(request)

                SecurityContextHolder.getContext().authentication = authentication
            }
        }

        filterChain.doFilter(request, response)
    }
}

📌 이 필터의 존재로 인해:

  • 세션 ❌
  • 매 요청마다 JWT 검증
  • 인증 정보는 요청 단위로 SecurityContext에 세팅

5. SecurityConfig – Stateless 구조 완성

이제 모든 걸 조립한다.


📄 SecurityConfig.kt (JWT 최종 버전)

@Bean
fun securityFilterChain(
    http: HttpSecurity,
    authenticationManager: AuthenticationManager,
    jwtProvider: JwtProvider,
    userDetailsService: CustomUserDetailsService
): SecurityFilterChain {

    val loginFilter = CustomAuthenticationFilter(authenticationManager, jwtProvider)
    val jwtFilter = JwtAuthenticationFilter(jwtProvider, userDetailsService)

    http
        .csrf { it.disable() }
        .sessionManagement {
            it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        }

        .authorizeHttpRequests {
            it.requestMatchers("/api/login", "/public/**").permitAll()
            it.anyRequest().authenticated()
        }

        .addFilter(loginFilter)
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)

    return http.build()
}

📌 진짜 중요한 설정

.sessionManagement {
    it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}

👉 이제 서버는 세션을 절대 만들지 않는다.


6. 실제 사용 흐름 정리

1️⃣ 로그인

POST /api/login

{
  "accessToken": "jwt-token"
}

2️⃣ 인증 필요한 API 호출

GET /private/hello
Authorization: Bearer jwt-token

로그인한 사용자만 접근 가능

7. 이 구조를 이해했다면, 이제 이런 말이 가능해진다

  • “JWT 기반 인증 구조 이해합니다”
  • “세션 없는 인증 구현해봤습니다”
  • “SecurityContext 흐름 알고 있습니다”
  • “필터 기반 인증 구현 경험 있습니다”

👉 주니어 → 중급으로 넘어가는 지점이다.


다음 글 예고 (5편, 마지막)

권한(Role) 기반 접근 제어 + @PreAuthorize 실전 사용

  • ROLE_USER / ROLE_ADMIN 분기
  • URL 기반 권한 제어
  • 메서드 레벨 권한 제어
  • 실무에서 권한 설계할 때 주의점
  • 관리자 API 보호 전략

여기까지 가면
스프링 시큐리티 풀코스 완주다.


출처

  • Spring Security 공식 문서
  • JWT RFC 7519
  • 실무 Kotlin + Spring Security 운영 경험

 

스프링시큐리티, springsecurity, 코틀린, kotlin, jwt, stateless, authentication, authorization, 백엔드개발, 개발블로그

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함
반응형