SwiftUI ViewModel에서 Swift Concurrency로 스레드를 안전하고 효율적으로 사용하는 법

Minsang Kang·2025년 6월 30일

iOS Develop

목록 보기
12/12
post-thumbnail

이번에 SwiftUI를 본격적으로 사용하면서 개발한 PR에 대해 코드리뷰를 진행하면서
await MainActor.run 구문이 있는 곳과 없는 곳이 있더라구요
문득 그러면 @Published 프로퍼티를 set 할 때 항상 써야하나? 라는 생각이 들어서
GPT를 토대로 좀 확실하게 정리된 내용을 블로그로 남기고자 합니다.

GPT 채팅내용 기반 GPT가 정리한 내용입니다.


SwiftUI ViewModel에서 Swift Concurrency로 스레드를 안전하고 효율적으로 사용하는 법

SwiftUI에서 ObservableObject로 상태를 관리할 때
비동기 로직(async/await)과 Combine(Publisher)를 어떻게 MainActor와 함께 안전하게 다루는지
그리고 성능을 위해 Actor context를 어떻게 분리해야 하는지
실전 패턴을 예시 코드와 함께 정리합니다.


📌 목차

  1. ViewModel에 @MainActor를 붙이는 이유
  2. Publisher 수신 시 receive(on:)는 꼭 필요할까?
  3. Task {} vs Task.detached {} 언제 쓰나?
  4. await suspension과 Actor context 복귀
  5. nonisolated는 언제 쓰는가?
  6. 전체 흐름 핵심 요약
  7. 실전 예시 코드로 보는 Best Practice

✅ 1. ViewModel에 @MainActor를 붙이는 이유

  • SwiftUI@Published로 상태 변화를 감지하고 objectWillChange 퍼블리셔를 통해 View를 업데이트합니다.
  • 이 퍼블리셔는 항상 메인 스레드에서 발행되어야 UI 충돌이 나지 않습니다.
  • 따라서 ViewModel은 @MainActor를 붙여서 모든 상태 변경과 관련 메서드가 메인 스레드에서 안전하게 실행되도록 보장합니다.
@MainActor
final class MyViewModel: ObservableObject {
    @Published var text: String = ""

    func updateText(_ value: String) {
        text = value
    }
}

✅ 2. Publisher 수신 시 receive(on:)는 필수인가?

  • 외부 Publisher 값을 sink로 수신해서 ViewModel 상태를 변경할 때,
  • ViewModel에 @MainActor가 붙어 있으면 Swift runtime이 isolation 규칙에 따라 자동으로 MainActor에서 실행해줍니다.
  • 따라서 receive(on: DispatchQueue.main)을 명시하지 않아도 안전!
func bindPublisher(_ publisher: AnyPublisher<String, Never>) {
    publisher
        .sink { [weak self] value in
            self?.text = value  // ✅ MainActor isolation 덕분에 안전
        }
        .store(in: &cancellables)
}

✅ 3. Task {} vs Task.detached {} 언제 쓰나?

상황추천
네트워크 호출, 간단한 I/OTask {}
CPU-heavy 연산, 파일 I/O, DB 쓰기 등 무거운 작업Task.detached {}

✔️ Task {}

  • 상위 Actor context를 계승한다.
  • ViewModel이 @MainActor라면 기본적으로 MainActor에서 실행됨.
  • await suspension point가 있으면 I/O는 백그라운드로 넘어갔다가 다시 MainActor로 돌아옴.
Task {
    let data = await api.fetch()
    self.text = data  // MainActor isolation 보장
}

✔️ Task.detached {}

  • 상위 Actor context를 무시하고 독립적으로 실행됨.
  • 백그라운드에서 무거운 작업을 안전하게 실행할 수 있음.
  • 다만 @Published 상태 변경은 반드시 await MainActor.run으로 돌아와야 안전.
Task.detached {
    let result = heavyCalculation()
    await MainActor.run {
        self.text = result
    }
}

✅ 4. await suspension과 Actor context 복귀

  • Actor isolation은 상태 보호 규칙이고, 물리 스레드는 suspension 시 바뀔 수 있음.
  • await로 suspend되면 비동기 I/O는 백그라운드에서 돌아가고,
  • suspend가 끝나면 Swift runtime이 Actor isolation 규칙에 따라 다시 MainActor로 복귀시킴.
@MainActor
func fetchData() async {
    let value = await fetchRemote() // 백그라운드 I/O
    self.text = value               // MainActor로 복귀
}

✅ 5. nonisolated는 언제 쓰는가?

  • nonisolated는 함수나 프로퍼티가 Actor isolation을 따르지 않도록 선언합니다.
  • 읽기 전용 순수 getter에서만 사용하는 게 안전합니다.
  • 내부 상태에 접근한다면 Data Race가 발생하지 않도록 immutable이거나 별도 동기화 필요.
@MainActor
class Example {
    var counter: Int = 0

    nonisolated func getStaticInfo() -> String {
        return "Swift Concurrency Rules"
    }
}

✅ 6. 핵심 요약

개념설명
@MainActor ViewModel상태 변경은 항상 메인 스레드에서 안전ViewModel 기본값으로 쓰기
Publisher 수신ViewModel isolation 따르므로 receive(on:) 필요 없음
Task {}상위 Actor 계승, 간단한 I/O
Task.detached {}Actor context 분리, 무거운 작업await MainActor.run으로 복귀
suspensionI/O는 background → 복귀는 Actor isolation
nonisolated상태 변경 없는 getter 전용

✅ 7. 실전 예시: 종합 ViewModel

@MainActor
final class LogDailyViewModel: ObservableObject {
    @Published var text: String = ""

    let api: SomeAPIProtocol

    init(api: SomeAPIProtocol) {
        self.api = api
    }

    func sendNetworkRequest() {
        Task {
            let data = await api.fetch() // 간단한 네트워크 I/O
            self.text = data
        }
    }

    func saveFileHeavyTask() {
        Task.detached {
            let result = try await HeavyFileIO().process()
            await MainActor.run {
                self.text = result
            }
        }
    }

    func bindPublisher(_ publisher: AnyPublisher<String, Never>) {
        publisher
            .sink { [weak self] value in
                self?.text = value // MainActor isolation 덕분에 안전
            }
            .store(in: &cancellables)
    }

    nonisolated func staticInfo() -> String {
        "This is static, no state mutation"
    }
}

✅ 마무리

Swift Concurrency에서 Actor isolation을 정확히 이해하면
⚡️ UI 상태는 안전하게 보호하고
⚡️ 무거운 연산은 백그라운드로 보내 성능도 잡을 수 있습니다.

ViewModel을 @MainActor로 선언하고
Task와 Task.detached, await MainActor.run을 상황에 맞게 조합하세요!
이렇게 하면 스레드 안전과 효율을 한 번에 챙길 수 있습니다.

profile
 iOS Developer

0개의 댓글