Swift Concurrency 마스터하기 - SwiftUI와 함께 쓰는 비동기 패턴
이번 편에선 SwiftUI와 Swift Concurrency를 어떻게 똑똑하게 같이 쓰는지,
특히 ViewModel과 상태 관리 측면에서 어떤 전략이 유효한지 다뤄봅니다.
특히 Pull to Refresh와 무한 스크롤(pagination) 같은 현실적인 UI 상황을 함께 살펴볼 거예요.
SwiftUI는 선언형이라 비동기 흐름을 잘못 다루면 쉽게 꼬입니다.
그래서 구조적으로 잘 짜는 게 핵심이죠 💡
🧱 기본 구조 잡기 - ViewModel에서 비동기 처리하기
SwiftUI에서 async 작업은 대체로 ObservableObject 뷰모델 안에서 처리합니다.
중요한 포인트는 아래 3가지:
@Published는 항상 메인 스레드에서만 바꿔야 함 (즉, MainActor 필요!)- async 작업은
Task로 분리해서 관리 - View에서 직접 async 코드를 호출하더라도 상태는 ViewModel이 책임져야 안정적임
샘플 ViewModel 템플릿
@MainActor
class ArticleViewModel: ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
@Published var hasMore = true
private var currentPage = 1
func fetchInitialArticles() async {
isLoading = true
defer { isLoading = false }
do {
let newArticles = try await ArticleAPI.fetch(page: 1)
self.articles = newArticles
self.currentPage = 2
self.hasMore = !newArticles.isEmpty
} catch {
print("초기 로딩 실패: \(error)")
}
}
func fetchMoreArticlesIfNeeded(currentItem: Article) async {
guard hasMore, !isLoading, articles.last == currentItem else { return }
isLoading = true
defer { isLoading = false }
do {
let moreArticles = try await ArticleAPI.fetch(page: currentPage)
self.articles += moreArticles
self.currentPage += 1
self.hasMore = !moreArticles.isEmpty
} catch {
print("더 불러오기 실패: \(error)")
}
}
}
@MainActor를 붙여서 ViewModel 내부 모든 작업이 메인에서 동작하게 보장합니다.
특히 @Published 프로퍼티를 async 함수 안에서 바꾸는 경우 꼭 필요해요.
🔄 Pull to Refresh 구현하기
iOS 15부터 SwiftUI에서 refreshable modifier로 간편하게 Pull to Refresh를 구현할 수 있습니다.
struct ArticleListView: View {
@StateObject private var viewModel = ArticleViewModel()
var body: some View {
List(viewModel.articles) { article in
ArticleRow(article: article)
}
.refreshable {
await viewModel.fetchInitialArticles()
}
.task {
await viewModel.fetchInitialArticles()
}
}
}
refreshable {}은 사용자가 아래로 당길 때 실행됩니다..task {}는 뷰가 처음 나타날 때 실행됩니다 (onAppear보다 선호되는 방식).
📝 팁: 네트워크 상태, 에러 표시 등은 별도로 @Published var error: Error? 같은 프로퍼티로 관리해주면 좋아요.
📜 무한 스크롤 (Scroll 중 데이터 로딩)
스크롤 리스트 하단에 도달했을 때 자동으로 다음 페이지를 불러오는 패턴입니다.
LazyVStack과 함께 쓰면 깔끔하게 구현돼요.
View 코드 예시
struct ArticleListView: View {
@StateObject private var viewModel = ArticleViewModel()
var body: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.articles) { article in
ArticleRow(article: article)
.onAppear {
Task {
await viewModel.fetchMoreArticlesIfNeeded(currentItem: article)
}
}
}
if viewModel.isLoading {
ProgressView()
.padding()
}
}
}
.task {
await viewModel.fetchInitialArticles()
}
}
}
핵심 포인트
- 각 아이템이 나타날 때
.onAppear를 통해 스크롤 도달을 감지 - ViewModel의
articles.last == currentItem으로 마지막 아이템인 경우만 다음 페이지 로딩 isLoading으로 중복 요청 방지
⚠️ 중요한 점은 onAppear는 뷰 업데이트에 따라 자주 호출되므로, 가드 조건을 탄탄하게 걸어주는 것이 성능에 중요해요!
🙋 자주 묻는 질문 (FAQ)
Q. .task vs .onAppear 차이가 뭔가요?.task는 SwiftUI의 새로운 비동기 뷰 수명 주기입니다. 여러 번 호출되지 않도록 보장되기 때문에 onAppear보다 안정적이에요. 특히 async 작업에는 .task를 사용하는 걸 권장해요.
Q. ViewModel에서 async 작업할 때 Task {}로 감싸야 하나요?
View에서 호출할 땐 Task가 필요하지만, ViewModel은 @MainActor로 선언되어 있다면 내부 async 함수만 호출하면 됩니다. View에서만 Task로 감싸주는 거죠.
Q. SwiftUI에서 @Published 값을 async로 바꾸면 충돌 나나요?
네, MainActor 없이 바꾸면 충돌이 날 수 있어요. 항상 ViewModel은 @MainActor로 감싸서 안전하게 관리하세요.
Q. Pull to Refresh와 무한스크롤을 같이 써도 되나요?
물론입니다! 같은 ViewModel을 공유하면서 조건 분기만 잘 처리하면 전혀 문제 없어요.
✨ 마무리하며
이번 편에서는 SwiftUI에서 비동기 작업을 잘 다루기 위한 패턴들을 다뤄봤습니다.
- Pull to Refresh는
refreshable로 깔끔하게 - 무한 스크롤은
onAppear + LazyVStack으로 안전하게 - ViewModel은 항상
@MainActor, 상태는@Published로!
이런 패턴을 익혀두면 사용자 경험도 부드럽고, 코드도 한층 깔끔해져요.
다음 편에서는 이걸 더 확장해서 State-driven 비동기 UI 설계법이나 에러 처리/로딩 상태 다루는 패턴들도 파볼 수 있겠네요 👀
혹시 여기에 더 다루고 싶은 SwiftUI 상황이나 컴포넌트가 있다면, 언제든 얘기해주세요!
이전글:
'프로그래밍' 카테고리의 다른 글
| Swift Concurrency, 중복 요청 방지와 Task 취소 처리 전략 (0) | 2025.04.15 |
|---|---|
| Swift Concurrency, 상태 기반 비동기 UI 설계 패턴 (Loading / Success / Error) (0) | 2025.04.14 |
| Swift Concurrency, 실전에 바로 쓰는 패턴 모음.zip (1) | 2025.04.12 |
| SwiftData 3편, 완벽하진 않지만 기대되는 이유 (0) | 2025.04.11 |
| Swift Concurrency, 실무 예제로 배우는 Structured Concurrency (0) | 2025.04.11 |