Skip to content

Refactor/#264 테스트3#267

Closed
mini-min wants to merge 4 commits intodevelopfrom
refactor/#264
Closed

Refactor/#264 테스트3#267
mini-min wants to merge 4 commits intodevelopfrom
refactor/#264

Conversation

@mini-min
Copy link
Member

@mini-min mini-min commented Mar 13, 2026

✨ 해결한 이슈

  • Resolved: #이슈번호

🛠️ 작업내용

구현 내용 SE 13 mini 15 pro
GIF

🖥️ 주요 코드 설명

HomeView

  • 쏼라쏼라
// 코드는 이 사이에 작성하면 됩니다. 

📂 참고한 내용

✅ Checklist

  • 필요없는 주석, 프린트문 제거했는지 확인
  • 컨벤션 지켰는지 확인

Summary by CodeRabbit

  • Chores

    • Version bumped to 1.2.3
    • Updated project build settings for latest Xcode compatibility
  • Bug Fixes

    • Improved thread-safety for UI updates and app navigation, preventing potential crashes and race conditions
    • Enhanced Apple login flow with modern async handling for increased reliability

@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

Updates project to Xcode 2630, version 1.2.3 with strict Swift concurrency enabled. Migrates AppDelegate from completion-based callbacks to async/await. Adds @MainActor annotations across UI protocols and methods. Converts DispatchQueue operations to Task-based concurrency throughout the codebase.

Changes

Cohort / File(s) Summary
Project Configuration
TOASTER-iOS.xcodeproj/project.pbxproj, TOASTER-iOS.xcodeproj/xcshareddata/xcschemes/TOASTER-iOS.xcscheme
Updated LastUpgradeCheck to 2630, bumped MARKETING_VERSION to 1.2.3, and added Swift concurrency settings (SWIFT_STRICT_CONCURRENCY=complete, SWIFT_DEFAULT_ACTOR_ISOLATION=nonisolated, STRING_CATALOG_GENERATE_SYMBOLS=YES).
AppDelegate Concurrency Migration
TOASTER-iOS/Application/AppDelegate.swift
Migrated Apple credential state check from completion-based callback to async/await using withCheckedContinuation. Added nonisolated annotations to notification delegate methods and wrapped UI updates in MainActor Tasks.
Coordinator Actor Isolation
TOASTER-iOS/Application/Coordinator/Coordinator.swift, TOASTER-iOS/Application/Coordinator/CoordinatorFinishOutput.swift, TOASTER-iOS/Application/Coordinator/Factory/CoordinatorFactory.swift, TOASTER-iOS/Application/Coordinator/Factory/ViewControllerFactory.swift, TOASTER-iOS/Application/Coordinator/Router.swift
Added @MainActor attribute to protocol declarations, enforcing main-thread execution for all conformers.
Coordinator Navigation Updates
TOASTER-iOS/Application/Coordinator/Coordinators/TabBarCoordinator.swift
Wrapped onFinish callback invocation in Task @MainActor to ensure main-thread safety for UI notifications.
Delegate Protocol Actor Annotations
TOASTER-iOS/Present/.../View/Component/*, TOASTER-iOS/Present/.../View/Cell/*
Added @MainActor to delegate protocols: SelectClipHeaderViewDelegate, AddClipBottomSheetViewDelegate, ClipCollectionHeaderViewDelegate, DetailClipListCollectionViewCellDelegate, UserClipCollectionViewCellDelegate, ChangeClipBottomSheetViewDelegate, RemindAlarmOffBottomSheetViewDelegate, RemindEditViewDelegate, TimerRepeatBottomSheetDelegate.
Async Delay Refactoring
TOASTER-iOS/Present/AddLink/AddLinkViewController.swift, TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift
Replaced DispatchQueue-based delays with Swift Concurrency Task.sleep(nanoseconds:) followed by MainActor execution for UI updates.
UI Factory & Progress Handling
TOASTER-iOS/Present/Home/View/Component/CompositioinalFactory.swift, TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift
Added @MainActor to CompositionalFactory enum. Wrapped UIProgressView updates in MainActor.assumeIsolated block.
Unsafe Concurrency Access
TOASTER-iOS/Global/Resources/Config.swift, TOASTER-iOS/Network/Base/APIInterceptor.swift, TOASTER-iOS/Network/Base/NetworkService.swift
Added nonisolated(unsafe) annotations to infoDictionary, isTokenRefreshed property, and NetworkService.shared singleton, permitting concurrent access without actor isolation.
Authentication & ViewModel Updates
TOASTER-iOS/Present/Login/Adapter/KakaoAuthenticateAdapter.swift, TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift
Removed @MainActor from Kakao authentication methods. Removed DispatchQueue.main.async wrapper from remindViewType observer.
UpdateAlertManager Refactoring
TOASTER-iOS/Present/UpdateAlert/UpdateAlertManager.swift
Changed from final class to struct. Added @MainActor annotation to showUpdateAlert method.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A rabbit's ode to concurrency threads,
MainActors dancing where my code spreads,
Async/await hops replace old callbacks,
Swift strictness keeps our logic on track,
Concurrency springs forward—no turning back! 🐇✨

🚥 Pre-merge checks | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is largely a template placeholder with no actual content filled in; the issue number, work details, code explanations, and references are all empty or generic template text. Complete the description with the actual issue number, detailed explanation of Swift concurrency changes, rationale for actor isolation additions, and nonisolated(unsafe) usage justification.
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Refactor/#264 테스트3' is vague and does not clearly convey the actual changes, which involve comprehensive Swift concurrency refactoring with actor isolation and async/await migration. Use a more descriptive title that reflects the primary change, such as 'Refactor: Migrate to Swift concurrency with actor isolation' or similar.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/#264
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (3)
TOASTER-iOS/Present/AddLink/AddLinkViewController.swift (1)

405-410: Consider handling task cancellation explicitly.

Using try? on Task.sleep silently ignores CancellationError. If the task is cancelled (e.g., user dismisses the view during loading), the save request will still be sent after the delay completes, which may not be the intended behavior.

Additionally, this unstructured Task cannot be cancelled externally since it's not stored. If cancellation support is needed (e.g., view dismissal should abort the save), consider storing the task handle.

♻️ Suggested approach to respect cancellation
                 task: { _ in
                     Task {
-                        try? await Task.sleep(for: .seconds(0.5))
-                        await MainActor.run { [weak self] in
-                            self?.requestSaveLink.send()
+                        do {
+                            try await Task.sleep(for: .seconds(0.5))
+                            await MainActor.run { [weak self] in
+                                self?.requestSaveLink.send()
+                            }
+                        } catch {
+                            // Task was cancelled; do not proceed with save
                         }
                     }
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Present/AddLink/AddLinkViewController.swift` around lines 405 -
410, The Task sleeping before calling requestSaveLink currently swallows
CancellationError and is untrackable; change it to store the Task in a property
on AddLinkViewController (e.g., a saved Task handle like saveDelayTask), use try
await Task.sleep(...) without `try?` and handle CancellationError by returning
early (or check Task.isCancelled after sleep) before calling
self?.requestSaveLink.send(), and cancel that stored task at appropriate
lifecycle events (e.g., viewWillDisappear or deinit) so the delayed save can be
aborted when the view is dismissed.
TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift (1)

80-99: Inconsistent main thread handling in fetchAlarmCheck.

The .notDetermined case (lines 84-87) wraps its state mutations in DispatchQueue.main.async, but .denied and .authorized cases (lines 88-91) set deviceAlarmSetting directly on the callback's background thread. This inconsistency may lead to the thread-safety issue mentioned above.

Consider either:

  • Wrapping all cases in DispatchQueue.main.async for consistency, or
  • Annotating the class with @MainActor and using Task { @mainactor in ... } for the callback body.
♻️ Suggested consistent approach
     func fetchAlarmCheck() {
         UNUserNotificationCenter.current().getNotificationSettings { permission in
-            switch permission.authorizationStatus {
-            case .notDetermined:
-                DispatchQueue.main.async {
+            DispatchQueue.main.async {
+                switch permission.authorizationStatus {
+                case .notDetermined:
                     self.remindViewType = .deviceOnAppOnNoneData
                     self.bottomSheetAction?()
+                case .denied:
+                    self.deviceAlarmSetting = false
+                case .authorized:
+                    self.deviceAlarmSetting = true
+                default:
+                    print("unknown Error")
                 }
-            case .denied:
-                self.deviceAlarmSetting = false
-            case .authorized:
-                self.deviceAlarmSetting = true
-            default:
-                print("unknown Error")
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift` around lines 80 -
99, In fetchAlarmCheck, you're mutating UI/state from the
UNUserNotificationCenter.getNotificationSettings callback inconsistently: only
the .notDetermined branch uses DispatchQueue.main.async while .denied and
.authorized update deviceAlarmSetting off the main thread; fix by ensuring all
state/UI mutations happen on the main thread—either wrap the entire switch body
in DispatchQueue.main.async (so .notDetermined, .denied, .authorized all set
remindViewType/deviceAlarmSetting there) or mark the view model `@MainActor` and
run the callback body on the main actor (e.g., Task { `@MainActor` in ... }) so
deviceAlarmSetting and remindViewType updates are always main-thread safe.
TOASTER-iOS/Global/Resources/Config.swift (1)

26-31: Replace [String: Any] cache with a typed [String: String] to eliminate nonisolated(unsafe).

All 10 callers cast dictionary values to String before use. Changing the cache type from [String: Any] to [String: String] (line 26) removes the need for the unsafe annotation while maintaining identical behavior:

nonisolated(unsafe) private static let infoDictionary: [String: String] = {
    let dict = Bundle.main.infoDictionary ?? [:]
    return dict.mapValues { String(describing: $0) }
}()

Alternatively, remove the cache entirely and use Bundle.main.object(forInfoDictionaryKey:) directly in each accessor if one-time reads are acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Global/Resources/Config.swift` around lines 26 - 31, The cached
static infoDictionary currently uses nonisolated(unsafe) with type [String:
Any]; change it to a typed [String: String] so the unsafe annotation can be
removed: initialize infoDictionary by reading Bundle.main.infoDictionary (or
defaulting to [:]) and map all values to strings (e.g., via mapValues {
String(describing: $0) }), keeping the identifier infoDictionary and callers
that expect String values unchanged; alternatively, remove the cache and replace
uses with Bundle.main.object(forInfoDictionaryKey:) if you prefer one-off reads.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@TOASTER-iOS/Application/AppDelegate.swift`:
- Around line 53-58: The async Task updating isLogin can race with scene setup;
make isLogin an observable state and notify consumers instead of silently
setting it: change isLogin to a `@Published` (or `@MainActor` `@Published`) property
on AppDelegate, keep checkAppleLogin() async, but after awaiting its result
publish the value (or post a NotificationCenter notification like
.loginStateUpdated) from the Task, and update SceneDelegate (or other consumers)
to observe AppDelegate.shared.$isLogin or subscribe to the notification and
defer UI/setup until the observed value arrives; ensure SceneDelegate uses the
published subscription (or notification callback) to react to login state rather
than reading isLogin synchronously during didFinishLaunchingWithOptions.
- Around line 149-164: The notification handler currently calls
completionHandler() immediately after spawning a Task, which can return to the
system before the UI navigation (popToRootViewController and
tabBarController.selectedIndex assignment) finishes; fix it by moving the
completionHandler() into the Task so it runs after the UI work completes (e.g.,
perform the UIWindowScene lookup and calls to
ToasterNavigationController.popToRootViewController(...) and
TabBarController.selectedIndex = 0 on the `@MainActor` Task and then call
completionHandler() at the end), or alternatively await the Task's completion
before calling completionHandler() so the UI updates finish first.

In `@TOASTER-iOS/Network/Base/APIInterceptor.swift`:
- Line 19: Remove the nonisolated(unsafe) private var isTokenRefreshed and all
uses of that shared mutable flag in APIInterceptor; instead have adapt() always
read the current token from KeyChain (or the secure token store) at the start of
each request and apply it, and have retry() perform the refresh and persist the
new token back to KeyChain (without toggling any shared boolean). Update adapt()
and retry() so adapt() only reads KeyChain for the token, and retry() writes the
refreshed token to KeyChain (and triggers any necessary re-attempts) to
eliminate the race between adapt() and retry().

In `@TOASTER-iOS/Network/Base/NetworkService.swift`:
- Line 12: Replace the unsafe opt-out by making NetworkService actor-isolated or
by proving Sendable for all contained services; specifically remove
nonisolated(unsafe) static let shared = NetworkService() and instead (a) convert
NetworkService to an actor (actor NetworkService) and expose a single isolated
shared instance, or (b) ensure every concrete service (AuthAPIService,
UserAPIService, ToasterAPIService, ClipAPIService, SearchAPIService,
TimerAPIService, PopupAPIService and their BaseAPIService superclass) and their
MoyaProvider properties conform to Sendable and are implemented thread-safe,
then reintroduce a Sendable-safe shared; update callers to await or hop to the
actor as needed to preserve isolation.

In
`@TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift`:
- Around line 112-113: The Task block in ChangeClipBottomSheetView.swift uses
Task.sleep(for: .seconds(0.5)) which requires iOS 16+, breaking compilation for
iOS 15 deployment; replace that call with Task.sleep(nanoseconds: 500_000_000)
or wrap the entire Task { ... } block in an `@available`(iOS 16.0, *) guard so the
code only runs on iOS 16+, updating the Task invocation inside the async context
where Task.sleep is called to use the nanoseconds variant if you want iOS 15
compatibility.

In `@TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift`:
- Around line 141-147: The KVO callback for estimatedProgress (the observation
stored in progressObservation created via observe(\.estimatedProgress)) must not
call MainActor.assumeIsolated because the callback may run off the main actor;
replace it with an explicit main-actor hop: inside the observer closure, remove
MainActor.assumeIsolated and dispatch the UI update to the main actor (e.g.,
start a Task { `@MainActor` in ... } or DispatchQueue.main.async) then set
self?.progressView.progress using Float(object.estimatedProgress); keep the weak
self capture and the rest of the observation logic in LinkWebViewController
unchanged.

---

Nitpick comments:
In `@TOASTER-iOS/Global/Resources/Config.swift`:
- Around line 26-31: The cached static infoDictionary currently uses
nonisolated(unsafe) with type [String: Any]; change it to a typed [String:
String] so the unsafe annotation can be removed: initialize infoDictionary by
reading Bundle.main.infoDictionary (or defaulting to [:]) and map all values to
strings (e.g., via mapValues { String(describing: $0) }), keeping the identifier
infoDictionary and callers that expect String values unchanged; alternatively,
remove the cache and replace uses with Bundle.main.object(forInfoDictionaryKey:)
if you prefer one-off reads.

In `@TOASTER-iOS/Present/AddLink/AddLinkViewController.swift`:
- Around line 405-410: The Task sleeping before calling requestSaveLink
currently swallows CancellationError and is untrackable; change it to store the
Task in a property on AddLinkViewController (e.g., a saved Task handle like
saveDelayTask), use try await Task.sleep(...) without `try?` and handle
CancellationError by returning early (or check Task.isCancelled after sleep)
before calling self?.requestSaveLink.send(), and cancel that stored task at
appropriate lifecycle events (e.g., viewWillDisappear or deinit) so the delayed
save can be aborted when the view is dismissed.

In `@TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift`:
- Around line 80-99: In fetchAlarmCheck, you're mutating UI/state from the
UNUserNotificationCenter.getNotificationSettings callback inconsistently: only
the .notDetermined branch uses DispatchQueue.main.async while .denied and
.authorized update deviceAlarmSetting off the main thread; fix by ensuring all
state/UI mutations happen on the main thread—either wrap the entire switch body
in DispatchQueue.main.async (so .notDetermined, .denied, .authorized all set
remindViewType/deviceAlarmSetting there) or mark the view model `@MainActor` and
run the callback body on the main actor (e.g., Task { `@MainActor` in ... }) so
deviceAlarmSetting and remindViewType updates are always main-thread safe.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e17f2103-20c4-42e3-81a0-a52fb17b7b15

📥 Commits

Reviewing files that changed from the base of the PR and between 2a479fc and 4e941e6.

📒 Files selected for processing (27)
  • TOASTER-iOS.xcodeproj/project.pbxproj
  • TOASTER-iOS.xcodeproj/xcshareddata/xcschemes/TOASTER-iOS.xcscheme
  • TOASTER-iOS/Application/AppDelegate.swift
  • TOASTER-iOS/Application/Coordinator/Coordinator.swift
  • TOASTER-iOS/Application/Coordinator/CoordinatorFinishOutput.swift
  • TOASTER-iOS/Application/Coordinator/Coordinators/TabBarCoordinator.swift
  • TOASTER-iOS/Application/Coordinator/Factory/CoordinatorFactory.swift
  • TOASTER-iOS/Application/Coordinator/Factory/ViewControllerFactory.swift
  • TOASTER-iOS/Application/Coordinator/Router.swift
  • TOASTER-iOS/Global/Resources/Config.swift
  • TOASTER-iOS/Network/Base/APIInterceptor.swift
  • TOASTER-iOS/Network/Base/NetworkService.swift
  • TOASTER-iOS/Present/AddLink/AddLinkViewController.swift
  • TOASTER-iOS/Present/AddLink/View/SelectClipHeaderView.swift
  • TOASTER-iOS/Present/Clip/View/Component/AddClipBottomSheetView.swift
  • TOASTER-iOS/Present/Clip/View/Component/ClipCollectionHeaderView.swift
  • TOASTER-iOS/Present/DetailClip/View/Cell/DetailClipListCollectionViewCell.swift
  • TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift
  • TOASTER-iOS/Present/Home/View/Cell/UserClipCollectionViewCell.swift
  • TOASTER-iOS/Present/Home/View/Component/CompositioinalFactory.swift
  • TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift
  • TOASTER-iOS/Present/Login/Adapter/KakaoAuthenticateAdapter.swift
  • TOASTER-iOS/Present/Remind/View/Component/RemindAlarmOffBottomSheetView.swift
  • TOASTER-iOS/Present/Remind/View/Component/RemindTimerEditBottomSheetView.swift
  • TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift
  • TOASTER-iOS/Present/RemindAdd/TimerAdd/View/TimerRepeatBottomSheetView.swift
  • TOASTER-iOS/Present/UpdateAlert/UpdateAlertManager.swift
💤 Files with no reviewable changes (1)
  • TOASTER-iOS/Present/Login/Adapter/KakaoAuthenticateAdapter.swift

Comment on lines +53 to 58
Task { [weak self] in
let result = await self?.checkAppleLogin() ?? false
await MainActor.run { [weak self] in
self?.isLogin = result
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential race condition with isLogin state.

The isLogin property is set asynchronously inside a Task, but didFinishLaunchingWithOptions returns true immediately. If SceneDelegate or other code reads isLogin before the Apple login check completes, it may see a stale value.

Consider using a more robust pattern such as:

  • A @Published property with async observation
  • A completion-based initialization flow
  • Delaying scene setup until login state is determined
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Application/AppDelegate.swift` around lines 53 - 58, The async
Task updating isLogin can race with scene setup; make isLogin an observable
state and notify consumers instead of silently setting it: change isLogin to a
`@Published` (or `@MainActor` `@Published`) property on AppDelegate, keep
checkAppleLogin() async, but after awaiting its result publish the value (or
post a NotificationCenter notification like .loginStateUpdated) from the Task,
and update SceneDelegate (or other consumers) to observe
AppDelegate.shared.$isLogin or subscribe to the notification and defer UI/setup
until the observed value arrives; ensure SceneDelegate uses the published
subscription (or notification callback) to react to login state rather than
reading isLogin synchronously during didFinishLaunchingWithOptions.

Comment on lines +149 to 164
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
Task { @MainActor in
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let navigationViewController = windowScene.windows
.first(where: { $0.isKeyWindow })?
.rootViewController as? ToasterNavigationController {
navigationViewController.popToRootViewController(animated: false)
if let tabBarController = navigationViewController.topViewController as? TabBarController {
tabBarController.selectedIndex = 0
}
}
}
completionHandler()
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

completionHandler called before UI work completes.

The completionHandler() at line 163 is called immediately after spawning the Task, not after the UI navigation completes. This signals to the system that notification handling is finished while the navigation may still be in progress.

Consider awaiting the Task completion or moving the completion handler inside the Task:

🐛 Proposed fix
 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                         didReceive response: UNNotificationResponse,
                                         withCompletionHandler completionHandler: `@escaping` () -> Void) {
     Task { `@MainActor` in
         if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let navigationViewController = windowScene.windows
             .first(where: { $0.isKeyWindow })?
             .rootViewController as? ToasterNavigationController {
             navigationViewController.popToRootViewController(animated: false)
             if let tabBarController = navigationViewController.topViewController as? TabBarController {
                 tabBarController.selectedIndex = 0
             }
         }
+        completionHandler()
     }
-    completionHandler()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Application/AppDelegate.swift` around lines 149 - 164, The
notification handler currently calls completionHandler() immediately after
spawning a Task, which can return to the system before the UI navigation
(popToRootViewController and tabBarController.selectedIndex assignment)
finishes; fix it by moving the completionHandler() into the Task so it runs
after the UI work completes (e.g., perform the UIWindowScene lookup and calls to
ToasterNavigationController.popToRootViewController(...) and
TabBarController.selectedIndex = 0 on the `@MainActor` Task and then call
completionHandler() at the end), or alternatively await the Task's completion
before calling completionHandler() so the UI updates finish first.

/// 해당 프로퍼티가 true 일 경우 기존의 Requset 의 Header 를 변경
/// 재발급을 받은 적이 없는 false 의 경우 TargetType 을 통해 Header 를 설정한 Requset 를 기본으로 사용
private var isTokenRefreshed = false
nonisolated(unsafe) private var isTokenRefreshed = false
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "APIInterceptor.swift" -type f

Repository: Link-MIND/TOASTER-iOS

Length of output: 112


🏁 Script executed:

cat -n TOASTER-iOS/Network/Base/APIInterceptor.swift

Repository: Link-MIND/TOASTER-iOS

Length of output: 4203


Remove the nonisolated(unsafe) flag and the shared mutable refresh state entirely.

The singleton APIInterceptor has a genuine data race: adapt() reads and clears isTokenRefreshed at lines 31–36, while retry() writes it at line 72. When multiple requests retry concurrently after a 401, the first adapt() clears the flag, causing subsequent overlapping retries to skip the token refresh. The nonisolated(unsafe) annotation only silences the compiler warning—it doesn't prevent the race.

Suggested fix

Remove the flag and instead read the token from KeyChain on every request:

 final class APIInterceptor: RequestInterceptor {
-    nonisolated(unsafe) private var isTokenRefreshed = false

     static let shared = APIInterceptor()

     func adapt(_ urlRequest: URLRequest, for session: Session, completion: `@escaping` (Result<URLRequest, Error>) -> Void) {
         print("adapt 진입")
         
         var modifiedRequest = urlRequest
-        if isTokenRefreshed {
-            print("토큰 재발급 후 URLRequset 변경")
-            modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
-            
-            isTokenRefreshed = false
-        }
+        modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
         completion(.success(modifiedRequest))
     }

     func retry(_ request: Request, for session: Session, dueTo error: Error, completion: `@escaping` (RetryResult) -> Void) {
         NetworkService.shared.authService.postRefreshToken { [weak self] result in
             switch result {
             case .success(let result):
                 guard let serverAccessToken = result?.data.accessToken, let serverRefreshToken = result?.data.refreshToken else {
                     completion(.doNotRetry)
                     return
                 }
                 
                 let keyChainResult = KeyChainService.saveTokens(accessKey: serverAccessToken, refreshKey: serverRefreshToken)
                 
                 if keyChainResult.accessResult == true && keyChainResult.refreshResult == true {
-                    self?.isTokenRefreshed = true
-                    
                     guard request.request != nil else {
                         completion(.doNotRetry)
                         return
                     }
                     
                     completion(.retry)
                 } else {
                     completion(.doNotRetry)
                 }
             default:
                 completion(.doNotRetry)
             }
         }
     }
 }

This eliminates the race by using KeyChain as the single source of truth for the token, ensuring all concurrent requests use the latest refreshed token.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
nonisolated(unsafe) private var isTokenRefreshed = false
final class APIInterceptor: RequestInterceptor {
static let shared = APIInterceptor()
func adapt(_ urlRequest: URLRequest, for session: Session, completion: `@escaping` (Result<URLRequest, Error>) -> Void) {
print("adapt 진입")
var modifiedRequest = urlRequest
modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
completion(.success(modifiedRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: `@escaping` (RetryResult) -> Void) {
NetworkService.shared.authService.postRefreshToken { [weak self] result in
switch result {
case .success(let result):
guard let serverAccessToken = result?.data.accessToken, let serverRefreshToken = result?.data.refreshToken else {
completion(.doNotRetry)
return
}
let keyChainResult = KeyChainService.saveTokens(accessKey: serverAccessToken, refreshKey: serverRefreshToken)
if keyChainResult.accessResult == true && keyChainResult.refreshResult == true {
guard request.request != nil else {
completion(.doNotRetry)
return
}
completion(.retry)
} else {
completion(.doNotRetry)
}
default:
completion(.doNotRetry)
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Network/Base/APIInterceptor.swift` at line 19, Remove the
nonisolated(unsafe) private var isTokenRefreshed and all uses of that shared
mutable flag in APIInterceptor; instead have adapt() always read the current
token from KeyChain (or the secure token store) at the start of each request and
apply it, and have retry() perform the refresh and persist the new token back to
KeyChain (without toggling any shared boolean). Update adapt() and retry() so
adapt() only reads KeyChain for the token, and retry() writes the refreshed
token to KeyChain (and triggers any necessary re-attempts) to eliminate the race
between adapt() and retry().

final class NetworkService {

static let shared = NetworkService()
nonisolated(unsafe) static let shared = NetworkService()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A40 -B2 --type swift \
'protocol\s+(AuthAPIServiceProtocol|UserAPIServiceProtocol|ToasterAPIServiceProtocol|ClipAPIServiceProtocol|SearchAPIServiceProtocol|TimerAPIServiceProtocol|PopupAPIServiceProtocol)\b|(?:final\s+)?(?:class|actor|struct)\s+(AuthAPIService|UserAPIService|ToasterAPIService|ClipAPIService|SearchAPIService|TimerAPIService|PopupAPIService)\b|@unchecked Sendable|Sendable' \
TOASTER-iOS

Repository: Link-MIND/TOASTER-iOS

Length of output: 33752


🏁 Script executed:

#!/bin/bash
# Check BaseAPIService for any Sendable/actor annotations
rg -n -A20 "class BaseAPIService" --type swift TOASTER-iOS

Repository: Link-MIND/TOASTER-iOS

Length of output: 1971


🏁 Script executed:

#!/bin/bash
# Check NetworkService.shared full context and declaration
rg -n -B5 -A5 "nonisolated.*shared.*NetworkService" --type swift TOASTER-iOS

Repository: Link-MIND/TOASTER-iOS

Length of output: 869


nonisolated(unsafe) suppresses compile-time safety without proof of thread-safety.

The concrete services exposed through shared are reference types without Sendable conformance or actor isolation. Every service (AuthAPIService, UserAPIService, ToasterAPIService, ClipAPIService, SearchAPIService, TimerAPIService, PopupAPIService) is a final class inheriting from non-Sendable BaseAPIService, with a non-Sendable MoyaProvider property. This converts strict concurrency from a compiler guarantee into a manual audit burden. Prove the service graph is thread-safe or use actor isolation instead of opting out of safety checks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Network/Base/NetworkService.swift` at line 12, Replace the unsafe
opt-out by making NetworkService actor-isolated or by proving Sendable for all
contained services; specifically remove nonisolated(unsafe) static let shared =
NetworkService() and instead (a) convert NetworkService to an actor (actor
NetworkService) and expose a single isolated shared instance, or (b) ensure
every concrete service (AuthAPIService, UserAPIService, ToasterAPIService,
ClipAPIService, SearchAPIService, TimerAPIService, PopupAPIService and their
BaseAPIService superclass) and their MoyaProvider properties conform to Sendable
and are implemented thread-safe, then reintroduce a Sendable-safe shared; update
callers to await or hop to the actor as needed to preserve isolation.

Comment on lines +112 to +113
Task {
try? await Task.sleep(for: .seconds(0.5))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'IPHONEOS_DEPLOYMENT_TARGET' TOASTER-iOS.xcodeproj/project.pbxproj
rg -n 'Task\.sleep\(for:' TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift

Repository: Link-MIND/TOASTER-iOS

Length of output: 394


Use Task.sleep(nanoseconds:) or add an @available guard for iOS 16.0+.

The project has deployment targets as low as iOS 15.0, but Task.sleep(for:) requires iOS 16.0+. This code will fail to compile for the iOS 15.0 target. Either replace with Task.sleep(nanoseconds: 500_000_000) (available since iOS 13.0) or wrap the entire Task block in an @available(iOS 16.0, *) guard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift`
around lines 112 - 113, The Task block in ChangeClipBottomSheetView.swift uses
Task.sleep(for: .seconds(0.5)) which requires iOS 16+, breaking compilation for
iOS 15 deployment; replace that call with Task.sleep(nanoseconds: 500_000_000)
or wrap the entire Task { ... } block in an `@available`(iOS 16.0, *) guard so the
code only runs on iOS 16+, updating the Task invocation inside the async context
where Task.sleep is called to use the nanoseconds variant if you want iOS 15
compatibility.

Comment on lines 141 to +147
progressObservation = $0.observe(
\.estimatedProgress,
options: [.new]) { [weak self] object, _ in
let progress = Float(object.estimatedProgress)
self?.progressView.progress = progress
MainActor.assumeIsolated {
let progress = Float(object.estimatedProgress)
self?.progressView.progress = progress
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Swift MainActor.assumeIsolated documentation semantics main actor

💡 Result:

MainActor.assumeIsolated is a synchronous “trust me + verify” escape hatch:

  • What it promises (to the compiler): inside the closure, code is treated as running with @MainActor isolation, so you can make synchronous accesses that would normally require an await hop. Apple’s docs summarize it as: assume the current task is executing on the main actor’s serial executor, or stop program execution.[1]

  • What it checks (at runtime): the stdlib implementation fetches MainActor.shared’s serial executor and checks that the current task is already executing on that executor. If not, it crashes with fatalError("Incorrect actor executor assumption; Expected same executor as MainActor...").[2]

  • How it works (mechanically): after the executor check succeeds, it uses withoutActuallyEscaping + unsafeBitCast to erase the @MainActor annotation from the closure type and then calls it immediately (no hop, no suspension).[2]

  • Important nuance: the check is executor-based, not “main-thread”-based; if some other actor is configured to use the same serial executor as MainActor, the check can still succeed (this is explicitly documented in the stdlib comments).[2]

  • Intended use: only from synchronous code; the API is marked unavailable from async contexts with guidance to “await the call to the @MainActor closure directly”.[2]

Sources: Apple Developer Documentation entry for MainActor.assumeIsolated[1], Swift standard library implementation (stdlib/public/Concurrency/MainActor.swift).[2]


🌐 Web query:

NSKeyValueObservation callback main thread execution guarantee iOS

💡 Result:

NSKeyValueObservation (the Swift block-based KVO API) does not guarantee its callback runs on the main thread.

KVO notifications are delivered on the same thread where the observed property change happens—so if the property is mutated on a background thread, your changeHandler will run on that background thread as well. Apple explicitly states this for KVO: “If the change to the property occurs on a secondary thread, the observeValueForKeyPath:… code executes on that same thread.” [1]

If you need to update UI (main-thread-only work), you must hop to the main execution context yourself (e.g. DispatchQueue.main.async { … } / await MainActor.run { … }). Apple discusses using a “Receptionist” style approach to redirect work from a background execution context to the main one for UI updates. [2]

Sources

  1. Apple Docs – Cocoa Design Patterns (KVO threading note) [1]
  2. Apple Docs – Streamline Your App with Design Patterns (Receptionist pattern / execution context) [2]

Don't use MainActor.assumeIsolated in this observer.

MainActor.assumeIsolated asserts the callback is already on the main actor—it doesn't hop there. Since NSKeyValueObservation callbacks run on whatever thread the property mutation occurs, this will crash at runtime if estimatedProgress changes on a background thread. Use an explicit main-actor hop instead.

🔧 Safer main-actor hop
             progressObservation = $0.observe(
                 \.estimatedProgress,
-                 options: [.new]) { [weak self] object, _ in
-                     MainActor.assumeIsolated {
-                         let progress = Float(object.estimatedProgress)
-                         self?.progressView.progress = progress
-                     }
-                 }
+                options: [.new]) { [weak self] object, _ in
+                    let progress = Float(object.estimatedProgress)
+                    Task { `@MainActor` in
+                        self?.progressView.progress = progress
+                    }
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift`
around lines 141 - 147, The KVO callback for estimatedProgress (the observation
stored in progressObservation created via observe(\.estimatedProgress)) must not
call MainActor.assumeIsolated because the callback may run off the main actor;
replace it with an explicit main-actor hop: inside the observer closure, remove
MainActor.assumeIsolated and dispatch the UI update to the main actor (e.g.,
start a Task { `@MainActor` in ... } or DispatchQueue.main.async) then set
self?.progressView.progress using Float(object.estimatedProgress); keep the weak
self capture and the rest of the observation logic in LinkWebViewController
unchanged.

Comment on lines 26 to 30
private var remindViewType: RemindViewType = .deviceOnAppOnNoneData {
didSet {
DispatchQueue.main.async {
self.dataChangeAction?(self.remindViewType)
}
self.dataChangeAction?(self.remindViewType)
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how dataChangeAction is used and whether the caller handles main thread dispatch
echo "=== Searching for setupDataChangeAction call sites ==="
rg -n -A 10 'setupDataChangeAction' --type swift

echo ""
echo "=== Checking if RemindViewModel or related View uses `@MainActor` ==="
rg -n '@MainActor' --type swift -g '*Remind*'

Repository: Link-MIND/TOASTER-iOS

Length of output: 3051


🏁 Script executed:

#!/bin/bash
# Check RemindViewController class declaration and `@MainActor` annotation
echo "=== RemindViewController class declaration ==="
rg -n -B 5 -A 20 'class RemindViewController' TOASTER-iOS/Present/Remind/View/RemindViewController.swift | head -40

echo ""
echo "=== Check reloadCollectionViewWithView implementation ==="
rg -n -B 2 -A 10 'reloadCollectionViewWithView' TOASTER-iOS/Present/Remind/View/RemindViewController.swift

echo ""
echo "=== Check other closures passed to setupDataChangeAction ==="
rg -n -B 2 -A 10 'setupAlarmBottomSheet\|deleteAction\|unAuthorizedAction' TOASTER-iOS/Present/Remind/View/RemindViewController.swift | head -80

Repository: Link-MIND/TOASTER-iOS

Length of output: 2444


🏁 Script executed:

#!/bin/bash
# Check network callback implementations and their thread contexts
echo "=== Check fetchTimerData, deleteTimerData, patchTimerData callbacks ==="
rg -n -B 5 -A 15 'fetchTimerData|deleteTimerData|patchTimerData' TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift | head -150

echo ""
echo "=== Check getNotificationSettings implementation ==="
rg -n -B 5 -A 20 'func getNotificationSettings' TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift

Repository: Link-MIND/TOASTER-iOS

Length of output: 3780


🏁 Script executed:

#!/bin/bash
# Search for getNotificationSettings more broadly
echo "=== Search for getNotificationSettings in all Swift files ==="
rg -n 'getNotificationSettings' --type swift

echo ""
echo "=== Look at the RemindViewModel file to find deviceAlarmSetting didSet ==="
rg -n -B 2 -A 10 'deviceAlarmSetting' TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift | head -50

Repository: Link-MIND/TOASTER-iOS

Length of output: 1481


🏁 Script executed:

#!/bin/bash
# Verify the exact line 28 change and confirm the DispatchQueue was removed
echo "=== View the complete remindViewType property definition ==="
sed -n '26,30p' TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift

echo ""
echo "=== Confirm this is a didSet observer that previously had DispatchQueue ==="
git log -p --all -S 'DispatchQueue.main.async' -- TOASTER-iOS/Present/Remind/ViewModel/RemindViewModel.swift | head -100

Repository: Link-MIND/TOASTER-iOS

Length of output: 365


Thread-safety issue: dataChangeAction will execute on background threads.

Removing DispatchQueue.main.async allows the closure to run on whatever thread triggers the remindViewType didSet. Since reloadCollectionViewWithView performs direct UI updates (timerCollectionView.reloadData()), this violates UIKit's requirement that all UI operations occur on the main thread.

The remindViewType property is mutated from background threads in two ways:

  1. getNotificationSettings callback (lines 88–91): UNUserNotificationCenter.current().getNotificationSettings() executes its completion on a background thread, sets deviceAlarmSetting, which triggers setupAlarm(), which directly modifies remindViewType.

  2. Network callbacks (lines 102–151): Network service completion handlers execute on background threads and modify timerData, whose didSet can update remindViewType.

Restore the DispatchQueue.main.async wrapper at line 28, or annotate the class with @MainActor if completing a concurrency migration.

@github-actions
Copy link

🤖 iOS Code Review — ⚖️ Balanced (균형 모드)

✅ 리뷰할 이슈가 없습니다. 좋은 코드네요!

📋 심각도 기준 · 감도 변경
심각도 의미
🔴 P1 꼭 반영
🟠 P2 적극 고려
🟡 P3 웬만하면 반영
🔵 P4 넘어가도 OK
💬 P5 사소한 의견

감도 변경: 워크플로우의 sensitivity 값 또는 .codereview.yml 파일에서 설정

@mini-min mini-min closed this Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant