사이드 프로젝트에서 서버 인증을 도입하면서 JWT(JSON Web Token)를 다루게 되었습니다. 기존에도 로그인 API를 호출하면 토큰이 내려오고, 이후 요청마다 토큰을 실어 보내면 된다는 기본적인 흐름을 알고, 실무에서 사용하고 있었지만 깊게 알고 있지 못하다는 생각이 들어 정리하게 되었습니다.
토큰이 만료되면 어떻게 갱신해야 하는지, 여러 API 요청이 동시에 날아가는 상황에서 토큰 갱신이 중복으로 발생하면 어떻게 되는지, 그리고 대규모 사용자 환경에서 토큰 갱신 요청이 서버에 몰리는 문제는 어떻게 해결하는지, 이런 질문들에 대해 학습하고 정리한 내용을 공유합니다.
기초적인 JWT 구조부터 시작해서, iOS에서의 인증 흐름, 토큰 갱신 전략, 그리고 심화 주제인 Thundering Herd 문제와 Jitter Backoff 전략까지 다룹니다.
JWT(JSON Web Token)는 두 시스템 간에 정보를 안전하게 전달하기 위한 개방형 표준(RFC 7519)입니다. 주로 인증(Authentication)과 정보 교환에 사용되며, 토큰 자체에 필요한 정보를 담고 있어 서버가 별도의 세션 저장소를 유지하지 않아도 되는 Stateless 특성을 가집니다.
JWT는 .(점)으로 구분된 세 부분으로 구성됩니다.
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
각 부분은 Base64URL로 인코딩되어 있으며, HTTP 헤더나 URL 파라미터로 전달하기에 적합한 형태입니다.
토큰의 타입과 서명에 사용할 알고리즘 정보를 담고 있습니다.
{
"alg": "HS256",
"typ": "JWT"
}
alg는 서명 알고리즘(HMAC SHA256, RSA 등), typ는 토큰 타입을 나타냅니다.
토큰에 담을 실제 데이터인 클레임(Claims)을 포함합니다. 클레임은 세 종류로 나뉩니다.
Registered Claims — JWT 표준에서 정의한 예약된 클레임입니다.
| 클레임 | 설명 |
|---|---|
iss | 토큰 발급자 (Issuer) |
sub | 토큰 주체 (Subject) |
exp | 만료 시간 (Expiration Time) |
iat | 발급 시간 (Issued At) |
aud | 토큰 대상자 (Audience) |
Public Claims — 충돌 방지를 위해 IANA JWT Registry에 등록하거나 URI 형태로 정의하는 클레임입니다.
Private Claims — 서버와 클라이언트 간 합의하에 사용하는 커스텀 클레임입니다.
{
"sub": "1234567890",
"name": "Zerom",
"iat": 1516239022,
"exp": 1516242622
}
Payload는 Base64URL로 인코딩될 뿐 암호화되지 않습니다. 민감한 정보(비밀번호 등)는 절대 Payload에 담지 않아야 합니다.
Header와 Payload가 변조되지 않았음을 검증하기 위한 서명입니다. 서버만 알고 있는 비밀 키를 사용하여 생성합니다.
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
클라이언트가 토큰을 서버에 전달하면, 서버는 동일한 방식으로 서명을 재생성하여 토큰의 무결성을 검증합니다. 서명이 일치하지 않으면 토큰은 위변조된 것으로 간주됩니다.
iOS 앱에서 JWT 기반 인증은 일반적으로 Access Token과 Refresh Token 두 가지 토큰을 사용하는 구조를 따릅니다.
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 용도 | API 요청 시 인증 수단 | Access Token 갱신 수단 |
| 수명 | 짧음 (15분 ~ 1시간) | 김 (7일 ~ 30일) |
| 저장 위치 | 메모리 또는 Keychain | Keychain |
| 탈취 위험 시 | 짧은 수명으로 피해 최소화 | 서버에서 무효화 가능 |
Access Token의 수명을 짧게 설정하는 이유는 보안 때문입니다. 토큰이 탈취되더라도 짧은 시간 안에 만료되어 피해를 최소화할 수 있습니다. 대신 사용자가 매번 로그인하는 불편함을 줄이기 위해 Refresh Token으로 새 Access Token을 발급받는 구조를 사용합니다.
1. 로그인 요청
Client ──── [email/password] ────▶ Server
Client ◀── [accessToken + refreshToken] ── Server
2. API 요청
Client ──── [Authorization: Bearer accessToken] ────▶ Server
Client ◀── [200 OK + 응답 데이터] ── Server
3. Access Token 만료 시
Client ──── [Authorization: Bearer accessToken] ────▶ Server
Client ◀── [401 Unauthorized] ── Server
4. 토큰 갱신
Client ──── [refreshToken] ────▶ Server
Client ◀── [새 accessToken (+ 새 refreshToken)] ── Server
5. 원래 요청 재시도
Client ──── [Authorization: Bearer 새 accessToken] ────▶ Server
Client ◀── [200 OK + 응답 데이터] ── Server
JWT 토큰은 사용자의 인증 정보를 담고 있는 민감한 데이터입니다. iOS에서 토큰을 저장할 때 UserDefaults를 사용하는 경우가 있는데, 이는 보안상 적절하지 않습니다.
UserDefaults는 단순한 plist 파일로 저장되어 탈옥된 기기에서 쉽게 읽을 수 있습니다. 반면 Keychain은 iOS가 제공하는 암호화된 저장소로, 하드웨어 수준의 보안을 제공합니다. Keychain에 저장된 데이터는 앱이 삭제되어도 유지되므로, 앱 첫 실행 시 이전 토큰을 정리하는 로직을 추가하는 것이 좋습니다.
실제 앱에서는 "401 응답이 오면 토큰을 갱신하고 재시도한다"는 로직을 모든 API 호출마다 반복하지 않습니다. 이 로직을 한 곳에 모아 처리하는 Interceptor 패턴을 사용합니다.
Swift Concurrency의 actor를 활용하면 토큰 갱신 시 발생할 수 있는 동시성 문제를 깔끔하게 해결할 수 있습니다.
actor AuthManager {
private var accessToken: String?
private var refreshToken: String?
private var refreshTask: Task<String, Error>?
func validToken() async throws -> String {
// 이미 갱신 중이라면 기존 Task의 결과를 대기
if let refreshTask {
return try await refreshTask.value
}
guard let accessToken else {
throw AuthError.notAuthenticated
}
if isTokenValid(accessToken) {
return accessToken
}
return try await refreshAccessToken()
}
private func refreshAccessToken() async throws -> String {
let task = Task { () -> String in
defer { self.refreshTask = nil }
guard let refreshToken else {
throw AuthError.notAuthenticated
}
let response = try await APIClient.refreshToken(refreshToken)
self.accessToken = response.accessToken
self.refreshToken = response.refreshToken
return response.accessToken
}
self.refreshTask = task
return try await task.value
}
private func isTokenValid(_ token: String) -> Bool {
guard let payload = decodeJWTPayload(token),
let exp = payload["exp"] as? TimeInterval else {
return false
}
// 만료 30초 전에 미리 갱신
return Date().timeIntervalSince1970 < (exp - 30)
}
}
이 구현에서 핵심은 refreshTask 프로퍼티입니다. 여러 API 요청이 동시에 401을 받아 토큰 갱신을 시도할 때, 첫 번째 호출만 실제 갱신 요청을 보내고 나머지는 동일한 Task를 await 합니다. actor의 직렬화 특성 덕분에 별도의 Lock 없이도 안전하게 동작합니다.
iOS에서 JWT의 Payload를 디코딩하여 만료 시간 등의 정보를 확인할 수 있습니다. JWT의 두 번째 부분(Payload)은 Base64URL로 인코딩되어 있으므로 디코딩만 하면 됩니다.
func decodeJWTPayload(_ jwt: String) -> [String: Any]? {
let segments = jwt.components(separatedBy: ".")
guard segments.count == 3 else { return nil }
var base64 = segments[1]
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Base64 패딩 처리
let remainder = base64.count % 4
if remainder > 0 {
base64.append(String(repeating: "=", count: 4 - remainder))
}
guard let data = Data(base64Encoded: base64) else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
이 디코딩은 서명 검증 없이 Payload만 읽는 것입니다. 서명 검증은 서버에서 수행하며, 클라이언트에서는 만료 시간 확인 등 제한적인 용도로만 사용합니다.
AuthManager를 활용한 API 요청 래퍼를 만들면, 인증 로직이 모든 API 호출에 자동으로 적용됩니다.
struct APIClient {
private let authManager = AuthManager()
private let session = URLSession.shared
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var urlRequest = endpoint.urlRequest
let token = try await authManager.validToken()
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
switch httpResponse.statusCode {
case 200..<300:
return try JSONDecoder().decode(T.self, from: data)
case 401:
// 토큰이 만료된 경우 갱신 후 재시도
let newToken = try await authManager.validToken()
urlRequest.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
let (retryData, _) = try await session.data(for: urlRequest)
return try JSONDecoder().decode(T.self, from: retryData)
default:
throw NetworkError.serverError(httpResponse.statusCode)
}
}
}
앞서 다룬 Interceptor 패턴은 하나의 클라이언트 내부에서 동시 갱신 요청을 제어하는 방법입니다. 하지만 서버 관점에서 보면, 수천 대의 클라이언트가 비슷한 시점에 Access Token이 만료되어 동시에 갱신 요청을 보내는 상황이 발생할 수 있습니다. 이것이 Thundering Herd 문제입니다.
Thundering Herd(우르르 몰려드는 떼)는 다수의 클라이언트가 특정 이벤트(토큰 만료, 서버 재시작 등)에 반응하여 동시에 같은 요청을 보내는 현상입니다.
예를 들어, 서버가 Access Token의 수명을 1시간으로 설정하고, 많은 사용자가 비슷한 시간대에 로그인했다면, 1시간 후 수천 개의 토큰 갱신 요청이 서버에 동시에 도달합니다. 이로 인해 서버에 순간적인 과부하가 발생하고, 일부 요청이 실패하며, 실패한 요청들이 재시도하면서 부하가 더 심해지는 악순환에 빠질 수 있습니다.
토큰 갱신에 실패했을 때 단순히 재시도하면 상황이 더 악화됩니다.
// 나쁜 예: 고정 간격 재시도
func refreshWithFixedRetry() async throws -> String {
for attempt in 0..<3 {
do {
return try await performRefresh()
} catch {
try await Task.sleep(for: .seconds(2)) // 모든 클라이언트가 정확히 2초 후 재시도
}
}
throw AuthError.refreshFailed
}
100개의 클라이언트가 동시에 실패하면, 정확히 2초 후에 100개의 재시도 요청이 다시 몰려듭니다. 문제가 해결되지 않고 2초 간격으로 반복될 뿐입니다.
지수 백오프(Exponential Backoff)는 재시도 간격을 지수적으로 증가시켜 서버에 회복할 시간을 주는 전략입니다.
1차 재시도: 1초 후
2차 재시도: 2초 후
3차 재시도: 4초 후
4차 재시도: 8초 후
서버 부하를 줄이는 데 도움이 되지만, 여전히 한 가지 문제가 있습니다. 모든 클라이언트가 동일한 시점에 실패했다면, 동일한 간격으로 재시도하므로 여전히 요청이 동시에 몰립니다.
Jitter(지터)는 재시도 간격에 무작위성을 추가하여 요청을 시간축에 고르게 분산시키는 기법입니다. AWS Architecture Blog에서 소개한 세 가지 Jitter 전략이 대표적입니다.
sleep = random(0, min(cap, base × 2^attempt))
전체 범위에서 무작위 값을 선택합니다. 대기 시간이 0에 가까울 수도 있지만, 전체적으로 서버 부하를 가장 효과적으로 분산시킵니다.
temp = min(cap, base × 2^attempt)
sleep = temp / 2 + random(0, temp / 2)
지수 백오프 값의 절반은 보장하고, 나머지 절반에서 무작위 값을 더합니다. 최소 대기 시간이 보장되므로 너무 짧은 재시도를 방지합니다.
sleep = min(cap, random(base, previousSleep × 3))
이전 대기 시간을 기반으로 다음 범위를 결정합니다. 자기 조정(self-adjusting) 특성이 있어 유연한 분산이 가능합니다.
AWS의 분석에 따르면, Full Jitter가 서버 부하 측면에서 가장 효율적이며 대부분의 상황에서 권장됩니다.
이 세 가지 전략을 iOS에서 구현하면 다음과 같습니다.
enum JitterStrategy {
case full
case equal
case decorrelated
}
struct RetryConfig {
let maxRetries: Int
let baseDelay: TimeInterval // 초 단위
let maxDelay: TimeInterval // cap 값
let strategy: JitterStrategy
}
func retry<T>(
config: RetryConfig,
operation: () async throws -> T
) async throws -> T {
var previousSleep: TimeInterval = config.baseDelay
for attempt in 0..<config.maxRetries {
do {
return try await operation()
} catch {
if attempt == config.maxRetries - 1 { throw error }
let cap = min(config.maxDelay, config.baseDelay * pow(2, Double(attempt)))
let sleep: TimeInterval
switch config.strategy {
case .full:
sleep = Double.random(in: 0...cap)
case .equal:
sleep = cap / 2 + Double.random(in: 0...(cap / 2))
case .decorrelated:
sleep = min(config.maxDelay, Double.random(in: config.baseDelay...(previousSleep * 3)))
}
previousSleep = sleep
try await Task.sleep(for: .seconds(sleep))
}
}
throw AuthError.refreshFailed
}
이를 토큰 갱신에 적용하면 다음과 같습니다.
func refreshTokenWithJitter() async throws -> String {
let config = RetryConfig(
maxRetries: 4,
baseDelay: 0.5,
maxDelay: 16.0,
strategy: .full
)
return try await retry(config: config) {
try await performRefresh()
}
}
| 전략 | 서버 부하 | 총 완료 시간 | 구현 복잡도 | 적합한 상황 |
|---|---|---|---|---|
| Full Jitter | 가장 낮음 | 약간 김 | 낮음 | 대부분의 경우 권장 |
| Equal Jitter | 낮음 | 보통 | 보통 | 최소 대기 시간 보장이 필요할 때 |
| Decorrelated Jitter | 보통 | 약간 짧음 | 높음 | 이전 상태 기반 적응이 필요할 때 |
JWT 기반 인증은 단순히 "토큰을 보내고 받는 것"이 아니라, 토큰의 구조를 이해하고, 안전하게 저장하며, 만료 시 효율적으로 갱신하는 일련의 과정입니다.
| 단계 | 핵심 포인트 |
|---|---|
| JWT 구조 | Header(알고리즘) + Payload(클레임) + Signature(서명) |
| 토큰 저장 | UserDefaults 대신 Keychain 사용 |
| 토큰 갱신 | actor 기반 AuthManager로 동시 갱신 요청 제어 |
| Interceptor | API 클라이언트에 인증 로직을 한 곳에 집중 |
| Thundering Herd 대응 | Exponential Backoff + Jitter로 서버 부하 분산 |
특히 토큰 갱신 실패 시 단순 재시도가 아닌 Jitter Backoff 전략을 적용하면, 서버의 안정성을 크게 향상시킬 수 있습니다. 대부분의 경우 Full Jitter 전략이 가장 효율적이며, AWS에서도 이를 표준 접근법으로 권장하고 있습니다.
글에 대한 피드백이나 더 좋은 방법이 있다면 댓글로 공유해주세요.