RxSwift (2)
내용이 길어질듯 해서 새롭게 작성한다.
Traits
Trait는 Observable을 특정 상황에 맞게 제한한 변형 타입이다. 방출할 수 있는 이벤트 종류를 미리 좁혀놓아서, “이 스트림은 값을 여러 번 방출하지 않고 딱 한 번만 결과를 낸다” 같은 의도를 타입 자체로 표현할 수 있다. RxSwift에는 크게 세 가지 Trait가 있다.
1. 세 가지 Trait 종류
- Single:
success(value)또는error중 하나만 방출하고 종료된다.success는completed가 뒤따르는 것과 같은 의미로, 값과 함께 정상 종료됨을 나타낸다. 파일 읽기, 네트워크 요청처럼 “결과가 하나만 나오는” 작업에 적합하다. - Completable: 값 없이
completed또는error만 방출한다. 성공/실패 여부만 알리고 싶을 때 사용한다 (예: 로컬 DB 저장 완료 여부). - Maybe: Single과 Completable을 합친 형태로,
success(value)/completed/error셋 중 하나를 방출할 수 있다. 값이 있을 수도, 없을 수도 있는 상황에 적합하다.
세 Trait 모두 이벤트를 하나만 방출하면 스트림이 즉시 종료된다는 공통점이 있다.
2. Single 사용 예시 (파일 읽기)
로컬 파일을 읽는 작업은 “성공(내용 반환)” 또는 “실패(에러)” 둘 중 하나로 끝나는 대표적인 케이스라 Single로 표현하기 적합하다.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
enum FileError: Error {
case fileNotFound
case unreadable
case encodingFailed
}
func loadFile(named fileName: String) -> Single<String> {
return Single<String>.create { single in
let disposable = Disposables.create()
guard let path = Bundle.main.path(forResource: fileName, ofType: "txt") else {
single(.failure(FileError.fileNotFound))
return disposable
}
guard let data = FileManager.default.contents(atPath: path) else {
single(.failure(FileError.unreadable))
return disposable
}
guard let contents = String(data: data, encoding: .utf8) else {
single(.failure(FileError.encodingFailed))
return disposable
}
single(.success(contents))
return disposable
}
}
func singles() {
let single = loadFile(from: "quote")
let bag = DisposeBag()
// single.subscribe { (event) in
//
// switch event {
//
// case .error(let error):
//
// print(error)
//
// case .success(let quote):
//
// print(quote)
// }
// }.disposed(by: bag)
single.subscribe(onSuccess: { print($0) }, onFailure: { print($0) }).disposed(by: bag)
}
onNext/onCompleted처럼 이벤트마다 분기하지 않고, onSuccess와 onFailure 두 케이스만 처리하면 되는 게 Observable 대비 Single의 장점이다.
참고: 예전 RxSwift 버전에서는
SingleEvent가success(Element)/error(Swift.Error)두 케이스를 가진 별도 enum이었지만, 최신 버전에서는SingleEvent가 Swift 표준 라이브러리의Result<Success, Failure>타입으로 대체되었다.Result는.success/.failure두 케이스만 가지므로, 예전 강의 자료에 나오는single(.error(...))는 지금 기준으로는single(.failure(...))로 써야 컴파일된다. 기능적 의미는 동일하고 이름만Result컨벤션에 맞춰 바뀐 것이다.
1
2
3
4
5
6
7
// Before
public enum SingleEvent<Element> {
case success(Element)
case error(Swift.Error)
}
// After
public typealias SingleEvent<Element> = Result<Element, Swift.Error>
3. 핵심 요약
- Single: 값 1개 성공 또는 실패 (
onSuccess/onFailure) - Completable: 값 없이 성공 또는 실패 (
onCompleted/onError) - Maybe: 값 있는 성공 / 값 없는 성공 / 실패 셋 중 하나 (
onSuccess/onCompleted/onError) - Trait는 새로운 스트림 방식이 아니라, Observable이 방출할 수 있는 이벤트 종류를 의도에 맞게 제한한 것
- 결과가 딱 한 번만 나오는 비동기 작업(파일 읽기, 네트워크 요청, 로컬 저장 등)을 표현할 때 Observable보다 의도가 명확해진다
4. Combine과 비교 (Future)
Combine에서 Single과 가장 유사한 타입은 Future다. “결과가 딱 한 번만 나오는 비동기 작업”을 표현한다는 개념 자체가 동일하다.
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
func loadFile(named fileName: String) -> Future<String, FileError> {
return Future { promise in
guard let path = Bundle.main.path(forResource: fileName, ofType: "txt") else {
promise(.failure(.fileNotFound))
return
}
guard let data = FileManager.default.contents(atPath: path) else {
promise(.failure(.unreadable))
return
}
guard let contents = String(data: data, encoding: .utf8) else {
promise(.failure(.encodingFailed))
return
}
promise(.success(contents))
}
}
var cancellables = Set<AnyCancellable>()
loadFile(named: "quote")
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
},
receiveValue: { quote in print(quote) }
)
.store(in: &cancellables)
RxSwift Single이 success/error 중 하나만 방출하고 끝나는 것처럼, Future도 promise(.success(...)) 또는 promise(.failure(...))를 딱 한 번만 호출하고 종료된다.
다만 동작 방식에는 차이가 있다. Single은 구독 전까지 아무 작업도 시작하지 않는 lazy 스트림이지만, Future는 생성되는 즉시 클로저가 실행되는 eager 방식이다.
그래서 Future는 구독자가 없어도 이미 작업이 진행 중일 수 있고, 여러 번 구독해도 이미 계산된 결과를 공유한다는 점에서 Single과 실질적인 차이가 생긴다.
Subjects
지금까지 다룬 Observable은 전부 미리 정해진 값을 방출하는 형태였다. 하지만 실제 앱에서는 사용자 입력이나 네트워크 응답처럼 코드 실행 중에 값이 생기는 경우가 대부분이다. 이렇게 실행 중에 값을 직접 주입할 수 있는 Observable이 Subject다. RxSwift에는 여러 종류의 Subject가 있으며, 그중 가장 기본이 되는 것이 Publish Subject다.
1. Publish Subject 개념
- 핵심 동작: 구독 시점 이후에 방출된 값만 받을 수 있다. 구독 전에 이미 지나간 값은 새로 구독해도 받을 수 없다.
- 구독자가 없을 때: Subject가 값을 방출해도 구독자가 없으면 그 값은 그냥 유실된다.
- 여러 구독자: 같은 시점 이후에 구독한 모든 구독자는 그 이후 방출되는 값을 동일하게 받는다. 단, 구독 시점이 다르면 그 이전 값은 못 받는다.
- completed/error 이후 구독: Subject가 이미
completed나error로 끝난 뒤에 새로 구독하면, 그 완료/에러 이벤트만 즉시 받고 이후에는 아무 값도 받지 않는다. - 완료 이후 재사용 불가:
completed나error를 보낸 뒤에 다시onNext를 호출해도, 기존 구독자든 새 구독자든 그 값을 받지 못한다.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
func subject() {
let publish = PublishSubject<Int>()
publish.on(.next(1))
// 아직 구독자가 없어서 값 1은 그냥 유실됨
let subscriber = publish.subscribe { (event) in
print("1)", event.element ?? event)
}
// 첫 번째 구독자 등록 (이 시점 이후 값부터 받음)
publish.onNext(2)
// 1) 2
// -> 첫 번째 구독자만 있으므로 2만 받음
let bag = DisposeBag()
publish.subscribe { (event) in
print("2)", event.element ?? event)
}.disposed(by: bag)
// 두 번째 구독자 등록 (이전 값인 2는 받지 못함)
publish.onNext(3)
// 1) 3
// 2) 3
// -> 두 구독자 모두 활성 상태라 3을 같이 받음
subscriber.dispose()
// 첫 번째 구독자 수동 dispose -> 이후 값을 받지 못함
publish.onNext(4)
// 2) 4
// -> 두 번째 구독자만 받음, 첫 번째는 이미 해제됨
publish.onCompleted()
// 2) completed
// -> 두 번째 구독자가 완료 이벤트를 받음
publish.subscribe { (event) in
print("3)", event.element ?? event)
}.disposed(by: bag)
// 세 번째 구독자는 completed 이후에 구독
// 3) completed
// -> 완료된 Subject를 구독하면 completed 이벤트만 즉시 전달받음
publish.onNext(6)
// 아무 출력 없음
// -> 이미 completed된 Subject는 재사용 불가, 어떤 구독자도 값을 받지 못함
}
subject()
핵심 요약
- Publish Subject는 구독 시점 이후에 방출된 값만 각 구독자에게 전달한다
- 구독자가 없을 때 방출된 값은 그냥 유실되고, 나중에 구독해도 다시 받을 수 없다
completed나error로 종료된 뒤에는 재사용이 불가능하며, 종료 이후 구독한 구독자는 그 종료 이벤트만 즉시 받는다
2. Behavior Subject
- 핵심 동작: Publish Subject와 거의 같지만, 구독하는 순간 가장 최근에 방출된 값(또는 초기값)을 즉시 받는다는 차이가 있다.
- 초기값 필수: Behavior Subject는 생성 시 반드시 초기값을 지정해야 한다. 초기값이 필요 없다면 Publish Subject를 쓰는 게 맞다.
- 구독 시점 동작: 구독자는 구독하는 즉시 그 시점까지의 최신 값을 받고, 이후로는 새로 방출되는 값을 이어서 받는다.
- error 이후:
onError가 발생하면 시퀀스가 종료되고, 이후에onNext를 호출해도 반영되지 않는다. 이 시점 이후 새로 구독하면 마지막 값이 아니라 해당 error 이벤트만 즉시 받는다.
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
40
41
42
43
44
45
46
47
48
49
50
51
enum FetchError: Error {
case failed
}
func behaviorSubject() {
let subject = BehaviorSubject(value: "Loading...")
// 초기값 "Loading..." 지정 (Behavior Subject는 초기값 필수)
let bag = DisposeBag()
subject.subscribe { (event) in
print("1)", event.element ?? event)
}.disposed(by: bag)
// 첫 번째 구독자 등록 -> 구독 즉시 초기값 "Loading..." 받음
// 1) Loading...
subject.onNext("Fetched Value")
// 1) Fetched Value
// -> 첫 번째 구독자만 있으므로 이 값을 받음
subject.subscribe { (event) in
print("2)", event.element ?? event)
}.disposed(by: bag)
// 두 번째 구독자 등록 -> 구독 즉시 최신값 "Fetched Value" 받음
// 2) Fetched Value
subject.onError(FetchError.failed)
// 1) failed
// 2) failed
// -> 두 구독자 모두 error 이벤트 받음, 시퀀스 종료
subject.onNext("New Value")
// 아무 출력 없음
// -> 이미 error로 종료된 Subject라 반영되지 않음
subject.subscribe { (event) in
print("3)", event.element ?? event)
}.disposed(by: bag)
// 세 번째 구독자는 error 이후에 구독
// 3) failed
// -> 최신 값이 아니라 종료된 error 이벤트만 즉시 받음
}
behaviorSubject()
핵심 요약
- Behavior Subject는 구독 시점에 초기값 또는 가장 최근 값을 즉시 전달한다는 점에서 Publish Subject와 다르다
- 생성 시 초기값이 반드시 필요하며, 초기값이 필요 없다면 Publish Subject를 사용한다
error나completed로 종료된 뒤에는 재사용이 불가능하며, 종료 이후 구독한 구독자는 최신 값이 아니라 그 종료 이벤트만 즉시 받는다
3. Replay Subject
- 핵심 동작: 지정한 개수(buffer size)만큼 가장 최근 값들을 저장해두고, 새로 구독하는 구독자에게 즉시 그 버퍼값을 전달한다.
- 버퍼 크기 지정:
ReplaySubject<T>.create(bufferSize:)로 생성하며, 버퍼 크기를 초과하는 값은 오래된 것부터 자동으로 버려진다. - 구독 시점 동작: 구독하는 순간 버퍼에 남아있는 값들을 순서대로 즉시 받고, 이후로는 새로 방출되는 값을 이어서 받는다.
- completed/error 이후 구독: 종료된 뒤에 새로 구독하면, 버퍼에 남아있던 값들을 받은 뒤 마지막에 종료 이벤트까지 받는다.
- 메모리 주의: 버퍼 크기를 너무 크게 잡거나 이미지처럼 무거운 객체를 담으면 메모리 부담이 커질 수 있으므로 신중하게 설정해야 한다.
- dispose 시 버퍼 초기화: Subject 자체를 dispose하면 버퍼에 있던 값도 모두 사라지며, 이후에는 재구독이 불가능하다.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
enum SearchError: Error {
case unknown
}
func replay() {
let subject = ReplaySubject<String>.create(bufferSize: 2)
// 최근 2개 값만 버퍼에 유지
subject.onNext("YouTube")
// 아직 구독자가 없지만 버퍼에는 저장됨
let bag = DisposeBag()
subject.subscribe { (event) in
print("1)", event.element ?? event)
}.disposed(by: bag)
// 첫 번째 구독자 등록 -> 버퍼에 있던 YouTube를 즉시 받음
// 1) YouTube
subject.onNext("Netflix")
subject.onNext("Prime Video")
// 1) Netflix
// 1) Prime Video
// -> 첫 번째 구독자는 라이브로 두 값을 이어서 받음
// 버퍼는 최근 2개만 유지하므로 이제 Netflix, Prime Video만 남음
subject.subscribe { (event) in
print("2)", event.element ?? event)
}.disposed(by: bag)
// 두 번째 구독자 등록 -> 버퍼에 남은 최근 2개(Netflix, Prime Video)를 즉시 받음
// 2) Netflix
// 2) Prime Video
// -> YouTube는 이미 버퍼에서 밀려나 받지 못함
subject.onError(SearchError.unknown)
// 1) unknown
// 2) unknown
// -> 두 구독자 모두 error 이벤트 받음, 시퀀스 종료
subject.onNext("IMDB")
// 아무 출력 없음
// -> 이미 error로 종료된 Subject라 반영되지 않음
subject.subscribe { (event) in
print("3)", event.element ?? event)
}.disposed(by: bag)
// 세 번째 구독자는 error 이후에 구독
// 3) Netflix
// 3) Prime Video
// 3) unknown
// -> 버퍼에 남아있던 값 2개를 먼저 받고, 마지막으로 error 이벤트까지 받음
}
replay()
핵심 요약
- Replay Subject는 구독 시점에 버퍼에 저장된 최근 N개의 값을 즉시 전달한다는 점에서 Behavior Subject(초기값 1개)보다 확장된 형태다
- 버퍼 크기를 초과한 오래된 값은 자동으로 사라지며, 크기를 너무 크게 잡으면 메모리 부담이 생길 수 있다
error나completed로 종료된 뒤 구독하면 버퍼값을 먼저 받고 마지막에 종료 이벤트를 받는다- Subject 자체를 dispose하면 버퍼가 초기화되며, 이후에는 재구독이 불가능하다
4. Async Subject
- 핵심 동작: 오직
completed시점에만, 그 직전에 방출된 마지막 값 하나만 모든 구독자에게 전달한다. - 완료 전에는 아무것도 안 옴: 중간에
onNext를 아무리 여러 번 호출해도, 완료되기 전까지는 구독자가 어떤 값도 받지 못한다. - error 발생 시:
completed없이error가 발생하면 값은 전혀 전달되지 않고 error 이벤트만 받는다. - 완료 이후 구독: 완료 이후에 새로 구독해도 마지막 값을 즉시 받는다는 점에서 Behavior Subject와 비슷하게 동작한다.
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
40
41
42
43
44
45
46
47
48
49
enum SomeError: Error { case unknown }
func async() {
let index = AsyncSubject<Int>()
index.onNext(0)
// 아직 completed가 아니라서 값이 전달되지 않음
let bag = DisposeBag()
index.subscribe { (event) in
print("1)", event.element ?? event)
}.disposed(by: bag)
// 첫 번째 구독자 등록 -> 아무 값도 안 옴 (completed 전이므로)
index.onNext(1)
index.subscribe { (event) in
print("2)", event.element ?? event)
}.disposed(by: bag)
// 두 번째 구독자 등록 -> 역시 아직 아무 값도 안 옴
index.onNext(2)
index.onNext(3)
index.onCompleted()
// 1) 3
// 1) completed
// 2) 3
// 2) completed
// -> 완료되는 순간, 두 구독자 모두 마지막 값(3)과 completed를 동시에 받음
index.subscribe { (event) in
print("3)", event.element ?? event)
}.disposed(by: bag)
// 세 번째 구독자는 completed 이후에 구독
// 3) 3
// 3) completed
// -> 완료 후 구독해도 마지막 값을 즉시 받음
}
async()
핵심 요약
- Async Subject는
completed가 발생하는 순간에만 마지막 값 하나를 모든 구독자에게 동시에 전달한다 - 중간에 아무리 여러 번
onNext를 호출해도 완료 전까지는 아무 값도 전달되지 않는다 error로 끝나면 값 없이 error 이벤트만 전달되고, 완료 이후 구독해도 마지막 값을 즉시 받는다는 점은 Behavior Subject와 유사하다
5. Relay
- 핵심 동작: Relay는 Subject를 감싼 래퍼(wrapper)로, 일반 Subject와 달리
completed나error를 절대 보낼 수 없다.accept()로 값만 계속 전달하는 용도로 쓴다. - RxCocoa 필요: Relay는 RxSwift 코어가 아니라 RxCocoa에 포함되어 있어서,
import RxCocoa가 필요하다. - 두 종류:
- BehaviorRelay: Behavior Subject의 래퍼. 초기값이 필수이며, 구독하지 않아도
.value로 현재 값을 바로 읽을 수 있다. - PublishRelay: Publish Subject의 래퍼. 초기값이 필요 없다.
- BehaviorRelay: Behavior Subject의 래퍼. 초기값이 필수이며, 구독하지 않아도
- variable의 후속: 예전 RxSwift에서 쓰이던
Variable은 deprecated 되었고, 지금은 BehaviorRelay가 그 대체재로 쓰인다.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
enum SomeError: Error { case unknown }
func behaviorWrapper() {
let relay = BehaviorRelay(value: 0)
// 초기값 0 지정 (BehaviorRelay도 BehaviorSubject처럼 초기값 필수)
print("Current value", relay.value)
// Current value 0
// -> 구독 없이도 relay.value로 현재 값을 바로 읽을 수 있음
relay.accept(1)
// onNext 대신 accept()로 값을 전달
print("Current value", relay.value)
// Current value 1
let bag = DisposeBag()
relay.subscribe { (event) in
print("1)", event.element ?? event)
}.disposed(by: bag)
// 구독 즉시 최신값 1을 받음 (BehaviorSubject와 동일한 동작)
// 1) 1
relay.accept(2)
// 1) 2
print("Current value", relay.value)
// Current value 2
// relay.accept(SomeError.unknown) -> 컴파일 에러, Relay는 error를 보낼 수 없음
// relay.asObservable().onError(...) -> 컴파일 에러, 마찬가지로 불가능
// relay.asObservable().onCompleted() -> 컴파일 에러, complete도 불가능
}
behaviorWrapper()
func publishWrapper() {
let relay = PublishRelay<Int>()
// PublishRelay는 초기값 필요 없음 (PublishSubject와 동일한 구독 규칙)
relay.accept(0)
// 아직 구독자가 없어서 값 0은 유실됨
let bag = DisposeBag()
relay.subscribe { (event) in
print("1)", event.element ?? event)
}.disposed(by: bag)
// 첫 번째 구독자 등록 (이 시점 이후 값부터 받음)
relay.accept(1)
// 1) 1
// relay.accept(SomeError.unknown) -> 컴파일 에러
// relay.asObservable().onError(...) -> 컴파일 에러
// relay.asObservable().onCompleted() -> 컴파일 에러
}
publishWrapper()
핵심 요약
- Relay는 Subject를 감싼 래퍼로,
completed나error를 절대 보낼 수 없다는 점이 가장 큰 차이다 - RxCocoa에 포함되어 있어 별도로
import RxCocoa가 필요하다 - BehaviorRelay는 초기값이 필수이고
.value로 현재 값을 즉시 읽을 수 있으며, PublishRelay는 초기값이 필요 없다 - 값을 넣을 때는
onNext대신accept()를 사용한다 - 예전에 쓰이던
Variable은 deprecated 되었고, 지금은 BehaviorRelay가 그 역할을 대체한다
6. Subject in Practice
지금까지는 플레이그라운드에서 개념만 익혔다면, 이번에는 실제 앱에서 Publish Subject를 어떻게 활용하는지 살펴본다. 진행률(progress) 표시는 Publish Subject의 대표적인 실전 사용 예다. 다운로드, 업로드, 내보내기처럼 진행 상황을 스트림으로 흘려보내야 하는 작업에 적합하다.
- 구조:
Timer가 1초마다 진행률을 갱신하면서subject.onNext(progress)로 값을 방출한다. - 완료 처리: 진행률이 1(100%)에 도달하면
subject.onCompleted()를 호출해 스트림을 종료하고, 타이머도 함께invalidate()한다. - 에러 처리: 진행 중 실패 조건을 만나면
subject.onError(...)를 호출해 스트림을 에러로 종료한다. - UI 반영:
subscribe(onNext:onError:onCompleted:)로 각 이벤트에 따라 프로그레스 바 갱신, 에러 알럿 표시, 완료 후 버튼 노출 등 UI 상태를 분기한다.
참고: 아래 코드는 UIKit 기반 예제라
UIProgressView,IBOutlet등 UIKit 컴포넌트를 사용한다. SwiftUI 프로젝트라면@State/@Published값을 Subject 대신 쓰는 경우가 많지만, “진행률을 스트림으로 흘려보내고 completed/error로 종료를 명확히 구분한다”는 핵심 패턴 자체는 동일하게 적용할 수 있다.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class ViewController: UIViewController {
@IBOutlet weak var progressView: UIProgressView!
@IBOutlet weak var progressLabel: UILabel!
@IBOutlet weak var openButton: UIButton!
var timer: Timer!
var progress: Float = 0
var subject = PublishSubject<Float>()
var bag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
progressView.progress = 0
progressLabel.text = "0%"
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(timerInvoked), userInfo: nil, repeats: true)
// 1초마다 timerInvoked 호출 -> 진행률 갱신
subject.subscribe(onNext: { (num) in
self.progressView.progress = num
self.progressLabel.text = String(format: "%d%%", Int(num * 100))
// 진행 중: 프로그레스 바와 라벨 갱신
}, onError: { (error) in
self.showAlert(with: "Failed", message: error.localizedDescription).subscribe().disposed(by: self.bag)
// 에러 발생: 알럿으로 에러 표시
}, onCompleted: {
self.progressView.isHidden = true
self.progressLabel.isHidden = true
self.openButton.isHidden = false
// 완료: 진행률 UI 숨기고 완료 후 버튼(Open) 노출
}, onDisposed: nil).disposed(by: bag)
}
enum SomeError: Error { case unknown }
@objc func timerInvoked() {
progress += 0.2
subject.onNext(progress)
// 0.2씩 증가하며 진행률 방출
if progress == 1 {
subject.onCompleted()
timer.invalidate()
// 100% 도달 -> 완료 처리, 타이머 중단
}
else if progress == 0.6 {
subject.onError(SomeError.unknown)
timer.invalidate()
// 60% 지점에서 의도적으로 에러 발생시켜 실패 케이스 테스트
}
}
}
에러 발생 시 알럿을 띄우는 부분은 UIViewController에 extension으로 재사용 가능한 형태로 분리했다. Completable을 활용해 “알럿을 보여주고, 사용자가 dismiss 버튼을 눌러야 완료되는” 흐름을 하나의 반응형 스트림으로 표현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension UIViewController {
func showAlert(with title: String, message: String) -> Completable {
return Completable.create(subscribe: { [weak self] (completable) -> Disposable in
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: { (_) in
completable(.completed)
// Dismiss 버튼을 누르는 순간 Completable을 완료 처리
}))
self?.present(alert, animated: true, completion: nil)
return Disposables.create {
self?.dismiss(animated: true, completion: nil)
// dispose 시점(완료 이후)에 알럿을 닫음
}
})
}
}
핵심 요약
- Publish Subject는 진행률처럼 시간에 따라 값이 계속 바뀌고, 특정 시점에 completed 또는 error로 명확히 종료되는 작업을 표현하기에 적합하다
onNext로 중간 상태를,onCompleted/onError로 최종 상태를 구분해서 UI 분기 처리를 깔끔하게 나눌 수 있다Completable은 값 없이 성공/실패만 필요한 일회성 작업(여기서는 “사용자가 알럿을 닫을 때까지 기다리기”)을 표현하는 데 적합하다- 재사용 가능한 UI 패턴(알럿 표시 등)은
UIViewController의 extension으로 분리해두면 여러 화면에서 공통으로 활용할 수 있다
RxSwift vs Combine 비교
지금까지 다룬 Traits와 Subject를 기준으로 RxSwift와 Combine을 비교하면 다음과 같다.
| 구분 | RxSwift | Combine |
|---|---|---|
| 값 1개 성공/실패 | Single (onSuccess/onFailure) | Future (생성 즉시 실행되는 eager 방식이라 완전히 동일하진 않음) |
| 값 있을 수도 없을 수도 | Maybe | 직접 대응하는 기본 타입 없음 |
| 값 없이 성공/실패만 | Completable | 직접 대응하는 기본 타입 없음 (AnyPublisher<Never, Error>로 흉내 가능) |
| 구독 시점 이후 값만 받음 | PublishSubject | PassthroughSubject |
| 구독 시 최신값 즉시 받음 (초기값 필수) | BehaviorSubject | CurrentValueSubject |
| 구독 시 최근 N개 값을 버퍼로 받음 | ReplaySubject | 직접 대응하는 기본 타입 없음 (버퍼링 직접 구현 필요) |
| 완료 시점에만 마지막 값 1개 전달 | AsyncSubject | 직접 대응하는 기본 타입 없음 |
| completed/error를 보낼 수 없는 Subject | PublishRelay / BehaviorRelay (RxCocoa) | 직접 대응하는 타입 없음 (@Published가 그나마 제일 가까움) |
이 비교표에서 드러나는 가장 큰 차이는, RxSwift는 “이 스트림이 정확히 어떤 패턴으로 값을 방출하는지”를 타입 자체로 세분화해서 표현하는 반면, Combine은 PassthroughSubject/CurrentValueSubject 두 개와 AnyPublisher로 대부분의 상황을 커버한다는 점이다. ReplaySubject나 AsyncSubject, Relay처럼 RxSwift에는 있지만 Combine 기본 제공 타입에는 없는 경우, Combine에서는 직접 버퍼링 로직을 구현하거나 @Published + 커스텀 로직으로 유사하게 흉내내야 한다.
코드 비교
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Publish Subject vs PassthroughSubject
// RxSwift
let publishSubject = PublishSubject<Int>()
publishSubject.subscribe(onNext: { print($0) }).disposed(by: bag)
publishSubject.onNext(1)
// Combine
let passthroughSubject = PassthroughSubject<Int, Never>()
passthroughSubject.sink(receiveValue: { print($0) }).store(in: &cancellables)
passthroughSubject.send(1)
// Behavior Subject vs CurrentValueSubject
// RxSwift
let behaviorSubject = BehaviorSubject(value: 0)
behaviorSubject.subscribe(onNext: { print($0) }).disposed(by: bag)
// 구독 즉시 초기값 0을 받음
// Combine
let currentValueSubject = CurrentValueSubject<Int, Never>(0)
currentValueSubject.sink(receiveValue: { print($0) }).store(in: &cancellables)
// 구독 즉시 초기값 0을 받음, currentValueSubject.value로 현재 값도 바로 읽을 수 있음
// Single vs Future
// RxSwift
func loadFile(named fileName: String) -> Single<String> {
return Single<String>.create { single in
single(.success("파일 내용"))
return Disposables.create()
}
}
// Combine
func loadFile(named fileName: String) -> Future<String, Error> {
return Future { promise in
promise(.success("파일 내용"))
}
}
// Completable vs AnyPublisher<Never, Error>
// RxSwift
func showAlert(with title: String, message: String) -> Completable {
return Completable.create { completable in
completable(.completed)
return Disposables.create()
}
}
// Combine (Completable에 정확히 대응하는 기본 타입이 없어 Empty로 흉내)
func showAlert(with title: String, message: String) -> AnyPublisher<Never, Error> {
return Empty(completeImmediately: true)
.eraseToAnyPublisher()
}



