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
8 changes: 8 additions & 0 deletions Projects/Core/Concurrency/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.layer(
name: "CoreConcurrency",
bundleSuffix: "core.concurrency",
isolation: .mainActor
)
113 changes: 113 additions & 0 deletions Projects/Core/Concurrency/Sources/TaskBox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Foundation
import Synchronization

/// ViewModel이 들고 있는 비동기 작업들을 키로 관리한다.
///
/// 이전에는 ViewModel마다 `Task` 필드를 나열하고(홈 9개, 검색 5개) `deinit`에서 전부
/// 취소하며, 재진입 방지는 `guard xxxTask == nil else { return }` 같은 수기 가드로
/// 처리했다. 필드를 하나 늘릴 때마다 `deinit`에 한 줄을 잊지 않아야 했고, 어떤 작업이
/// 어떤 재진입 규칙을 따르는지는 주석으로만 남아 있었다.
///
/// `Policy`가 그 규칙을 타입으로 만든다.
/// `Mutex` 기반인 이유: 소유자의 `deinit`에서 일괄 취소할 수 있어야 하는데, `deinit`은
/// nonisolated라 액터 격리된 저장 프로퍼티를 만질 수 없다. 기존 ViewModel들이 `deinit`에
/// Task 취소를 나열하던 자리를 그대로 대체하려면 격리 밖에서도 접근 가능해야 한다.
public nonisolated final class TaskBox<Key: Hashable & Sendable>: @unchecked Sendable {
/// 같은 키의 작업이 이미 돌고 있을 때 무엇을 할지.
public nonisolated enum Policy: Sendable {
/// 기존 작업을 취소하고 새로 시작한다 — 최신 입력만 의미가 있는 조회에 쓴다
/// (위치 재조회, 키워드 검색).
case replaceExisting
/// 기존 작업이 끝날 때까지 새 요청을 무시한다 — 중복 발사가 해로운 동작에 쓴다
/// (당김 새로고침, 제출).
case skipIfRunning
/// 같은 키라도 독립적으로 돌린다 — 저장처럼 "나중 것이 앞 것을 취소하면 안 되는"
/// 작업에 쓴다. 키는 추적·일괄 취소 목적으로만 쓰인다.
case independent
}

private struct Storage {
var tasks: [Key: Task<Void, Never>] = [:]
/// `.independent`로 띄운 작업들 — 키로 덮어쓰지 않고 따로 모은다.
var detached: [Task<Void, Never>] = []
}

private let storage = Mutex(Storage())

public init() {}

deinit {
cancelAll()
}

/// 정책에 따라 작업을 시작한다. 시작하지 않은 경우 `false`를 돌려준다.
@discardableResult
public func run(
_ key: Key,
_ policy: Policy = .replaceExisting,
_ body: @escaping @Sendable () async -> Void
) -> Bool {
let shouldStart = storage.withLock { storage -> Bool in
switch policy {
case .skipIfRunning:
guard let existing = storage.tasks[key], !existing.isCancelled else { return true }
return false
case .replaceExisting:
storage.tasks[key]?.cancel()
return true
case .independent:
return true
}
}
guard shouldStart else { return false }

let task = Task { [weak self] in
await body()
// 완료된 작업을 남겨 두면 `.skipIfRunning`이 영구히 막힌다 —
// 이 정리를 잊는 것이 기존 코드에서 실제로 났던 버그다(pull-to-refresh 영구 잠김).
self?.finish(key, policy: policy)
}

storage.withLock { storage in
if case .independent = policy {
storage.detached.append(task)
} else {
storage.tasks[key] = task
}
}
return true
}

public func isRunning(_ key: Key) -> Bool {
storage.withLock { storage in
guard let task = storage.tasks[key] else { return false }
return !task.isCancelled
}
}

public func cancel(_ key: Key) {
storage.withLock { $0.tasks.removeValue(forKey: key) }?.cancel()
}

/// 소유자 해제·로그아웃 등에서 부른다. `deinit`에서도 안전하다.
public func cancelAll() {
let (tasks, detached) = storage.withLock { storage -> ([Task<Void, Never>], [Task<Void, Never>]) in
let result = (Array(storage.tasks.values), storage.detached)
storage.tasks.removeAll()
storage.detached.removeAll()
return result
}
for task in tasks { task.cancel() }
for task in detached { task.cancel() }
}

private func finish(_ key: Key, policy: Policy) {
storage.withLock { storage in
if case .independent = policy {
storage.detached.removeAll { $0.isCancelled }
} else {
storage.tasks[key] = nil
}
}
}
}
146 changes: 146 additions & 0 deletions Projects/Core/Concurrency/Tests/TaskBoxTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
@testable import CoreConcurrency
import Foundation
import Synchronization
import Testing

private nonisolated final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func increment() { lock.lock(); value += 1; lock.unlock() }
func get() -> Int { lock.lock(); defer { lock.unlock() }; return value }
}

/// 모듈 기본 격리가 `.mainActor`라, 명시하지 않으면 `Hashable` 적합성까지 메인 액터
/// 격리를 얻어 `TaskBox`의 `Sendable` 제약을 만족하지 못한다.
private nonisolated enum Key: Hashable, Sendable { case load, save, refresh }

struct TaskBoxTests {

// MARK: - replaceExisting

/// 최신 입력만 의미가 있는 조회 — 새 요청이 이전 것을 취소한다.
@Test
func replaceExisting_cancelsPrevious() async {
let box = TaskBox<Key>()
let firstCancelled = Counter()

box.run(.load, .replaceExisting) {
try? await Task.sleep(for: .milliseconds(200))
if Task.isCancelled { firstCancelled.increment() }
}
box.run(.load, .replaceExisting) {}

try? await Task.sleep(for: .milliseconds(50))
#expect(firstCancelled.get() == 1)
}

// MARK: - skipIfRunning

/// 중복 발사가 해로운 동작 — 진행 중이면 새 요청을 무시한다.
@Test
func skipIfRunning_ignoresWhileRunning() async {
let box = TaskBox<Key>()
let started = Counter()

box.run(.refresh, .skipIfRunning) {
started.increment()
try? await Task.sleep(for: .milliseconds(100))
}
// Task는 생성 즉시 실행되지 않는다 — 첫 작업이 시작될 때까지 양보한다.
while started.get() == 0 { await Task.yield() }

let secondStarted = box.run(.refresh, .skipIfRunning) { started.increment() }

#expect(secondStarted == false)
#expect(started.get() == 1)
}

/// 회귀: 완료된 작업을 정리하지 않으면 `.skipIfRunning`이 **영구히 막힌다.**
/// 기존 코드에서 실제로 났던 버그다(당김 새로고침이 취소 후 영원히 잠김).
@Test
func skipIfRunning_allowsNewRunAfterCompletion() async {
let box = TaskBox<Key>()
let started = Counter()

box.run(.refresh, .skipIfRunning) { started.increment() }
while box.isRunning(.refresh) { await Task.yield() }

let secondStarted = box.run(.refresh, .skipIfRunning) { started.increment() }
while box.isRunning(.refresh) { await Task.yield() }

#expect(secondStarted == true)
#expect(started.get() == 2)
}

/// 취소된 작업도 키를 붙잡고 있으면 안 된다.
@Test
func skipIfRunning_allowsNewRunAfterCancel() async {
let box = TaskBox<Key>()
box.run(.refresh, .skipIfRunning) { try? await Task.sleep(for: .seconds(10)) }

box.cancel(.refresh)

#expect(box.run(.refresh, .skipIfRunning) {} == true)
}

// MARK: - independent

/// 저장은 나중 것이 앞 것을 취소하면 안 된다 — 둘 다 끝까지 간다.
@Test
func independent_runsConcurrentlyWithoutCancelling() async {
let box = TaskBox<Key>()
let completed = Counter()

for _ in 0..<3 {
box.run(.save, .independent) {
try? await Task.sleep(for: .milliseconds(20))
guard !Task.isCancelled else { return }
completed.increment()
}
}
try? await Task.sleep(for: .milliseconds(120))

#expect(completed.get() == 3)
}

// MARK: - 정리

@Test
func cancelAll_stopsEverything() async {
let box = TaskBox<Key>()
let finished = Counter()

// `try?`가 취소를 삼키므로 취소 여부를 명시적으로 확인해야 한다 —
// 그러지 않으면 sleep 이후 코드가 그대로 실행된다.
box.run(.load) {
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { return }
finished.increment()
}
box.run(.save, .independent) {
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { return }
finished.increment()
}
while !box.isRunning(.load) { await Task.yield() }

box.cancelAll()
try? await Task.sleep(for: .milliseconds(50))

#expect(finished.get() == 0)
#expect(box.isRunning(.load) == false)
}

@Test
func cancel_affectsOnlyGivenKey() async {
let box = TaskBox<Key>()
box.run(.load) { try? await Task.sleep(for: .seconds(10)) }
box.run(.refresh) { try? await Task.sleep(for: .seconds(10)) }

box.cancel(.load)

#expect(box.isRunning(.load) == false)
#expect(box.isRunning(.refresh) == true)
box.cancelAll()
}
}
17 changes: 13 additions & 4 deletions Projects/Feature/Search/Sources/SearchViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,17 +178,26 @@ final class SearchViewController: UIViewController {
viewModel.onStateChange = { [weak self] state in
self?.render(state)
}
viewModel.onFieldsChange = { [weak self] fields in
self?.renderFields(fields)
}

emptyState.onAction = { [weak self] in
self?.viewModel.didTapEmptyAction()
}
render(viewModel.state)
renderFields(viewModel.fields)
}

/// 직전 상태 — 바뀐 것만 반영하기 위해 VC가 들고 있다. 특히 입력 슬롯은 매번
/// setText하면 `UITextField` 커서가 튀므로 값이 실제로 달라졌을 때만 쓴다.
private var rendered: SearchViewModel.State?

private func render(_ state: SearchViewModel.State) {
defer { rendered = state }
if rendered?.fields != state.fields {
renderFields(state.fields)
}
renderContent(state.content)
}

private func renderContent(_ state: SearchViewModel.Content) {
activityIndicator.stopAnimating()
emptyState.isHidden = true
showsRecentHeader = false
Expand Down
Loading
Loading