포스트

SwiftUI Combine (1)

이전에 정리를 한적이 있긴한데, 강의에 적힌걸 번역해서 여기에 적어본다.

내용이 꽤나 많을지도?

Combine이란?

Combine은 비동기 값을 데이터 스트림으로 처리하는 Apple의 프레임워크다.

4가지 핵심 구성요소가 있다.

  • Publisher – 시간이 지남에 따라 값을 생산하는 소스 (네트워크 요청, 타이머, Subject 등)
  • Subscriber – 값을 소비하는 리스너 (.sink)
  • Operator – Publisher에 체이닝하여 스트림을 변환하거나 제어하는 메서드 (map, filter, decode, debounce 등)
  • Subject – 값을 수동으로 .send()로 밀어넣을 수 있는 특수 Publisher. Subscriber 역할도 한다.
    • PassthroughSubject – 새 값만 구독자에게 방출한다.
    • CurrentValueSubject – 최신 값을 저장하고 있어, 새 구독자에게 즉시 현재 값을 전달한다.

UFC 파이터 앱 예시

1
2
3
4
5
6
7
8
9
let subject = CurrentValueSubject<[MMAFighter], Never>([])

subject
    .map { $0.count }       // operator
    .sink { count in        // subscriber
        print("Roster has \(count) fighters")
    }

subject.send([MMAFighter(name: "Jon Jones", fightTeam: "Jackson Wink", country: "USA", record: "28-1", age: 36)])

출력:

1
Roster has 1 fighters
  • Subject = Publisher이자 새 값을 밀어넣는 곳
  • Operator = map이 파이터 목록을 숫자로 변환
  • Subscriber = sink가 숫자를 출력

간단한 멘탈 모델

  • Publishers는 값을 방출한다.
  • Operators는 값을 변환한다.
  • Subscribers는 값을 소비한다.
  • Subject는 값을 수동으로 보낼 수 있는 특수한 Publisher다.

Combine vs Async/Await vs Escaping Closure

1. Escaping Closure (기존 방식)

비동기 작업이 끝났을 때 실행될 클로저를 전달하는 방식이다.

1
2
3
4
5
6
7
8
9
10
func fetchFighters(completion: @escaping ([MMAFighter]) -> Void) {
    DispatchQueue.global().async {
        let fighters = [MMAFighter(name: "Jon Jones", fightTeam: "...", country: "USA", record: "28-1", age: 36)]
        completion(fighters)
    }
}

fetchFighters { fighters in
    print("Got:", fighters)
}

장점: 단순하고 널리 쓰이며 이해하기 쉽다.

단점:

  • 콜백이 중첩되기 쉽다 (“콜백 지옥”)
  • 여러 요청을 합치거나 재시도 로직을 구성하기 어렵다.
  • 스트림 값을 변환하는 내장 도구가 없다.

2. Async/Await (현대 Swift)

비동기 작업을 동기 코드처럼 보이게 만드는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
func fetchFighters() async throws -> [MMAFighter] {
    [MMAFighter(name: "Jon Jones", fightTeam: "...", country: "USA", record: "28-1", age: 36)]
}

Task {
    do {
        let fighters = try await fetchFighters()
        print("Got:", fighters)
    } catch {
        print("Error:", error)
    }
}

장점:

  • 훨씬 깔끔하고 콜백 중첩이 없다.
  • try/throw로 에러 처리가 편리하다.
  • 일회성 비동기 작업(네트워크 호출, DB 쿼리)에 최적이다.

단점:

  • 시간이 지남에 따라 값이 계속 오는 스트림(실시간 검색, 알림, 지속적인 업데이트)에는 적합하지 않다.
  • 그런 경우엔 AsyncStream 등의 도구가 별도로 필요하다.

3. Combine

비동기 값을 Publisher로 표현하며, 시간이 지남에 따라 여러 값을 방출할 수 있다. Operator로 변환/필터링하고, Subscriber로 소비한다.

1
2
3
4
5
6
service.fetchFighters(search: "jon")
    .map { $0.count }
    .sink { count in
        print("Found \(count) fighters")
    }
    .store(in: &cancellables)

장점:

  • 데이터 스트림(텍스트 변경, 타이머, 로스터 업데이트)에 최적이다.
  • Combine, Merge, Debounce, Retry 등 조합이 가능하다.
  • SwiftUI의 @Published와 통합하여 반응형 UI를 만들 수 있다.

단점:

  • 학습 곡선이 있다 (Publisher, Subscriber, Subject).
  • 단순 일회성 비동기 작업에는 Async/Await보다 무겁다.

핵심 차이

  • Escaping Closure = 저수준, 일회성 콜백. 단순하지만 제한적.
  • Async/Await = 일회성 비동기 작업에 최적. 동기 코드처럼 읽힌다.
  • Combine = 시간이 지남에 따라 값이 계속 변하는 스트림에 최적. 선언적으로 반응한다.

UFC 앱 맥락에서

  • Escaping Closure: “파이터 한 번 가져오고 콜백 줘”
  • Async/Await: “파이터 한 번 가져와, 깔끔하게”
  • Combine: “JSON이 바뀌거나 검색어가 바뀌거나 로스터가 업데이트될 때마다 UI를 자동으로 반영해줘”

Publisher의 종류

Publisher는 값을 시간이 지남에 따라 생산할 수 있는 모든 것이다.

방출할 값의 타입(Output)과 에러 타입(Failure)을 정의한다. Subscriber가 붙어서 값을 받는다.

크게 두 가지로 나뉜다.

  • Built-in Publisher → Apple이 제공
  • Subject Publisher → 직접 생성하고 제어 (PassthroughSubject, CurrentValueSubject)

1. Built-in Publisher

Sequence Publisher

기존 컬렉션을 Publisher로 변환한다.

1
2
3
4
[1, 2, 3].publisher   // 1, 2, 3 순서대로 방출
Just(value)           // 값 하나 방출 후 완료
Empty()               // 아무것도 방출하지 않고 완료
Fail()                // 즉시 에러 발생

Timer Publisher

1
2
Timer.publish(every: 1, on: .main, in: .common)
// 설정한 간격마다 값을 방출

Notification Publisher

1
2
NotificationCenter.Publisher
// 알림이 발생할 때 값을 방출

URLSession Publisher

1
2
URLSession.shared.dataTaskPublisher(for: url)
// 네트워크 데이터와 응답을 방출

Property Publisher

1
2
3
4
// SwiftUI ObservableObject 안에서
@Published var property: Type

$property  // Publisher로 사용 가능, 프로퍼티 변경을 구독

Operator로 생성된 Publisher

map, filter, combineLatest 등 많은 Operator는 새 Publisher를 반환한다.

1
2
let numbers = [1, 2, 3].publisher
let squared = numbers.map { $0 * $0 }  // 새 Publisher

2. Subject Publisher

.send()로 값을 수동으로 밀어넣을 수 있는 특수 Publisher다.

PassthroughSubject<Output, Failure>

  • 새 값만 방출한다.
  • 나중에 구독한 Subscriber에게 이전 값을 전달하지 않는다.
  • 이벤트에 사용한다.

CurrentValueSubject<Output, Failure>

  • 항상 최신 값을 저장한다.
  • 새 구독자에게 즉시 현재 값을 전달한다.
  • 상태에 사용한다.

3. Custom Publisher

Publisher 프로토콜을 직접 구현하여 만들 수 있다. 다만 고급 주제이며 직접 프레임워크를 만드는 게 아니라면 보통 필요 없다.


한눈에 정리

  • 시퀀스 Publisher (Just, .publisher, Empty, Fail)
  • 시스템 Publisher (Timer, NotificationCenter, URLSession)
  • 프로퍼티 Publisher (@Published)
  • Subject (PassthroughSubject, CurrentValueSubject)
  • Operator로 생성된 Publisher (모든 Operator는 새 Publisher를 생성)

Subject: PassthroughSubject vs CurrentValueSubject

쉬운 설명

PassthroughSubject — 메가폰

  • 누군가 듣고 있을 때 소리치면 들린다.
  • 아무도 없을 때 소리치면 메시지는 사라진다.
  • 나중에 참여한 사람은 이전에 한 말을 못 듣는다. 앞으로 하는 말만 들린다.

MMA 로스터로 비유하면:

  • 로스터를 업데이트하면 구독자는 그 업데이트만 본다.
  • 나중에 구독한 사람은 이전 로스터를 못 본다. 이후 업데이트만 본다.

CurrentValueSubject — 화이트보드

  • 항상 현재 상태가 적혀있다.
  • 누가 들어오든 즉시 현재 내용을 볼 수 있다.
  • 업데이트하면 모두가 새 내용을 보고, 화이트보드는 그 값을 유지한다.

MMA 로스터로 비유하면:

  • 구독자는 즉시 현재 전체 로스터를 받는다.
  • 파이터를 추가하면 모두가 업데이트된 전체 로스터를 본다.
  • 나중에 구독한 사람도 최신 로스터를 즉시 받는다.

예시 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import Combine

var cancellables = Set<AnyCancellable>()

// PassthroughSubject: 업데이트만 전달, 이전 값 없음
let passthroughRoster = PassthroughSubject<[String], Never>()

// CurrentValueSubject: 항상 최신 값 보유
let currentRoster = CurrentValueSubject<[String], Never>(["Jon Jones"])

// --- PassthroughSubject 예시 ---
print("=== PassthroughSubject Example ===")
passthroughRoster
    .sink { print("Subscriber1 sees:", $0) }
    .store(in: &cancellables)

passthroughRoster.send(["Jon Jones", "Islam Makhachev"])

// 첫 번째 업데이트 이후 새 구독자 참여
passthroughRoster
    .sink { print("Subscriber2 sees:", $0) }
    .store(in: &cancellables)

passthroughRoster.send(["Jon Jones", "Islam Makhachev", "Israel Adesanya"])

// --- CurrentValueSubject 예시 ---
print("\n=== CurrentValueSubject Example ===")
currentRoster
    .sink { print("Subscriber1 sees:", $0) }
    .store(in: &cancellables)

currentRoster.send(["Jon Jones", "Islam Makhachev"])

// 업데이트 이후 새 구독자 참여
currentRoster
    .sink { print("Subscriber2 sees:", $0) }
    .store(in: &cancellables)

currentRoster.send(["Jon Jones", "Islam Makhachev", "Israel Adesanya"])

출력:

1
2
3
4
5
6
7
8
9
10
11
=== PassthroughSubject Example ===
Subscriber1 sees: ["Jon Jones", "Islam Makhachev"]
Subscriber1 sees: ["Jon Jones", "Islam Makhachev", "Israel Adesanya"]
Subscriber2 sees: ["Jon Jones", "Islam Makhachev", "Israel Adesanya"]

=== CurrentValueSubject Example ===
Subscriber1 sees: ["Jon Jones"]
Subscriber1 sees: ["Jon Jones", "Islam Makhachev"]
Subscriber2 sees: ["Jon Jones", "Islam Makhachev"]
Subscriber1 sees: ["Jon Jones", "Islam Makhachev", "Israel Adesanya"]
Subscriber2 sees: ["Jon Jones", "Islam Makhachev", "Israel Adesanya"]

핵심 정리

  • PassthroughSubject: 구독 중일 때만 값을 받는다. 이전 값은 없다.
  • CurrentValueSubject: 구독 즉시 현재 값을 받고, 이후 모든 업데이트를 계속 받는다.

Stream이란?

Combine에서 Stream은 시간이 지남에 따라 도착하는 값의 시퀀스다.

Swift에서 익숙한 정적 시퀀스인 Array는 이렇다.

1
let numbers = [1, 2, 3]

반복할 수 있지만 모든 값이 이미 존재한다.

Stream은 모든 값이 아직 없는 Array와 같다. 값들이 시간이 지남에 따라 “흘러 들어온다”.


Stream의 특성

  • 순서 보장: 값은 생산된 순서대로 도착한다.
  • 비동기성: 다음 값이 언제 올지 모른다.
  • 잠재적으로 무한: 스트림은 종료되거나(complete), 에러가 나거나(fail), 영원히 계속될 수 있다.

Combine에서의 예시

1
2
3
4
5
6
7
8
let subject = PassthroughSubject<String, Never>()

subject
    .sink { print("Received:", $0) }

subject.send("Jon Jones")
subject.send("Islam Makhachev")
subject.send("Israel Adesanya")

출력:

1
2
3
Received: Jon Jones
Received: Islam Makhachev
Received: Israel Adesanya
  • Stream은 시간이 지남에 따라 방출되는 파이터 이름의 흐름이다.
  • .send()를 호출할 때마다 Stream에 값이 추가된다.
  • Subscriber는 값이 흘러올 때마다 Stream을 듣는다.

Stream vs Array 비교

  • Array = 모든 값이 처음부터 존재한다.
  • Stream = 값이 시간이 지남에 따라 나타나고, 도착할 때마다 반응한다.

비동기 작업에서 Stream이 중요한 이유

  • 네트워크 호출 한 번 (파이터 한 번 가져오기) = 단일 값. Stream이 아님. 일회성 비동기 결과 (Async/Await이 더 적합).
  • 실시간 타이핑 검색창 = Stream. 키 입력마다 새 값이 생성되고 순서대로 반응해야 한다.
  • 로스터 업데이트 피드 = Stream. 시간이 지남에 따라 파이터가 추가/삭제/변경될 수 있다.

Combine에서:

  • Publisher가 Stream을 생산한다.
  • Subscriber가 Stream을 소비한다.
  • Operator가 값이 흐르면서 Stream을 변환한다.

.sink.send

.sink란?

Publisher에서 데이터를 듣는 방법이다. (🎧 헤드폰 비유)

  • 데이터를 직접 제어하지 않는다.
  • 새 값이 도착할 때만 반응한다.

헤드폰을 꽂는 것과 같다. 음악이 나오면 들리지만, 직접 음악을 틀지는 않는다.

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

numbers.sink { value in
    print("Got:", value)
}

출력:

1
2
3
Got: 1
Got: 2
Got: 3

.send란?

Subject에 새 데이터를 밀어넣는 방법이다. (🎛️ 라디오 DJ 비유)

  • 값을 직접 공급한다.
  • 구독 중인 모든 Subscriber가 보낸 값을 받는다.

라디오 방송국의 DJ와 같다. 음악을 틀면 듣고 있는 모두가 듣는다.

1
2
3
4
5
6
7
8
9
10
import Combine

let subject = PassthroughSubject<String, Never>()

subject.sink { value in
    print("Got:", value)
}

subject.send("Jon Jones")
subject.send("Islam Makhachev")

출력:

1
2
Got: Jon Jones
Got: Islam Makhachev

한 줄 요약

  • .sink = “흘러오는 것을 듣겠다”
  • .send = “무언가를 밀어넣겠다” (Subject에서만 동작)

UFC 앱에서의 활용

  • .sink는 서비스가 파이터를 발행할 때 ViewModel이나 SwiftUI View에서 소비할 때 사용한다.
  • .send는 Subject를 사용하여 새 파이터를 로스터에 추가하는 등 변경을 수동으로 발행할 때 사용한다.

Subscriber란?

Combine에서 Subscriber는 Publisher를 구독하고, Publisher가 새 값을 보낼 때마다 반응하는 것이다.

  • Publisher = “나는 시간이 지남에 따라 값을 생산할 수 있다”
  • Subscriber = “나는 그 값을 받고 싶다”

Subscriber 없이는 Publisher가 아무것도 하지 않는다. Subscriber가 있어야 데이터 스트림에 생명력을 불어넣는다.


예시: UFC 파이터 (간단)

파이터 이름 목록을 관리하는 ViewModel:

1
2
3
4
5
import Combine

class ViewModel {
    @Published var names: [String] = []
}

@Published는 자동으로 names를 Publisher로 만든다. names가 변경될 때마다 새 값을 방출한다.

이제 Publisher를 구독한다:

1
2
3
4
5
6
7
8
9
10
11
12
let vm = ViewModel()
var cancellables = Set<AnyCancellable>()

vm.$names   // Publisher
    .sink { updatedNames in
        print("Subscriber received names:", updatedNames)
    }
    .store(in: &cancellables)

// names 배열 변경
vm.names = ["Jon Jones", "Islam Makhachev"]
vm.names = ["Jon Jones", "Islam Makhachev", "Israel Adesanya"]

출력:

1
2
3
Subscriber received names: []
Subscriber received names: ["Jon Jones", "Islam Makhachev"]
Subscriber received names: ["Jon Jones", "Islam Makhachev", "Israel Adesanya"]

무슨 일이 일어나는가

  • Subscriber가 붙으면(.sink), 현재 names 값(빈 배열)을 즉시 받는다.
  • vm.names가 업데이트되면 Publisher가 자동으로 새 배열을 보낸다.
  • Subscriber는 매번 반응하여 업데이트된 목록을 출력한다.

핵심 정리

  • .sink가 Subscriber를 생성한다.
  • Subscriber는 Publisher가 값을 방출할 때마다 반응하는 코드다.
  • @Published를 사용하면 모든 프로퍼티 변경이 자동으로 Subscriber에게 새 데이터를 방출한다.

@Published를 Subject 대신 앱에서 사용하는 이유

@Published는 실제로 무엇을 하는가?

아래와 같이 선언하면:

1
@Published var fighters: [MMAFighter] = []

두 가지를 자동으로 얻는다.

  1. 일반 프로퍼티처럼 읽고 쓸 수 있는 저장 프로퍼티 (fighters)
1
fighters = [MMAFighter(name: "Jon Jones", ...)]
  1. 프로퍼티가 변경될 때마다 새 값을 방출하는 Publisher ($fighters)
1
2
3
vm.$fighters.sink { updated in
    print("Fighters changed:", updated)
}

@Published는 이미 상태 저장소 + Publisher다.


Subject와 비교

@Published가 없다면, CurrentValueSubject로 같은 동작을 수동으로 구현할 수 있다.

1
2
3
4
5
6
7
8
let fightersSubject = CurrentValueSubject<[MMAFighter], Never>([])

// 상태 업데이트
fightersSubject.send([MMAFighter(name: "Jon Jones", ...)])

// 상태 관찰
fightersSubject
    .sink { print("Fighters changed:", $0) }

동작하긴 하지만, 직접 관리해야 할 것들이 생긴다.

  • 상태가 변경될 때마다 .send를 직접 호출해야 한다.
  • Subject를 어딘가에 저장해야 한다.
  • 원시 프로퍼티와 Subject 둘 다 노출된다.

@Published가 그것을 대체하는 이유

@Published를 사용하면:

  • 프로퍼티(fighters)가 현재 상태를 보유한다.
  • Wrapper가 프로퍼티가 변경될 때마다 자동으로 업데이트를 보낸다.
  • SwiftUI View는 @StateObject 또는 @ObservedObject로 직접 관찰할 수 있다.

따라서 ViewModel에서:

1
@Published var fighters: [MMAFighter] = []

이것은 사실상 아래의 축약형이다.

1
2
3
4
5
6
var fightersSubject = CurrentValueSubject<[MMAFighter], Never>([])

var fighters: [MMAFighter] {
    get { fightersSubject.value }
    set { fightersSubject.send(newValue) }
}

핵심 아이디어

“상태를 저장하고 변경될 때 UI에 알리는 것”이 목적이라면, @Published가 이미 내부적으로 Subject다.

@Published와 별도의 Subject를 함께 사용하면 작업이 중복된다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.