콘텐츠 대표 이미지 - iOS Combine 프레임워크로 배우는 반응형 프로그래밍: Publisher부터 실전 아키텍처까지
iOS · 반응형 프로그래밍

iOS Combine 프레임워크로 배우는 반응형 프로그래밍: Publisher부터 실전 아키텍처까지

데이터가 흐르고, 화면이 반응한다. 애플이 만든 공식 반응형 엔진 Combine의 구조와 실전 사용법을 처음부터 끝까지 정리했습니다.

01. 왜 지금 다시 Combine인가

iOS 개발을 하다 보면 누구나 한 번쯤 이런 코드를 마주칩니다.
네트워크 콜백 안에 또 다른 콜백, 그 안에 델리게이트, 그 옆엔 NotificationCenter 옵저버, 저 멀리엔 KVO.
이른바 "비동기 스파게티"입니다.

2019년 WWDC에서 애플은 이 혼돈을 하나의 문법으로 묶겠다며 Combine을 발표했습니다.
SwiftUI와 같은 해에 태어난 쌍둥이 형제였고, 실제로 SwiftUI의 상태 갱신 엔진 상당 부분이 Combine의 개념 위에 서 있습니다.

2021년 async/await와 AsyncSequence가 등장하면서 "Combine은 끝났다"는 말도 돌았습니다.
하지만 현실은 다릅니다. 여전히 Combine이 압도적으로 유리한 영역이 존재합니다.

Combine이 아직 대체 불가능한 지점

1. 시간 기반 연산자 — debounce, throttle, timeout, delay는 async/await 세계에서 직접 구현하려면 꽤 고통스럽습니다.

2. 멀티캐스팅 — 하나의 소스를 여러 구독자에게 브로드캐스트하는 share(), multicast()는 Structured Concurrency로 흉내 내기 까다롭습니다.

3. 상태 브로드캐스터 — @Published, CurrentValueSubject는 SwiftUI/UIKit 양쪽에서 여전히 표준 어휘입니다.

4. 스트림 결합 — combineLatest, zip, merge로 여러 이벤트 소스를 엮는 선언적 표현력.

즉, Combine은 "끝난 기술"이 아니라 "역할이 명확해진 기술"입니다.
일회성 비동기 작업은 async/await가, 지속적으로 흐르는 이벤트 스트림은 Combine이 맡는 시대가 온 것이죠.

Combine 데이터 파이프라인 Publisher 값을 내보내는 쪽 map / filter 변환 단계 debounce 시간 제어 Operator 체이닝 결과 Sub- scriber Demand(요구)는 Subscriber → Publisher 역방향으로 전달됩니다

02. 세 가지 핵심 프로토콜: Publisher, Subscriber, Subscription

Combine은 생각보다 단출합니다. 본질은 세 개의 프로토콜뿐입니다.
나머지 200개가 넘는 연산자는 전부 이 셋의 조합으로 만들어진 파생물입니다.

Publisher — 값을 방출하는 주체

public protocol Publisher {
    associatedtype Output
    associatedtype Failure: Error

    func receive<S>(subscriber: S)
        where S: Subscriber,
              Self.Failure == S.Failure,
              Self.Output == S.Input
}

여기서 중요한 건 연관 타입이 두 개라는 점입니다.
Output은 흘려보낼 값의 타입, Failure는 실패 시 방출할 에러 타입입니다.

실패할 수 없는 퍼블리셔는 Failure == Never로 표현합니다.
@Published 프로퍼티 래퍼가 만드는 퍼블리셔가 대표적으로 Never 타입입니다.

실무 포인트 — Combine 초심자가 가장 많이 만나는 컴파일 에러는 대부분 Failure 타입 불일치입니다.
Never와 Error를 맞추려면 setFailureType(to:), mapError(_:), replaceError(with:) 세 연산자를 기억하세요.

Subscriber — 값을 받는 주체

public protocol Subscriber: CustomCombineIdentifierConvertible {
    associatedtype Input
    associatedtype Failure: Error

    func receive(subscription: Subscription)
    func receive(_ input: Self.Input) -> Subscribers.Demand
    func receive(completion: Subscribers.Completion<Self.Failure>)
}

세 개의 메서드가 곧 구독의 생애주기입니다.

1) 구독이 성립되면 receive(subscription:)이 한 번 호출됩니다.
2) 값이 올 때마다 receive(_:)가 호출되고, 반환값으로 "추가로 몇 개 더 받을지"를 알립니다.
3) 스트림이 끝나면 receive(completion:)이 딱 한 번 호출됩니다. 완료 후에는 어떤 값도 오지 않습니다.

Subscription — 연결을 관리하는 매개체

public protocol Subscription: Cancellable, CustomCombineIdentifierConvertible {
    func request(_ demand: Subscribers.Demand)
}

Subscription은 Publisher와 Subscriber 사이의 계약서입니다.
Subscriber가 "값 3개 주세요"라고 요청(Demand)하면, Publisher는 그 수량만큼만 내보냅니다.

Backpressure(배압)라는 개념

Combine이 RxSwift와 크게 다른 지점이 바로 이 Demand 기반 흐름 제어입니다.

Rx는 기본적으로 Publisher가 원하는 만큼 밀어내는 push 방식입니다.
반면 Combine은 Subscriber가 "받을 수 있는 만큼"을 명시하는 pull-push 하이브리드입니다.

Subscribers.Demand는 .none, .max(n), .unlimited 세 가지 값을 가집니다.
Demand는 누적되며 절대 감소하지 않습니다. 이미 준 요구를 회수할 방법은 없고, 멈추려면 구독을 취소해야 합니다.

03. 기본 Publisher 도감

Combine이 기본 제공하는 퍼블리셔들을 상황별로 정리하면 이렇습니다.

퍼블리셔Output / Failure주 용도
Just(value)T / Never값 하나 내보내고 즉시 완료
Empty()T / E값 없이 즉시 완료 (혹은 영원히 대기)
Fail(error:)T / E즉시 에러로 종료
Future { }T / E일회성 비동기 작업 래핑
Deferred { }T / E구독 시점까지 생성 지연
Sequence.publisherElement / Never배열·범위를 스트림으로
Timer.publishDate / Never주기적 이벤트
URLSession.dataTaskPublisher(Data, URLResponse) / URLError네트워크 요청
NotificationCenter.publisherNotification / Never시스템·앱 알림
PassthroughSubjectT / E수동 이벤트 발행 (상태 없음)
CurrentValueSubjectT / E현재값 보유 + 발행
@PublishedT / Never프로퍼티 변화 자동 발행

Future의 함정: 즉시 실행된다

가장 많이 실수하는 지점입니다. Future의 클로저는 구독 여부와 무관하게 생성 즉시 실행됩니다.

// 위험: 구독자가 없어도 네트워크 호출이 나간다
let future = Future<Int, Never> { promise in
    print("실행됨!")   // 이 시점에 바로 출력
    promise(.success(42))
}

// 안전: 구독 시점에 비로소 실행
let deferred = Deferred {
    Future<Int, Never> { promise in
        print("구독 후 실행됨!")
        promise(.success(42))
    }
}

또한 Future는 결과를 캐싱합니다.
여러 번 구독해도 클로저는 한 번만 실행되고, 이후 구독자에게는 저장된 값이 즉시 전달됩니다.
이 동작이 필요하면 장점, 아니라면 Deferred로 감싸야 합니다.

Subject: 명령형 세계와의 다리

final class SearchViewModel {
    private let querySubject = PassthroughSubject<String, Never>()
    private let stateSubject = CurrentValueSubject<LoadState, Never>(.idle)

    var statePublisher: AnyPublisher<LoadState, Never> {
        stateSubject.eraseToAnyPublisher()
    }

    func updateQuery(_ text: String) {
        querySubject.send(text)
    }
}

PassthroughSubject는 버튼 탭처럼 지나가는 이벤트에,
CurrentValueSubject는 로딩 상태처럼 항상 현재값이 있어야 하는 상태에 씁니다.

캡슐화 주의 — Subject를 외부에 그대로 노출하면 아무나 send()를 호출할 수 있습니다.
반드시 private으로 두고 eraseToAnyPublisher()로 감싼 읽기 전용 프로퍼티만 공개하세요.

04. 연산자 카탈로그: 실전에서 진짜 쓰는 것들

연산자는 200개가 넘지만, 실무에서 90% 이상의 상황은 아래 20여 개로 해결됩니다.

변환 계열

// map: 값을 1:1 변환
publisher.map { $0 * 2 }

// tryMap: 변환 중 throw 가능 (Failure가 Error로 승격)
publisher.tryMap { try JSONDecoder().decode(User.self, from: $0) }

// flatMap: 값을 새로운 Publisher로 치환하고 평탄화
userIDPublisher.flatMap { id in
    api.fetchProfile(id: id)
}

// switchToLatest: 가장 최근 스트림만 남기고 이전 것 취소
searchTextPublisher
    .map { api.search(query: $0) }
    .switchToLatest()

// scan: 이전 결과를 누적
publisher.scan(0) { acc, next in acc + next }

// compactMap: nil 제거
publisher.compactMap { Int($0) }

flatMap vs switchToLatest, 결정적 차이

검색창에 "s", "sw", "swi", "swift"를 빠르게 입력했다고 합시다.

flatMap: 4개의 네트워크 요청이 모두 살아있고, 응답 순서가 뒤바뀌면 "s"의 결과가 마지막에 도착해 화면을 덮어씁니다.

switchToLatest: 새 요청이 시작되면 이전 요청을 자동 취소합니다. 항상 최신 쿼리의 결과만 도착합니다.

검색·자동완성에서는 거의 언제나 switchToLatest가 정답입니다.
flatMap(maxPublishers: .max(1))로 동시 실행 수를 제한하는 방법도 있습니다.

필터링 계열

publisher.filter { $0 > 10 }
publisher.removeDuplicates()               // 연속 중복 제거
publisher.removeDuplicates(by: { $0.id == $1.id })
publisher.first(where: { $0.isValid })
publisher.dropFirst(1)
publisher.prefix(5)                        // 5개 받고 완료
publisher.replaceNil(with: 0)
publisher.replaceEmpty(with: [])

시간 제어 계열 — Combine의 하이라이트

// debounce: 입력이 멈춘 뒤 N초 후 마지막 값 방출
searchField
    .debounce(for: .milliseconds(400), scheduler: RunLoop.main)

// throttle: N초 구간마다 최대 1개 (latest: true면 마지막 값)
scrollOffset
    .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)

// delay: 전체 스트림을 N초 뒤로 밀기
publisher.delay(for: .seconds(1), scheduler: DispatchQueue.main)

// timeout: N초 내 값이 없으면 실패/완료
apiCall.timeout(.seconds(10), scheduler: DispatchQueue.global()) {
    NetworkError.timeout
}

// measureInterval / collect(byTime:)
publisher.collect(.byTime(RunLoop.main, .seconds(1)))

debounce vs throttle 구분법
debounce는 "조용해질 때까지 기다린다" — 검색어 입력, 자동 저장.
throttle은 "일정 간격으로 샘플링한다" — 스크롤 위치, 위치 추적, 실시간 좌표.

결합 계열

// combineLatest: 각 스트림의 최신값 조합 (모두 최소 1번은 방출해야 시작)
Publishers.CombineLatest3(emailValid, passwordValid, agreedToTerms)
    .map { $0 && $1 && $2 }
    .assign(to: \.isEnabled, on: signUpButton)

// zip: 인덱스를 맞춰 쌍으로 묶음 (짝이 맞아야 방출)
Publishers.Zip(imageUpload, metadataUpload)

// merge: 동일 타입 스트림을 하나로 합침 (순서는 도착순)
Publishers.MergeMany(cacheStream, networkStream)

// prepend / append
publisher.prepend(initialValue)
마블 다이어그램으로 보는 결합 연산자 combineLatest 1 2 3 A B 1A 2A 2B 3B 양쪽 최신값을 계속 재조합 zip 1 2 3 A B 1A 2B 순번을 맞춰 1:1로만 묶음

05. 에러 처리: Failure 타입과의 싸움

Combine 학습 곡선의 절반은 에러 타입 정렬입니다.
파이프라인의 모든 단계가 동일한 Failure 타입으로 맞춰져야 컴파일이 통과합니다.

연산자역할Failure 변화
mapError에러 타입 변환E1 → E2
replaceError(with:)에러를 기본값으로 대체E → Never
catch에러 시 다른 퍼블리셔로 전환E1 → E2 (또는 Never)
tryCatch복구 중 다시 throw 가능E → Error
retry(n)실패 시 n회 재구독변화 없음
setFailureType(to:)Never를 특정 에러로 승격Never → E
assertNoFailure()실패 시 크래시(디버그용)E → Never

실전 에러 파이프라인

enum APIError: Error {
    case network(URLError)
    case decoding(DecodingError)
    case server(statusCode: Int)
    case unknown
}

func fetchUsers() -> AnyPublisher<[User], APIError> {
    URLSession.shared
        .dataTaskPublisher(for: usersURL)
        .tryMap { data, response -> Data in
            guard let http = response as? HTTPURLResponse else {
                throw APIError.unknown
            }
            guard (200..<300).contains(http.statusCode) else {
                throw APIError.server(statusCode: http.statusCode)
            }
            return data
        }
        .decode(type: [User].self, decoder: JSONDecoder())
        .mapError { error -> APIError in
            switch error {
            case let apiError as APIError:       return apiError
            case let urlError as URLError:       return .network(urlError)
            case let decodeError as DecodingError: return .decoding(decodeError)
            default:                             return .unknown
            }
        }
        .retry(2)
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

retry의 치명적 오해

retry(n)은 업스트림 퍼블리셔를 다시 구독합니다.
따라서 Future처럼 결과를 캐싱하는 퍼블리셔에는 재시도가 먹히지 않습니다.

또한 retry는 즉시 재시도합니다. 지수 백오프가 필요하면 직접 구현해야 합니다.

extension Publisher {
    func retryWithBackoff(
        retries: Int,
        initialDelay: TimeInterval = 0.5,
        scheduler: DispatchQueue = .global()
    ) -> AnyPublisher<Output, Failure> {
        var attempt = 0
        return self.catch { error -> AnyPublisher<Output, Failure> in
            attempt += 1
            guard attempt <= retries else {
                return Fail(error: error).eraseToAnyPublisher()
            }
            let delay = initialDelay * pow(2.0, Double(attempt - 1))
            return Just(())
                .delay(for: .seconds(delay), scheduler: scheduler)
                .setFailureType(to: Failure.self)
                .flatMap { _ in self.retryWithBackoff(
                    retries: retries - attempt,
                    initialDelay: initialDelay,
                    scheduler: scheduler) }
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

가장 중요한 규칙: 에러가 발생하면 스트림은 영구히 종료됩니다.
UI에 바인딩된 스트림(예: 검색 결과)에서 에러로 종료되면 이후 사용자가 아무리 입력해도 반응이 없습니다.

해결책은 에러를 내부 스트림에 가두는 것입니다.

searchQuery
    .debounce(for: .milliseconds(400), scheduler: RunLoop.main)
    .map { query in
        api.search(query)
            .map { Result.success($0) }
            .catch { Just(Result.failure($0)) }   // 여기서 봉인
    }
    .switchToLatest()
    .sink { result in /* 외부 스트림은 절대 안 죽음 */ }

06. Scheduler: 어느 스레드에서 일할 것인가

Combine에서 스레드를 다루는 두 연산자는 subscribe(on:)과 receive(on:)입니다.
이름이 비슷해 헷갈리지만 역할은 완전히 다릅니다.

연산자영향 범위무엇을 바꾸나
subscribe(on:)업스트림 (위쪽)구독 시작·값 생성·취소가 일어나는 스레드
receive(on:)다운스트림 (아래쪽)이후 연산자와 sink가 실행되는 스레드
heavyComputationPublisher
    .subscribe(on: DispatchQueue.global(qos: .userInitiated))  // 무거운 작업은 백그라운드
    .map { expensiveTransform($0) }                            // 여전히 백그라운드
    .receive(on: DispatchQueue.main)                           // 여기서부터 메인
    .sink { self.label.text = $0 }                             // 안전하게 UI 갱신
    .store(in: &cancellables)

암기 문장: "subscribe는 위를, receive는 아래를 바꾼다."
그래서 receive(on: .main)은 항상 sink 바로 위에 두는 것이 기본 패턴입니다.

Scheduler 종류별 특성

ImmediateScheduler — 스케줄링 없이 현재 스레드에서 즉시 실행. 테스트에 유용합니다.

DispatchQueue — GCD 기반. 가장 범용적이지만 항상 비동기 디스패치가 발생합니다.

RunLoop — 스레드의 런루프 기반. RunLoop.main은 UI 트래킹 모드에서 동작이 지연될 수 있습니다.

OperationQueue — 동시성 제어(maxConcurrentOperationCount)가 필요할 때.

RunLoop.main vs DispatchQueue.main
스크롤 중(UITrackingRunLoopMode)에는 RunLoop.main에 스케줄된 작업이 멈춥니다.
UI 바인딩에는 DispatchQueue.main을, debounce의 타이밍에는 상황에 맞춰 선택하세요.

07. 메모리 관리: Cancellable과 순환 참조

Combine에서 발생하는 버그의 1순위는 메모리 누수입니다.
원인은 대부분 두 가지로 압축됩니다.

① AnyCancellable을 붙잡지 않는 경우

// 잘못된 코드: cancellable이 즉시 해제되어 구독이 끊김
func load() {
    api.fetch()
        .sink { print($0) }   // 반환값 버림 → 스트림 즉시 종료
}

// 올바른 코드
private var cancellables = Set<AnyCancellable>()

func load() {
    api.fetch()
        .sink { print($0) }
        .store(in: &cancellables)
}

AnyCancellable은 deinit 시 자동으로 cancel()을 호출합니다.
이게 Combine의 우아한 점이자, 동시에 초심자를 괴롭히는 지점입니다.

② sink 클로저의 강한 참조

// 순환 참조: self → cancellables → sink 클로저 → self
viewModel.$items
    .sink { items in
        self.tableView.reloadData()   // self 강한 캡처
    }
    .store(in: &cancellables)

// 해결
viewModel.$items
    .sink { [weak self] items in
        self?.tableView.reloadData()
    }
    .store(in: &cancellables)

assign(to:on:)의 숨은 강한 참조

assign(to:on:)은 대상 객체를 강하게 캡처합니다.
self에 assign하면 곧바로 순환 참조입니다.

// 누수 발생
publisher.assign(to: \.text, on: self.label)   // self.label은 OK지만
publisher.assign(to: \.title, on: self)        // ← 이건 누수

// iOS 14+ : @Published에 직접 바인딩 (내부적으로 순환 없음)
publisher.assign(to: &$title)

// 또는 weak sink 사용
publisher.sink { [weak self] in self?.title = $0 }

assign(to: &$property) 형태는 AnyCancellable을 반환하지 않고,
@Published의 생명주기에 구독이 묶이므로 순환 참조가 발생하지 않습니다.

순환 참조가 생기는 경로 ViewModel (self) cancellables Set<AnyCancellable> sink 클로저 captures self 강한 참조가 원을 이루면 둘 다 해제되지 않는다 해법: [weak self] 캡처 리스트 또는 assign(to: &$property)

08. SwiftUI와 Combine: 상태 흐름의 설계

SwiftUI의 ObservableObject는 사실상 Combine 프로토콜입니다.

public protocol ObservableObject: AnyObject {
    associatedtype ObjectWillChangePublisher: Publisher = ObservableObjectPublisher
        where Self.ObjectWillChangePublisher.Failure == Never
    var objectWillChange: Self.ObjectWillChangePublisher { get }
}

@Published 프로퍼티가 바뀌면 objectWillChange가 발화하고,
SwiftUI는 이를 구독해 해당 뷰를 다시 그립니다.

검색 화면 전체 구현

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var query: String = ""
    @Published private(set) var results: [Repository] = []
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var errorMessage: String?

    private let service: SearchServicing
    private var cancellables = Set<AnyCancellable>()

    init(service: SearchServicing) {
        self.service = service
        bind()
    }

    private func bind() {
        $query
            .removeDuplicates()
            .debounce(for: .milliseconds(400), scheduler: DispatchQueue.main)
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .handleEvents(receiveOutput: { [weak self] text in
                self?.isLoading = !text.isEmpty
                self?.errorMessage = nil
            })
            .map { [weak self] text -> AnyPublisher<Result<[Repository], Error>, Never> in
                guard let self, !text.isEmpty else {
                    return Just(.success([])).eraseToAnyPublisher()
                }
                return self.service.search(query: text)
                    .map(Result.success)
                    .catch { Just(Result.failure($0)) }
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                self?.isLoading = false
                switch result {
                case .success(let items):
                    self?.results = items
                case .failure(let error):
                    self?.results = []
                    self?.errorMessage = error.localizedDescription
                }
            }
            .store(in: &cancellables)
    }
}

이 30여 줄 안에 디바운스, 중복 제거, 공백 정리, 이전 요청 취소, 로딩 상태, 에러 격리가 모두 들어 있습니다.
같은 기능을 명령형으로 작성하면 타이머 관리와 URLSessionTask 취소 코드만으로도 배는 길어집니다.

@MainActor와 Combine — Swift Concurrency가 도입된 이후, ViewModel에 @MainActor를 붙이는 것이 권장됩니다.
다만 sink 클로저 내부에서 액터 격리 경고가 날 수 있으므로, 반드시 receive(on: DispatchQueue.main)으로 스레드를 정렬한 뒤 상태를 갱신하세요.

폼 유효성 검증 패턴

final class SignUpViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var passwordConfirm = ""
    @Published private(set) var isFormValid = false
    @Published private(set) var passwordMessage = ""

    private var cancellables = Set<AnyCancellable>()

    private var emailValid: AnyPublisher<Bool, Never> {
        $email
            .map { $0.contains("@") && $0.contains(".") && $0.count >= 6 }
            .eraseToAnyPublisher()
    }

    private var passwordValid: AnyPublisher<Bool, Never> {
        Publishers.CombineLatest($password, $passwordConfirm)
            .map { pw, confirm in
                pw.count >= 8 && pw == confirm
            }
            .eraseToAnyPublisher()
    }

    init() {
        Publishers.CombineLatest(emailValid, passwordValid)
            .map { $0 && $1 }
            .removeDuplicates()
            .assign(to: &$isFormValid)

        Publishers.CombineLatest($password, $passwordConfirm)
            .map { pw, confirm -> String in
                if pw.isEmpty { return "" }
                if pw.count < 8 { return "8자 이상 입력해 주세요" }
                if pw != confirm { return "비밀번호가 일치하지 않습니다" }
                return "사용 가능합니다"
            }
            .assign(to: &$passwordMessage)
    }
}

선언적 코드의 진가가 드러나는 예시입니다.
"이메일이 유효하고 그리고 비밀번호가 유효하면 폼이 유효하다"는 문장이 코드 그대로 표현됩니다.

09. UIKit에서의 Combine 활용

SwiftUI 전용 기술로 오해받지만, Combine은 UIKit에서도 충분히 강력합니다.
다만 애플이 UIKit용 퍼블리셔를 공식 제공하지 않아 약간의 브리징이 필요합니다.

UIControl 이벤트 퍼블리셔 만들기

extension UIControl {
    struct EventPublisher: Publisher {
        typealias Output = Void
        typealias Failure = Never

        let control: UIControl
        let event: UIControl.Event

        func receive<S: Subscriber>(subscriber: S)
        where S.Input == Void, S.Failure == Never {
            let subscription = EventSubscription(
                subscriber: subscriber, control: control, event: event)
            subscriber.receive(subscription: subscription)
        }
    }

    final class EventSubscription<S: Subscriber>: Subscription
    where S.Input == Void, S.Failure == Never {
        private var subscriber: S?
        private weak var control: UIControl?
        private let event: UIControl.Event

        init(subscriber: S, control: UIControl, event: UIControl.Event) {
            self.subscriber = subscriber
            self.control = control
            self.event = event
            control.addTarget(self, action: #selector(handle), for: event)
        }

        func request(_ demand: Subscribers.Demand) { }

        func cancel() {
            control?.removeTarget(self, action: #selector(handle), for: event)
            subscriber = nil
        }

        @objc private func handle() {
            _ = subscriber?.receive(())
        }
    }

    func publisher(for event: UIControl.Event) -> EventPublisher {
        EventPublisher(control: self, event: event)
    }
}

이렇게 한 번 만들어 두면 UIKit 코드가 극적으로 깔끔해집니다.

searchBar.searchTextField
    .publisher(for: .editingChanged)
    .compactMap { [weak searchBar] in searchBar?.text }
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()
    .sink { [weak self] text in self?.viewModel.updateQuery(text) }
    .store(in: &cancellables)

submitButton
    .publisher(for: .touchUpInside)
    .throttle(for: .seconds(1), scheduler: RunLoop.main, latest: false)
    .sink { [weak self] in self?.viewModel.submit() }
    .store(in: &cancellables)

UIKit에서 유용한 내장 퍼블리셔

NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
— 키보드 대응

textField.publisher(for: \.text) — KVO 기반 프로퍼티 감시 (NSObject 한정)

NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
— 앱 생명주기
Publishers.Merge(
    NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillShowNotification)
        .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect }
        .map { $0.height },
    NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillHideNotification)
        .map { _ in CGFloat(0) }
)
.receive(on: DispatchQueue.main)
.sink { [weak self] height in
    self?.bottomConstraint.constant = -height
    UIView.animate(withDuration: 0.25) { self?.view.layoutIfNeeded() }
}
.store(in: &cancellables)

10. Combine과 async/await의 상호 운용

둘은 경쟁 관계가 아니라 보완 관계입니다. 애플도 양방향 변환 API를 제공합니다.

Publisher → async

// values: AsyncPublisher로 변환
for await value in publisher.values {
    print(value)
}

// 단일 값만 필요하면 first()와 조합
let user = try await api.fetchUser(id: 1)
    .first()
    .values
    .first(where: { _ in true })

async → Publisher

extension Publishers {
    static func fromAsync<T>(
        _ operation: @escaping () async throws -> T
    ) -> AnyPublisher<T, Error> {
        Deferred {
            Future { promise in
                Task {
                    do {
                        let value = try await operation()
                        promise(.success(value))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// 사용
searchQuery
    .debounce(for: .milliseconds(400), scheduler: DispatchQueue.main)
    .map { query in
        Publishers.fromAsync { try await api.search(query) }
            .catch { _ in Just([Repository]()) }
    }
    .switchToLatest()
    .assign(to: &$results)

언제 무엇을 쓸까

상황권장이유
단발성 API 호출async/await코드가 직선적, 에러 처리 단순
텍스트 입력 디바운스Combinedebounce 연산자 존재
여러 상태 조합CombinecombineLatest의 선언성
순차적 다단계 작업async/await구조적 동시성, 취소 전파
UI 바인딩 (SwiftUI)@Published / Observation프레임워크 통합
실시간 소켓 스트림둘 다 가능AsyncStream도 우수
브로드캐스트 (1:N)Combineshare(), multicast()

Observation 프레임워크 이후 — iOS 17부터 @Observable 매크로가 등장하며 SwiftUI 상태 관리에서 ObservableObject의 역할이 축소되었습니다.
그러나 비즈니스 로직 계층의 이벤트 스트림 처리에서는 Combine의 연산자 생태계가 여전히 유효합니다.
실무에서는 "UI 바인딩은 Observation, 데이터 파이프라인은 Combine"이라는 하이브리드 구성이 늘고 있습니다.

11. 테스트: 시간을 지배하는 법

반응형 코드의 테스트는 까다롭습니다. debounce가 걸린 스트림을 검증하려고 실제로 0.4초를 기다릴 수는 없죠.

기본 패턴: XCTestExpectation

func test_search_returnsResults() {
    let expectation = expectation(description: "검색 결과 수신")
    var received: [Repository] = []

    sut.$results
        .dropFirst()
        .sink { value in
            received = value
            expectation.fulfill()
        }
        .store(in: &cancellables)

    sut.query = "combine"

    wait(for: [expectation], timeout: 2.0)
    XCTAssertEqual(received.count, 3)
}

Scheduler 추상화로 시간 제어

더 견고한 방법은 Scheduler를 주입하는 것입니다.

final class SearchViewModel: ObservableObject {
    private let scheduler: AnySchedulerOf<DispatchQueue>

    init(service: SearchServicing,
         scheduler: AnySchedulerOf<DispatchQueue> = .main) {
        self.scheduler = scheduler
        // ...
        $query
            .debounce(for: .milliseconds(400), scheduler: scheduler)
            // ...
    }
}

// 테스트
func test_debounce() {
    let testScheduler = DispatchQueue.test
    let sut = SearchViewModel(service: mock, scheduler: testScheduler.eraseToAnyScheduler())

    sut.query = "a"
    sut.query = "ab"
    sut.query = "abc"

    testScheduler.advance(by: .milliseconds(399))
    XCTAssertEqual(mock.callCount, 0)     // 아직 호출 안 됨

    testScheduler.advance(by: .milliseconds(1))
    XCTAssertEqual(mock.callCount, 1)     // 딱 한 번만
    XCTAssertEqual(mock.lastQuery, "abc")
}

AnySchedulerOf와 DispatchQueue.test는 Point-Free의 swift-combine-schedulers 라이브러리에서 제공하는 도구입니다.
가상 시간을 앞당겨(advance(by:)) 테스트를 즉시, 결정론적으로 실행할 수 있습니다.

재능넷 '지식인의 숲'에 올라오는 iOS 실무 아티클에서도 반복해서 강조되는 원칙이 있습니다.
"테스트 가능한 반응형 코드의 첫걸음은 시간과 스레드를 외부에서 주입받는 것"입니다.

12. 성능과 디버깅

handleEvents로 파이프라인 들여다보기

publisher
    .handleEvents(
        receiveSubscription: { print("구독 시작: \($0)") },
        receiveOutput:       { print("값 방출: \($0)") },
        receiveCompletion:   { print("완료: \($0)") },
        receiveCancel:       { print("취소됨") },
        receiveRequest:      { print("요구: \($0)") }
    )
    .sink { _ in }
    .store(in: &cancellables)

더 간단하게는 print("태그") 연산자를 끼워 넣으면 모든 생애주기 이벤트가 콘솔에 찍힙니다.

publisher
    .print("🔍 검색")
    .map { ... }
    .print("🔄 변환후")
    .sink { ... }

share()로 중복 작업 제거

// 문제: 구독이 3번이면 네트워크 요청도 3번
let request = api.fetchConfig()
request.sink { ... }.store(in: &cancellables)
request.sink { ... }.store(in: &cancellables)
request.sink { ... }.store(in: &cancellables)

// 해결: 업스트림을 공유
let shared = api.fetchConfig().share()
// 요청은 1회, 결과는 3명에게 전달

share()의 타이밍 함정 — share()는 이미 지나간 값을 저장하지 않습니다.
첫 구독자가 값을 받은 뒤 두 번째 구독자가 붙으면 아무것도 못 받습니다.

과거 값까지 재생하려면 multicast(subject:) + CurrentValueSubject 조합이나
.share(replay:) 커스텀 구현이 필요합니다.

성능 체크리스트

1. removeDuplicates()를 적극 사용해 불필요한 다운스트림 실행을 줄이세요.

2. receive(on:)을 남발하면 컨텍스트 스위칭 비용이 누적됩니다. 필요한 지점 한 곳에만 두세요.

3. 무거운 변환은 subscribe(on:)으로 백그라운드에 배치하되, 스레드 홉이 정말 필요한지 먼저 측정하세요.

4. eraseToAnyPublisher()는 타입 정보를 지워 컴파일러 최적화를 방해합니다. 모듈 경계에서만 사용하세요.

5. 긴 체이닝은 컴파일 시간을 폭증시킵니다. 중간에 타입을 명시하거나 함수로 쪼개세요.

Combine 기반 앱 아키텍처 레이어 View (SwiftUI / UIKit) @Published 구독 · 사용자 입력을 Subject로 전달 ViewModel debounce · switchToLatest · combineLatest 로 상태 조립 Repository / Service AnyPublisher 반환 · 캐시와 네트워크 merge Data Source URLSession · CoreData · UserDefaults · WebSocket 이벤트 하향 데이터 상향

13. 실무 안티패턴 모음

① sink 안에서 또 sink 하기

// 나쁨: 중첩 구독, 취소 관리 불가
userPublisher.sink { user in
    self.api.fetchPosts(user.id).sink { posts in
        self.posts = posts
    }.store(in: &self.cancellables)   // 계속 쌓임
}.store(in: &cancellables)

// 좋음: flatMap / switchToLatest로 평탄화
userPublisher
    .map { self.api.fetchPosts($0.id) }
    .switchToLatest()
    .assign(to: &$posts)

② 모든 곳에 eraseToAnyPublisher 남발

타입 소거는 런타임 박싱 비용과 인라이닝 방해를 동반합니다.
public API 경계에서만 사용하고, 내부 체이닝에서는 구체 타입을 유지하세요.
Swift 5.7 이상에서는 some Publisher<Output, Failure> 반환도 고려할 수 있습니다.

③ Subject를 상태 저장소로 오용

여러 개의 CurrentValueSubject를 흩뿌려 두면 단일 진실 공급원이 깨집니다.
상태는 하나의 구조체로 묶고, 단일 @Published var state: ViewState로 관리하는 편이 추적하기 쉽습니다.

struct ViewState: Equatable {
    var items: [Item] = []
    var isLoading = false
    var error: String?
}

@Published private(set) var state = ViewState()

④ 메인 스레드 갱신 누락

URLSession의 퍼블리셔는 백그라운드 스레드에서 값을 방출합니다.
receive(on: DispatchQueue.main) 없이 UI를 건드리면 비결정적 크래시가 발생합니다.
Xcode의 Main Thread Checker를 항상 켜두세요.

⑤ 구독을 매번 새로 만들기

viewWillAppear에서 bind()를 호출하면 화면에 재진입할 때마다 구독이 중복됩니다.
init 또는 viewDidLoad에서 단 한 번만 바인딩하거나,
매번 만든다면 cancellables.removeAll()로 이전 구독을 정리하세요.

14. 커스텀 연산자 만들기

Combine의 진짜 확장성은 직접 만드는 연산자에서 나옵니다.
대부분은 기존 연산자를 조합한 Publisher 익스텐션으로 충분합니다.

extension Publisher {
    /// 값이 nil이 아닌 것만 통과시키고 언래핑
    func unwrap<T>() -> Publishers.CompactMap<Self, T>
    where Output == T? {
        compactMap { $0 }
    }

    /// 로딩 상태를 자동으로 토글
    func withLoading(
        _ isLoading: @escaping (Bool) -> Void
    ) -> AnyPublisher<Output, Failure> {
        handleEvents(
            receiveSubscription: { _ in isLoading(true) },
            receiveCompletion: { _ in isLoading(false) },
            receiveCancel: { isLoading(false) }
        )
        .eraseToAnyPublisher()
    }

    /// 에러를 Result로 감싸 스트림 생존 보장
    func asResult() -> AnyPublisher<Result<Output, Failure>, Never> {
        map(Result.success)
            .catch { Just(.failure($0)) }
            .eraseToAnyPublisher()
    }
}

extension Publisher where Failure == Never {
    /// weak self 패턴을 간결하게
    func weakAssign<Root: AnyObject>(
        to keyPath: ReferenceWritableKeyPath<Root, Output>,
        on object: Root
    ) -> AnyCancellable {
        sink { [weak object] value in
            object?[keyPath: keyPath] = value
        }
    }
}

사용 예시입니다.

api.fetchItems()
    .withLoading { [weak self] in self?.isLoading = $0 }
    .asResult()
    .receive(on: DispatchQueue.main)
    .sink { [weak self] result in
        self?.handle(result)
    }
    .store(in: &cancellables)

설계 원칙 — 커스텀 연산자는 한 가지 일만 해야 합니다.
"로딩 + 에러 + 스레드 전환 + 캐싱"을 전부 하는 만능 연산자는 디버깅 지옥의 시작입니다.

15. 학습 로드맵과 마무리

Combine을 제대로 익히는 순서를 제안하면 다음과 같습니다.

4주 학습 플랜

1주차 — 기초 어휘
Just, Future, PassthroughSubject, CurrentValueSubject, sink, assign, AnyCancellable.
Playground에서 print() 연산자로 값이 흐르는 모습을 눈으로 확인하세요.

2주차 — 연산자 감각
map, filter, flatMap, switchToLatest, debounce, combineLatest, removeDuplicates.
마블 다이어그램을 직접 손으로 그려보면 체화 속도가 훨씬 빠릅니다.

3주차 — 에러와 스레드
mapError, catch, retry, replaceError, subscribe(on:), receive(on:).
실제 API를 붙여 네트워크 레이어를 하나 만들어 보세요.

4주차 — 아키텍처와 테스트
MVVM 바인딩, Scheduler 주입, 커스텀 연산자, 메모리 프로파일링.
Instruments의 Leaks/Allocations로 순환 참조를 직접 잡아보는 경험이 중요합니다.

Combine을 쓰지 말아야 할 때

모든 도구는 적정 용도가 있습니다. 다음 상황이라면 재고해 보세요.

· 팀에 반응형 경험자가 없고 일정이 촉박한 프로젝트
· 단순한 요청-응답만 존재하는 소규모 앱 — async/await가 훨씬 읽기 쉽습니다
· iOS 12 이하 지원이 필요한 경우 — Combine은 iOS 13+ 전용입니다
· 이미 RxSwift 기반 코드베이스가 크게 구축된 경우 — 혼용은 인지 부하만 키웁니다

정리: Combine이 주는 것

선언성 — "무엇을 할지"만 쓰고 "어떻게 순서를 맞출지"는 프레임워크에 맡깁니다.

합성성 — 작은 퍼블리셔를 레고처럼 조립해 복잡한 흐름을 만듭니다.

취소 가능성 — AnyCancellable 하나로 모든 하위 작업이 연쇄 취소됩니다.

일관성 — 네트워크·타이머·알림·KVO·사용자 입력이 전부 같은 문법으로 표현됩니다.

반응형 프로그래밍은 패러다임 전환입니다.
"값을 가져와서 처리한다"에서 "값이 흐르는 길을 미리 설계한다"로 사고방식을 바꿔야 합니다.

처음 2주는 분명히 답답합니다. 컴파일 에러 메시지도 불친절하고, Failure 타입 맞추다가 하루가 갈 때도 있습니다.
하지만 그 고비를 넘기면 이전으로 돌아가기 어려워집니다.
검색 디바운스, 이전 요청 취소, 로딩 상태 관리가 단 다섯 줄로 끝나는 경험을 하고 나면요.

재능넷 '지식인의 숲'에는 이런 iOS 심화 주제들이 계속 쌓이고 있습니다.
오늘 정리한 내용을 실제 프로젝트의 한 화면에만 적용해 보세요.
가장 좋은 학습은 돌아가는 코드를 직접 만들어 보는 것이니까요.

다음 단계 추천
· 애플 공식 문서의 Combine 섹션과 Receiving and Handling Events with Combine 샘플
· WWDC19 "Introducing Combine", "Combine in Practice" 세션
· WWDC21 "Meet AsyncSequence" — Combine과 비교하며 보면 이해가 깊어집니다
· Point-Free의 swift-combine-schedulers 오픈소스 코드 읽기

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0