diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift index c740c9b7..e58a5b17 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift @@ -17,7 +17,8 @@ final class AuthInterceptor: RequestInterceptor { static let shared = AuthInterceptor() /// accessToken이 만료되었을 때 재시도할 상태코드 목록 - private let refreshStatusCodes: Set = [401, 403] + /// 서버는 토큰 만료·무효에 401, 권한 부족(남의 리뷰 수정 등)에 403을 준다 + private let refreshStatusCodes: Set = [401] /// 모든 요청에 accessToken을 부착 (단, 재발급 요청은 예외) func adapt(_ urlRequest: URLRequest, @@ -41,18 +42,28 @@ final class AuthInterceptor: RequestInterceptor { completion(.success(request)) } - /// 인증 실패(401, 403) 발생 시 refreshToken으로 accessToken 재발급 시도 - /// 성공하면 동일 요청을 재시도, 실패하면 그대로 실패 처리 + /// 인증 실패(401) 발생 시 refreshToken으로 accessToken 재발급 시도 + /// 성공하면 동일 요청을 한 번만 재시도, 실패하면 그대로 실패 처리 func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { - - // 응답 코드가 401 또는 403인 경우만 재발급 시도 + + // 응답 코드가 401인 경우만 재발급 시도 guard let statusCode = (request.task?.response as? HTTPURLResponse)?.statusCode, refreshStatusCodes.contains(statusCode) else { return completion(.doNotRetryWithError(error)) } + + // 재발급 후 재시도한 요청이 또 실패하면 반복하지 않음 + guard request.retryCount == 0 else { + return completion(.doNotRetryWithError(error)) + } + + // 비로그인 상태면 재발급할 토큰이 없음 + guard !RealmService.shared.getRefreshToken().isEmpty else { + return completion(.doNotRetryWithError(error)) + } print("retry 호출 – statusCode: \(statusCode)") @@ -62,6 +73,9 @@ final class AuthInterceptor: RequestInterceptor { try await TokenRefresher.shared.refreshIfNeeded() await MainActor.run { completion(.retry) } } catch { + if case TokenRefresherError.sessionExpired = error { + TokenRefresher.sessionExpiredPublisher.send() + } await MainActor.run { completion(.doNotRetryWithError(error)) } } } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift index ffee35c4..8ea5389b 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift @@ -36,10 +36,17 @@ final class AuthenticationManager { do { try await TokenManager.shared.refreshIfNeededWithThrow() return .authenticated - } catch { - // 3. 갱신 실패하면 토큰 삭제 후 세션 만료 처리 - RealmService.shared.deleteAll(Token.self) + } catch TokenRefresherError.sessionExpired { + // 3. refreshToken까지 만료되면 로그아웃과 같은 기준으로 계정 상태 정리 + // resetDB는 메인 전용 상태(찜 목록)도 비우므로 메인에서 실행 + await MainActor.run { + AnalyticsIdentityManager.reset() + RealmService.shared.resetDB() + } return .sessionExpired + } catch { + // 4. 오프라인·서버 오류는 기존 토큰으로 진입하고, 이후 401 재발급 흐름에 맡김 + return .authenticated } } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift index 8bc9239f..85adaafc 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift @@ -31,9 +31,15 @@ final class TokenManager { /// 재발급이 필요하면 TokenRefresher로 실행 func refreshIfNeeded() async { + // 비로그인 상태면 재발급할 토큰이 없다 + guard !RealmService.shared.getRefreshToken().isEmpty else { return } + if isTokenExpiringSoon() { do { try await TokenRefresher.shared.refreshIfNeeded() + } catch TokenRefresherError.sessionExpired { + // 포그라운드 복귀 시점에 바로 로그인 화면으로 안내 + TokenRefresher.sessionExpiredPublisher.send() } catch { print("앱 시작/포그라운드 시 재발급 실패: \(error)") } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift index 17db670d..e894bb13 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift @@ -63,14 +63,6 @@ actor TokenRefresher { provider.request(.reissuance) { result in switch result { case .success(let response): - // 서버가 refreshToken도 만료된 경우 - if response.statusCode == 403 { - // Publisher로 세션 만료 이벤트 발행 - Self.sessionExpiredPublisher.send() - continuation.resume(throwing: TokenRefresherError.sessionExpired) - return - } - do { let base = try response.map(BaseResponse.self) guard let result = base.result else { @@ -83,6 +75,12 @@ actor TokenRefresher { } case .failure(let error): + // ReissueRouter는 .successCodes라 2xx가 아닌 응답은 여기로 온다 + // refreshToken이 만료·무효면 서버가 401을 준다 + if let statusCode = error.response?.statusCode, [401, 403].contains(statusCode) { + continuation.resume(throwing: TokenRefresherError.sessionExpired) + return + } continuation.resume(throwing: error) } } diff --git a/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift b/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift index b1528398..8f9edaa9 100644 --- a/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift @@ -49,3 +49,10 @@ extension PartnershipRouter: TargetType { } } } + +extension PartnershipRouter { + // 401이 에러로 처리돼야 AuthInterceptor가 토큰을 재발급한다 + var validationType: ValidationType { + .successCodes + } +} diff --git a/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift b/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift index 243b06c7..37905f5d 100644 --- a/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift +++ b/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift @@ -300,19 +300,6 @@ final class SetNickNameViewController: BaseViewController { updateSaveButtonState() } - - private func navigateToLogin() { - let loginVC = LoginViewController() - loginVC.toastMessage = TextLiteral.Common.sessionExpired - loginVC.toastType = .info - - DispatchQueue.main.async { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) { - keyWindow.replaceRootViewController(UINavigationController(rootViewController: loginVC)) - } - } - } } // MARK: - Network @@ -322,9 +309,7 @@ extension SetNickNameViewController { UserNicknameRouter.setNickname(nickname: nickname), responseType: Bool.self, useAuth: true - ) { [weak self] result in - guard let self = self else { return } - + ) { result in switch result { case .success: if let user = UserInfoManager.shared.getCurrentUserInfo() { @@ -333,8 +318,7 @@ extension SetNickNameViewController { completion(true) case .failure: - RealmService.shared.resetDB() - self.navigateToLogin() + // 실패 안내는 저장 버튼 처리부에서 한 번에 보여준다 completion(false) } } @@ -371,13 +355,11 @@ extension SetNickNameViewController { } } catch { print("닉네임 에러 응답 디코딩 실패: \(error.localizedDescription)") - RealmService.shared.resetDB() - self.navigateToLogin() + self.showToast(message: TextLiteral.Common.errorOccured, type: .danger) } } else { - // 그 외 모든 에러 처리 - RealmService.shared.resetDB() - self.navigateToLogin() + // 그 외 에러(네트워크·서버)는 안내만 하고 다시 확인할 수 있게 둔다 + self.showToast(message: TextLiteral.Common.errorOccured, type: .danger) } } } @@ -405,8 +387,6 @@ extension SetNickNameViewController { case .failure(let error): print("학과 등록 실패: \(error.localizedDescription)") - RealmService.shared.resetDB() - self.navigateToLogin() completion(false) } } diff --git a/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift b/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift index c1034647..1c5e4ad1 100644 --- a/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift +++ b/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift @@ -133,19 +133,6 @@ final class MyReviewViewController: BaseViewController { alert.addAction(cancelAction) present(alert, animated: true, completion: nil) } - - private func navigateToLogin() { - let loginVC = LoginViewController() - loginVC.toastMessage = TextLiteral.Common.sessionExpired - loginVC.toastType = .info - - DispatchQueue.main.async { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) { - keyWindow.replaceRootViewController(UINavigationController(rootViewController: loginVC)) - } - } - } } extension MyReviewViewController: UITableViewDelegate { @@ -217,8 +204,7 @@ extension MyReviewViewController { case .failure(let error): print("내 리뷰 조회 실패: \(error.localizedDescription)") - RealmService.shared.resetDB() - self.navigateToLogin() + self.showToast(message: TextLiteral.Common.errorOccured, type: .danger) } } } @@ -243,8 +229,7 @@ extension MyReviewViewController { self.showToast(message: TextLiteral.MyPage.deleteMyReviewSuccess) case .failure(let error): print("리뷰 삭제 실패: \(error.localizedDescription)") - RealmService.shared.resetDB() - self.navigateToLogin() + self.showToast(message: TextLiteral.Review.deleteReviewFail, type: .danger) } } } diff --git a/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift index c5c2cfb6..b52e54cd 100644 --- a/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift +++ b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift @@ -15,6 +15,8 @@ import KakaoSDKAuth class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private var cancellables = Set() + /// 앱 시작 인증 흐름이 끝나기 전의 세션 만료는 그 흐름이 직접 처리한다 + private var hasFinishedLaunchAuthentication = false // MARK: - UIWindowSceneDelegate Methods @@ -274,6 +276,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let result = await AuthenticationManager.shared.checkAuthentication() await MainActor.run { + hasFinishedLaunchAuthentication = true + switch result { case .authenticated: // 이미 로그인된 유저도 앱 실행 시 식별 @@ -397,7 +401,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - Session Expiration Handler private func handleSessionExpired() { - RealmService.shared.deleteAll(Token.self) - transitionToLogin(withMessage: "세션이 만료되어 다시 로그인해주세요.") + // 스플래시 중이면 시작 인증 흐름이 로그인 화면으로 보낸다 + guard hasFinishedLaunchAuthentication else { return } + // 동시에 실패한 요청들이 각각 이벤트를 보내므로 첫 번째만 처리 + guard !RealmService.shared.getToken().isEmpty else { return } + + // 로그아웃과 같은 기준으로 계정 상태 정리 + AnalyticsIdentityManager.reset() + RealmService.shared.resetDB() + transitionToLogin(withMessage: TextLiteral.Common.sessionExpired) } }