SwiftUI 데이터 모델에 actor를 사용하면 안되는 이유
Swift Actor 기본 개념
Actor는 독립적인 실행 컨텍스트를 가지고 있으며, 이 컨텍스트 안의 데이터는 자동으로 동기화되어 안전하게 관리됩니다.
여러 작업(Task)이 동시에 Actor에 접근하려고 할때, 시스템은 이를 알아서 순차적으로 처리합니다.
마치 줄을 서서 차례를 기다리듯이, 한 번에 하나의 작업(Task)만 Actor의 데이터에 접근할 수 있게 되어 데이터 경장상태(race condition)을 효과적으로 예방할 수 있습니다.
actor ChatRepository {
private var messages: [Message] = []
func addMessage(_ message: Message) {
messages.append(message)
}
func getAllMessages() -> [Message] {
return messages
}
}
위 코드에서 message 배열은 actor에 의해 보호되므로, 여러 테스크가 동시에 접근해도 안전합니다.
addMessage나 getAllMessage 메서드는 암묵적으로 비동기(async) 함수가 됩니다. 왜냐하면 task가 actor에 접근한 시점에 다른 task가 먼저 acotr의 컨텍스트에서 작업을 실행하고 있으면 해당 task는 자신이 차례가 올때까지 기다리는 동안 task는 자신이 점유하고 있는 쓰레드를 반납할 수 있어야하기 때문입니다.
SwiftUI 데이터 모델링
iOS 개발의 기본 원칙으로, UI 관련 작업은 메인 쓰레드에서만 해야 합니다.
SwiftUI가 제공하는 @ObservableObject나 @Observable 메크로는 데이터 모델의 변경을 감지해서 이를 화면에 반영해주는 역할을 수행합니다. 즉 클래스에 이들을 사용할때 우리는 암묵적으로 "이 데이터 모델의 변경사항은 메인 쓰레드에서 처리할 것"이라고 약속하는 것입니다.
// iOS 13+ (ObservableObject 프로토콜 사용)
class UserPreferences: ObservableObject {
@Published var username: String = "Guest"
@Published var isDarkModeEnabled: Bool = false
}
// iOS 17+ (@Observable 매크로 사용)
@Observable
class UserSettings {
var username: String = "Guest"
var isDarkModeEnabled: Bool = false
}
SwiftUI를 사용하면서 Actor로 모델링을 하면 발생하는 문제점
Actor를 데이터 모델로 사용하면 액터의 격리 특성과 SwiftUI의 메인 액터 요구사항이 충돌합니다.
// 🚫 SwiftUI와 함께 사용하기에 좋지 않은 패턴
actor UserProfileActor {
var name: String = "Guest"
var bio: String = ""
func updateName(_ newName: String) {
name = newName
}
}
struct ProfileView: View {
var userProfile: UserProfileActor
@State private var nameInput: String = ""
var body: some View {
VStack {
// ⚠️ 문제 1: Actor의 프로퍼티에 직접 접근 불가
// Text(userProfile.name) // 컴파일 에러!
TextField("이름", text: $nameInput)
Button("업데이트") {
// ⚠️ 문제 2: 비동기 호출이 필요함
Task {
await userProfile.updateName(nameInput)
// ⚠️ 문제 3: UI 업데이트는 메인 액터에서 해야 함
await MainActor.run {
// UI 업데이트 코드
}
}
}
}
}
}
위 코드에서 나타나는 다음 문제들은 코드를 복잡하게 만들고, SwiftUI의 선언적 패러다임과 충돌합니다.
- Actor의 프로퍼티에 동기적으로 접근할 수 없습니다. 항상 await를 사용해 비동기적으로 접근해야 합니다.
- SwiftUI의 선언적 View 구조는 비동기 접근을 직접 지원하지 않습니다.
- Actor에서 데이터를 읽은 후 UI를 업데이트하려면, 다시 Main Actor로 전환해야 합니다.
- 양방향 바인딩($ 접두사)이 Actor 프로퍼티와 작동하지 않습니다.
그냥 Actor를 안쓰면 되잖아?
Actor를 그냥 사용하지 않으면 해결될 문제인데 왜 굳이 사용하려 해서 위와 같은 문제를 야기할까요?
데이터 모델링을 하다보면 다음과 같은 상황들이 생길 수 있습니다.
// 여러 곳에서 접근하는 공유 데이터
class SharedUserData: ObservableObject {
@Published var user: User?
@Published var preferences: [String: Any] = [:]
@Published var notifications: [Notification] = []
// 여러 비동기 작업에서 동시에 접근할 수 있음
// -> 데이터 경쟁 상태 가능성!
}
위와 같이 사용자 정보를 여러화면에서 공유하며 사용하기 위해 위와 같이 데이터를 모델링한 경우
여러 뷰에서 비동기 작업을 통해 동시에 접근할 수 있으며 이는 데이터 경쟁 상태를 초래합니다.
이때 "class를 actor로 전환함으로써 시스템이 알아서 데이터 상태 동기화를 보장해주지 않을 까?"라고 생각할 수 있습니다.
또한 MVVM 같은 패턴에 익숙한 개발자들은 ViewModel을 "데이터를 관리하는 중앙 객체"로 생각합니다. 따라서 동시성 안전성이 필요할 때 기존 ViewModel을 Actor로 변환하는 것이 자연스러운 단계로 보일 수 있습니다.
// 이런 코드가 작동할 것이라고 오해
actor UserViewModel {
@Published var name: String = "" // ❌ Actor 내부에서 @Published 사용 불가
func updateName(_ newName: String) {
name = newName // name이 변경되면 SwiftUI가 자동으로 업데이트될 것으로 예상
}
}
사실 많은 경우는 actor라는 개념이 아직 익숙하지 않아서 발생하는 문제입니다.
Swift의 동시성 모델은 비교적 최근에 도입되었으며, Actor, Task, async/await과 같은 개념은 많은 개발자에게 여전히 새롭습니다. "@MainActor"와 일반 Actor의 관계, 그리고 SwiftUI의 데이터 흐름 모델과의 상호작용을 완전히 이해하지 못한 상태에서 결정을 내리게 됩니다.
그러면 Actor는 어떻게 쓸 수 있는데?
기존의 SwiftUI가 제공하는 선언적 화면 구성 및 변경감지 싸이클을 활용하면서도
새로 도입된 actor의 데이터 자동 동기화를 이용하기 위해서는 다음 방법을 사용할 수 있습니다.
클래스에 @ObservableObject 또는 @Observable를 사용하여 데이터 모델링을 하고
해당 클래스에 @MainActor를 붙여 모델 데이터의 변경사항 업데이트가 메인쓰레드에서 이루어지도록 보장합니다.
그리고 비동기 작업을 담당하는 별도의 actor를 만들어 활용합니다.
// ✅ 개선된 접근법: 데이터 모델은 @MainActor + 전용 서비스 Actor
// 네트워크 작업을 위한 전용 Actor
actor FeedService {
func fetchPosts() async throws -> [Post] {
// 네트워크 요청 로직
let url = URL(string: "https://api.example.com/posts")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw FeedError.networkError
}
return try JSONDecoder().decode([Post].self, from: data)
}
func toggleLike(postId: String) async throws -> Post {
// 좋아요 토글 네트워크 요청
let url = URL(string: "https://api.example.com/posts/\(postId)/like")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw FeedError.networkError
}
return try JSONDecoder().decode(Post.self, from: data)
}
enum FeedError: Error {
case networkError
case decodingError
}
}
// UI 데이터 모델은 MainActor에서 실행
@MainActor
@Observable
class FeedViewModel {
// UI 상태 및 데이터
var posts: [Post] = []
var isLoading: Bool = false
var errorMessage: String?
// 전용 서비스 Actor
private let feedService = FeedService()
// 게시물 불러오기
func fetchPosts() async {
isLoading = true
errorMessage = nil
do {
// 네트워크 작업은 Actor에 위임
let newPosts = try await feedService.fetchPosts()
// UI 업데이트는 자동으로 MainActor에서 처리
posts.append(contentsOf: newPosts)
isLoading = false
} catch {
errorMessage = "게시물을 불러오지 못했습니다: \(error.localizedDescription)"
isLoading = false
}
}
// 게시물 좋아요 토글
func toggleLike(for postId: String) async {
guard let index = posts.firstIndex(where: { $0.id == postId }) else { return }
do {
// 네트워크 작업은 Actor에 위임
let updatedPost = try await feedService.toggleLike(postId: postId)
// UI 업데이트는 자동으로 MainActor에서 처리
posts[index] = updatedPost
} catch {
errorMessage = "좋아요를 처리하지 못했습니다: \(error.localizedDescription)"
}
}
// 게시물 필터링 (CPU 집약적인 작업이 필요한 경우)
func filterPosts(matching query: String) async {
if query.isEmpty {
// 빈 쿼리는 즉시 처리
await fetchPosts()
return
}
isLoading = true
// CPU 집약적인 작업은 Task로 백그라운드에서 처리
await Task.detached(priority: .userInitiated) {
// 복잡한 필터링 로직
let filtered = await self.performComplexFiltering(query: query)
// UI 업데이트는 MainActor로 전환
await MainActor.run {
self.posts = filtered
self.isLoading = false
}
}.value
}
private func performComplexFiltering(query: String) async -> [Post] {
// 복잡한 필터링 로직 (CPU 집약적)
return posts.filter { post in
// 복잡한 필터링 조건...
return post.title.localizedCaseInsensitiveContains(query) ||
post.content.localizedCaseInsensitiveContains(query)
}
}
}
// SwiftUI에서 사용
struct FeedView: View {
@State private var viewModel = FeedViewModel()
@State private var searchQuery = ""
var body: some View {
NavigationStack {
List {
ForEach(viewModel.posts) { post in
PostRowView(post: post)
.onTapGesture {
// ✅ 간단하게 Task로 비동기 메소드 호출
Task {
await viewModel.toggleLike(for: post.id)
// ✅ 상태 업데이트 자동으로 처리됨
}
}
}
}
.searchable(text: $searchQuery)
.onChange(of: searchQuery) {
// 검색어 변경 시 필터링
Task {
await viewModel.filterPosts(matching: searchQuery)
}
}
.overlay {
if viewModel.isLoading {
ProgressView("로딩 중...")
}
}
.task {
// ✅ View가 나타날 때 자연스럽게 비동기 작업 시작
await viewModel.fetchPosts()
// ✅ 상태 업데이트 자동으로 처리됨
}
.refreshable {
// ✅ 새로고침도 간단하게
await viewModel.fetchPosts()
}
.alert("오류", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("확인", role: .cancel) {
viewModel.errorMessage = nil
}
} message: {
if let error = viewModel.errorMessage {
Text(error)
}
}
.navigationTitle("피드")
}
}
}
- 명확한 책임 분리: FeedService actor는 네트워크 작업만 담당하고, @MainActor 어노테이션이 있는 FeedViewModel은 UI 상태 관리만 담당합니다.
- SwiftUI와의 자연스러운 통합: ViewModel이 @MainActor에서 실행되므로 SwiftUI 바인딩이 원활하게 작동합니다.
- 간소화된 코드: 데이터와 UI 상태를 수동으로 동기화할 필요가 없습니다.
- 명확한 비동기 흐름: 비동기 작업의 시작과 완료 지점이 명확하게 보입니다.
이러한 접근법은 SwiftUI의 선언적 UI 패러다임과 Actor 모델이 제공하는 동시성 안전성의 장점을 모두 활용할 수 있게 해줍니다.
@MainActor 동작
@MainActor가 붙은 메서드나 프로퍼티는 await 이후에도 반드시 메인 스레드에서 실행됨이 보장됩니다.
@MainActor
class UserViewModel: Observable {
var name = "홍길동"
func loadUserData() async {
print("시작 - 항상 메인 스레드")
// 다른 스레드에서 실행되는 무거운 작업
await heavyTask()
// 여기도 메인 스레드에서 실행됨이 보장됨
print("작업 완료 후 - 여전히 메인 스레드")
name = "새 이름" // UI 업데이트도 안전
}
private func heavyTask() async {
// 이 작업은 백그라운드 스레드에서 실행될 수 있음
await Task.sleep(for: .seconds(2))
}
}
이게 가능한 이유는 Swift의 액터 시스템이 작동하는 방식 때문입니다.
- @MainActor는 단순한 스레드 지정이 아니라 "액터 컨텍스트"를 정의합니다.
- await 이후의 코드를 실행할 때, Swift 런타임은 해당 코드가 어떤 액터 컨텍스트에 속해있는지 확인합니다.
- @MainActor로 표시된 코드는 반드시 메인 액터의 컨텍스트에서 실행되어야 한다고 명시되어 있으므로, Swift 런타임이 자동으로 메인 스레드로 전환합니다.
실행 순서를 코드상에서 따라가 보면 아래와 같습니다.
@MainActor
func someFunction() async {
// 1. 메인 스레드
print("시작")
await someBackgroundWork()
// 2. await 이후: Swift 런타임이 확인
// "이 다음 코드는 @MainActor에 속해 있네?
// 그럼 메인 스레드로 전환해야겠다"
// 3. 다시 메인 스레드
print("완료")
}
이것은 마치 우리가 직접 DispatchQueue.main.async를 호출하는 것과 비슷하지만, 더 안전하고 자동화된 방식입니다:
// 예전 방식
func oldStyleFunction() {
backgroundQueue.async {
// 백그라운드 작업
DispatchQueue.main.async {
// UI 업데이트
}
}
}
// 현대적인 방식
@MainActor
func modernFunction() async {
await backgroundWork()
// 자동으로 메인 스레드에서 실행됨
updateUI()
}
따라서 @MainActor가 붙은 코드에서는 await 이후에도 메인 스레드 실행이 보장되므로, UI 업데이트나 다른 메인 스레드 작업을 안전하게 수행할 수 있습니다.