문제 요약
1. API 요청에서 자동 토큰 갱신이 동작하지 않음
•
모든 네트워크 요청에 provider.request(...)만 사용
•
401 (EXPIRED_TOKEN) 응답이 와도 토큰 자동 갱신 및 재요청이 발생하지 않음
2. TokenRefreshPlugin() 인스턴스를 계속 새로 생성
MoyaProvider(... plugins: [MoyaPlugin.shared, TokenRefreshPlugin()])
Swift
복사
•
이로 인해 동시다발적으로 토큰 갱신 요청이 중복 발생 → Race Condition 유발
3. 앱을 3~5회 재실행 시, 로그아웃 상태가 되어 로그인 화면으로 이동
•
앱을 3~5회 재실행 시, 로그아웃 상태가 되어 초기 화면으로 이동
•
콘솔에 에러 로그는 출력되지 않음
•
AppState 인스턴스를 뷰 내에서 매번 새로 생성했기 때문
private var appState = AppState()
Swift
복사
•
이로 인해 @Published var isLoggedIn 값이 초기화되며 상태가 불일치하게 됨
4. JWT 토큰 만료 여부를 정확하게 판단하지 못함
•
기존에는 만료 여부를 확인하지 않거나 단순 문자열 조건으로 검사 (토큰이 “있기만 하면” 유효하다고 판단)
func checkToken() {
do {
// AccessToken 확인
if let accessToken = try TokenKeychainManager.shared.getAccessToken(), !accessToken.isEmpty {
isLoggedIn = true
} else {
isLoggedIn = false
}
} catch {
isLoggedIn = false
}
}
Swift
복사
◦
단순히 accessToken이 존재하는지만 확인하지, 그 accessToken이 유효한(expiration 검증) 토큰인지는 체크하지 않음
•
만료된 토큰으로 API 요청이 시도되며 불필요한 401 오류 발생
원인 분석
문제 | 원인 |
자동 토큰 갱신 실패 | requestWithTokenRefresh() 미사용 |
중복 토큰 갱신 발생 | TokenRefreshPlugin() 인스턴스를 매 요청마다 생성 |
앱 재실행 시 자동 로그아웃 | 뷰에서 AppState()를 직접 생성해 상태가 초기화됨 |
토큰 만료 판별 실패 | JWT payload의 exp 값을 파싱하지 않음 |
해결 방법
1. TokenRefreshPlugin을 싱글톤 패턴으로 변경
•
Before
TokenRefreshPlugin()
Swift
복사
•
After
TokenRefreshPlugin.shared
Swift
복사
•
전역에서 한 번만 인스턴스를 생성해 공유함으로써 중복 호출 방지
•
MoyaProvider도 다음과 같이 수정
private let provider = MoyaProvider<UserInfoTargetType>(plugins: [MoyaPlugin.shared, TokenRefreshPlugin.shared])
Swift
복사
2. API 요청 방식 변경
•
기존에는 .request(...)만 사용해 401 응답 후 처리 미흡
•
변경 후, 자동으로 토큰 갱신 + 요청 재시도 처리됨
provider.requestWithTokenRefresh(.updateUserInfo(request: request)) { result in ... }
Swift
복사
•
401 응답 시 토큰을 자동 갱신하고 재요청까지 처리해줌
3. AppState 전역 싱글톤 + @StateObject로 감싸기
class AppState: ObservableObject {
static let shared = AppState()
...
}
Swift
복사
•
AppState에 싱글톤 정의함
•
MementoApp에서 @StateObject로 감싸서 뷰가 상태 변경을 감지하도록 설정
@StateObject private var appState = AppState.shared
Swift
복사
@StateObject는 SwiftUI의 뷰 생명주기와 함께 상태를 유지하며, 뷰가 다시 렌더링돼도 상태를 보존함
→ 인증 상태의 단일 진실의 근원(Single Source of Truth) 역할 수행
4. JWT 만료 여부를 exp 기반으로 정밀 검사
JWT란?
JWT(JSON Web Token)는 인증에 널리 사용되는 토큰 포맷이며 다음과 같은 3개의 Base64 인코딩된 문자열로 구성됨
header.payload.signature
Swift
복사
[예시]
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3NDQyMDY5MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Swift
복사
•
header: 암호화 알고리즘 정보 (alg)
•
payload: 실제 인증 정보 (사용자 정보, exp, sub 등)
•
signature: 위조 방지를 위한 서명
exp란?
•
exp는 JWT 토큰의 만료 시간(Unix timestamp) 을 나타내는 표준 클레임 (예: 1744206900)
[예시]
{
"sub": "123",
"exp": 1744206900
}
JSON
복사
•
현재 시각이 exp보다 크면 토큰은 만료됨
Date().timeIntervalSince1970 > exp
Swift
복사
해결 코드
func isTokenExpired(_ token: String) -> Bool {
let parts = token.split(separator: ".")
guard parts.count == 3,
let payloadData = Data(base64Encoded: String(parts[1]).base64Padded()),
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
let exp = json["exp"] as? TimeInterval else { return true }
return Date().timeIntervalSince1970 > exp
}
extension String {
func base64Padded() -> String {
let padding = 4 - count % 4
return self + String(repeating: "=", count: padding % 4)
}
}
Swift
복사
•
JWT payload에 포함된 exp 값 기반으로 만료 시간 정밀 파싱
•
Base64 URL-safe 문자열의 길이 보정까지 처리
•
hasValidToken() 등 여러 검증 함수에서 활용 가능
isTokenExpired() 메서드 분석
1.
JWT 문자열 분리
header.payload.signature에서 payload만 추출
let parts = token.split(separator: ".") // ["header", "payload", "signature"]
let payload = parts[1]
Swift
복사
2.
base64 디코딩 전 패딩 복구
let payloadData = Data(base64Encoded: String(parts[1]).base64Padded())
Swift
복사
•
JWT는 base64 URL-safe 인코딩이므로 padding(=)이 종종 생략되어 있음
•
이를 복구하는 게 base64Padded() 역할
extension String {
func base64Padded() -> String {
let padding = 4 - count % 4
return self + String(repeating: "=", count: padding % 4)
}
}
Swift
복사
◦
base64 인코딩 문자열은 길이가 4의 배수여야 정상적으로 디코딩됨
◦
패딩이 부족할 경우, =을 추가해줌
3.
JSON 파싱
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
Swift
복사
•
디코딩된 payload(base64 → Data)를 JSON 딕셔너리로 변환
4.
exp 추출 및 비교
let exp = json["exp"] as? TimeInterval
return Date().timeIntervalSince1970 > exp
Swift
복사
•
현재 시간(timeIntervalSince1970)이 exp보다 크면 만료됨 → true 반환
사용 코드
func hasValidToken() -> Bool {
do {
guard let accessToken = try getAccessToken(), !accessToken.isEmpty else {
return false
}
return !isTokenExpired(accessToken)
} catch {
return false
}
}
Swift
복사
1.
getAccessToken()
let accessToken = try getAccessToken()
Swift
복사
•
Keychain에 저장된 액세스 토큰을 불러옴
•
없거나 에러 발생 시 → catch 블록으로 이동 (false 반환)
2.
accessToken.isEmpty
guard let accessToken = ..., !accessToken.isEmpty else {
return false
}
Swift
복사
•
토큰이 빈 문자열일 수도 있기 때문에 추가 검증
•
nil이나 빈 문자열인 경우 → 즉시 false 반환
3.
isTokenExpired(accessToken)
return !isTokenExpired(accessToken)
Swift
복사
•
토큰이 만료되었는지 판단하는 메서드
•
만료되지 않았다면 → 유효한 토큰 → true 반환
•
만료되었으면 → false
4.
catch 블록
catch {
return false
}
Swift
복사
•
Keychain 접근 시 에러가 발생한 경우 (예: 권한 거부, 포맷 오류 등)
•
토큰이 없거나 에러일 경우 → 로그인 상태 아님으로 처리
요약
항목 | 설명 |
핵심 역할 | Keychain에 저장된 AccessToken의 존재 여부 및 유효성 확인 |
내부 로직 | 1. Keychain에서 토큰 조회
2. nil/빈 값 확인
3. exp 시간 체크 |
반환 | true → 로그인 유지
false → 로그아웃 처리 필요 |
최종 결과
항목 | 상태 |
401 응답 시 자동 토큰 갱신 | |
중복 갱신 방지 | |
앱 재실행 후 로그인 상태 유지 | |
토큰 만료 판단 정확성 |