반응형

SwiftUI Study – .task(id:)와 Task 취소를 활용해 안전한 비동기 로딩 패턴 설계하기

 

1. 왜 중요한가 (문제 배경)

SwiftUI에서 비동기 코드를 작성할 때 가장 많이 사용하는 도구는 다음과 같습니다.

  • .task { ... }
  • .task(id:)
  • Task { ... }
  • Task.detached { ... }
  • onDisappear { task.cancel() }

하지만 이를 잘못 사용하면 다음과 같은 문제가 발생합니다.

  • 화면 전환·상태 변경 시 동일 API가 여러 번 중복 호출
  • 이미 사라진 화면에서 늦게 도착한 응답이 상태를 덮어씀
  • 사용자가 빠르게 탭을 이동하면 취소되지 않은 Task들이 누적
  • List/Scroll에서 셀 재사용 시 중복 로딩과 깜빡임 발생

핵심은 다음과 같습니다.

“Swift Concurrency는 자동 취소(cancellation) 를 지원하지만,

설계를 잘못하면 그 장점을 전혀 누리지 못한다.”

.task(id:)와 명시적인 Task 관리 패턴을 이해하면

중복 호출·메모리 낭비·레이스 컨디션을 상당 부분 제거할 수 있다.

 

2. 잘못된 패턴 예시

❌ 예시 1: .task 안에서 Task를 또 만드는 패턴

struct WrongUserView: View {
    @State private var user: User?

    var body: some View {
        Text(user?.name ?? "로딩 중...")
            .task {
                Task {          // ❌ 불필요한 중첩 Task
                    user = try? await api.fetchUser()
                }
            }
    }
}

문제점

  • .task 자체가 이미 Task를 생성하므로, 내부 Task는 자동 취소와 분리됨
  • View가 사라져도 내부 Task는 계속 실행될 수 있음
  • Cancellation이 propagation 되지 않아 구조적 동시성 이점을 잃음

❌ 예시 2: 동일한 작업을 여러 상태 변화에서 중복 실행

struct WrongSearchView: View {
    @State private var query = ""
    @State private var result: [Item] = []

    var body: some View {
        VStack {
            TextField("검색", text: $query)
            List(result) { item in Text(item.title) }
        }
        .task {
            // ❌ 화면 진입시 1번
            // 이후 query 변경마다 onChange 등에서 또 호출할 수 있음
            result = (try? await api.search(query)) ?? []
        }
        .onChange(of: query) { newValue in
            Task {
                // ❌ 추가 Task 생성 → 중복 호출
                result = (try? await api.search(newValue)) ?? []
            }
        }
    }
}

문제점

  • 여러 위치에서 Task를 생성해 어떤 요청이 마지막인지 보장 어려움
  • 빠른 입력 시 이전 요청이 나중에 도착해 결과를 덮어쓸 수 있음 (레이스 컨디션)

❌ 예시 3: List 셀에서 .task 남발

struct WrongRow: View {
    let id: Int
    @State private var detail: Detail?

    var body: some View {
        HStack {
            Text("아이템 \(id)")
            Text(detail?.text ?? "로딩…")
        }
        .task {
            // ❌ 스크롤/재사용 시마다 반복 호출
            detail = try? await api.fetchDetail(id)
        }
    }
}

문제점

  • 스크롤할 때마다 새로운 Task 생성
  • 빠르게 스크롤하면 같은 id에 대해 수십 번 요청 가능
  • 취소 관리가 되지 않으므로 리소스 낭비

 

3. 올바른 패턴 예시

✅ 예시 1: .task(id:)를 사용해 상태 기반 로딩

struct UserView: View {
    let userID: Int
    @State private var user: User?

    var body: some View {
        VStack {
            if let user {
                Text(user.name)
            } else {
                ProgressView()
            }
        }
        .task(id: userID) {
            // userID가 바뀔 때마다 이전 Task는 자동 취소되고 새 Task가 시작됨
            user = try? await api.fetchUser(id: userID)
        }
    }
}

장점

  • userID가 변경되면 기존 요청은 자동 cancel
  • “마지막 요청만 유효”한 상태 보장
  • 의도치 않은 중복 호출 최소화

✅ 예시 2: 검색 요청에 debounce + 취소 패턴 적용

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var query: String = ""
    @Published var result: [Item] = []

    private var searchTask: Task<Void, Never>?

    func onQueryChanged(_ text: String) {
        searchTask?.cancel()   // 이전 검색 취소

        searchTask = Task { [weak self] in
            try? await Task.sleep(nanoseconds: 300_000_000)  // 0.3초 디바운스
            guard !Task.isCancelled else { return }

            let response = (try? await api.search(text)) ?? []
            await MainActor.run {
                self?.result = response
            }
        }
    }
}

View:

struct SearchView: View {
    @StateObject private var vm = SearchViewModel()

    var body: some View {
        VStack {
            TextField("검색", text: $vm.query)
            List(vm.result) { item in Text(item.title) }
        }
        .onChange(of: vm.query) { vm.onQueryChanged($0) }
    }
}

장점

  • 빠른 입력 시 이전 요청은 모두 취소
  • 마지막 검색어에 대한 결과만 UI를 갱신
  • 디바운스로 서버 부하 감소

✅ 예시 3: View 생명주기에 맞춘 Task 관리 (.onDisappear에서 취소)

struct AutoCancelView: View {
    @State private var text = "준비 중..."
    @State private var task: Task<Void, Never>?

    var body: some View {
        Text(text)
            .onAppear {
                task = Task {
                    text = "로딩 중..."
                    let value = try? await api.longRunningWork()
                    if let value {
                        text = value
                    }
                }
            }
            .onDisappear {
                task?.cancel()
            }
    }
}

장점

  • 화면에서 사라지면 Task도 함께 종료
  • 불필요한 네트워크/CPU 작업 제거
  • 메모리·배터리 낭비 감소

 

4. 실전 적용 팁

✔ 팁 1 – .task 안에서 Task를 다시 만들지 말 것

.task { } 자체가 Task 이므로, 내부에서 또 Task { }를 만들면 자동 취소와 분리된다.

✔ 팁 2 – “같은 종류의 작업”은 하나의 Task 핸들로 관리

검색, 상세 로딩 등은 Task? 프로퍼티로 들고 있다가 필요 시 .cancel().

✔ 팁 3 – .task(id:)는 “상태 기반 로딩”에 매우 적합

id가 변경될 때마다 이전 작업을 취소하고 새 작업만 유지할 수 있다.

✔ 팁 4 – List 셀 안에서 직접 .task로 API를 부르지 말고, 상위 ViewModel에서 일괄 로딩

셀은 이미 계산된 결과를 받아서 보여주기만 하는 것이 가장 안정적이다.

✔ 팁 5 – Task.isCancelled를 적극적으로 활용

긴 루프나 여러 단계 연산이 있을 경우 중간에 취소 상태를 체크해 즉시 종료할 것.

 

5. 정리

  • SwiftUI에서 비동기 로직을 안전하게 처리하려면 “언제 취소되는지”를 함께 설계해야 한다.
  • .task(id:)는 상태 변화에 맞춰 자동으로 취소/재시작을 관리해주는 강력한 도구다.
  • 검색·상세 로딩·롱런닝 작업 등은 Task 핸들을 직접 잡고 취소 패턴을 적용하는 것이 좋다.
  • 이 원칙을 적용하면 중복 호출·레이스 컨디션·불필요한 네트워크 트래픽을 크게 줄일 수 있다.
반응형
Posted by 까칠코더
,