Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[refactor] RefreshToken 시간복잡도 O(n) -> O(1) 으로 감소 #411

Open
wants to merge 1 commit into
base: suyeon
Choose a base branch
from
Open
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
15 changes: 5 additions & 10 deletions KkuMulKum.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2232,11 +2232,9 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = KkuMulKum/KkuMulKumDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = D2DRA3F792;
DEVELOPMENT_TEAM = D2DRA3F792;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = KkuMulKum/Resource/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "꾸물꿈";
Expand All @@ -2257,7 +2255,6 @@
PRODUCT_BUNDLE_IDENTIFIER = KkuMulKum.yizihn;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = KkumulkumDebug;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
Expand All @@ -2274,11 +2271,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = KkuMulKum/KkuMulKum.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = D2DRA3F792;
DEVELOPMENT_TEAM = D2DRA3F792;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = KkuMulKum/Resource/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "꾸물꿈";
Expand All @@ -2300,7 +2296,6 @@
PRODUCT_BUNDLE_IDENTIFIER = KkuMulKum.yizihn;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = KkumulkumRelease1;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
Expand Down
126 changes: 95 additions & 31 deletions KkuMulKum/Source/Core/Auth/TokenRefreshManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ class TokenRefreshManager {
private var isRefreshing = false
private let queue = DispatchQueue(label: "com.TokenRefreshManager.queue")

// 대기 중인 콜백을 저장하는 배열
private var pendingCompletions: [(Result<String, Error>) -> Void] = []

// 디바운싱 매커니즘을 위한 work item
private var refreshWorkItem: DispatchWorkItem?

// 네트워크 요청 횟수를 추적하는 카운터
private var requestCount = 0
private let maxRequestRetries = 3
Comment on lines +25 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxRequestRetries가 3인 기준이 궁금합니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재시도 지연 시간이 0.2초, 0.4초, 0.8초로 증가하면서 총 1.4초까지만 대기하고자 하는 목적입니다 대부분 저 안에 해결이 될것이라 생각했고 대게 3-5회 정도 반복하는게 일시적인 문제를 해결할 수 있다고 생각했습니다


init(authService: AuthServiceProtocol, provider: MoyaProvider<AuthTargetType>) {
self.authService = authService
self.provider = provider
Expand All @@ -28,52 +38,106 @@ class TokenRefreshManager {
return
}

// 이미 토큰 리프레시 진행 중이면 대기열에 추가
if self.isRefreshing {
DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
self.refreshToken(completion: completion)
}
self.pendingCompletions.append(completion)
return
}

self.isRefreshing = true

// 리프레시 토큰 확인
guard let currentRefreshToken = self.authService.getRefreshToken() else {
self.isRefreshing = false
completion(.failure(AuthError.tokenRefreshFailed))
return
}

self.provider.request(.refreshToken(refreshToken: currentRefreshToken)) { [weak self] result in
guard let self = self else {
completion(.failure(AuthError.tokenRefreshFailed))
return
}

self.queue.async {
self.isRefreshing = false

switch result {
case .success(let response):
do {
let reissueResponse = try response.map(ResponseBodyDTO<ReissueModel>.self)
if reissueResponse.success, let data = reissueResponse.data {
if self.authService.saveAccessToken(data.accessToken) &&
self.authService.saveRefreshToken(data.refreshToken) {
completion(.success(data.accessToken))
} else {
completion(.failure(AuthError.tokenRefreshFailed))
}
// 리프레시 진행 상태로 변경하고 현재 요청 추가
self.isRefreshing = true
self.pendingCompletions.append(completion)

// 디바운싱 구현: 짧은 시간 동안 추가 요청을 모아서 한 번에 처리
self.refreshWorkItem?.cancel()

let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.performTokenRefresh(with: currentRefreshToken)
}

self.refreshWorkItem = workItem

// 20ms 지연으로 짧은 시간 내 다수 요청을 하나로 통합
DispatchQueue.global().asyncAfter(deadline: .now() + 0.02, execute: workItem)
}
}

private func performTokenRefresh(with refreshToken: String) {
requestCount += 1
provider.request(.refreshToken(refreshToken: refreshToken)) { [weak self] result in
guard let self = self else { return }

self.queue.async {
Comment on lines +74 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위 작업은 글로벌 큐(Concurrent)에서 실행되고, 네트워크 작업이기 때문에 백그라운드에서 실행되는데 이 때 커스텀 직렬 큐로 하위 작업을 실행시켜야 하는지 잘 몰라서 여쭤보고 싶습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공유 상태(isRefreshing, pendingCompletions 등)를 업데이트하기위해 하위작업으로 동기화 시켜주는 작업입니다

switch result {
case .success(let response):
do {
let reissueResponse = try response.map(ResponseBodyDTO<ReissueModel>.self)
if reissueResponse.success, let data = reissueResponse.data {
if self.authService.saveAccessToken(data.accessToken) &&
self.authService.saveRefreshToken(data.refreshToken) {
self.handleSuccess(data.accessToken)
} else {
completion(.failure(AuthError.tokenRefreshFailed))
self.handleFailure(AuthError.tokenRefreshFailed)
}
} catch {
completion(.failure(error))
} else {
self.handleFailure(AuthError.tokenRefreshFailed)
}
case .failure(let error):
completion(.failure(error))
} catch {
self.handleFailure(error)
}

case .failure(let error):
// 지수 백오프 알고리즘으로 재시도
if self.shouldRetry() {
// 지수 백오프: 2^n * 100ms 대기
let delay = pow(2.0, Double(self.requestCount)) * 0.1
DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self,
let currentRefreshToken = self.authService.getRefreshToken() else { return }
Comment on lines +102 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else 문은 개행 부탁드려요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

갱!

self.performTokenRefresh(with: currentRefreshToken)
}
} else {
self.handleFailure(error)
}
}
}
}
}

// 성공 처리 - 모든 대기 콜백에 성공 결과 전달
private func handleSuccess(_ token: String) {
// 삽입 순서 보존 알고리즘 적용 (FIFO)
let callbacks = self.pendingCompletions
self.pendingCompletions = []
self.isRefreshing = false
self.requestCount = 0

DispatchQueue.main.async {
callbacks.forEach { $0(.success(token)) }
}
Comment on lines +117 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어차피 배열의 아이템마다 어떠한 동작을 수행하고, 배열을 비워야 한다면 아래와 같은 코드도 가능할 것 같습니다.

while let callback = self.pendingCompletions.popLast() {
    DispatchQueue.main.async {
        callback(.success(token))
    }
}
self.isRefreshing = false
self.requestCount = 0

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 의견입니다 하지만 해당 코드로 진행했을때 극단적으로 생각해서 중간에 값이 변경될 가능성이 있지 않을까요? (원자성X) 그리고 현재 코드는 단일 비동기 블록으로 처리하는 반면 제안주신 코드는 각 콜백마다 비동기 작업을 예약하는 형식이여서 스케쥴링 오버헤드가 좀더 발생할 여지가 있지 않을까요?
(물론 아주 극단적이고 아주아주 미미할것이라는건 알고있습니다,,)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 확실히 그러한 부분도 있을 수 있겠군요.
동시성 프로그래밍 속에서 race condition을 고려하면서 짜는 건 너무 어려운 것 같습니다.

지훈님의 TokenRefreshManager 또한 여러 DispatchQueue의 콜백이 존재하다 보니, 어떤 큐에서 어떻게 동작하는지 파악해야 하는 부분이 있네요.
이렇기 때문에 Actor 클래스가 등장한게 아닐까 싶습니다.
TokenRefreshManager를 Actor로 구현해 보시는 것도 쿨럭,,

}

// 실패 처리 - 모든 대기 콜백에 실패 결과 전달
private func handleFailure(_ error: Error) {
let callbacks = self.pendingCompletions
self.pendingCompletions = []
self.isRefreshing = false
self.requestCount = 0

DispatchQueue.main.async {
callbacks.forEach { $0(.failure(error)) }
}
Comment on lines +134 to +136
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

콜백에 성공 신호를 메인 큐에서 전달하는 이유가 궁금합니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드트리를 최대한 평평하게 하기 위함이였습니다만,, 글로벌 큐에서 작업을 했어도 되었을 작업이였습니다.
성능개선에만 집중하다보니 이상한 코드가 나와버렷네요

}

// 재시도 여부 결정 로직
private func shouldRetry() -> Bool {
return requestCount < maxRequestRetries
}
Comment on lines +139 to +142
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

계산 속성으로 구현해도 좋을 듯 하네요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은생각이에요!

}