반응형

SwiftUI Study – async/await 기반 데이터 스트림 처리: Task·AsyncSequence·Refreshable의 올바른 구조 설계 패턴

 

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

SwiftUI는 상태 기반 UI이기 때문에, 비동기 데이터 스트림 처리 방식이 앱의 안정성과 성능을 크게 좌우한다.

하지만 실무에서는 다음 문제가 매우 자주 발생한다.

  • .task 안에서 무한 루프 polling이 중복 실행됨
  • 화면이 사라졌는데도 Task가 계속 살아 있어 백그라운드에서 API 호출 지속
  • refreshable  .task 가 충돌해 중복 fetch
  • AsyncSequence를 잘못 처리해 메모리 누수 또는 중복 이벤트 처리
  • Task 취소를 고려하지 않아 “유령 데이터 업데이트(ghost update)” 발생

비동기 스트림을 SwiftUI에서 안정적으로 처리하려면

Task 생명주기 + AsyncSequence 처리 방식 + refreshable의 특성을 이해해야 한다.

본 팁은 기존 1~33번과 절대적으로 다른 고급 주제를 다룬다.

 

2. 잘못된 패턴 예시

❌ 예시 1: .task 안에서 무한 루프 polling이 중복 실행됨

.task {
    while true {
        let data = await api.fetch()
        items = data          // ❌ View가 새로 렌더링될 때마다 Task 다시 시작
        try? await Task.sleep(nanoseconds: 2_000_000_000)
    }
}

문제점

  • 화면이 다시 나타날 때마다 새로운 무한 루프 Task 생성
  • 사용자가 뒤로 가도 Task가 취소되지 않는 경우가 많음
  • API 요청이 수십 개 이상 중복 발생 → 서버/배터리/데이터 낭비

❌ 예시 2: refreshable과 .task를 함께 사용하여 중복 fetch

.task {
    await load()
}
.refreshable {
    await load()      // ❌ 서로 다른 두 로직이 동일한 API를 중복 호출
}

문제점

  • 사용자가 당겨서 새로고침할 때 .task 도 다시 실행 → 이중 요청
  • API 쿼터 제한 있는 환경에서 문제 심각

❌ 예시 3: AsyncSequence 스트림을 반복 구독하는 문제

.task {
    for await value in socket.stream {   // ❌ 뷰가 그릴 때마다 스트림 두 번, 세 번 구독됨
        messages.append(value)
    }
}

문제점

  • 스트림이 다중 구독을 허용하지 않는 경우 crash 또는 동작 멈춤
  • 허용하더라도 중복 이벤트 발생

❌ 예시 4: Task 취소 고려 없이 상태 업데이트

.task {
    let result = try await api.fetchData()
    state = result   // ❌ 화면이 사라진 뒤에도 업데이트될 수 있음
}

문제점

  • 사라진 화면의 상태를 업데이트 → UI 충돌
  • 경고 출력 또는 의도하지 않은 화면 변화 발생

 

3. 올바른 패턴 예시

✅ 예시 1: .task(id:)로 동일 Task가 중복 생성되지 않도록 제어

.task(id: isActive) {
    guard isActive else { return }

    for await value in stream {
        await MainActor.run {
            messages.append(value)
        }
    }
}

장점

  • id 변경 때만 Task가 재시작
  • 렌더링 변경에 따른 Task 중복 생성 방지

✅ 예시 2: Task 핸들을 저장해 cancel 제어

@State private var pollingTask: Task<Void, Never>?

.onAppear {
    pollingTask?.cancel()
    pollingTask = Task {
        while !Task.isCancelled {
            let data = await api.fetch()
            await MainActor.run { items = data }
            try? await Task.sleep(nanoseconds: 2_000_000_000)
        }
    }
}

.onDisappear {
    pollingTask?.cancel()
}

장점

  • 화면 생명주기와 Task 생명주기 동기화
  • API 중복 호출 문제 완전 해결

✅ 예시 3: refreshable과 task는 “서로 다른 역할”을 줘야 충돌 없음

.task {
    await loadInitial()   // 최초 로딩만 담당
}

.refreshable {
    await reload()        // 사용자 액션에 의한 새로고침
}

장점

  • 각각 명확한 역할
  • 중복 API 호출 없음
  • UI 반응성 향상

✅ 예시 4: AsyncSequence 스트림은 하나의 전담 ViewModel에서만 구독

@MainActor
final class ChatStreamVM: ObservableObject {
    @Published var messages: [Message] = []
    private var streamTask: Task<Void, Never>?

    func start(stream: AsyncStream<Message>) {
        streamTask?.cancel()

        streamTask = Task {
            for await message in stream {
                messages.append(message)
            }
        }
    }

    deinit {
        streamTask?.cancel()
    }
}

View에서는 단순 표현만:

@StateObject private var vm = ChatStreamVM()

장점

  • 스트림 중복 구독 방지
  • 구독자 수를 명확히 관리
  • View는 단순 표현 → 안정성 증가

✅ 예시 5: Task 취소 확인 후 안전한 UI 업데이트

.task {
    let result = try await api.fetch()

    guard !Task.isCancelled else { return }

    await MainActor.run {
        state = result
    }
}

장점

  • 사라진 화면에서 상태 업데이트되는 문제 완전 해결
  • race condition 방지

 

4. 실전 적용 팁

✔ 팁 1 – .task는 “View가 화면에 등장할 때” 실행됨

따라서 동일 Task가 중복 생성될 가능성이 있음 → id 또는 Task 핸들로 제어해야 함.

✔ 팁 2 – AsyncSequence는 여러 번 구독하면 안 되는 경우가 많다

WebSocket, Notifications, 파일 스트림 등은 단일 구독이 원칙.

✔ 팁 3 – refreshable은 사용자 액션 전용

task와 역할을 구분해야 예기치 않은 중복 호출 방지.

✔ 팁 4 – Task.isCancelled 확인은 필수

UI 업데이트 코드 앞에 항상 두면 안정성이 100% 올라감.

✔ 팁 5 – 스트림 처리 로직은 ViewModel이 전담

View는 표현(presentation)에만 집중하도록 설계.

 

5. 정리

  • SwiftUI의 비동기 스트림 처리에서 가장 중요한 것은 Task 생명주기 관리이다.
  • refreshable, task, AsyncSequence는 각각 다른 용도로 설계되었기 때문에
    역할을 구분해 쓰는 것이 안정성과 성능을 크게 향상시킨다.
  • Task 취소·스트림 단일 구독·상태 업데이트 시점 관리를 올바르게 적용하면
    실제 서비스 환경에서도 강력하고 안정적인 데이터 흐름을 만들 수 있다.
반응형
Posted by 까칠코더
,