Develup

[SwiftUI] SwiftUI와 async/await: 강력한 조합으로 비동기 UI 구현하기 본문

Swift/SwiftUI

[SwiftUI] SwiftUI와 async/await: 강력한 조합으로 비동기 UI 구현하기

Develup 2025. 3. 17. 23:05
반응형

Swift 5.5 이후 도입된 async/await는 비동기 프로그래밍 패러다임을 획기적으로 변화시켰습니다. 특히 선언적 UI 프레임워크인 SwiftUI와 함께 사용할 때 그 시너지 효과는 더욱 빛을 발합니다. 복잡한 비동기 로직을 간결하고 읽기 쉬운 코드로 작성할 수 있게 해주는 이 두 기술의 조합은 현대적인 iOS 앱 개발에서 필수적인 요소가 되었습니다. 이 글에서는 SwiftUI 환경에서 async/await를 활용하는 방법과 주요 패턴에 대해 알아보겠습니다.

SwiftUI의 상태 관리와 비동기 작업의 기본

SwiftUI는 상태 변화에 따라 UI를 자동으로 업데이트하는 선언적 프레임워크입니다. 비동기 작업의 결과를 UI에 반영하기 위해서는 SwiftUI의 상태 관리 시스템과 비동기 작업을 적절히 연결해야 합니다.

SwiftUI의 상태 프로퍼티와 비동기 작업

SwiftUI에서는 @State, @StateObject, @ObservedObject 등의 프로퍼티 래퍼를 사용하여 상태를 관리합니다. 비동기 작업의 결과를 이러한 상태에 반영하면 UI가 자동으로 업데이트됩니다.

예를 들어, 네트워크 요청의 결과를 표시하는 간단한 예제를 살펴봅시다:

struct ContentView: View {
    @State private var users: [User] = []
    @State private var isLoading = false
    @State private var errorMessage: String?
    
    var body: some View {
        VStack {
            if isLoading {
                ProgressView()
            } else if let error = errorMessage {
                Text(error)
                    .foregroundColor(.red)
            } else {
                List(users) { user in
                    Text(user.name)
                }
            }
            
            Button("Load Users") {
                Task {
                    await loadUsers()
                }
            }
        }
        .padding()
    }
    
    func loadUsers() async {
        isLoading = true
        errorMessage = nil
        
        do {
            // 네트워크 요청을 시뮬레이션하는 비동기 함수
            try await Task.sleep(for: .seconds(2))
            users = [
                User(id: "1", name: "Alice"),
                User(id: "2", name: "Bob"),
                User(id: "3", name: "Charlie")
            ]
        } catch {
            errorMessage = "Failed to load users: \(error.localizedDescription)"
        }
        
        isLoading = false
    }
}

struct User: Identifiable {
    let id: String
    let name: String
}

위 코드에서 볼 수 있듯이, loadUsers 함수는 비동기적으로 사용자 데이터를 가져옵니다. 이 함수가 실행되는 동안 isLoading 상태를 통해 로딩 인디케이터를 표시하고, 에러가 발생하면 errorMessage 상태를 업데이트하여 에러 메시지를 보여줍니다. 비동기 작업이 완료되면 users 배열을 업데이트하여 결과를 리스트에 표시합니다.

이 패턴은 비동기 작업의 다양한 상태(로딩, 에러, 성공)를 SwiftUI UI에 반영하는 기본적인 방법입니다. Task를 사용하여 비동기 함수를 호출하고, 상태 프로퍼티를 업데이트하여 UI 변경을 트리거합니다.

반응형

Task 수명주기와 SwiftUI View 수명주기 관리

SwiftUI에서 비동기 작업을 관리할 때 중요한 것은 View의 수명주기와 Task의 수명주기를 동기화하는 것입니다. .task 수정자를 사용하면 View가 나타날 때 자동으로 비동기 작업을 시작하고, View가 사라질 때 작업을 취소할 수 있습니다.

.task 수정자 활용하기

앞선 예제를 .task 수정자를 사용하도록 개선해 보겠습니다:

struct ContentView: View {
    @State private var users: [User] = []
    @State private var isLoading = false
    @State private var errorMessage: String?
    @State private var shouldRefresh = false
    
    var body: some View {
        VStack {
            if isLoading {
                ProgressView()
            } else if let error = errorMessage {
                Text(error)
                    .foregroundColor(.red)
            } else {
                List(users) { user in
                    Text(user.name)
                }
            }
            
            Button("Refresh") {
                shouldRefresh.toggle()
            }
        }
        .padding()
        .task(id: shouldRefresh) {
            await loadUsers()
        }
    }
    
    func loadUsers() async {
        isLoading = true
        errorMessage = nil
        
        do {
            try await Task.sleep(for: .seconds(2))
            users = [
                User(id: "1", name: "Alice"),
                User(id: "2", name: "Bob"),
                User(id: "3", name: "Charlie")
            ]
        } catch {
            errorMessage = "Failed to load users: \(error.localizedDescription)"
        }
        
        isLoading = false
    }
}

위 코드에서는 .task 수정자를 사용하여 View가 나타날 때 자동으로 loadUsers를 호출합니다. id 파라미터를 사용하여 shouldRefresh 상태가 변경될 때마다 작업을 다시 실행하도록 설정했습니다. 이렇게 하면 "Refresh" 버튼을 클릭할 때마다 새로운 Task가 시작되고 이전 Task는 자동으로 취소됩니다.

.task 수정자는 SwiftUI의 비동기 작업 관리에 매우 유용한 도구입니다. 다음과 같은 특징이 있습니다:

  1. View가 나타날 때 자동으로 작업을 시작합니다.
  2. View가 사라질 때 작업을 자동으로 취소합니다.
  3. id 파라미터를 통해 특정 상태가 변경될 때 작업을 다시 시작할 수 있습니다.
  4. 이전 작업이 진행 중이라면 자동으로 취소하고 새 작업을 시작합니다.

이 패턴은 사용자 인터페이스의 데이터 로딩, 특히 화면이 나타날 때 자동으로 데이터를 로드해야 하는 경우에 매우 유용합니다.

실전 패턴: async/await와 ObservableObject

더 복잡한 앱에서는 종종 MVVM 패턴을 사용하여 뷰 로직과 비즈니스 로직을 분리합니다. SwiftUI에서는 ObservableObject 프로토콜을 구현한 클래스를 사용하여 이를 구현할 수 있습니다.

다음은 ObservableObject를 사용하여 비동기 작업을 관리하는 예제입니다:

class UserViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    func loadUsers() async {
        isLoading = true
        errorMessage = nil
        
        do {
            // 실제 네트워크 요청을 시뮬레이션
            try await Task.sleep(for: .seconds(2))
            
            // 메인 스레드에서 @Published 프로퍼티를 업데이트해야 함
            await MainActor.run {
                self.users = [
                    User(id: "1", name: "Alice"),
                    User(id: "2", name: "Bob"),
                    User(id: "3", name: "Charlie")
                ]
                self.isLoading = false
            }
        } catch {
            await MainActor.run {
                self.errorMessage = "Failed to load users: \(error.localizedDescription)"
                self.isLoading = false
            }
        }
    }
    
    func searchUsers(query: String) async throws -> [User] {
        // 검색 로직 시뮬레이션
        try await Task.sleep(for: .seconds(1))
        return users.filter { $0.name.lowercased().contains(query.lowercased()) }
    }
}

struct UserListView: View {
    @StateObject private var viewModel = UserViewModel()
    @State private var searchQuery = ""
    @State private var searchResults: [User] = []
    @State private var isSearching = false
    
    var body: some View {
        VStack {
            // 검색 필드
            TextField("Search", text: $searchQuery)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
                .onChange(of: searchQuery) { newValue in
                    Task {
                        await searchUsers(query: newValue)
                    }
                }
            
            if viewModel.isLoading {
                ProgressView()
            } else if let error = viewModel.errorMessage {
                Text(error)
                    .foregroundColor(.red)
            } else if isSearching {
                List(searchResults) { user in
                    Text(user.name)
                }
            } else {
                List(viewModel.users) { user in
                    Text(user.name)
                }
            }
        }
        .task {
            await viewModel.loadUsers()
        }
    }
    
    func searchUsers(query: String) async {
        // 짧은 쿼리는 건너뜀
        guard query.count >= 2 else {
            isSearching = false
            return
        }
        
        isSearching = true
        
        do {
            searchResults = try await viewModel.searchUsers(query: query)
        } catch {
            searchResults = []
        }
    }
}

위 코드에서는 UserViewModel 클래스가 ObservableObject 프로토콜을 구현하고 @Published 프로퍼티를 사용하여 상태를 관리합니다. 뷰모델에서 비동기 작업을 수행하고, 결과를 @Published 프로퍼티에 반영하면 SwiftUI가 자동으로 UI를 업데이트합니다.

주목할 점은 비동기 컨텍스트에서 @Published 프로퍼티를 업데이트할 때 MainActor.run을 사용하는 것입니다. 이는 UI 업데이트가 메인 스레드에서 발생해야 하기 때문입니다. @MainActor 속성을 클래스 전체에 적용하는 방법도 있지만, 이 예제에서는 필요한 부분에만 MainActor.run을 사용했습니다.

또한 이 예제에서는 텍스트 필드의 값이 변경될 때마다 .onChange 수정자를 사용하여 검색 기능을 구현했습니다. 사용자가 입력할 때마다 새로운 Task가 시작되어 검색 결과를 업데이트합니다.

고급 패턴: 비동기 스트림과 SwiftUI

Swift의 비동기 시퀀스(AsyncSequence)를 사용하면 데이터 스트림을 비동기적으로 처리할 수 있습니다. 이는 실시간 업데이트나 스트리밍 데이터를 처리할 때 유용합니다.

다음은 AsyncSequence를 사용하여 실시간 업데이트를 구현하는 예제입니다:

class RealtimeUpdatesViewModel: ObservableObject {
    @Published var messages: [Message] = []
    private var messageCancellable: Task<Void, Never>?
    
    func startListening() {
        messageCancellable?.cancel()
        
        messageCancellable = Task {
            do {
                let stream = MessageService.shared.messageStream()
                for try await message in stream {
                    await MainActor.run {
                        self.messages.append(message)
                    }
                }
            } catch {
                print("Stream error: \(error)")
            }
        }
    }
    
    func stopListening() {
        messageCancellable?.cancel()
        messageCancellable = nil
    }
    
    deinit {
        stopListening()
    }
}

// 메시지 서비스 시뮬레이션
class MessageService {
    static let shared = MessageService()
    
    func messageStream() -> AsyncStream<Message> {
        AsyncStream { continuation in
            let task = Task {
                // 메시지 스트림 시뮬레이션
                for i in 1...100 {
                    try await Task.sleep(for: .seconds(2))
                    let message = Message(id: UUID().uuidString, text: "Message \(i)")
                    continuation.yield(message)
                }
                continuation.finish()
            }
            
            continuation.onTermination = { @Sendable _ in
                task.cancel()
            }
        }
    }
}

struct Message: Identifiable {
    let id: String
    let text: String
}

struct ChatView: View {
    @StateObject private var viewModel = RealtimeUpdatesViewModel()
    
    var body: some View {
        List(viewModel.messages) { message in
            Text(message.text)
        }
        .onAppear {
            viewModel.startListening()
        }
        .onDisappear {
            viewModel.stopListening()
        }
    }
}

위 코드에서는 AsyncStream을 사용하여 메시지 스트림을 시뮬레이션하고, 비동기 for 루프(for try await)를 사용하여 스트림에서 메시지를 수신합니다. 새 메시지가 도착할 때마다 @Published 속성을 업데이트하여 UI를 갱신합니다.

이 패턴은 웹소켓 연결, Server-Sent Events, 또는 다른 실시간 데이터 소스를 사용하는 앱에 유용합니다. Task를 저장하고 필요할 때 취소함으로써 리소스 누수를 방지할 수 있습니다.

결론

SwiftUI와 async/await의 조합은 iOS 앱에서 비동기 작업을 처리하는 강력한 방법을 제공합니다. 이 글에서는 기본적인 상태 관리부터 복잡한 비동기 스트림까지 다양한 패턴을 살펴보았습니다.

핵심 포인트를 정리하면 다음과 같습니다:

  1. .task 수정자를 사용하여 View의 수명주기와 비동기 작업을 연결할 수 있습니다.
  2. ObservableObject와 @Published를 사용하여 MVVM 패턴에서 비동기 작업을 관리할 수 있습니다.
  3. 비동기 컨텍스트에서 UI를 업데이트할 때는 MainActor를 사용해야 합니다.
  4. AsyncSequence를 사용하여 실시간 데이터 스트림을 처리할 수 있습니다.
  5. 비동기 작업을 적절히 취소하여 리소스 누수를 방지하는 것이 중요합니다.

SwiftUI와 async/await를 함께 사용하면 복잡한 비동기 로직을 간결하고 읽기 쉬운 코드로 작성할 수 있으며, 이는 현대적인 iOS 앱 개발에서 큰 장점입니다.

반응형