.task로 작업 수명을 뷰에 묶고, 로딩 상태를 LoadState enum으로 모델링했다. 그 코드에는 DispatchQueue.main.async가 한 줄도 없었다. 2부는 그 이유다. View는 @MainActor이고, await 뒤의 재개 위치도 main actor다. 이 규칙이 컴파일러 진단(경고·에러)으로 어떻게 드러나는지, 무거운 일은 어디로 보내는지, 그리고 refreshable · scenePhase로 사용자·시스템 이벤트에 반응하는 법을 본다.2부 목표 · 23분
View가 왜@MainActor인지 설명하고, "main actor 프로퍼티를 Sendable 클로저에서 읽음" 데이터 레이스 진단(현재 SDK에선 경고, 세션 데모에선 에러)을 캡처 리스트로 고친다.- 무거운 계산을 전용
actor로 보내 main actor를 비워 둔다. await사이에서 상태 가정이 깨지는 재진입 버그를 찾아 동기 구간의 플래그로 막는다.refreshable·scenePhase로 새로고침과 앱 상태 변화에 반응한다.
1. View는 @MainActor — Swift 6에서 달라지는 것
WWDC24 · What's new in SwiftUI: "Views in SwiftUI have always been evaluated on the main actor, and the view protocol is now marked with the main actor annotation to reflect that." 즉 iOS 18 SDK(Xcode 16)부터 View 프로토콜 선언 자체가 @MainActor @preconcurrency protocol View다. 세션 표현대로 뷰는 원래부터 main actor에서 평가됐고, 이제 그 사실이 타입에 적혔을 뿐이다. 뷰에 직접 붙이던 @MainActor는 지워도 동작이 같다.
"A type conforming to this protocol inherits @preconcurrency @MainActor isolation from the protocol if the conformance is declared in its original declaration. Isolation to the main actor is the default, but it's not required. Declare the conformance in an extension to opt-out the isolation." — Apple 문서 · View
풀이: struct X: View처럼 원래 선언에서 채택하면 타입 전체(모든 프로퍼티·메서드)가 main actor가 된다. extension에서 채택하면 이 상속을 끌 수 있다.
이게 UIKit 개발자에게 주는 의미는 단순하다. "UI는 메인 스레드에서"를 사람이 지키던 규칙이 컴파일러 검사로 올라왔다. WWDC21 · Protect mutable state with Swift actors는 main actor를 "an actor that represents the main thread"라고 정의하고, 런타임에는 DispatchQueue.main과 바꿔 쓸 수 있다고 설명한다.
| UIKit 습관 | SwiftUI + Swift Concurrency |
|---|---|
DispatchQueue.main.async { self.label.text = … } | main actor 함수 안에서 await 뒤에 그냥 대입. 재개 위치가 main actor다 |
DispatchQueue.global().async { 무거운 일 } | 별도 actor 또는 main actor에서 분리된 함수로 보내고 await |
| "이 메서드는 메인에서만 호출하세요" 주석 | @MainActor 표시 — 어기면 컴파일 에러 |
ViewModel이 백그라운드 콜백에서 @Published 수정 → 경고 | 모델 클래스에 @MainActor (WWDC21 10019 권장) |
무거운 일을 main actor 밖으로
await는 "기다리는 동안 양보"일 뿐, 그 자체로 계산을 백그라운드로 보내지 않는다. main actor 함수 안에서 동기 CPU 작업을 돌리면 그동안 프레임이 밀린다. 계산을 다른 격리 영역에 두고 await로 부른다. 가장 명확한 방법은 전용 actor다.
actor ThumbnailRenderer {
func render(_ data: Data) -> Data {
// 무거운 CPU 작업 — 이 actor의 실행 컨텍스트에서 돈다 (main actor 아님)
Data(data.reversed())
}
}
@MainActor
@Observable
final class GalleryModel {
private(set) var thumbnail: Data?
private let renderer = ThumbnailRenderer()
func makeThumbnail(from data: Data) async {
let result = await renderer.render(data) // main actor는 기다리는 동안 양보
thumbnail = result // 재개는 main actor — 바로 대입
}
}WWDC25 · Embracing Swift concurrency: "An @concurrent function will always switch off of an actor to run. If you want the function to stay on whatever actor it was called on, you can use the nonisolated keyword." 또 Xcode 26 새 앱 프로젝트는 모듈 전체를 기본 main actor로 두는 설정이 켜져 있다(WWDC25 · 266: "Swift 6.2 introduces a new language mode, which marks all types in a module with the @MainActor annotation implicitly"). 이 모드에서는 모델 클래스도 기본 main actor라서, 백그라운드용 타입은 nonisolated로 빼고 오프로딩할 함수에 @concurrent를 붙인다(WWDC25 · 270 데모).
nonisolated struct PhotoProcessor {
@concurrent
func process(_ data: Data) async -> Data {
Data(data.reversed()) // 호출한 actor와 무관하게 백그라운드에서 실행
}
}이것은 OS 버전이 아니라 컴파일러(언어) 기능이다. Xcode 15/16에서 빌드하는 iOS 17 코드는 위의 actor 방식을 쓴다.
데이터 레이스 진단 — Sendable 클로저에서 main actor 프로퍼티 읽기
SwiftUI는 일부 클로저를 백그라운드 스레드에서 부를 수 있고, 그런 API는 클로저를 @Sendable로 선언한다. WWDC25 266이 드는 예: visualEffect의 클로저, Layout 프로토콜 메서드, Shape의 path(in:), onGeometryChange의 첫 클로저. 여기서 self.pulse 같은 뷰 프로퍼티를 읽으면 main actor 격리 값을 다른 스레드에서 읽는 셈이라 컴파일러가 데이터 레이스 가능성을 진단한다("main actor-isolated property 'pulse' can not be referenced from a Sendable closure").
WWDC25 266 데모에서는 이 진단이 컴파일 에러로 나온다. 하지만 현재 iOS SDK(Swift 6.x 툴체인)로 직접 타입 검사해 보면 Swift 5 모드와 Swift 6 모드 모두 경고다. View가 @preconcurrency로 main actor를 채택해서 컴파일러가 에러 대신 경고로 낮춰 주기 때문이다. 경고라도 데이터 레이스 가능성은 그대로이니 에러처럼 고친다.
struct PulseText: View {
@State private var pulse = false
var body: some View {
Text("Extracting…")
.visualEffect { content, _ in
content.blur(radius: pulse ? 2 : 0) // self.pulse 접근
}
.onTapGesture { pulse.toggle() }
}
}struct PulseTextFixed: View {
@State private var pulse = false
var body: some View {
Text("Extracting…")
.visualEffect { [pulse] content, _ in
content.blur(radius: pulse ? 2 : 0) // 복사본만 읽는다
}
.onTapGesture { pulse.toggle() }
}
}세션의 해법 그대로다: "I can make a copy of the pulse variable in the closure's capture list, and refer to the copy instead." 뷰 전체(self)가 아니라 필요한 값만 넘기면 공유 자체가 사라진다.
컴파일러도 못 잡는 것 — await 사이에서 상태 가정이 깨진다 (재진입)
main actor라고 해서 함수가 끝까지 끊김 없이 도는 건 아니다. await에서 양보하는 동안 같은 함수가 또 호출될 수 있다. WWDC21 10133: "an await in your code means the world can move on and invalidate your assumptions." 데이터 레이스는 아니지만 로직 버그다. .task와 .refreshable이 동시에 같은 로딩 함수를 부르는 화면에서 흔하다.
@MainActor
@Observable
final class FeedModelWrong {
var items: [String] = []
var page = 0
func loadNextPage() async {
let next = page + 1
let newItems = await FeedAPI.fetch(page: next) // 여기서 양보 → 두 번째 호출이 끼어든다
items += newItems
page = next
}
}@MainActor
@Observable
final class FeedModel {
var items: [String] = []
private var page = 0
private var isLoadingPage = false
func loadNextPage() async {
guard !isLoadingPage else { return } // 동기 구간: 끼어들 틈이 없다
isLoadingPage = true
defer { isLoadingPage = false }
let next = page + 1
let newItems = await FeedAPI.fetch(page: next)
items += newItems
page = next
}
func reload() async {
guard !isLoadingPage else { return } // 첫 로딩 중 당기면 무시 → 인디케이터가 바로 사라진다
items = []
page = 0
await loadNextPage()
}
}
enum FeedAPI {
static func fetch(page: Int) async -> [String] {
(1...10).map { "Page \(page) · #\($0)" }
}
}reload()도 같은 플래그를 본다. 그래서 첫 페이지를 불러오는 도중에 당겨서 새로고침하면 그 요청은 무시되고, 클로저가 곧바로 끝나 인디케이터도 즉시 사라진다. 진행 중인 첫 로딩이 목록을 채운다.
2. refreshable과 scenePhase — 사용자·시스템 이벤트에 반응
refreshable — UIRefreshControl을 async 클로저 하나로
refreshable 문서에 따르면 iOS에서 List에 붙이면 표준 당겨서 새로고침이 켜지고, "The indicator remains visible for the duration of the refresh, which runs asynchronously." endRefreshing()을 부를 일이 없다. 클로저가 끝나는 순간이 곧 새로고침 끝이다.
struct FeedScreen: View {
@State private var model = FeedModel()
var body: some View {
List(model.items, id: \.self) { Text($0) }
.task { await model.loadNextPage() }
.refreshable { await model.reload() } // 끝날 때까지 인디케이터 유지
}
}.refreshable { Task { await model.reload() } }로 쓰면 클로저가 즉시 끝나서 인디케이터가 바로 사라진다. 작업은 뒤에서 계속 돈다. 클로저 자체가 async이므로 그 안에서 직접 await한다.
scenePhase — 앱이 앞/뒤로 갈 때
@Environment(\.scenePhase)는 SceneDelegate의 활성화 콜백 자리다. ScenePhase 문서의 핵심은 어디서 읽느냐다. 뷰 안에서 읽으면 그 뷰가 든 씬의 단계를, App에서 읽으면 모든 씬을 종합한 값을 준다(하나라도 active면 active). 그리고 "When an app enters the background phase, expect the app to terminate soon after."
struct TimerScreen: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isRunning = true
var body: some View {
Text(isRunning ? "타이머 동작 중" : "일시정지")
.onChange(of: scenePhase) { _, phase in
isRunning = (phase == .active)
}
}
}@main
struct StudyApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
Text("Home")
}
.onChange(of: scenePhase) { _, phase in
if phase == .background {
// 곧 종료될 수 있다 — 저장·리소스 정리
}
}
}
}① .refreshable 안에서 다시 Task로 감쌈 → 인디케이터가 바로 사라짐 ② await 전후로 같은 상태 가정을 유지한다고 믿음(재진입) ③ visualEffect 같은 Sendable 클로저에서 self의 프로퍼티를 직접 읽음 → 캡처 리스트 ④ await만 붙이면 계산이 백그라운드로 간다고 믿음 → 다른 격리 영역(actor)으로 보낸다.
✅ 복습 · 5분
main actor 모델의 async 함수 안에서 await 다음 줄에 state = …를 대입해도 되는 이유는?
await에서 잠시 양보했다가 다시 main actor에서 이어서 실행된다. 그래서 DispatchQueue.main.async 없이 바로 대입해도 메인 스레드에서 UI 상태를 바꾸는 것이다. 단, 양보하는 동안 다른 호출이 끼어들 수 있으니(재진입) 중요한 검사는 await 전 동기 구간에서 한다.visualEffect 클로저에서 pulse를 읽으면 데이터 레이스 진단이 나오는 이유와 고치는 법은?
@Sendable이라 SwiftUI가 메인이 아닌 스레드에서 부를 수 있다. 뷰의 pulse는 main actor 격리 값이므로 다른 스레드에서 읽으면 데이터 레이스 가능성이 생긴다. { [pulse] content, _ in … }처럼 캡처 리스트로 값만 복사하면 공유가 사라진다(WWDC25 266).🔮 코드 예측: List(items) { … }.refreshable { Task { await model.reload() } }에서 목록을 당기면 인디케이터는 언제 사라지나? (reload()는 2초 걸린다)
refreshable은 클로저가 끝날 때까지 인디케이터를 유지하는데, 클로저는 Task를 만들기만 하고 즉시 끝난다. reload()는 뒤에서 2초 동안 따로 돈다. .refreshable { await model.reload() }로 직접 기다려야 2초 동안 보인다.🔁 1부 복습: .task(id: category)에서 카테고리를 빠르게 두 번 바꾸면 첫 번째 작업은 어떻게 되나?
Task.sleep이나 네트워크 호출 같은 취소 지원 지점에서 CancellationError를 던지며 끝난다. 그래서 catch is CancellationError에서는 실패 화면을 띄우지 않는다.🛠 미니 과제 (선택 · 10분)
Preview 하나로 끝나는 카테고리별 명언 로더다. 네트워크 대신 Task.sleep으로 지연을 흉내 낸다.
enum Category: String, CaseIterable, Identifiable {
case life = "인생", work = "일", study = "공부"
var id: Self { self }
}
enum QuoteAPI {
static func fetch(_ category: Category) async throws -> [String] {
try await Task.sleep(for: .seconds(1))
if Bool.random() && category == .work { throw URLError(.timedOut) }
return (1...5).map { "\(category.rawValue) 명언 #\($0)" }
}
}
struct QuoteScreen: View {
@State private var category: Category = .life
@State private var quotes: [String] = []
var body: some View {
NavigationStack {
List(quotes, id: \.self) { Text($0) }
.toolbar {
Picker("카테고리", selection: $category) {
ForEach(Category.allCases) { Text($0.rawValue) }
}
.pickerStyle(.segmented)
}
}
}
}
#Preview { QuoteScreen() }- 요구 1 —
quotes배열 대신 1부의LoadState같은 enum(로딩 · 목록 · 실패)을 쓰고,switch로 로딩(ProgressView) · 목록 · 실패(ContentUnavailableView+ 다시 시도)를 그린다. - 요구 2 — 카테고리를 바꾸면 이전 요청이 취소되고 새로 불러오게
.task(id: category)를 쓴다. 취소는 실패 화면으로 보이지 않아야 한다. - 요구 3 — 목록을 당기면
.refreshable로 다시 불러온다(인디케이터가 1초간 보여야 한다).
확인 방법: Preview에서 세그먼트를 빠르게 인생 → 일 → 공부로 바꿔도 마지막 "공부" 목록만 나오는지, "일"에서 가끔 실패 화면이 뜨고 "다시 시도"가 동작하는지, 당겼을 때 인디케이터가 로딩이 끝날 때 사라지는지.
정답 예시 펼치기
enum QuoteLoadState {
case loading
case loaded([String])
case failed(String)
}
struct QuoteScreenAnswer: View {
@State private var category: Category = .life
@State private var state: QuoteLoadState = .loading
var body: some View {
NavigationStack {
Group {
switch state {
case .loading:
ProgressView()
case .loaded(let quotes):
List(quotes, id: \.self) { Text($0) }
.refreshable { await load() } // 직접 await
case .failed(let message):
ContentUnavailableView {
Label("불러오지 못했다", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("다시 시도") {
state = .loading
Task { await load() }
}
}
}
}
.task(id: category) { // 바뀌면 이전 작업 취소
state = .loading // 카테고리 전환 = 항상 ProgressView
await load()
}
.toolbar {
Picker("카테고리", selection: $category) {
ForEach(Category.allCases) { Text($0.rawValue) }
}
.pickerStyle(.segmented)
}
}
}
private func load() async {
// 상태를 .loading으로 바꾸지 않는다 — 새로고침 중엔 목록 유지
do {
state = .loaded(try await QuoteAPI.fetch(category))
} catch is CancellationError {
// 카테고리가 바뀌어 취소됨 — 새 작업이 상태를 채운다
} catch {
state = .failed(error.localizedDescription)
}
}
}포인트: .loading 전환은 부르는 쪽이 정한다. 카테고리를 바꿀 때(.task(id:))와 다시 시도할 때는 먼저 .loading으로 바꿔 이전 목록 대신 ProgressView를 보여 준다. 취소는 catch is CancellationError에서 조용히 무시한다. 새로고침 중에는 load()가 상태를 .loading으로 바꾸지 않아야 List가 사라지지 않고 인디케이터가 유지된다. List가 사라지면 그 위에 걸린 refreshable도 함께 사라진다.