반응형
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 핸들을 직접 잡고 취소 패턴을 적용하는 것이 좋다.
- 이 원칙을 적용하면 중복 호출·레이스 컨디션·불필요한 네트워크 트래픽을 크게 줄일 수 있다.
반응형

