반응형

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·헛요청 없이 안정적인 동작을 보장할 수 있다.
반응형
Posted by 까칠코더
,