← SwiftUI 14일 마스터 12 / 14 · 2부

Day 12 · 2부 · @MainActor·Swift 6와 이벤트 대응

await 뒤 main actor 재개, 무거운 일 오프로딩, Sendable 클로저의 데이터 레이스 진단(현재 SDK에선 경고)과 재진입 버그, refreshable과 scenePhase로 이벤트에 반응하는 법을 다룬다.
진행률
0 / 0 완료
1부에서 .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는 지워도 동작이 같다.

📝 Apple 원문

"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 메인 스레드 다른 actor 백그라운드 실행 state = .loading await = 양보 스크롤·애니메이션 계속 state = .loaded(…) render(data) 무거운 일 await 뒤 재개 위치는 다시 main actor — DispatchQueue.main.async가 필요 없다
WWDC21 10019가 말한 "yielding the main actor": await 동안 main actor는 비어 있어 UI가 멈추지 않고, 작업이 끝나면 같은 함수가 main actor에서 이어서 실행된다.

무거운 일을 main actor 밖으로

await는 "기다리는 동안 양보"일 뿐, 그 자체로 계산을 백그라운드로 보내지 않는다. main actor 함수 안에서 동기 CPU 작업을 돌리면 그동안 프레임이 밀린다. 계산을 다른 격리 영역에 두고 await로 부른다. 가장 명확한 방법은 전용 actor다.

✅ 계산은 전용 actor에서, 결과 대입은 main 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 — 바로 대입
    }
}
🆕 최신 API Swift 6.2 · Xcode 26

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 데모).

Xcode 26 · Swift 6.2 컴파일러에서 — 항상 백그라운드로 전환
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를 채택해서 컴파일러가 에러 대신 경고로 낮춰 주기 때문이다. 경고라도 데이터 레이스 가능성은 그대로이니 에러처럼 고친다.

❌ 데이터 레이스 진단(현재 SDK에선 경고, 세션 데모에선 에러) — main actor 격리 프로퍼티 pulse를 Sendable 클로저에서 읽음
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() }
    }
}
✅ 캡처 리스트로 값만 복사 — Bool은 Sendable 값 타입
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이 동시에 같은 로딩 함수를 부르는 화면에서 흔하다.

❌ await 전의 page를 믿고 계산 — 동시에 두 번 불리면 같은 페이지를 두 번 붙인다
@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
    }
}
✅ 검사와 표시는 await 전 동기 구간에서 — 중복 호출을 막는다
@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()을 부를 일이 없다. 클로저가 끝나는 순간이 곧 새로고침 끝이다.

✅ 처음엔 .task, 당기면 .refreshable — 같은 모델, 재진입은 모델이 막는다
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로 감싸지 않는다

.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."

.active 앞에서 입력 받는 중 .inactive 보이지만 입력 없음 .background 안 보임 · 곧 종료될 수 있음 UIKit: sceneDidBecomeActive UIKit: sceneWillResignActive UIKit: sceneDidEnterBackground 뷰에서 읽으면 그 씬의 단계 · App에서 읽으면 모든 씬의 종합
세 콜백 메서드가 enum 값 하나로 합쳐졌다. 변화에 반응하려면 onChange(of: scenePhase)를 건다.
✅ 뷰 단위 — 이 씬이 active일 때만 타이머 동작
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)
            }
    }
}
✅ 앱 단위 — 모든 씬이 background가 되면 저장
@main
struct StudyApp: App {
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            Text("Home")
        }
        .onChange(of: scenePhase) { _, phase in
            if phase == .background {
                // 곧 종료될 수 있다 — 저장·리소스 정리
            }
        }
    }
}
🚨 흔한 실수 요약 (2부)

① .refreshable 안에서 다시 Task로 감쌈 → 인디케이터가 바로 사라짐 ② await 전후로 같은 상태 가정을 유지한다고 믿음(재진입) ③ visualEffect 같은 Sendable 클로저에서 self의 프로퍼티를 직접 읽음 → 캡처 리스트 ④ await만 붙이면 계산이 백그라운드로 간다고 믿음 → 다른 격리 영역(actor)으로 보낸다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
main actor 모델의 async 함수 안에서 await 다음 줄에 state = …를 대입해도 되는 이유는?
main actor로 격리된 함수는 await에서 잠시 양보했다가 다시 main actor에서 이어서 실행된다. 그래서 DispatchQueue.main.async 없이 바로 대입해도 메인 스레드에서 UI 상태를 바꾸는 것이다. 단, 양보하는 동안 다른 호출이 끼어들 수 있으니(재진입) 중요한 검사는 await 전 동기 구간에서 한다.
쉽게 말하면 주방장이 오븐에 빵을 넣고 다른 요리를 하다가, 빵이 다 되면 같은 주방장이 꺼낸다. 다른 사람에게 부탁할 필요가 없다.
visualEffect 클로저에서 pulse를 읽으면 데이터 레이스 진단이 나오는 이유와 고치는 법은?
현재 SDK에선 경고, WWDC25 266 데모에선 에러로 나오지만 이유는 같다. 이 클로저는 @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)에서 카테고리를 빠르게 두 번 바꾸면 첫 번째 작업은 어떻게 되나?
id가 바뀌는 순간 SwiftUI가 이전 작업을 취소하고 새 작업을 시작한다. 취소는 협력형이라 첫 작업은 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() }

확인 방법: Preview에서 세그먼트를 빠르게 인생 → 일 → 공부로 바꿔도 마지막 "공부" 목록만 나오는지, "일"에서 가끔 실패 화면이 뜨고 "다시 시도"가 동작하는지, 당겼을 때 인디케이터가 로딩이 끝날 때 사라지는지.

정답 예시 펼치기
✅ 정답 예시 (시작 코드의 Category · QuoteAPI를 그대로 사용)
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도 함께 사라진다.

출처 · 참고자료