From 665bc485b9fcd7579e8aa112684664bf0b108d2b Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:41:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#464]=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EC=84=B8=EC=85=98=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Foundation/AuthInterceptor.swift | 3 +++ .../Data/Network/Foundation/TokenManager.swift | 3 +++ .../Data/Network/Foundation/TokenRefresher.swift | 14 ++++++-------- .../Utility/Application/SceneDelegate.swift | 9 +++++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift index c740c9b7..8200b346 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift @@ -62,6 +62,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/TokenManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift index 8bc9239f..fc8e12e3 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift @@ -31,6 +31,9 @@ final class TokenManager { /// 재발급이 필요하면 TokenRefresher로 실행 func refreshIfNeeded() async { + // 비로그인 상태면 재발급할 토큰이 없다 + guard !RealmService.shared.getRefreshToken().isEmpty else { return } + if isTokenExpiringSoon() { do { try await TokenRefresher.shared.refreshIfNeeded() 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/Utility/Application/SceneDelegate.swift b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift index c5c2cfb6..d396a30d 100644 --- a/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift +++ b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift @@ -397,7 +397,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - Session Expiration Handler private func handleSessionExpired() { - RealmService.shared.deleteAll(Token.self) - transitionToLogin(withMessage: "세션이 만료되어 다시 로그인해주세요.") + // 동시에 실패한 요청들이 각각 이벤트를 보내므로 첫 번째만 처리 + guard !RealmService.shared.getToken().isEmpty else { return } + + // 로그아웃과 같은 기준으로 계정 상태 정리 + AnalyticsIdentityManager.reset() + RealmService.shared.resetDB() + transitionToLogin(withMessage: TextLiteral.Common.sessionExpired) } } From 83d1adbd2844e5ec414c72723af6d0a40584e5f8 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:42:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[#464]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=8C=80=EC=83=81=EC=9D=84=20401=EB=A1=9C?= =?UTF-8?q?=20=ED=95=9C=EC=A0=95=ED=95=98=EA=B3=A0=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=201=ED=9A=8C=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Foundation/AuthInterceptor.swift | 21 ++++++++++++++----- .../Network/Router/PartnershipRouter.swift | 7 +++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift index 8200b346..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)") 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 + } +} From fc0bbd546c75c50e4abea7dd10e86fce826d9568 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:43:39 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[#464]=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=98=A4=EB=A5=98=EB=A1=9C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9D=B4=20=EC=82=AD=EC=A0=9C=EB=90=98=EA=B1=B0=EB=82=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Foundation/AuthenticationManager.swift | 7 +++-- .../SetNickNameViewController.swift | 30 ++++--------------- .../MyReviewViewController.swift | 19 ++---------- 3 files changed, 12 insertions(+), 44 deletions(-) diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift index ffee35c4..505e2121 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift @@ -36,10 +36,13 @@ final class AuthenticationManager { do { try await TokenManager.shared.refreshIfNeededWithThrow() return .authenticated - } catch { - // 3. 갱신 실패하면 토큰 삭제 후 세션 만료 처리 + } catch TokenRefresherError.sessionExpired { + // 3. refreshToken까지 만료되면 토큰 삭제 후 세션 만료 처리 RealmService.shared.deleteAll(Token.self) return .sessionExpired + } catch { + // 4. 오프라인·서버 오류는 기존 토큰으로 진입하고, 이후 401 재발급 흐름에 맡김 + return .authenticated } } 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) } } } From 9fb598fc244d1a7e89842216c71cc804ff0eb818 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:47:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[#464]=20=ED=8F=AC=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EB=B3=B5=EA=B7=80=20=EC=8B=9C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=A7=8C=EB=A3=8C=EB=A5=BC=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=ED=95=98=EA=B3=A0=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=95=EB=A6=AC=20=EA=B8=B0=EC=A4=80=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Foundation/AuthenticationManager.swift | 8 ++++++-- .../Sources/Data/Network/Foundation/TokenManager.swift | 3 +++ .../App/Sources/Utility/Application/SceneDelegate.swift | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift index 505e2121..8ea5389b 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift @@ -37,8 +37,12 @@ final class AuthenticationManager { try await TokenManager.shared.refreshIfNeededWithThrow() return .authenticated } catch TokenRefresherError.sessionExpired { - // 3. refreshToken까지 만료되면 토큰 삭제 후 세션 만료 처리 - RealmService.shared.deleteAll(Token.self) + // 3. refreshToken까지 만료되면 로그아웃과 같은 기준으로 계정 상태 정리 + // resetDB는 메인 전용 상태(찜 목록)도 비우므로 메인에서 실행 + await MainActor.run { + AnalyticsIdentityManager.reset() + RealmService.shared.resetDB() + } return .sessionExpired } catch { // 4. 오프라인·서버 오류는 기존 토큰으로 진입하고, 이후 401 재발급 흐름에 맡김 diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift index fc8e12e3..85adaafc 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift @@ -37,6 +37,9 @@ final class TokenManager { if isTokenExpiringSoon() { do { try await TokenRefresher.shared.refreshIfNeeded() + } catch TokenRefresherError.sessionExpired { + // 포그라운드 복귀 시점에 바로 로그인 화면으로 안내 + TokenRefresher.sessionExpiredPublisher.send() } catch { print("앱 시작/포그라운드 시 재발급 실패: \(error)") } diff --git a/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift index d396a30d..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,6 +401,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - Session Expiration Handler private func handleSessionExpired() { + // 스플래시 중이면 시작 인증 흐름이 로그인 화면으로 보낸다 + guard hasFinishedLaunchAuthentication else { return } // 동시에 실패한 요청들이 각각 이벤트를 보내므로 첫 번째만 처리 guard !RealmService.shared.getToken().isEmpty else { return }