반응형
SwiftUI Study – 고급 비동기 흐름 제어: 최신 요청만 반영·취소·디바운싱·동시 로딩 패턴
1. 왜 중요한가 (문제 배경)
SwiftUI에서 네트워크/비동기 로직을 다루다 보면 다음과 같은 상황이 자주 발생합니다.
- 검색어를 빠르게 입력하면 이전 검색 요청이 아직 끝나지 않았는데 새로운 검색을 시작해야 함
- 필터/정렬 조건이 바뀔 때마다 API를 호출하되, 가장 마지막 요청의 결과만 반영해야 함
- 여러 API를 동시에 호출해 화면을 구성하되, 중간에 화면이 사라지면 모든 작업을 안전하게 취소해야 함
- 사용자가 스크롤을 내리면서 페이지를 로딩할 때, 같은 페이지를 중복 요청하지 않아야함
이 문제들은 단순히 task {} / onAppear 구분 수준을 넘어,
“비동기 흐름 전체를 설계하는 관점”이 필요합니다.
이 팁에서는:
- switchLatest 스타일 “최신 요청만 살리고 이전 요청 취소”
- 디바운싱(debounce) 기반 API 호출 제어
- async let / TaskGroup 으로 동시 로딩 및 취소 전파
- 페이징 중복 로딩 방지 패턴
같은 고급 패턴에 집중합니다.
2. 나이브(naive)하게 구현했을 때 문제 패턴
❌ 예시 1: 검색어 입력마다 새 Task를 만들지만 이전 요청은 계속 살아 있음
@State private var query: String = ""
@State private var results: [Item] = []
func search() {
Task {
results = try await api.search(query: query) // ❌ 이전 search Task는 취소되지 않음
}
}
문제점
- 사용자가 s, sw, swi, swif, swift 순서로 입력하면
검색 요청이 5번 동시에 실행될 수 있음 - 네트워크 낭비 + 늦게 끝난 이전 요청이 마지막에 덮어써서
“검색 결과가 입력과 맞지 않게 보이는” 현상 발생
❌ 예시 2: 필터 조건이 바뀔 때마다 load() 호출하지만 이전 요청 처리가 끝까지 진행
@State private var filter: Filter = .all
.onChange(of: filter) { newFilter in
Task {
items = try await api.load(filter: newFilter) // ❌ 기존 load 작업은 계속 진행
}
}
문제점
- 네트워크 지연이 큰 환경에서 순서 역전(race condition) 발생
- 나중에 선택한 필터가 먼저 끝나고, 이전 필터의 응답이 나중에 와서 화면을 덮어써 버림
❌ 예시 3: 여러 API를 동시에 호출하되 중앙에서 취소 전파가 되지 않는 경우
Task {
let a = try await api.loadA()
let b = try await api.loadB()
// ❌ 중간에 화면이 사라져도 이 Task는 자동으로 취소되지 않을 수 있음
}
문제점
- 부모 Task 구조 없이 여기저기 Task를 만들면
“화면 생명주기와 상관없이 계속 살아 있는” 비동기 작업이 생긴다.
3. 올바른 패턴 예시
✅ 패턴 1 – 최신 요청만 유지하는 “switchLatest 스타일” 구현
검색 또는 필터 변경에 매우 자주 쓰는 패턴입니다.
@MainActor
final class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [Item] = []
@Published var isLoading = false
private var searchTask: Task<Void, Never>?
func search(query: String) {
// 이전 요청 취소
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
guard !query.isEmpty else {
await MainActor.run { self.results = [] }
return
}
await MainActor.run { self.isLoading = true }
defer {
Task { @MainActor in self.isLoading = false }
}
do {
let items = try await api.search(query: query)
// 취소되었는지 최종 확인
guard !Task.isCancelled else { return }
await MainActor.run {
// 가장 최신 query와 일치하는 경우에만 반영
if self.query == query {
self.results = items
}
}
} catch {
// 에러 처리 로직 (필요 시)
}
}
}
}
View:
TextField("검색어", text: $vm.query)
.onChange(of: vm.query) { newValue in
vm.search(query: newValue)
}
포인트
- 이전 Task는 항상 cancel()
- 응답이 돌아왔을 때도 Task.isCancelled 확인
- 마지막으로, ViewModel의 현재 query와 응답 당시의 query가 동일할 때만 결과 반영 → 완전한 switchLatest
✅ 패턴 2 – 디바운싱(debounce) 검색 구현
빠르게 타이핑할 때 매 글자마다 API를 호출하지 않고,
타이핑이 잠시 멈췄을 때 한 번만 요청하도록 만드는 패턴입니다.
@MainActor
final class DebouncedSearchVM: ObservableObject {
@Published var query: String = ""
@Published var results: [Item] = []
private var debounceTask: Task<Void, Never>?
func onQueryChanged(_ text: String) {
debounceTask?.cancel()
debounceTask = Task { [weak self] in
// 300ms 동안 추가 입력이 없으면 실행
try? await Task.sleep(nanoseconds: 300_000_000)
guard let self, !Task.isCancelled else { return }
let items = try await api.search(query: text)
await MainActor.run {
if self.query == text {
self.results = items
}
}
}
}
}
View:
TextField("검색", text: $vm.query)
.onChange(of: vm.query) { new in
vm.onQueryChanged(new)
}
포인트
- 사용자 입력이 “멈췄을 때”만 API 호출
- 네트워크 비용 절감 + UX 향상
- 항상 최신 query만 반영
✅ 패턴 3 – async let으로 동시 로딩 + 부모 Task에 취소 위임
@MainActor
final class DashboardVM: ObservableObject {
@Published var stats: Stats?
@Published var notifications: [Notification] = []
@Published var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
async let statsTask = api.loadStats()
async let notiTask = api.loadNotifications()
let (stats, notifications) = try await (statsTask, notiTask)
self.stats = stats
self.notifications = notifications
} catch {
// 필요 시 에러 처리
}
}
}
View:
struct DashboardView: View {
@StateObject private var vm = DashboardVM()
var body: some View {
content
.task {
await vm.load()
}
}
}
포인트
- async let 으로 두 요청을 병렬 실행
- View의 task가 사라지면 하위 async let들도 자동 취소 (구조화된 동시성)
- 불필요한 Task 핸들 관리가 필요 없음
✅ 패턴 4 – 스크롤 페이징에서 중복 요청 방지
@MainActor
final class PagingVM: ObservableObject {
@Published var items: [Item] = []
@Published var isLoadingPage = false
private var currentPage = 1
private var canLoadMore = true
func loadNextIfNeeded(currentItem item: Item?) async {
guard let item else { return }
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
if index < thresholdIndex { return }
await loadNextPage()
}
private func loadNextPage() async {
guard !isLoadingPage, canLoadMore else { return }
isLoadingPage = true
defer { isLoadingPage = false }
do {
let new = try await api.loadPage(page: currentPage)
if new.isEmpty {
canLoadMore = false
} else {
currentPage += 1
items.append(contentsOf: new)
}
} catch {
// 에러 처리
}
}
}
포인트
- isLoadingPage + canLoadMore 로 락(lock) 역할
- 같은 페이지를 두 번 이상 호출할 수 없도록 방지
- 스크롤 속도와 상관없이 안정적인 페이징
4. 실전 적용 팁
✔ 팁 1 – “항상 최신 요청만 유효”한 경우는 반드시 취소 패턴을 넣어라
- 검색, 자동 완성, 필터, 정렬 변경 등
- switchLatest + Task cancel + query 일치 확인이 기본 템플릿이 된다.
✔ 팁 2 – 디바운싱은 네트워크뿐 아니라 CPU도 아낀다
- validation, 복잡한 정렬/필터에도 debounce 패턴을 그대로 적용할 수 있다.
✔ 팁 3 – async let / TaskGroup은 “부모 Task 안에서만” 사용
- View의 .task {} 안에서 사용하는 것은 구조상 안전하다.
- 여기저기 Task {} 만 남발하면 취소 전파가 끊기니 피해야 한다.
✔ 팁 4 – ViewModel의 메서드는 “취소 가능성”을 항상 염두에 두고 작성
- Task.isCancelled 체크 후 조기 반환
- UI 반영 직전에 한 번 더 체크하는 습관이 중요하다.
✔ 팁 5 – “동시에 여러 번 실행되면 안 되는 작업”은 락 플래그 + defer 패턴으로 보호
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
- 페이징, 결제 요청, 중복 제출 방지 등에 필수 패턴이다.
5. 정리
- SwiftUI에서 비동기를 제대로 쓰려면 “한 번만 실행 vs 최신만 유지 vs 병렬 실행”을 구분해서 설계해야 한다.
- 검색/필터에는 switchLatest + 디바운싱,
대시보드/홈 화면에는 async let 병렬 로딩,
페이징에는 락 + 최신 위치 기반 조건이 기본 템플릿이 된다. - 이 패턴들을 ViewModel에 잘 녹여두면,
UI는 단순히 상태를 보여주기만 해도 레이스 컨디션·중복 fetch·헛요청 없이 안정적인 동작을 보장할 수 있다.
반응형


