← SwiftUI 14일 마스터 12 / 14 · 1부

Day 12 · 1부 · .task 수명과 로딩 상태

.task의 자동 취소와 협력형 취소, .task(id:) 디바운스, LoadState enum과 버튼 액션 속 Task의 수명을 다룬다.
진행률
0 / 0 완료
UIKit에서 비동기 작업을 붙이면 화면 수명 관리까지 떠안았다. viewDidAppear에서 요청을 시작하고, viewDidDisappear에서 취소하고, 콜백 안에서 DispatchQueue.main.async로 돌아와 라벨을 고쳤다. 하나라도 빠뜨리면 사라진 화면이 네트워크를 계속 쓰거나 백그라운드 스레드에서 UI를 건드렸다. SwiftUI에서는 .task가 작업 수명을 뷰 수명에 묶고, View의 @MainActor 격리가 "UI는 메인에서"를 컴파일러가 검사하게 만든다. 1부는 작업 수명(.task · .task(id:))과 로딩 상태 모델링을 다룬다.

1부 목표 · 25분

  • .onAppear { Task { } } 코드를 보고 취소되지 않는 문제를 찾아 .task / .task(id:)로 고친다.
  • onChange + Task 검색 코드를 .task(id:) 디바운스로 바꾸고, 취소를 오류 화면에서 걸러 낸다.
  • 로딩 화면의 isLoading · error · data 플래그 묶음을 LoadState enum 하나로 바꾸고, 모든 경우를 switch로 그린다.
  • 버튼 액션 안 Task { }가 언제 멈추고 언제 안 멈추는지 구분한다.

1. .task — 작업 수명을 뷰 수명에 묶는다 🔥 핵심

UIKit에서는 작업 핸들을 프로퍼티에 들고 있다가 화면이 사라질 때 직접 cancel()을 불렀다. 이 짝을 맞추는 건 개발자 몫이었다.

UIKit — 시작과 취소를 손으로 짝 맞춘다
import UIKit

final class PhotosViewController: UIViewController {
    private var loadTask: Task<Void, Never>?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        loadTask = Task { await self.load() }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        loadTask?.cancel()          // 빠뜨리면 화면이 사라져도 계속 돈다
    }

    private func load() async { }
}

.task는 이 짝을 모디파이어 하나로 만든다. WWDC21 · Discover concurrency in SwiftUI에서 소개됐고, 수명 규칙은 다음 한 문장이다.

📝 Apple 원문

"A task's lifetime is tied to the view's lifetime, so you can do things like waiting on an async sequence and responding to its values. And the task will be automatically canceled when the view's lifetime ends." — WWDC21 · Discover concurrency in SwiftUI

풀이: 작업은 뷰가 나타나기 전에 시작되고, 뷰의 수명이 끝나면 자동으로 취소된다. 문서는 더 구체적으로 "If the task doesn't finish before SwiftUI removes the view or the view changes identity, SwiftUI cancels the task"라고 적는다(Apple 문서 · task). Day 4의 identity가 여기서 다시 나온다.

뷰 등장 뷰 제거 · identity 변경 .task { } 작업 실행 ✕ 자동 취소 onAppear { Task { } } 작업 실행 계속 실행 (아무도 안 봄) .task는 취소 시점을 SwiftUI가 안다 — 뷰 identity가 끝나는 순간
같은 비동기 코드라도 .task로 붙이면 뷰가 끝날 때 취소 신호를 받고, onAppear 안의 Task는 끝까지 혼자 돈다.
❌ onAppear 안에서 Task — 화면을 떠나도 스트림을 계속 구독한다
struct TickerWrong: View {
    @State private var price = 0.0

    var body: some View {
        Text(price, format: .number)
            .onAppear {
                Task {
                    for await value in PriceFeed.stream() {
                        price = value          // 뷰가 사라진 뒤에도 계속 돈다
                    }
                }
            }
    }
}
✅ .task — 뷰가 사라지면 for await 루프가 취소로 끝난다
struct Ticker: View {
    @State private var price = 0.0

    var body: some View {
        Text(price, format: .number)
            .task {
                for await value in PriceFeed.stream() {
                    price = value
                }
            }
    }
}

enum PriceFeed {
    static func stream() -> AsyncStream<Double> {
        AsyncStream { continuation in
            let producer = Task {
                while !Task.isCancelled {
                    continuation.yield(Double.random(in: 90...110))
                    try? await Task.sleep(for: .seconds(1))
                }
                continuation.finish()
            }
            continuation.onTermination = { _ in producer.cancel() }  // 소비자가 끝나면 생산도 멈춤
        }
    }
}

취소는 "요청"이다 — 작업이 확인해야 멈춘다

Swift의 취소는 협력형이다. SwiftUI가 작업을 취소하면 취소 플래그가 켜질 뿐, 코드가 그 플래그를 확인하거나 취소를 지원하는 API(Task.sleep은 CancellationError를 던진다)를 await해야 실제로 멈춘다. 그리고 취소는 사용자가 볼 오류가 아니므로 따로 거른다.

✅ 루프마다 취소 확인 + CancellationError는 오류 UI에서 제외
struct ReportView: View {
    @State private var pages: [String] = []
    @State private var errorMessage: String?

    var body: some View {
        List(pages, id: \.self) { Text($0) }
            .overlay { if let errorMessage { Text(errorMessage) } }
            .task {
                do {
                    for index in 1...20 {
                        try Task.checkCancellation()           // 취소됐으면 여기서 throw
                        try await Task.sleep(for: .milliseconds(200))
                        pages.append("Page \(index)")
                    }
                } catch is CancellationError {
                    // 화면을 떠나서 취소됨 — 오류 문구를 띄우지 않는다
                } catch {
                    errorMessage = error.localizedDescription
                }
            }
    }
}
UIKitSwiftUI차이
viewDidAppear에서 Task 생성.task { }뷰가 나타나기 전 시작, 기본 우선순위 .userInitiated
viewDidDisappear에서 cancel()(자동)뷰 제거 · identity 변경 시 SwiftUI가 취소
값 변경 감지 → 이전 요청 취소 → 새 요청.task(id: value) { }2절
UIRefreshControl + endRefreshing().refreshable { await … }2부 2절 · 끝나면 인디케이터 자동 종료
⚠️ List·Lazy 스택 행의 .task는 스크롤만으로 취소될 수 있다

Lazy 컨테이너는 화면에 필요한 행만 만든다. (WWDC25 · 270: "only kicking off photo loading tasks for views that need to be rendered on screen"). 행이 화면 밖으로 밀려 사라지면 task 문서의 규칙이 적용된다: "SwiftUI will automatically cancel the task at some point after the view disappears before the action completes." 그래서 행에서 받은 결과는 행의 @State가 아니라 모델에 캐시하고, 빨리 스크롤하면 중간에 취소된 로딩이 생긴다고 가정한다.

2. .task(id:) — 값이 바뀌면 취소하고 다시 시작 🔥 핵심

task(id:) 문서: "If the id value changes, SwiftUI cancels and restarts the task." 새 값이 이전 값과 ==로 다르면 이전 작업을 취소하고 새 작업을 만든다. 그래서 id 타입은 Equatable이어야 한다.

이 성질은 검색 디바운스에 딱 맞는다. 작업 맨 앞에서 300ms를 자면, 그 사이 글자가 또 들어오면 id가 바뀌어 잠자던 작업이 취소된다. 사용자가 손을 멈춘 뒤의 마지막 작업만 실제 검색까지 간다. Combine의 debounce + switchToLatest가 모디파이어 하나로 줄어든다.

"s" 입력 "sw" 입력 "swi" 입력 id = "s" sleep 300ms… ✕ 취소 id = "sw" sleep 300ms… ✕ 취소 id = "swi" sleep 300ms 검색 → results id가 바뀔 때마다 이전 작업 취소 → 새 작업 시작. 끝까지 가는 건 마지막 하나
빠르게 친 세 글자 중 네트워크 요청까지 가는 건 "swi" 하나다. 앞의 두 작업은 sleep 도중 취소되어 CancellationError로 끝난다.
✅ 검색 디바운스 — .task(id: query) + Task.sleep
struct SearchScreen: View {
    @State private var query = ""
    @State private var results: [String] = []

    var body: some View {
        NavigationStack {
            List(results, id: \.self) { Text($0) }
                .searchable(text: $query)
                .task(id: query) {
                    guard !query.isEmpty else { results = []; return }
                    do {
                        try await Task.sleep(for: .milliseconds(300))  // 디바운스
                        results = await SearchAPI.search(query)
                    } catch {
                        // 새 글자가 들어와 취소됨 — 아무것도 하지 않는다
                    }
                }
                .navigationTitle("검색")
        }
    }
}

enum SearchAPI {
    static func search(_ query: String) async -> [String] {
        ["\(query)ift", "\(query)iftUI", "\(query)ift Testing"]
    }
}
❌ onChange + Task — 이전 요청이 취소되지 않아 늦게 온 옛 결과가 새 결과를 덮는다
struct SearchScreenWrong: View {
    @State private var query = ""
    @State private var results: [String] = []

    var body: some View {
        List(results, id: \.self) { Text($0) }
            .searchable(text: $query)
            .onChange(of: query) { _, newValue in
                Task {
                    results = await SearchAPI.search(newValue)  // 응답 순서 = 요청 순서라는 보장 없음
                }
            }
    }
}
🔑 핵심

.task(id:)의 id는 "이 작업이 의존하는 입력"이다. 상세 화면의 item.id, 선택된 카테고리, 검색어 같은 것을 넣는다. 입력이 여러 개면 Equatable 구조체로 묶어서 하나의 id로 넘긴다.

3. 로딩 상태는 enum 하나로 — 상태가 비동기와 UI를 잇는 다리

로딩 화면을 isLoading, errorMessage, items 세 변수로 만들면 2 × 2 × 2 = 8가지 조합이 생긴다. 그중 "로딩 중인데 에러도 있고 데이터도 있음" 같은 조합은 말이 안 되지만 코드는 막지 못한다. enum으로 바꾸면 가능한 상태만 표현된다.

idle .task loading 성공 loaded(Value) 에러 failed(String) 당겨서 새로고침 다시 시도
네 상태 중 정확히 하나만 참이다. "로딩 중이면서 실패" 같은 조합은 타입이 만들 수 없게 막는다.
❌ 플래그 세 개 — 불가능한 조합이 표현 가능하고, 뷰가 우선순위를 추측해야 한다
@Observable
final class ProfileModelWrong {
    var isLoading = false
    var errorMessage: String?
    var profile: Profile?
    // isLoading == true && errorMessage != nil 인 순간이 생길 수 있다
}
✅ enum + @MainActor 모델 — 상태 전이는 모델 안의 동기 대입으로
enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}

struct Profile { let name: String }

struct ProfileAPI {                              // 네트워크 대신 1초 지연
    func fetch() async throws -> Profile {
        try await Task.sleep(for: .seconds(1))
        return Profile(name: "Taylor")
    }
}

@MainActor
@Observable
final class ProfileModel {
    private(set) var state: LoadState<Profile> = .idle
    private let api = ProfileAPI()

    func load() async {
        if case .loading = state { return }    // 이미 로딩 중이면 무시
        state = .loading
        do {
            state = .loaded(try await api.fetch())
        } catch is CancellationError {
            state = .idle                       // 취소는 실패가 아니다
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}
✅ 뷰는 switch 하나 — 컴파일러가 빠진 경우를 잡는다
struct ProfileScreen: View {
    @State private var model = ProfileModel()

    var body: some View {
        Group {
            switch model.state {
            case .idle, .loading:
                ProgressView()
            case .loaded(let profile):
                Text(profile.name).font(.title)
            case .failed(let message):
                ContentUnavailableView {
                    Label("불러오지 못했다", systemImage: "wifi.exclamationmark")
                } description: {
                    Text(message)
                } actions: {
                    Button("다시 시도") { Task { await model.load() } }
                }
            }
        }
        .task { await model.load() }
    }
}

이 구조는 WWDC25 · Explore concurrency in SwiftUI가 권하는 경계 나누기와 같다. 뷰는 "이벤트가 났다"만 모델에 알리고, 비동기 로직은 모델이 들고, 결과는 상태에 동기로 대입한다.

📝 Apple 원문

"You can use a piece of state as a bridge. The state decouples the UI code from the async code. … As some async work finishes up, perform a synchronous mutation on the state, so that your UI can update as reactions to this change." — WWDC25 · Explore concurrency in SwiftUI

풀이: 상태(state)가 다리다. 비동기 작업이 끝나면 상태를 동기로 한 번 바꾸고, UI는 그 변화에 반응할 뿐이다. 덤으로 모델을 SwiftUI 없이 테스트할 수 있다(Day 13).

Button 액션은 왜 async가 아닌가

세션은 이 질문에 직접 답한다. 오래 걸리는 작업 전에 "진행 중" UI를 같은 프레임에 보여 줘야 하는데, async 클로저는 await에서 멈췄다가 언제 재개될지 모른다. 그래서 SwiftUI 콜백은 동기이고, 필요하면 안에서 Task로 비동기에 들어간다: "SwiftUI's action callbacks accept synchronous closures, which are necessary to set up UI updates, like my loading states."

✅ ① 동기로 로딩 표시 → ② Task에서 await → ③ 동기로 원복
struct SaveButton: View {
    @State private var isSaving = false
    let save: () async -> Void

    var body: some View {
        Button {
            isSaving = true                 // ① 탭한 그 프레임에 반영
            Task {
                await save()                // ② 비동기 작업
                isSaving = false            // ③ 끝나면 다시 동기 변경
            }
        } label: {
            Text("저장")
                .opacity(isSaving ? 0 : 1)                // 버튼 크기 유지
                .overlay { if isSaving { ProgressView() } }
        }
        .disabled(isSaving)
    }
}
⚠️ 버튼 액션 안의 Task는 뷰 수명에 묶이지 않는다

Task 문서: "A task runs regardless of whether you keep a reference to it." — 위 Task { }는 .task가 아니라서 SwiftUI가 취소해 주지 않는다. 저장 도중 화면을 닫아도 save()는 끝까지 돈다(저장처럼 끝나야 하는 일에는 오히려 맞다). 화면과 함께 멈춰야 하는 일이면 @State private var job: Task<Void, Never>?에 핸들을 보관했다가 onDisappear에서 job?.cancel()을 부르거나, 버튼은 트리거 값만 바꾸고 실제 작업은 .task(id:)가 하게 옮긴다.

♿ 접근성 한 줄

3절 SaveButton처럼 진행 중에 ProgressView를 겹쳐 보여 주고 버튼을 .disabled로 막으면, 버튼이 상호작용 불가 상태가 되어 VoiceOver 사용자에게도 "지금은 누를 수 없다"는 사실과 중복 탭 방지가 같이 전달된다.

🚨 흔한 실수 요약 (1부)

① onAppear { Task { } } — 취소되지 않음 → .task ② onChange + Task로 검색 — 옛 응답이 새 결과를 덮음 → .task(id:) ③ CancellationError를 오류 화면으로 표시 ④ isLoading·error·data 플래그 묶음 → LoadState enum ⑤ 버튼 액션의 Task가 화면과 함께 멈출 거라 믿음.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
.task { }와 .onAppear { Task { } }의 결정적 차이는?
.task의 작업은 뷰 수명에 묶여 뷰가 제거되거나 identity가 바뀌면 SwiftUI가 자동으로 취소한다. onAppear 안에서 만든 Task는 아무도 취소하지 않아서 화면을 떠나도 끝까지 돈다. 취소는 협력형이라 코드가 Task.checkCancellation()이나 취소를 지원하는 await를 거쳐야 실제로 멈춘다.
쉽게 말하면 .task는 방을 나가면 저절로 꺼지는 센서등이고, onAppear 속 Task는 끄는 걸 잊은 형광등이다.
SwiftUI의 Button 액션은 왜 async 클로저가 아니라 동기 클로저인가?
"진행 중" 표시 같은 UI 변경을 탭한 그 프레임에 동기로 반영해야 하기 때문이다. async 함수는 await에서 멈추면 언제 재개될지 몰라 애니메이션이 늦게 나올 수 있다(WWDC25 266). 먼저 상태를 동기로 바꾸고, 긴 작업만 Task 안에서 await한다.
쉽게 말하면 주문을 받자마자 "준비 중" 팻말부터 세우고, 요리는 그다음에 한다. 팻말을 요리 끝나고 세우면 손님은 주문이 안 된 줄 안다.
🔮 사용자가 검색칸에 "abc"를 0.1초 간격으로 빠르게 치고 멈췄다. print는 몇 번, 무엇이 찍히나?
struct PredictSearch: View {
    @State private var query = ""

    var body: some View {
        TextField("검색", text: $query)
            .task(id: query) {
                guard !query.isEmpty else { return }
                do {
                    try await Task.sleep(for: .milliseconds(300))
                    print("search:", query)
                } catch {
                    print("cancelled")
                }
            }
    }
}
🔮 코드 예측: 위 코드의 출력은?
cancelled가 두 번("a", "ab" 작업), 그다음 search: abc가 한 번 찍힌다. "a"와 "ab" 작업은 300ms 잠든 사이 id가 바뀌어 취소되고, Task.sleep이 CancellationError를 던져 catch로 간다. 맨 처음 빈 문자열 작업은 guard로 바로 끝난다.
쉽게 말하면 "3초 안에 또 부르면 앞 심부름은 취소!" 규칙이라, 마지막 부탁만 실제로 처리된다.
🔁 지난 Day 복습: SwiftUI에서 키보드를 내리는 정석은?
@FocusState로 연결한 값에 nil(enum) 또는 false(Bool)를 대입한다. 포커스와 값은 양방향으로 연결돼 있어서, 값을 바꾸면 포커스가 빠지고 키보드가 내려간다. 숫자 패드처럼 Return이 없는 키보드는 ToolbarItemGroup(placement: .keyboard)에 "완료" 버튼을 단다.
쉽게 말하면 "지금 발표자 = 없음"이라고 칠판에 쓰면 마이크가 저절로 꺼진다.
➡️ 2부 예고

2부는 await 뒤 코드가 왜 다시 main actor에서 도는지, Swift 6가 내는 Sendable 진단과 재진입 버그, 무거운 계산 오프로딩, 그리고 refreshable · scenePhase를 다룬다.

출처 · 참고자료