Search
🚨

토큰 갱신 버그

 문제 요약

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 응답 시 자동 토큰 갱신
동작함
중복 갱신 방지
싱글톤 적용 완료
앱 재실행 후 로그인 상태 유지
AppState.shared + @StateObject로 해결
토큰 만료 판단 정확성
 isTokenExpired() 도입으로 해결