# Swift에서 Combine vs async/await: 점진적 마이그레이션 패턴 > Swift에서 Combine에서 async/await로 마이그레이션하는 완전한 가이드: 점진적 전략, 브리징 패턴, iOS 코드베이스의 패러다임 공존. - Published: 2026-03-13 - Updated: 2026-05-01 - Author: SharpSkill - Tags: swift, ios, combine, async-await, migration - Reading time: 14 min --- async/await를 갖춘 Swift Concurrency의 등장은 iOS의 비동기 프로그래밍 관행을 변화시켰습니다. Combine을 사용하는 프로젝트에서는 마이그레이션 문제가 자연스럽게 제기됩니다. 모든 것을 다시 작성해야 합니까? 두 접근 방식이 공존할 수 있습니까? 어떤 패턴이 매끄러운 전환을 가능하게 합니까? 본 가이드는 점진적인 마이그레이션 전략을 탐구하여, Combine을 갑자기 포기하지 않고 async/await를 채택할 수 있도록 합니다. > **이 가이드가 다루는 내용** > > 본 가이드는 Combine에서 async/await로의 점진적 마이그레이션을 위한 구체적인 패턴을 제시하며, 양방향 브리징 예제와 기존 코드베이스에 적합한 공존 전략을 함께 제공합니다. ## 근본적인 차이 이해하기 마이그레이션을 시작하기 전에 Combine과 async/await를 구별하는 것이 무엇인지 이해하는 것이 필수적입니다. 이 두 접근 방식은 서로 다른 요구에 부응하며, 일부 사용 사례는 Combine에 의해 더 잘 처리됩니다. ### Combine의 정신적 모델 Combine은 데이터 스트림 모델에 기반합니다. Publisher가 시간이 지남에 따라 값을 방출하고, 연산자가 그 값을 변환하며, Subscriber가 최종 결과를 받습니다. 이 모델은 UI 이벤트, 알림 또는 WebSocket과 같은 연속적인 스트림에 뛰어납니다. ```swift // CombineExample.swift // Event stream with Combine - stream-based model import Combine class SearchViewModel { @Published var searchText = "" private var cancellables = Set() // Combine excels for continuous streams with transformations func setupSearch() { $searchText // Wait 300ms pause in typing .debounce(for: .milliseconds(300), scheduler: RunLoop.main) // Ignore consecutive duplicates .removeDuplicates() // Filter searches that are too short .filter { $0.count >= 3 } // Transform text into network request .flatMap { query in self.searchAPI(query: query) // Local error handling .catch { _ in Just([]) } } // Final subscription .sink { results in self.updateUI(with: results) } .store(in: &cancellables) } private func searchAPI(query: String) -> AnyPublisher<[SearchResult], Error> { // Network implementation } } ``` 이 코드는 Combine의 강점을 보여줍니다: 연속적인 이벤트 스트림을 처리하기 위해 선언적 연산자를 연결하는 것입니다. ### async/await의 정신적 모델 Async/await는 순차적인 모델을 채택합니다: 작업이 시작되고, 코드가 그 결과를 기다린 후 계속됩니다. 이 모델은 격리된 네트워크 요청이나 파일 읽기와 같은 일회성 작업에 더 직관적입니다. ```swift // AsyncAwaitExample.swift // One-off operations with async/await - sequential model import Foundation actor SearchService { // async/await excels for sequential operations func performSearch(query: String) async throws -> [SearchResult] { // Pre-validation - clear sequential reading guard query.count >= 3 else { return [] } // Network request with await let url = URL(string: "https://api.example.com/search?q=\(query)")! let (data, response) = try await URLSession.shared.data(from: url) // Response verification guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw SearchError.invalidResponse } // Result decoding let results = try JSONDecoder().decode([SearchResult].self, from: data) return results } } ``` 읽기는 선형적이며, 오류는 `try`로 자연스럽게 전파되고, 실행 흐름은 즉시 이해할 수 있게 됩니다. > **각 접근 방식을 언제 선택할지** > > Combine은 연속적인 스트림(UI 이벤트, 타이머, WebSocket)에 대해 여전히 적합합니다. Async/await는 일회성 작업(API 요청, 파일 읽기, 격리된 계산)에 더 적합합니다. ## Combine에서 async/await로의 브리징 마이그레이션의 첫 번째 단계는 종종 기존 Publisher를 async/await 코드에서 소비하는 것으로 구성됩니다. Swift는 이 브리징을 위한 네이티브 도구를 제공합니다. ### Publisher.values와 함께 AsyncSequence 사용하기 Swift 5.5부터 모든 Publisher는 `AsyncPublisher`를 반환하는 `.values` 프로퍼티를 노출합니다. 이 비동기 시퀀스는 `for await` 루프로 방출된 값을 반복할 수 있게 해줍니다. ```swift // BridgingCombineToAsync.swift // Publisher → AsyncSequence conversion via .values import Combine class NotificationObserver { private let notificationPublisher: AnyPublisher init() { // Existing Combine Publisher notificationPublisher = NotificationCenter.default .publisher(for: UIApplication.didBecomeActiveNotification) .eraseToAnyPublisher() } // Consuming the Publisher with async/await func observeNotifications() async { // .values converts the Publisher to AsyncSequence for await notification in notificationPublisher.values { // Process each notification await handleAppBecameActive(notification) } // This line is never reached for an infinite Publisher } private func handleAppBecameActive(_ notification: Notification) async { // Async processing logic } } ``` 이 접근 방식은 원본 Publisher를 보존하면서 비동기 컨텍스트에서 그것을 소비할 수 있게 해줍니다. ### firstValue로 단일 값 얻기 단일 값을 방출하는 Publisher(네트워크 요청과 같은)에 대해서는 `.values.first(where:)` 프로퍼티나 사용자 정의 익스텐션이 브리징을 단순화합니다. ```swift // SingleValueBridging.swift // Extension to extract a single value from a Publisher import Combine extension Publisher where Failure == Never { // Awaits and returns the first emitted value var firstValue: Output { get async { await withCheckedContinuation { continuation in var cancellable: AnyCancellable? cancellable = self.first() .sink { value in continuation.resume(returning: value) cancellable?.cancel() } } } } } extension Publisher { // Throwing version for Publishers with errors var firstValueThrowing: Output { get async throws { try await withCheckedThrowingContinuation { continuation in var cancellable: AnyCancellable? cancellable = self.first() .sink( receiveCompletion: { completion in if case .failure(let error) = completion { continuation.resume(throwing: error) } cancellable?.cancel() }, receiveValue: { value in continuation.resume(returning: value) } ) } } } } // Usage in async code class UserRepository { private let apiClient: APIClient func fetchCurrentUser() async throws -> User { // Consume an existing Publisher asynchronously try await apiClient.userPublisher().firstValueThrowing } } ``` 이 익스텐션은 브리징의 복잡성을 캡슐화하고 깨끗한 API를 제공합니다. ## async/await에서 Combine으로의 브리징 반대 방향의 마이그레이션도 마찬가지로 필요합니다: 기존 Combine 파이프라인에서 async 코드를 소비하는 것입니다. ### async 함수에서 Publisher 만들기 가장 직접적인 접근 방식은 async 호출을 캡슐화하기 위해 `Future`와 `Task`를 결합하여 사용합니다. ```swift // BridgingAsyncToCombine.swift // async → Publisher conversion via Future import Combine extension Publisher { // async flatMap operator for Combine pipelines func asyncMap( _ transform: @escaping (Output) async throws -> T ) -> AnyPublisher { flatMap { value in Future { promise in Task { do { // Execute the async transformation let result = try await transform(value) promise(.success(result)) } catch { promise(.failure(error)) } } } } .eraseToAnyPublisher() } } // Usage in a Combine pipeline class ImageProcessor { @Published var selectedImageURL: URL? private var cancellables = Set() func setupProcessingPipeline() { $selectedImageURL .compactMap { $0 } // Use an async function in the Combine pipeline .asyncMap { url in // downloadImage is an async function try await self.downloadImage(from: url) } .asyncMap { imageData in // processImage is also async try await self.processImage(imageData) } .receive(on: DispatchQueue.main) .sink( receiveCompletion: { completion in if case .failure(let error) = completion { print("Error: \(error)") } }, receiveValue: { processedImage in self.displayImage(processedImage) } ) .store(in: &cancellables) } private func downloadImage(from url: URL) async throws -> Data { let (data, _) = try await URLSession.shared.data(from: url) return data } private func processImage(_ data: Data) async throws -> UIImage { // Async image processing } } ``` ### 비동기 스트림을 위한 사용자 정의 Publisher 더 고급 요구 사항에는 사용자 정의 Publisher가 완전한 AsyncSequence 스트림을 캡슐화할 수 있습니다. ```swift // AsyncSequencePublisher.swift // Publisher wrapper for AsyncSequence import Combine struct AsyncSequencePublisher: Publisher { typealias Output = S.Element typealias Failure = Error private let sequence: S init(_ sequence: S) { self.sequence = sequence } func receive(subscriber: Sub) where Sub: Subscriber, Failure == Sub.Failure, Output == Sub.Input { let subscription = AsyncSubscription( sequence: sequence, subscriber: subscriber ) subscriber.receive(subscription: subscription) } } private final class AsyncSubscription: Subscription where Sub.Input == S.Element, Sub.Failure == Error { private var task: Task? private var subscriber: Sub? private let sequence: S init(sequence: S, subscriber: Sub) { self.sequence = sequence self.subscriber = subscriber } func request(_ demand: Subscribers.Demand) { // Start asynchronous iteration task = Task { do { for try await element in sequence { // Check subscription is still active guard subscriber != nil else { break } _ = subscriber?.receive(element) } subscriber?.receive(completion: .finished) } catch { subscriber?.receive(completion: .failure(error)) } } } func cancel() { task?.cancel() subscriber = nil } } // Convenience extension for any AsyncSequence extension AsyncSequence { var publisher: AsyncSequencePublisher { AsyncSequencePublisher(self) } } ``` ## 코드베이스에서의 공존 전략 대규모 코드베이스의 완전한 마이그레이션에는 시간이 걸립니다. 다음은 Combine과 async/await가 조화롭게 공존할 수 있도록 하는 패턴입니다. ### 추상화를 갖춘 계층화된 아키텍처 구현을 추상화하는 프로토콜을 정의하면 호출 코드를 수정하지 않고도 점진적인 마이그레이션이 가능합니다. ```swift // RepositoryAbstraction.swift // Abstraction enabling two implementations import Combine // Protocol defining the contract protocol UserRepositoryProtocol { // Modern async interface func fetchUser(id: String) async throws -> User // Legacy Combine interface (optional with default implementation) func fetchUserPublisher(id: String) -> AnyPublisher } // Default Publisher implementation based on async extension UserRepositoryProtocol { func fetchUserPublisher(id: String) -> AnyPublisher { Future { promise in Task { do { let user = try await self.fetchUser(id: id) promise(.success(user)) } catch { promise(.failure(error)) } } } .eraseToAnyPublisher() } } // Modern implementation - async first class UserRepository: UserRepositoryProtocol { private let apiClient: APIClient init(apiClient: APIClient) { self.apiClient = apiClient } func fetchUser(id: String) async throws -> User { // Native async implementation let url = URL(string: "https://api.example.com/users/\(id)")! let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data) } // fetchUserPublisher is provided by the default extension } ``` 이 접근 방식은 새로운 호출자가 async/await를 사용할 수 있도록 하면서 레거시 코드는 계속 Publisher를 사용할 수 있게 해줍니다. > **메모리 관리에 주의** > > 브리징 시 생성된 `Task`가 그것을 만든 객체보다 오래 살아남을 수 있습니다. 메모리 누수를 피하기 위해 항상 `[weak self]`를 사용하거나 작업을 명시적으로 취소하는 것이 바람직합니다. ### 하이브리드 ViewModel ViewModel은 전환 기간 동안 두 인터페이스를 모두 노출할 수 있습니다. ```swift // HybridViewModel.swift // ViewModel supporting both Combine and async/await import Combine import SwiftUI @MainActor class ProfileViewModel: ObservableObject { // Published state for SwiftUI (Combine) @Published private(set) var user: User? @Published private(set) var isLoading = false @Published private(set) var errorMessage: String? private let repository: UserRepositoryProtocol private var cancellables = Set() private var loadTask: Task? init(repository: UserRepositoryProtocol) { self.repository = repository } // Async interface for modern UIKit or SwiftUI with .task func loadUser(id: String) async { isLoading = true errorMessage = nil do { user = try await repository.fetchUser(id: id) } catch { errorMessage = error.localizedDescription } isLoading = false } // Combine interface for legacy code func loadUserPublisher(id: String) { isLoading = true errorMessage = nil repository.fetchUserPublisher(id: id) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in self?.isLoading = false if case .failure(let error) = completion { self?.errorMessage = error.localizedDescription } }, receiveValue: { [weak self] user in self?.user = user } ) .store(in: &cancellables) } // Clean cancellation func cancelLoading() { loadTask?.cancel() cancellables.removeAll() isLoading = false } } ``` ## 일반적인 Combine 연산자 마이그레이션 일부 Combine 연산자는 async/await에 직접적인 대응물이 없습니다. 다음은 이를 재현하는 방법입니다. ### async를 사용한 Debounce 등가물 ```swift // DebounceAsync.swift // Debounce implementation with async/await import Foundation actor Debouncer { private var task: Task? private let duration: Duration init(duration: Duration) { self.duration = duration } // Cancels previous execution and schedules a new one func debounce(_ operation: @escaping @Sendable () async -> Void) { task?.cancel() task = Task { do { // Wait for the specified duration try await Task.sleep(for: duration) // Execute operation if not cancelled await operation() } catch { // Task cancelled - expected behavior } } } } // Usage in a ViewModel @MainActor class SearchViewModel: ObservableObject { @Published var searchText = "" @Published private(set) var results: [SearchResult] = [] private let debouncer = Debouncer(duration: .milliseconds(300)) private let searchService: SearchService init(searchService: SearchService) { self.searchService = searchService } func onSearchTextChanged(_ text: String) { Task { await debouncer.debounce { [weak self] in guard let self else { return } await self.performSearch(text) } } } private func performSearch(_ query: String) async { guard query.count >= 3 else { results = [] return } do { results = try await searchService.search(query: query) } catch { // Error handling } } } ``` ### TaskGroup을 사용한 Merge 등가물 ```swift // MergeAsync.swift // Combining multiple async streams with TaskGroup import Foundation struct AsyncMerge { // Executes multiple async operations in parallel and returns all results static func merge( _ operations: [@Sendable () async throws -> T] ) async throws -> [T] { try await withThrowingTaskGroup(of: T.self) { group in // Launch all operations in parallel for operation in operations { group.addTask { try await operation() } } // Collect results var results: [T] = [] for try await result in group { results.append(result) } return results } } // Streaming version that emits results as they arrive static func mergeStream( _ operations: [@Sendable () async throws -> T] ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { await withThrowingTaskGroup(of: T.self) { group in for operation in operations { group.addTask { try await operation() } } do { for try await result in group { continuation.yield(result) } continuation.finish() } catch { continuation.finish(throwing: error) } } } } } } // Usage class DataAggregator { func fetchAllData() async throws -> AggregatedData { // Execute three requests in parallel let results = try await AsyncMerge.merge([ { try await self.fetchUsers() }, { try await self.fetchPosts() }, { try await self.fetchComments() } ]) return AggregatedData( users: results[0] as! [User], posts: results[1] as! [Post], comments: results[2] as! [Comment] ) } } ``` ## Combine이 여전히 선호되는 사용 사례 async/await의 장점에도 불구하고, 일부 시나리오는 여전히 Combine에 의해 더 잘 처리됩니다. ### 반응형 UI 이벤트 스트림 SwiftUI와 UIKit은 Combine 연산자(debounce, throttle, combineLatest)가 빛을 발하는 연속적인 이벤트 스트림을 생성합니다. ```swift // UIEventsCombine.swift // Combine remains optimal for reactive UI events import Combine import SwiftUI class FormViewModel: ObservableObject { @Published var email = "" @Published var password = "" @Published var confirmPassword = "" // Derived states computed via Combine @Published private(set) var isEmailValid = false @Published private(set) var isPasswordStrong = false @Published private(set) var passwordsMatch = false @Published private(set) var canSubmit = false private var cancellables = Set() init() { setupValidation() } private func setupValidation() { // Email validation with debounce $email .debounce(for: .milliseconds(300), scheduler: RunLoop.main) .map { email in let regex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/ return email.wholeMatch(of: regex) != nil } .assign(to: &$isEmailValid) // Password strength validation $password .map { password in password.count >= 8 && password.rangeOfCharacter(from: .uppercaseLetters) != nil && password.rangeOfCharacter(from: .decimalDigits) != nil } .assign(to: &$isPasswordStrong) // Password matching Publishers.CombineLatest($password, $confirmPassword) .map { password, confirm in !password.isEmpty && password == confirm } .assign(to: &$passwordsMatch) // Final combination to enable submit button Publishers.CombineLatest3($isEmailValid, $isPasswordStrong, $passwordsMatch) .map { $0 && $1 && $2 } .assign(to: &$canSubmit) } } ``` 이 선언적 패턴은 async/await로는 훨씬 더 장황해질 것입니다. ### WebSocket 연결 관리 WebSocket은 메시지를 지속적으로 방출하므로 Combine에 자연스러운 사용 사례입니다. ```swift // WebSocketCombine.swift // WebSocket with Combine for continuous stream import Combine import Foundation class WebSocketManager: ObservableObject { @Published private(set) var messages: [ChatMessage] = [] @Published private(set) var connectionState: ConnectionState = .disconnected private var webSocketTask: URLSessionWebSocketTask? private let messageSubject = PassthroughSubject() private var cancellables = Set() // Exposed Publisher for consumers var messagePublisher: AnyPublisher { messageSubject.eraseToAnyPublisher() } func connect(to url: URL) { webSocketTask = URLSession.shared.webSocketTask(with: url) webSocketTask?.resume() connectionState = .connected // Start reception loop receiveMessages() // Message processing pipeline messageSubject // Buffer messages to avoid too frequent UI updates .collect(.byTime(RunLoop.main, .milliseconds(100))) // Accumulate in history .scan([ChatMessage]()) { accumulated, new in accumulated + new } .assign(to: &$messages) } private func receiveMessages() { webSocketTask?.receive { [weak self] result in switch result { case .success(let message): if case .string(let text) = message, let data = text.data(using: .utf8), let chatMessage = try? JSONDecoder().decode(ChatMessage.self, from: data) { self?.messageSubject.send(chatMessage) } // Continue reception self?.receiveMessages() case .failure(let error): self?.connectionState = .error(error.localizedDescription) } } } } ``` ## 점진적 마이그레이션 체크리스트 성공적인 마이그레이션은 체계적인 접근 방식을 따릅니다. 다음은 권장되는 단계입니다. ### 1단계: 준비 - ✅ 코드베이스에서 사용되는 Publisher를 식별합니다 - ✅ 분류합니다: 연속 스트림 vs 일회성 작업 - ✅ 브리징 익스텐션을 만듭니다(firstValue, asyncMap) - ✅ 리포지토리를 위한 추상 프로토콜을 정의합니다 ### 2단계: 일회성 작업의 마이그레이션 - ✅ 단순한 네트워크 요청을 async/await로 변환합니다 - ✅ 파일 읽기를 마이그레이션합니다 - ✅ 데이터베이스 작업을 변환합니다 - ✅ 기본 구현을 통해 Publisher를 보존합니다 ### 3단계: ViewModel 적응 - ✅ 기존 ViewModel에 async 메서드를 추가합니다 - ✅ 새 화면에는 SwiftUI에서 `.task`를 사용합니다 - ✅ 호환성을 위해 @Published 바인딩을 유지합니다 ### 4단계: 정리 - ✅ 쓸모없게 된 Combine 메서드를 제거합니다 - ✅ 사용되지 않는 브리징 익스텐션을 삭제합니다 - ✅ 의도적으로 보존된 Combine 패턴을 문서화합니다 ## 결론 Combine에서 async/await로의 마이그레이션은 현대 Swift 프로젝트의 자연스러운 진화를 나타냅니다. 양방향 브리징 패턴을 사용하는 점진적인 접근 방식은 갑작스러운 단절 없이 async/await의 장점을 채택할 수 있게 해줍니다. **기억해야 할 핵심 사항:** - ✅ Combine과 async/await는 서로 다른 요구에 부응합니다 - ✅ `.values`는 Publisher를 AsyncSequence로 변환합니다 - ✅ `Future` + `Task`는 async 코드를 Publisher에 캡슐화합니다 - ✅ 추상 프로토콜은 공존을 용이하게 합니다 - ✅ Combine은 반응형 UI 스트림에 대해 여전히 적합합니다 - ✅ debounce와 같은 연산자는 async에서 재구성될 수 있습니다 - ✅ 점진적 마이그레이션은 회귀 위험을 줄입니다 목표는 Combine을 제거하는 것이 아니라 각 컨텍스트에 적합한 도구를 선택하는 것입니다: 일회성 작업에는 async/await, 연속적인 이벤트 스트림에는 Combine. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/ios/combine-vs-async-await-swift-progressive-migration-patterns