본문으로 건너뛰기 Swift Combine | 반응형 프로그래밍 완벽 가이드

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

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

이 글의 핵심

Swift Combine: 반응형 프로그래밍 Publisher와 Subscriber·Operator.

들어가며

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
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 시리즈를 완료했습니다! 다른 언어도 배워보세요:


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Swift Combine: 반응형 프로그래밍 완벽 가이드. Publisher와 Subscriber·Operator로 흐름을 잡고 원리·코드·실무 적용을 한글로 정리합니다. Swift·Combine·반응형프로그래밍 중… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Swift, Combine, 반응형프로그래밍 등으로 검색하시면 이 글이 도움이 됩니다.