SwiftUI Study – async/await 기반 데이터 스트림 처리: Task·AsyncSequence·Refreshable의 올바른 구조 설계 패턴
Dev Study/SwiftUI 2025. 12. 9. 11:10반응형
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 취소·스트림 단일 구독·상태 업데이트 시점 관리를 올바르게 적용하면
실제 서비스 환경에서도 강력하고 안정적인 데이터 흐름을 만들 수 있다.
반응형

