Skip to content
메모장
Go back

JWT 발급 및 세션 관리 — NORMAL / ADMIN 전략 비교

개요

카카오 OAuth2 로그인(카카오 OAuth2 로그인 구현)과 관리자 Credential 로그인은 진입점이 다르지만, 토큰 발급과 세션 관리는 같은 인프라를 공유한다.

카카오 로그인 → OAuth2SuccessHandler  ─┐
                                       ├─ JwtSigner (EcJwtSigner)
관리자 로그인 → AdminLoginService     ─┘  SessionManager

                                       Redis (세션 + refresh token)

1. JWT 인프라

서명 / 검증 — ES256 (ECDSA)

HS256 / RS256 / ES256 비교

JWT 서명 알고리즘의 주요 세 가지를 비교하면 아래와 같다.

항목HS256RS256ES256
방식대칭 (HMAC)비대칭 (RSA)비대칭 (ECDSA)
키 종류비밀키 1개private + publicprivate + public
검증 서버비밀키 보유 서버만public key 배포로 누구나public key 배포로 누구나
키 유출 시 위험토큰 위조 가능검증만 가능, 위조 불가검증만 가능, 위조 불가
동등 보안 강도의 키 크기256 bit3072 bit256 bit (P-256)
서명 크기32 byte384 byte (3072 bit 기준)64 byte
서명 / 검증 속도빠름느림빠름

HS256은 검증 서버마다 비밀키를 공유해야 해 서비스가 분리될수록 키 유출 경로가 늘어난다. 키가 노출되면 공격자가 임의의 토큰을 직접 발급할 수 있다.

RS256은 비대칭키라 public key만 배포하면 검증이 가능하다. 그러나 동등한 보안 강도를 위해 3072 bit 이상의 키가 필요하고, 서명 크기도 크다. JWT는 쿠키나 Authorization 헤더에 실리므로 토큰 크기가 작을수록 유리하다.

ES256은 RSA와 같은 비대칭키 보안성을 유지하면서 키 크기와 서명 크기가 훨씬 작다. P-256 곡선의 256 bit 키가 RSA 3072 bit와 동등한 보안 강도를 제공한다. 서명·검증 연산도 RSA보다 빠르다.

현재는 단일 서버지만, 추후 서비스가 분리될 때 각 서버에 public key만 배포하면 된다. private key는 토큰을 발급하는 IAM 서버 한 곳만 보유한다.

// 서명: EC Private Key (PKCS8 PEM)
sealed class EcJwtSigner(privateKeyPem: String) : JwtSigner {
    fun buildToken(
        subject: String,
        expiration: Long,
        claims: Map<String, Any>,
    ): String { ... }  // Nimbus JOSE — ES256
}

// 검증: EC Public Key (X509 PEM)
sealed class EcJwtVerifier(publicKeyPem: String) : JwtVerifier {
    fun verify(token: String): JWTClaimsSet { ... }
}

JwtAuthenticationFilter

모든 요청에서 accessToken 쿠키를 읽어 JWT를 검증하고 SecurityContextHolder에 세팅한다.

class JwtAuthenticationFilter(private val jwtVerifier: JwtVerifier) : OncePerRequestFilter() {
    override fun doFilterInternal(...) {
        val token = request.cookies?.find { it.name == "accessToken" }?.value
            ?: run { filterChain.doFilter(request, response); return }

        val claims = jwtVerifier.verify(token)
        SecurityContextHolder.getContext().authentication = JwtAuthenticationToken(claims)
        filterChain.doFilter(request, response)
    }
}

JWT Properties — 역할별 분리

NORMAL 유저와 ADMIN 유저의 JWT 만료 시간을 별도 설정으로 관리한다.

@Component
class JwtPropertyFactory(
    @UserJwt  private val normalProps: JwtProperties,   // jwt.*
    @AdminJwt private val adminProps: JwtProperties,    // admin.jwt.*
) {
    fun getProperty(role: UserRole): JwtProperties = when (role) {
        UserRole.NORMAL -> normalProps
        UserRole.ADMIN  -> adminProps
    }
}
# application.yml
jwt:                          # NORMAL 유저
  public-key: ${JWT_PUBLIC_KEY}
  private-key: ${JWT_PRIVATE_KEY}
  expiration: 1800000         # 30분
  refresh-expiration: 604800000  # 7일

admin:
  jwt:                        # ADMIN 유저
    public-key: ${ADMIN_JWT_PUBLIC_KEY}
    private-key: ${ADMIN_JWT_PRIVATE_KEY}
    expiration: 1800000
    refresh-expiration: 604800000

2. 세션 도메인 모델

Session / SessionStore

data class SessionStore(
    val userId: UUID,
    val userRole: UserRole,
) {
    // Redis ZSet 키 — 유저별 세션 목록
    val sessionStoreKey: String
        get() = "iam:${userRole.displayName}:$userId:session_index"

    val maxSessionCount: Int
        get() = when (userRole) {
            UserRole.NORMAL -> 1
            UserRole.ADMIN  -> 3
        }
}

class Session private constructor(val id: UUID, val sessionStore: SessionStore) {
    // Redis String 키 — 개별 세션의 refresh token
    val sessionKey: String
        get() = "iam:${sessionStore.userRole.displayName}:${sessionStore.userId}:sessions:$id"

    companion object {
        fun generate(sessionStore: SessionStore): Session = Session(UUID.randomUUID(), sessionStore)
        fun of(id: String, sessionStore: SessionStore): Session = Session(UUID.fromString(id), sessionStore)
    }
}

Redis 키 구조

타입TTL
iam:{role}:{userId}:session_indexZSetsessionId (score: 생성 epoch ms)-
iam:{role}:{userId}:sessions:{sessionId}Stringrefresh token JWT역할별 설정값

3. 토큰 발급 비교 — NORMAL vs ADMIN

두 진입점 모두 동일한 JwtSignerSessionManager를 사용하지만, 세션 정리 전략이 다르다.

항목NORMAL (OAuth2SuccessHandler)ADMIN (AdminLoginService)
인증 방식Spring OAuth2 (카카오)ID/Password Credential
JWT Properties@UserJwt@AdminJwt
세션 정리 전략cleanSessionsrotateAndDelete
최대 동시 세션 수13
role claim"normal""admin"

NORMAL — cleanSessions

만료된 고아 세션만 제거한다. refresh token이 이미 Redis TTL로 소멸한 세션 인덱스 항목을 ZSet에서 삭제한다. 살아있는 세션은 건드리지 않는다.

override fun cleanSessions(sessionStore: SessionStore) {
    val sessions = sessionRepository.getAllSessions(sessionStore)
    sessions.forEach { session ->
        val token = refreshTokenRepository.getToken(session)
        if (token == null) {
            sessionRepository.removeSession(session)  // 고아 인덱스 정리
        }
    }
}

NORMAL 유저는 maxSessionCount = 1이므로, 고아 정리 후에도 살아있는 세션이 있으면 새 세션이 그 위에 추가된다. 사실상 새 로그인이 기존 세션을 자연스럽게 대체하는 구조다.

ADMIN — rotateAndDelete

세션 수가 한계(maxSessionCount = 3)에 도달하면 가장 오래된 세션을 명시적으로 제거하고 새 세션을 추가한다.

override fun rotateAndDelete(sessionStore: SessionStore) {
    // ZSet에서 score 가장 낮은(가장 오래된) 세션을 pop
    sessionRepository.rotateSession(sessionStore)?.run {
        refreshTokenRepository.delete(this)  // refresh token도 함께 삭제
    }
}

AdminLoginService 전체

@Service
class AdminLoginService(
    private val adminUserRepository: AdminUserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val jwtSigner: JwtSigner,
    @AdminJwt private val jwtProperties: JwtProperties,
    private val sessionManager: SessionManager,
) : AdminLoginUseCase {

    override fun execute(command: AdminLoginCommand): TokenResult {
        val adminUser = adminUserRepository.findByLoginId(command.loginId)
            ?: throw UnauthorizedException(IamErrorCode.CREDENTIAL_INVALID)

        if (!passwordEncoder.matches(command.rawPassword, adminUser.credentials.password))
            throw UnauthorizedException(IamErrorCode.CREDENTIAL_INVALID)

        val sessionStore = SessionStore(adminUser.id.value, UserRole.ADMIN)
        val session = Session.generate(sessionStore)

        val accessToken = jwtSigner.buildToken(
            subject = adminUser.id.value.toString(),
            expiration = jwtProperties.expiration,
            claims = mapOf("type" to TokenType.ACCESS.value, "sessionId" to session.id.toString(), "role" to UserRole.ADMIN.displayName),
        )
        val refreshToken = jwtSigner.buildToken(
            subject = adminUser.id.value.toString(),
            expiration = jwtProperties.refreshExpiration,
            claims = mapOf("type" to TokenType.REFRESH.value, "sessionId" to session.id.toString(), "role" to UserRole.ADMIN.displayName),
        )

        sessionManager.rotateAndDelete(sessionStore)   // 3개 초과 시 oldest 제거
        sessionManager.saveSession(session, refreshToken, jwtProperties.refreshExpiration)

        return TokenResult(accessToken, refreshToken, jwtProperties, UserRole.ADMIN)
    }
}

4. Rotating Refresh Token — 탈취 감지

RefreshTokenService는 NORMAL / ADMIN 역할에 관계없이 동일하게 동작한다. refresh token의 role claim을 읽어 JwtPropertyFactory로 적절한 properties를 선택한다.

@Service
class RefreshTokenService(
    private val jwtSigner: JwtSigner,
    private val refreshTokenParser: RefreshTokenParser,
    private val sessionManager: SessionManager,
    private val jwtPropertyFactory: JwtPropertyFactory,
) : RefreshTokenUseCase {

    override fun execute(refreshToken: String): TokenResult {
        val parsed = refreshTokenParser.parse(refreshToken)  // 서명 검증 + claims 추출
        val role = parsed.session.sessionStore.userRole
        val properties = jwtPropertyFactory.getProperty(role)

        val stored = sessionManager.getTokenFromSession(parsed.session)
            ?: throw UnauthorizedException(IamErrorCode.LOGIN_REQUIRED)

        // 저장된 토큰 ≠ 전달된 토큰 → 탈취 의심
        if (stored != refreshToken) {
            sessionManager.removeSession(parsed.session)
            throw UnauthorizedException(IamErrorCode.TOKEN_STOLEN)
        }

        // 토큰 회전: 기존 세션 제거 → 새 세션 생성
        val newSession = Session.generate(parsed.session.sessionStore)
        val newAccessToken = jwtSigner.buildToken(
            subject = parsed.userId,
            expiration = properties.expiration,
            claims = mapOf("type" to "access", "sessionId" to newSession.id.toString(), "role" to role.displayName),
        )
        val newRefreshToken = jwtSigner.buildToken(
            subject = parsed.userId,
            expiration = properties.refreshExpiration,
            claims = mapOf("type" to "refresh", "sessionId" to newSession.id.toString(), "role" to role.displayName),
        )

        sessionManager.removeSession(parsed.session)
        sessionManager.saveSession(newSession, newRefreshToken, properties.refreshExpiration)

        return TokenResult(newAccessToken, newRefreshToken, properties, role)
    }
}

탈취 감지 원리: refresh token을 사용하면 즉시 새 토큰으로 교체된다. 공격자가 탈취한 토큰을 나중에 사용하면 Redis에 저장된 토큰과 다르므로 세션이 강제 삭제된다.


5. Logout

@Service
class LogOutService(
    private val refreshTokenParser: RefreshTokenParser,
    private val sessionManager: SessionManager,
) : LogOutUseCase {

    override fun execute(refreshToken: String) {
        val parsed = refreshTokenParser.parse(refreshToken)
        sessionManager.removeSession(parsed.session)
        // ZSet session_index 항목 제거 + String refresh token 키 삭제
    }
}

NORMAL / ADMIN 구분 없이 동일 로직. refresh token에서 sessionId와 role을 파싱해 Redis 키를 특정한다.


6. AuthController

@RestController
class AuthController(
    private val refreshTokenUseCase: RefreshTokenUseCase,
    private val logOutUseCase: LogOutUseCase,
    private val adminLoginUseCase: AdminLoginUseCase,
) {
    @PostMapping("/auth/refresh")
    fun refresh(
        @CookieValue refreshToken: String,
        response: HttpServletResponse,
    ): ResponseEntity<Void> {
        val result = refreshTokenUseCase.execute(refreshToken)
        response.addHeader(SET_COOKIE, buildCookie("accessToken", result.accessToken, path = "/").toString())
        response.addHeader(SET_COOKIE, buildCookie("refreshToken", result.refreshToken, path = "/auth").toString())
        return ResponseEntity.ok().build()
    }

    @PostMapping("/auth/logout")
    fun logout(
        @CookieValue refreshToken: String,
        response: HttpServletResponse,
    ): ResponseEntity<Void> {
        logOutUseCase.execute(refreshToken)
        response.addHeader(SET_COOKIE, expireCookie("accessToken").toString())
        response.addHeader(SET_COOKIE, expireCookie("refreshToken").toString())
        return ResponseEntity.ok().build()
    }

    @PostMapping("/auth/admin/login")
    fun adminLogin(
        @RequestBody @Valid request: AdminLoginRequest,
        response: HttpServletResponse,
    ): ResponseEntity<Void> {
        val result = adminLoginUseCase.execute(AdminLoginCommand(request.username, request.password))
        response.addHeader(SET_COOKIE, buildCookie("accessToken", result.accessToken, path = "/").toString())
        response.addHeader(SET_COOKIE, buildCookie("refreshToken", result.refreshToken, path = "/auth").toString())
        return ResponseEntity.ok().build()
    }
}

핵심 설계 포인트 요약

항목선택이유
JWT 알고리즘ES256 (ECDSA)비대칭키 → 공개키로만 검증 가능, MSA 확장 용이
Refresh Token 보관Redis (TTL)서버 주도 만료, 세션 수 제한, 탈취 감지 가능
Refresh Token 전략Rotating재사용 감지 시 세션 강제 삭제 → 탈취 피해 최소화
세션 정리 전략NORMAL: cleanSessions (고아 정리) / ADMIN: rotateAndDelete (oldest 제거)NORMAL은 단일 세션이라 고아만 정리하면 충분, ADMIN은 다중 세션을 명시적으로 순환
최대 세션 수NORMAL=1, ADMIN=3일반 유저 중복 로그인 방지, 관리자는 다수 디바이스 허용
JWT Properties 분리@UserJwt / @AdminJwt역할별 만료 시간을 독립 설정으로 제어

Share this post:

Previous Post
Spring Security + 애플 로그인 구현 (OIDC + client_secret JWT)
Next Post
Spring Security + 카카오 OAuth2 로그인 구현 (DDD 아키텍처)