Swift Combine | 반응형 프로그래밍 완벽 가이드

Swift Combine | 반응형 프로그래밍 완벽 가이드

이 글의 핵심

Swift Combine에 대한 실전 가이드입니다. 반응형 프로그래밍 완벽 가이드 등을 예제와 함께 설명합니다.

들어가며

Combine은 시간에 따라 들어오는 값을 PublisherSubscriber로 연결합니다. UI 이벤트·네트워크 응답을 스트림처럼 합성할 때 씁니다.


1. Publisher와 Subscriber

기본 사용

import Combine

// Just: 단일 값 발행
let publisher = Just("Hello")

let cancellable = publisher.sink { value in
    print(value)  // Hello
}

// PassthroughSubject: 수동 발행
let subject = PassthroughSubject<String, Never>()

let subscription = subject.sink { value in
    print("받음: \(value)")
}

subject.send("첫 번째")
subject.send("두 번째")
subject.send(completion: .finished)

CurrentValueSubject

let subject = CurrentValueSubject<Int, Never>(0)

subject.sink { value in
    print("값: \(value)")
}

subject.send(1)
subject.send(2)

print("현재 값: \(subject.value)")  // 2

2. Operator

변환 Operator

let numbers = [1, 2, 3, 4, 5].publisher

// map
numbers
    .map { $0 * 2 }
    .sink { print($0) }
// 2, 4, 6, 8, 10

// filter
numbers
    .filter { $0 % 2 == 0 }
    .sink { print($0) }
// 2, 4

// reduce
numbers
    .reduce(0, +)
    .sink { print("합계: \($0)") }
// 합계: 15

결합 Operator

let pub1 = Just(1)
let pub2 = Just(2)

// zip: 쌍으로 결합
Publishers.Zip(pub1, pub2)
    .sink { print("\($0), \($1)") }
// 1, 2

// combineLatest: 최신 값 결합
let subject1 = PassthroughSubject<Int, Never>()
let subject2 = PassthroughSubject<String, Never>()

subject1.combineLatest(subject2)
    .sink { print("\($0), \($1)") }

subject1.send(1)
subject2.send("A")  // 1, A
subject1.send(2)    // 2, A

3. @Published

SwiftUI와 통합

import SwiftUI
import Combine

class ViewModel: ObservableObject {
    @Published var count = 0
    @Published var message = ""
    
    func increment() {
        count += 1
        message = "Count: \(count)"
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.message)
            
            Button("증가") {
                viewModel.increment()
            }
        }
    }
}

4. 실전 예제

예제: 검색 기능

import Combine
import SwiftUI

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var results: [String] = []
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] text in
                self?.search(text)
            }
            .store(in: &cancellables)
    }
    
    func search(_ text: String) {
        let allItems = ["사과", "바나나", "오렌지", "포도", "딸기"]
        results = allItems.filter { $0.contains(text) }
    }
}

struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()
    
    var body: some View {
        VStack {
            TextField("검색", text: $viewModel.searchText)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            
            List(viewModel.results, id: \.self) { item in
                Text(item)
            }
        }
    }
}

실전 심화 보강

실전 예제: 페이지네이션 API를 flatMap으로 이어 붙이기

아래는 첫 응답의 nextPageURL이 있으면 다음 요청을 이어서 배출하는 패턴입니다. 실제 네트워크 대신 Future를 시뮬레이션합니다.

import Combine
import Foundation

struct Page: Decodable {
    let items: [String]
    let next: URL?
}

func fetchPage(url: URL) -> AnyPublisher<Page, Error> {
    // 실제로는 URLSession.shared.dataTaskPublisher
    Future { promise in
        promise(.success(Page(items: ["a", "b"], next: nil)))
    }
    .eraseToAnyPublisher()
}

enum PageLoader {
    static func allItems(start: URL) -> AnyPublisher<[String], Error> {
        func loop(_ url: URL) -> AnyPublisher<[String], Error> {
            fetchPage(url: url)
                .flatMap { page -> AnyPublisher<[String], Error> in
                    if let next = page.next {
                        return loop(next)
                            .map { page.items + $0 }
                            .eraseToAnyPublisher()
                    } else {
                        return Just(page.items)
                            .setFailureType(to: Error.self)
                            .eraseToAnyPublisher()
                    }
                }
                .eraseToAnyPublisher()
        }
        return loop(start)
    }
}

실무에서는 flatMap(maxPublishers: .max(1))로 동시 요청 수를 제한하고, retry/retry(on:)에 백오프를 걸어 네트워크 안정성을 높입니다.

자주 하는 실수

  • sink 반환값을 저장하지 않아 구독이 즉시 취소되는 경우.
  • @Publishedprivate로 두지 않고 내부 상태가 외부에 노출되는 경우.
  • combineLatest의 초기 방출 조건을 몰라 첫 값이 안 나오는 문제를 겪는 경우.

주의사항

  • Scheduler 선택(메인 큐 vs 백그라운드)에 따라 UI 업데이트 레이스가 생깁니다.
  • 메모리 순환 참조[weak self]store(in:) 패턴으로 끊습니다.

실무에서는 이렇게

  • 입력 → 디바운스 → switchToLatest검색·자동완성을 구현합니다.
  • 에러는 **catch/replaceError**로 UI에 친화적인 메시지로 매핑합니다.
  • 단위 테스트는 TestScheduler(iOS 15+) 또는 커스텀 스케줄러로 시간을 진행합니다.

비교 및 대안

방식메모
CombineApple 네이티브, SwiftUI와 궁합
AsyncSequence + AsyncStreamSwift 5.5+ 단순 파이프라인
RxSwift레거시 코드베이스

추가 리소스


정리

핵심 요약

  1. Publisher: 값 발행, Just, PassthroughSubject
  2. Subscriber: 값 구독, sink
  3. Operator: map, filter, combineLatest
  4. @Published: 자동 발행, SwiftUI 통합
  5. AnyCancellable: 구독 취소

다음 단계

Swift 시리즈를 완료했습니다! 다른 언어도 배워보세요:

  • Kotlin 시작하기
  • Rust 시작하기
  • Java 시작하기

관련 글

  • Swift 시작하기 | iOS 개발 공식 언어 완벽 입문
  • Swift 변수와 타입 | var, let, 옵셔널
  • Swift 함수와 클로저 | 함수 정의, 클로저, 고차 함수
  • Swift 클래스와 구조체 | Class, Struct, Enum
  • Swift 프로토콜과 확장 | Protocol, Extension