Combine Framework: Swift에서의 반응형 프로그래밍
Swift에서 비동기 데이터 스트림을 다루기 위한 Combine을 마스터합니다. Publishers, Subscribers, Operators와 iOS 앱을 위한 고급 패턴을 다룹니다.

반응형 프로그래밍은 iOS 애플리케이션에서 비동기 이벤트와 데이터 스트림을 다루는 방식을 근본적으로 바꿉니다. Apple의 네이티브 프레임워크인 Combine은 복잡한 데이터 파이프라인을 선언적이고 타입 안전하게 구성할 수 있는 방법을 제공합니다. 본 가이드는 기본 개념부터 프로덕션에 적용 가능한 패턴까지 순차적으로 살펴봅니다.
Combine은 iOS 13+에 기본으로 포함되어 있으며, Apple의 최적화 덕분에 더 좋은 성능을 제공하고 SwiftUI와 매끄럽게 통합됩니다. 외부 의존성을 관리할 필요가 없습니다.
Combine의 핵심 개념
Combine은 세 가지 핵심 개념 위에 세워져 있습니다. 값을 방출하는 Publishers, 그것을 받아들이는 Subscribers, 그리고 둘 사이에서 데이터를 변환하는 Operators입니다. 이 구조 덕분에 반응형이며 합성 가능한 데이터 파이프라인을 만들 수 있습니다.
Publisher: 데이터 소스
Publisher는 시간이 지남에 따라 값의 시퀀스를 방출할 수 있는 타입입니다. 각 Publisher는 두 개의 연관 타입을 선언합니다. 방출되는 값의 타입(Output)과 발생할 수 있는 오류 타입(Failure)입니다. 다양한 종류의 Publisher를 만드는 방법은 다음과 같습니다.
import Combine
// Just: emits a single value then completes
// Useful for converting a simple value to a Publisher
let singleValue = Just("Hello Combine")
// CurrentValueSubject: stores and emits the current value
// Perfect for representing state that changes over time
let counter = CurrentValueSubject<Int, Never>(0)
// PassthroughSubject: emits values without storing them
// Ideal for one-time events (taps, notifications)
let buttonTaps = PassthroughSubject<Void, Never>()
// Future: emits a single value asynchronously
// Wraps an async operation that returns a result
let asyncOperation = Future<String, Error> { promise in
// Simulate a network call
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
promise(.success("Data loaded"))
}
}오류 타입에 Never를 지정하면 해당 Publisher는 절대 실패하지 않는다는 의미입니다. 컴파일 타임에 보장되므로 오류 처리 코드를 단순하게 유지할 수 있습니다.
Subscriber: 값 수신하기
Subscriber는 Publisher를 구독하여 그 값을 받습니다. sink 메서드는 Subscriber를 만드는 가장 일반적인 방법으로, 두 개의 클로저를 받습니다. 하나는 오류 또는 완료를 처리하고, 다른 하나는 수신된 각 값을 처리합니다.
import Combine
// Variable to store subscriptions
// Without this reference, the subscription would be immediately cancelled
var cancellables = Set<AnyCancellable>()
let publisher = ["Swift", "Combine", "iOS"].publisher
// sink() creates a Subscriber that receives values
publisher
.sink(
// Called when the Publisher completes or fails
receiveCompletion: { completion in
switch completion {
case .finished:
print("✅ Completed successfully")
case .failure(let error):
print("❌ Error: \(error)")
}
},
// Called for each emitted value
receiveValue: { value in
print("Received: \(value)")
}
)
// store() keeps a reference to the subscription
.store(in: &cancellables)
// Output:
// Received: Swift
// Received: Combine
// Received: iOS
// ✅ Completed successfullysink()가 반환하는 AnyCancellable은 반드시 보관해야 합니다. 참조가 없으면 구독이 즉시 취소되어 어떤 값도 받을 수 없습니다.
Operators로 데이터 변환하기
Operators는 Combine의 핵심입니다. 데이터 스트림을 선언적으로 변환, 필터링, 결합할 수 있게 해 줍니다. 각 Operator는 새로운 Publisher를 반환하므로 체이닝하여 사용할 수 있습니다.
핵심 변환 Operators
변환 Operators는 방출된 각 값을 가공합니다. map은 값을 변환하고, flatMap은 중첩된 Publisher를 평탄화하며, compactMap은 nil을 걸러냅니다.
import Combine
var cancellables = Set<AnyCancellable>()
// map: transforms each value
// Equivalent to map on arrays
[1, 2, 3, 4, 5].publisher
.map { $0 * 2 } // Multiply each number by 2
.sink { print("Doubled: \($0)") }
.store(in: &cancellables)
// Output: 2, 4, 6, 8, 10
// compactMap: transforms AND filters out nil
// Useful for optional conversions
["1", "two", "3", "four", "5"].publisher
.compactMap { Int($0) } // Convert to Int, ignore failures
.sink { print("Valid number: \($0)") }
.store(in: &cancellables)
// Output: 1, 3, 5
// flatMap: flattens nested Publishers
// Essential for chaining async operations
struct User { let id: Int; let name: String }
func fetchUser(id: Int) -> AnyPublisher<User, Never> {
// Simulate an API call
Just(User(id: id, name: "User \(id)"))
.delay(for: .milliseconds(100), scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
[1, 2, 3].publisher
.flatMap { id in fetchUser(id: id) } // Each ID becomes an API call
.sink { user in print("User: \(user.name)") }
.store(in: &cancellables)필터링 Operators
필터링 Operators는 파이프라인을 통과하는 값을 제어합니다. 불필요한 처리를 피하고 성능을 최적화하기 위해 필수적입니다.
import Combine
var cancellables = Set<AnyCancellable>()
let numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5].publisher
// filter: keeps only values that satisfy the condition
numbers
.filter { $0 > 2 } // Keep only numbers > 2
.sink { print("Filtered: \($0)") }
.store(in: &cancellables)
// Output: 3, 3, 3, 4, 5, 5
// removeDuplicates: removes consecutive identical values
numbers
.removeDuplicates() // Eliminate consecutive duplicates
.sink { print("Without duplicates: \($0)") }
.store(in: &cancellables)
// Output: 1, 2, 3, 4, 5
// debounce: waits for a pause before emitting
// Perfect for real-time search
let searchText = PassthroughSubject<String, Never>()
searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates() // Ignore if text hasn't changed
.sink { query in
print("Search: \(query)")
// Launch API call here
}
.store(in: &cancellables)
// Simulate rapid typing
searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift") // Only "Swift" is emitted after 300msiOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
여러 Publisher 결합하기
실제 애플리케이션에서는 여러 데이터 소스를 결합해야 하는 경우가 많습니다. Combine은 이러한 다중 스트림을 다루기 위한 여러 Operators를 제공합니다.
CombineLatest와 Zip
combineLatest는 어떤 Publisher가 값을 방출할 때마다 다른 Publisher들의 최신 값과 결합하여 방출합니다. zip은 모든 Publisher가 값을 방출할 때까지 기다린 후 결합합니다.
import Combine
var cancellables = Set<AnyCancellable>()
// Simulate a form with validation
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")
// combineLatest: combines the latest values from each Publisher
// Emits on every change from either source
Publishers.CombineLatest(email, password)
.map { email, password in
// Validate that email contains @ and password > 6 chars
let isEmailValid = email.contains("@")
let isPasswordValid = password.count >= 6
return isEmailValid && isPasswordValid
}
.sink { isFormValid in
print("Form valid: \(isFormValid)")
}
.store(in: &cancellables)
email.send("user@example.com") // false (password empty)
password.send("123456") // true (both are valid)
// zip: waits for one value from each Publisher before emitting
// Useful for synchronizing parallel operations
let firstAPI = PassthroughSubject<String, Never>()
let secondAPI = PassthroughSubject<Int, Never>()
Publishers.Zip(firstAPI, secondAPI)
.sink { stringValue, intValue in
print("Received pair: \(stringValue), \(intValue)")
}
.store(in: &cancellables)
firstAPI.send("Hello") // No emission, waiting for secondAPI
secondAPI.send(42) // Emits: ("Hello", 42)
firstAPI.send("World") // No emission, waiting for secondAPI
secondAPI.send(100) // Emits: ("World", 100)스트림을 통합하는 Merge
merge는 같은 타입의 여러 Publisher를 단일 스트림으로 합칩니다. 값은 어떤 Publisher가 보냈는지에 관계없이 방출 순서대로 도착합니다.
import Combine
var cancellables = Set<AnyCancellable>()
// Multiple user notification sources
let pushNotifications = PassthroughSubject<String, Never>()
let localNotifications = PassthroughSubject<String, Never>()
let inAppMessages = PassthroughSubject<String, Never>()
// Merge unifies all streams into one
Publishers.Merge3(pushNotifications, localNotifications, inAppMessages)
.sink { message in
// Handle all notifications the same way
print("📬 Notification: \(message)")
}
.store(in: &cancellables)
pushNotifications.send("New message") // 📬 Notification: New message
localNotifications.send("Reminder: meeting") // 📬 Notification: Reminder: meeting
inAppMessages.send("Welcome!") // 📬 Notification: Welcome!Combine에서의 오류 처리
오류 처리는 Combine의 핵심에 내장되어 있습니다. Publisher의 Failure 타입 덕분에 컴파일러가 모든 오류가 처리되었는지 검증할 수 있습니다.
복구 전략
Combine은 오류 처리를 위한 여러 Operators를 제공합니다. 다른 Publisher로 대체하는 catch, 다시 시도하는 retry, 기본값으로 대체하는 replaceError 등이 있습니다.
import Combine
var cancellables = Set<AnyCancellable>()
enum APIError: Error {
case networkError
case invalidResponse
case serverError(Int)
}
// Simulate an API call that can fail
func fetchData() -> AnyPublisher<String, APIError> {
Fail(error: APIError.networkError)
.eraseToAnyPublisher()
}
// retry: retries N times before propagating the error
fetchData()
.retry(3) // Try up to 3 times
.catch { error -> Just<String> in
// catch: replaces the error with a fallback Publisher
print("Error after 3 attempts: \(error)")
return Just("Cached data") // Fallback value
}
.sink(
receiveCompletion: { _ in },
receiveValue: { print("Result: \($0)") }
)
.store(in: &cancellables)
// replaceError: replaces any error with a fixed value
// Simpler than catch when only a default value is needed
fetchData()
.replaceError(with: "Error - default value")
.sink { print("With fallback: \($0)") }
.store(in: &cancellables)setFailureType(to:)을 사용하면 Never Publisher를 실패할 수 있는 Publisher로 바꿀 수 있고, 반대로는 replaceError(with:) 또는 catch를 사용합니다.
Combine과 함께하는 MVVM 패턴
Combine은 MVVM(Model-View-ViewModel) 패턴과 자연스럽게 통합됩니다. ViewModel이 Publisher를 노출하면 View가 이를 관찰하면서 데이터와 UI 사이에 반응형 바인딩이 형성됩니다.
완전한 반응형 ViewModel
검색, 로딩, 오류 처리를 포함한 사용자 목록용 ViewModel 예제입니다.
import Combine
import Foundation
// Data model
struct User: Codable, Identifiable {
let id: Int
let name: String
let email: String
}
// ViewModel with reactive state
final class UserListViewModel: ObservableObject {
// MARK: - Published Properties (observed by SwiftUI)
@Published var users: [User] = [] // User list
@Published var searchQuery: String = "" // Search text
@Published var isLoading: Bool = false // Loading state
@Published var errorMessage: String? // Optional error message
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let userService: UserServiceProtocol
// MARK: - Computed Properties
// Filters users based on search query
var filteredUsers: [User] {
guard !searchQuery.isEmpty else { return users }
return users.filter {
$0.name.localizedCaseInsensitiveContains(searchQuery)
}
}
// MARK: - Initialization
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
setupBindings()
}
// MARK: - Private Methods
private func setupBindings() {
// Observe searchQuery changes
// debounce prevents too frequent calls
$searchQuery
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] query in
// Server-side search logic if needed
print("Search updated: \(query)")
}
.store(in: &cancellables)
}
// MARK: - Public Methods
func loadUsers() {
isLoading = true
errorMessage = nil
userService.fetchUsers()
.receive(on: DispatchQueue.main) // Ensure UI updates on main thread
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] users in
self?.users = users
}
)
.store(in: &cancellables)
}
}Combine과 URLSession을 사용한 서비스
URLSession은 dataTaskPublisher를 통해 Combine과 네이티브로 통합됩니다. 재사용 가능한 네트워크 서비스를 만드는 방법은 다음과 같습니다.
import Combine
import Foundation
protocol UserServiceProtocol {
func fetchUsers() -> AnyPublisher<[User], Error>
}
final class UserService: UserServiceProtocol {
private let baseURL = URL(string: "https://api.example.com")!
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func fetchUsers() -> AnyPublisher<[User], Error> {
let url = baseURL.appendingPathComponent("users")
return session.dataTaskPublisher(for: url)
// Check HTTP status code
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200..<300 ~= httpResponse.statusCode else {
throw URLError(.badServerResponse)
}
return data
}
// Decode JSON to Swift model
.decode(type: [User].self, decoder: decoder)
// Erase concrete type to return AnyPublisher
.eraseToAnyPublisher()
}
}iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
SwiftUI 통합
Combine과 SwiftUI는 강력한 조합을 이룹니다. ObservableObject의 @Published 프로퍼티는 자동으로 뷰 업데이트를 트리거합니다.
Combine ViewModel을 사용하는 SwiftUI 뷰
ViewModel을 SwiftUI 뷰에 연결하는 방법은 다음과 같습니다.
import SwiftUI
struct UserListView: View {
// StateObject: creates and owns the ViewModel
@StateObject private var viewModel = UserListViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
// Centered loading indicator
ProgressView("Loading...")
} else if let error = viewModel.errorMessage {
// Error view with retry button
VStack(spacing: 16) {
Text("Error: \(error)")
.foregroundStyle(.red)
Button("Retry") {
viewModel.loadUsers()
}
}
} else {
// User list
List(viewModel.filteredUsers) { user in
UserRowView(user: user)
}
}
}
.navigationTitle("Users")
.searchable(text: $viewModel.searchQuery) // Direct binding
.onAppear {
viewModel.loadUsers() // Load on first appearance
}
}
}
}
struct UserRowView: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}고급 패턴
취소와 자동 정리
구독 라이프사이클을 관리하는 것은 메모리 누수를 피하기 위해 매우 중요합니다. AnyCancellable을 함께 사용하는 cancellables 패턴은 자동 정리를 보장합니다.
import Combine
final class DataManager {
// Set of cancellables: automatically cancelled on destruction
private var cancellables = Set<AnyCancellable>()
// Individual cancellable for fine-grained control
private var currentRequest: AnyCancellable?
func startPolling() {
// Timer that emits every 5 seconds
Timer.publish(every: 5, on: .main, in: .common)
.autoconnect() // Starts automatically
.sink { [weak self] _ in
self?.fetchLatestData()
}
.store(in: &cancellables)
}
func fetchLatestData() {
// Cancel the previous request if it exists
currentRequest?.cancel()
currentRequest = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.example.com/data")!)
.map(\.data)
.decode(type: [String].self, decoder: JSONDecoder())
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.sink { data in
print("Data received: \(data)")
}
}
deinit {
// All cancellables are automatically cancelled
print("DataManager destroyed, subscriptions cancelled")
}
}스레딩을 위한 Schedulers
Schedulers는 작업이 어떤 스레드에서 실행될지를 제어합니다. 백그라운드 작업에는 subscribe(on:)을, UI 업데이트에는 receive(on:)을 사용합니다.
import Combine
import Foundation
var cancellables = Set<AnyCancellable>()
func loadAndProcessData() -> AnyPublisher<ProcessedData, Error> {
URLSession.shared.dataTaskPublisher(for: apiURL)
// Perform parsing on a background thread
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.map(\.data)
.decode(type: RawData.self, decoder: JSONDecoder())
// Heavy processing on background thread
.map { rawData in
// This expensive operation runs in the background
processData(rawData)
}
// Return to main thread for UI
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}결론
Combine은 iOS 앱에서 비동기 데이터 스트림을 다루기 위한 강력하고 선언적인 접근 방식을 제공합니다. 핵심 요점은 다음과 같습니다.
✅ Publishers가 시간에 따라 값을 방출합니다
✅ Subscribers가 그 값을 수신하고 처리합니다
✅ Operators가 스트림을 변환하고 결합합니다
✅ AnyCancellable이 구독의 라이프사이클을 관리합니다
✅ SwiftUI와 함께하는 @Published가 자동 반응형 바인딩을 만듭니다
✅ Schedulers가 스레딩을 제어해 최적의 성능을 끌어냅니다
Combine을 마스터하면 견고하고 유지보수가 쉬우며 반응형인 iOS 앱을 만들 수 있습니다. SwiftUI와의 네이티브 통합 덕분에 현대 iOS 개발에서 빼놓을 수 없는 도구가 되었습니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Swift에서 Combine vs async/await: 점진적 마이그레이션 패턴
Swift에서 Combine에서 async/await로 마이그레이션하는 완전한 가이드: 점진적 전략, 브리징 패턴, iOS 코드베이스의 패러다임 공존.

2026년 iOS 접근성 면접 질문: VoiceOver와 Dynamic Type
iOS 면접 대비를 위한 핵심 접근성 질문: VoiceOver, Dynamic Type, 시맨틱 traits, 접근성 감사.

Swift Macros: 메타프로그래밍 실전 예제
Swift Macros 완전 가이드: freestanding 및 attached 매크로 작성, swift-syntax를 활용한 AST 조작, 보일러플레이트를 줄이는 실전 예제 제공.