Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ final class AuthInterceptor: RequestInterceptor {
static let shared = AuthInterceptor()

/// accessToken이 만료되었을 때 재시도할 상태코드 목록
private let refreshStatusCodes: Set<Int> = [401, 403]
/// 서버는 토큰 만료·무효에 401, 권한 부족(남의 리뷰 수정 등)에 403을 준다
private let refreshStatusCodes: Set<Int> = [401]

/// 모든 요청에 accessToken을 부착 (단, 재발급 요청은 예외)
func adapt(_ urlRequest: URLRequest,
Expand All @@ -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)")

Expand All @@ -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)) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
6 changes: 6 additions & 0 deletions EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
14 changes: 6 additions & 8 deletions EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SignResponse>.self)
guard let result = base.result else {
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ extension PartnershipRouter: TargetType {
}
}
}

extension PartnershipRouter {
// 401이 에러로 처리돼야 AuthInterceptor가 토큰을 재발급한다
var validationType: ValidationType {
.successCodes
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -333,8 +318,7 @@ extension SetNickNameViewController {
completion(true)

case .failure:
RealmService.shared.resetDB()
self.navigateToLogin()
// 실패 안내는 저장 버튼 처리부에서 한 번에 보여준다
completion(false)
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -405,8 +387,6 @@ extension SetNickNameViewController {

case .failure(let error):
print("학과 등록 실패: \(error.localizedDescription)")
RealmService.shared.resetDB()
self.navigateToLogin()
completion(false)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
Expand Down
15 changes: 13 additions & 2 deletions EATSSU/App/Sources/Utility/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import KakaoSDKAuth
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var cancellables = Set<AnyCancellable>()
/// 앱 시작 인증 흐름이 끝나기 전의 세션 만료는 그 흐름이 직접 처리한다
private var hasFinishedLaunchAuthentication = false

// MARK: - UIWindowSceneDelegate Methods

Expand Down Expand Up @@ -274,6 +276,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let result = await AuthenticationManager.shared.checkAuthentication()

await MainActor.run {
hasFinishedLaunchAuthentication = true

switch result {
case .authenticated:
// 이미 로그인된 유저도 앱 실행 시 식별
Expand Down Expand Up @@ -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 }
Comment thread
Hrepay marked this conversation as resolved.
// 동시에 실패한 요청들이 각각 이벤트를 보내므로 첫 번째만 처리
guard !RealmService.shared.getToken().isEmpty else { return }
Comment thread
Hrepay marked this conversation as resolved.

// 로그아웃과 같은 기준으로 계정 상태 정리
AnalyticsIdentityManager.reset()
RealmService.shared.resetDB()
transitionToLogin(withMessage: TextLiteral.Common.sessionExpired)
}
}
Loading