2024. 8. 11. 21:29ㆍFramework, Library
아마도 이번 글이 마지막 Combine 시리즈 글이 될 것 같다.
그동안 설명한 <Combine 진짜 알기 쉽게 정리해서 올려줄게> 시리즈로 작성했던 글을 간략하게 돌아보자면,
1탄은 Combine을 왜 공부하는지 / Combine의 핵심 3가지 개념 (Publisher, Subscriber, Operator)이 각각 무엇인지 / 작동 흐름은 어떻게 되는지 살펴봤었고,
2탄은 <Wizard School Signup>이라는 예제를 통해 다양한 Publisher와 Operator의 종류와 구체적인 구현 흐름을 살펴봤었다.
오늘 3탄 글에서는 2탄 글의 예제를 이어 - 앞에서 만들었던 Publisher를 subscribe하는 Subscriber를 직접 만들어보고 동시에 다양한 Subscriber의 생성 방법에 대해서 알아보게 될 거다.
+ 추가로 Subject라는 Publisher와 Subscriber의 성격을 모두 갖고 있는 특별한 녀석도 마지막에 설명해 보겠다!
1️⃣ "구독 관리자"의 역할을 하는 Cancellable과 AnyCancellable 이해하기
Publisher는 데이터 스트림을 제공한다.
이 Publisher가 제공하는 데이터 스트림(= 연속적인 일련의 값 방출)을 Subsiber가 구독(subscribe)함으로써 Combine의 동작 흐름이 이루어지게 된다고 그동안 배워왔었다.
이때, Publisher에 대한 구독이 생성되면 (= Subscriber가 형성되면), Cancellable이라는 프로토콜을 준수하는 객체가 반환된다고 한다.
이게 어떤 의미인지는 아래 코드를 보면 알아들을 수 있을 거다.
Subscriber의 기본 제공 타입에 대해서는 아직 배우지는 않았지만, (아래에서 다룰 거다)
아무튼 assign이랑 sink라는 키워드를 사용해서 trickNamePublisher의 데이터 스트림을 구독하는 코드를 생성한 거라고 그냥 이해해 보자.
결론부터 보면 두 상황 모두, canceller라는 이름의 객체에 Cancellable을 준수하는 어떤 객체가 반환되어 담기게 될 거라는 의미이다.
// let trickNamePublisher = ... <String, Never>
// Subscriber 두 가지 방법
let canceller = trickNamePublisher.assign(to: \.someProperty, on: someObject)
let canceller = trickNamePublisher.sink { trickName in
// ...
}
이게 무슨 말이야...
갑자기 어려운 말이 막 쏟아져 이해가 안 될 거라고 생각한다.
Cancellable이 어디서 왜 튀어나왔고, 이 Cancellable이 하는 역할이 도대체 무엇인지 모르는 상황이니까... 이제 설명 들어간다!
💡 Cancellable은 단순하게 말해 "구독 관리자"와 같다.
처음 구독을 시작할 때 만들어져, Publisher의 데이터 스트림을 구독하는 동안 언제든지 구독을 자유롭게 취소(cancel)할 수 있는 기능을 제공한다.
*여기서의 관리자의 역할이란? 구독을 생성하고, 필요에 따라 구독을 종료하거나 취소하는 등의 구독 생명주기(Life-Cycle)를 관리하는 것!
구독을 취소하는 것은 별거 아닌 거 같지만, 굉장히 중요한 역할을 한다고 볼 수 있는 것이 바로 Combine을 메모리 관리 측면에서 볼 때이다.
비동기 작업이나 / 연속적인 값이 방출된다는 측면에서, 불필요한 subsribe 관계가 계속 유지되어 있는 것은 꽤 많은 애플리케이션의 메모리를 잡아먹는(memory leak) 문제가 될 수 있다는 걸 느낄 거라 생각한다.
Cancellable은 언제든지 구독을 자유롭게 취소할 수 있는 cancel()이라는 메서드를 제공함으로써 불필요한 subsribe를 명시적으로 해제하고 / 메모리 누수 문제를 방지할 수 있도록 도와준다고 볼 수 있다.
- Cancellable이 어디서 왜 튀어나온 거야? -> 구독이 생성될 때 만들어져, 언제든지 해당 구독을 취소할 수 있도록 관리하려고!
- Cancellable이 하는 역할이 도대체 뭐야? -> 불필요한 구독이 지속되어 메모리 누수(memory leak)가 발생하는 상황을 해결하지!
protocol Cancellable {
func cancel()
}
final class AnyCancellable: Cancellable {}
AnyCancellable은 이 Cancellable이라는 프로토콜을 채택해 구독 관리(cancel)의 구체적인 구현부를 적어둔 클래스다.
특히, AnyCancellable이 메모리에서 해제될 때 자동으로 cancel() 메서드를 호출할 수 있기 때문에 / 구독을 명시적으로 취소하지 않더라도 개발자는 AnyCancellable을 통해 구독을 관리하면, 객체가 해제될 때 자동으로 구독이 취소되는 로직을 사용할 수 있게 된다.
나는 두 가지 방법의 AnyCancellable 타입의 객체를 만들어 이번 예제에서 사용하는 구독을 관리했다.
- Set<AnyCancellable> : 여러 구독을 하나의 Set에 저장해서 관리한다. Set이기 때문에 구독이 중복될 수 없으며, 객체가 만들어진 VC가 메모리에서 해제되면 자동으로 cancel()이 호출되어 모든 구독이 일괄적으로 취소된다.
- AnyCancellable? : 하나의 특정 구독만을 개별적으로 관리한다. 특정 구독이 필요 없으면 직접 수동으로 취소할 수 있으며, 마찬가지로 객체가 만들어진 VC가 메모리에서 해제되면 자동으로 cancel()이 호출되어 특정 구독이 취소된다.
private var cancellables = Set<AnyCancellable>()
private var signupButtonStream: AnyCancellable?
이제 구독 관리하는 방법도 알았으니, 진짜 Subscriber를 구현하러 가볼까?
2️⃣ Subscriber types (1) - Key Path Assignment를 통해 Subscriber 만들기
Subscriber 기본 개념은 아래와 같이 정의할 수 있었다.
💡 Subsribers declare a type that can receive input from a publisher. = Subscriber는 Publisher로부터 받을 수 있는 입력 타입을 정의합니다.
Subscriber 프로토콜을 채택해서 3개 receive 메서드들을 구현하면서 정의할 수도 있겠지만,
사실 커스텀으로 작성하는 것보다는 애플에서 제공해 주는 기본 Subscriber 타입을 사용하는 것이 더 권장되고 간편하기 때문에 후자의 방법으로 사용하고자 한다.
먼저 Key Path Assignment 방식의 Subscriber부터 살펴보자.
💡 Key Path Assignment Subscriber
Key-value 방식(to:)으로 특정 객체의 프로퍼티(on:)에 Publisher에서 방출된 값을 할당하는 Subscriber
데이터를 UI의 속성에 직접 연결할 수 있다는 점에서 간편하고, 항상 값이 할당되어야 하기 때문에 Publisher의 Failure는 Never여야 한다.
2탄 글 리마인드해서 첫 번째로 만들 Subscriber의 Publisher를 간략하게만 우선 설명해 보겠다.
name과 password 두 값의 유효성이 모두 검증되면 (String, String) 타입의 단일 튜플로 방출하고 / combineLatest를 사용해서 두 값 중 하나라도 유효하지 않은 경우에는 nil을 방출하는 validateCredentials Publisher가 있었다. (절대 Failure는 일어나지 않는 Never)
// var validatedCredentials: AnyPublisher<(String, String)?, Never>
이 Subscriber에서 해야 할 역할은 Publisher에서 방출되는 값을 바탕으로 버튼의 활성여부(isEnabled)를 조정하는 것이다.
순서대로 어떻게 Subscriber가 연결되었는지를 차근차근 설명해 보겠다.
#1.
해당 Subscriber를 관리할 수 있도록 위에서 생성해 둔 단일 AnyCancellable? 타입의 객체에 Subscriber를 대입했다.
*구독을 취소하고 싶은 경우에는 self.signupButtonStream.cancel()을 호출해서 메모리 누수를 방지할 수 있도록!
#2.
Publisher에서는 (String, String) 타입의 튜플로 값이 방출되고 있었기 때문에,
signupButton 객체의 isEnabled 속성에 바로 대입하고 싶었던 나는 해당 방출 값을 true/false를 나타내는 Boolean 값으로 map Operator를 이용해 바꿔줬다. (nil이 아닌 경우 true / nil인 경우 false)
#3.
UI와 관련된 작업은 메인 스레드에서 처리해야하므로, receive(on:) 코드를 사용해 작동 스레드를 지정해 줬다.
#4.
Key Path Assignment로 validatedCredentials Publisher에 대한 Subscriber를 형성함과 동시에 / 위의 map Operator를 통해 변환된 Boolean값을 바로 signupButton의 isEnabled 속성에 직접 할당해 줬다.
// MARK: - Subscriber
private func subscribeValidatedCredentials() {
self.signupButtonStream = self.validatedCredentials
.map { $0 != nil }
.receive(on: RunLoop.main)
.assign(to: \.isEnabled, on: signupButton)
}
// MARK: - UI Components
private let signupButton = UIButton()
signupButton.backgroundColor = $0.isEnabled ? .systemGreen : .systemGray
validatedCredentials Publisher를 구독하는 첫 번째 Subscriber가 만들어졌다!
assign(to:on:) 방식을 사용한 해당 Subscriber의 전체 흐름은 아래와 같이 정리해 볼 수 있겠다.
: validatedCredentials에서 방출되는 (String, String)? 타입의 값 (앞은 name, 뒤는 password - 유효한 경우에만 넘어옴)
-> nil을 체크 후 Boolean 타입(true/false)으로 변환 -> 메인 스레드에서 signupButton의 isEnabled 속성에 할당 -> isEnabled 속성에 따라 button의 backgroundColor 색 UI 반영
3️⃣ Subscriber types (2) - sink를 통해 간단한 Subscriber 만들기
이번에는 name과 Button 각각 유효성을 체크하던 validatedUsername과 validatedPassword Publisher의 Subscriber를 구현할 거다.
두 Publisher 모두 <String?, Never> 타입을 방출하도록 되어있으며, 유효한 값인 경우 String을 / 유효하지 않은 값인 경우 nil을 방출한다고 보면 된다.
// var validatedUsername: AnyPublisher<String?, Never>
// var validatedPassword: AnyPublisher<String?, Never>
마찬가지로 Subscriber에서 해야 할 역할은 Publisher에서 방출되는 값을 바탕으로 이미지의 색상을 바꾸는 것이었다.
이번에는 assign(to:on:) 방식보다 더 간단하게 사용할 수 있는 sink Subscriber를 사용해 볼 거다.
💡 Sink Subscriber
Publisher로부터 방출된 값을 클로저로 처리하는 매우 간단한 Subscriber
파라미터 중에서 receiveCompletion은 종료 이벤트를 / receiveValue는 새롭게 방출된 값을 처리할 수 있는 블록이다.
실제 구현부는 매우 단순하다.
위와 마찬가지로 UI를 처리해 주기 위해 .receive(on:) 메서드로 작업을 진행할 메인 스레드를 지정해주고, sink (receiveValue) 클로저 내에 값에 따라 변화를 주게 될 UI 작업을 추가한 게 코드의 전부!
차이점이 있다면 이번에는 개별적으로 구독 관리자를 만든 것이 아니라,
Set<AnyCancellable> 타입으로 전체 구독을 관리하도록 했기 때문에 -> 각 Subscriber를 .store(in:) 메서드를 사용해서 구독 관리자에 저장하도록 했다는 점만 보면 되겠다.
// MARK: - Subscriber
private func subscribeValidatedUserName() {
validatedUsername
.receive(on: RunLoop.main)
.sink { [weak self] validatedUsername in
// UI 변화가 어쩌구 저쩌구...
self?.usernameImage.tintColor = (validatedUsername == nil) ? .systemRed : .systemGreen
}
.store(in: &cancellables)
}
private func subscribeValidatedPassword() {
validatedPassword
.receive(on: RunLoop.main)
.sink { [weak self] validatedPassword in
// UI 변화가 어쩌구 저쩌구...
self?.passwordImage.tintColor = (validatedPassword == nil) ? .systemRed : .systemGreen
}
.store(in: &cancellables)
}
두 번째 Subscriber는 첫 번째보다 더 간단하게 구현할 수 있었다!
두 개의 Subscriber를 바탕으로 Combine에서 기본으로 제공해 주는 Subscriber를 마지막으로 정리해 보고 마무리하겠다.
- assign(to:on:) : Key-value 방식(to:)으로 특정 객체의 프로퍼티(on:)에 Publisher에서 방출된 값을 할당하는 Subscriber
- sink(receiveCompletion:receiveValue:) : Publisher로부터 방출된 값을 클로저로 처리하는 매우 간단한 Subscriber
4️⃣ Advanced Subscriber - Subject와 SwiftUI의 BindableObject
지금부터 살펴볼 내용은 Combine에서 Subject라는 조금 특별한 특성을 갖고 있는 녀석이다.
Combine 시리즈 3탄까지 오면서 다양한 Publisher와 Subscriber의 종류를 살펴봤지만, 이 부분에서 Subject를 배우고 나면 각 핵심 개념에서 사용할 수 있는 선택지가 하나씩 더 넓어지게 될 거다.
우아? 이게 무슨 말이냐고?
💡 Subject는 Publisher와 Subscriber의 역할을 동시에 수행한다.
Publisher의 역할을 수행한다는 것의 의미는 Subject의 구현 코드를 살펴보면 알 수 있다.
Subject 프로토콜은 Publisher를 채택하고 있다.
메서드 부분을 보게 되면, 2가지 send 메서드를 통해 Publisher의 Output 또는 Completion(결과)를 보내줄 수 있도록 되어있는 것이 보이는가?
이처럼 Subject는 send라는 메서드를 통해 stream에 값을 주입할 수 있다는 점에서 Publisher의 특징을 갖는다고 볼 수 있다.
*여기서 send 메서드를 사용한다는 것을 "외부"에서 값을 주입한다고 설명하기도 한다.
protocol Subject: Publisher, AnyObject {
func send(_ value: Self.Output)
func send(completion: Subscribers.Completion<Self.Failure>)
}
Subscriber의 역할을 수행한다는 것의 의미는 upstream Publisher를 구독(subscribe)할 수 있다는 의미다.
다시 말해, Subject는 다른 Publisher로부터 값을 받아 내부 상태를 업데이트하거나 / 다른 Publisher에게 값을 전달할 수 있다는 것!
Subject는 두 가지 종류로 다시 나뉜다.
- PassthroughSubject : 방출값을 저장하지 않고 downstream Subsriber에게 값을 보내주는 Subject (A subject that broadcasts elements to downstream suscribers.)
- CurrentValueSubject : 마지막으로 수신된 값의 기록을 저장하고 값이 변경될 때마다 새로운 값을 publish 하는 Subject (A subject that wraps a single value and publishes a new element whenever the value changes.)
각 Subject가 어떤 차이점을 갖고 있는지 보다 명확하게 구분하기 위해 예시 코드를 살펴보자.
먼저 PassthroughSubject를 만든 상황을 살펴보자면,
passthrough 이름과 같이, Subject에서 값이 Publish 되면 그냥 물 흐르듯이 Subscriber에게 통과되면서 값이 전달된다.
초기값도 없고 / 과거 값을 저장하지도 않으며 / 값이 발행되면 모든 구독자들에게 즉시 전달되는 것 이것이 PassthroughSubject다.
let passthroughSubject = PassthroughSubject<String, Never>()
// First Subscriber
let cancellable1 = passthroughSubject.sink { value in
print("Subscriber 1 received: \(value)")
}
// send를 통한 값 publish
passthroughSubject.send("Hello") // "Subscriber 1 received: Hello"
passthroughSubject.send("World") // "Subscriber 1 received: World"
// Second Subscriber
let cancellable2 = passthroughSubject.sink { value in
print("Subscriber 2 received: \(value)")
}
// send를 통한 값 publish
passthroughSubject.send("Combine")
// "Subscriber 1 received: Combine"
// "Subscriber 2 received: Combine"
하지만 CurrentValueSubject는 이와 상황이 조금 다르다.
가장 최신의 값을 저장하고 있는 특성이 있기 때문에 선언과 동시에 초깃값을 지정해줘야 하는 것이 1번 차이점이고,
send를 통해 값이 Publish할 때도 새롭게 등록된 Subscriber에 대해서는 기존에 저장하고 있던 값을 즉시 전달한다는 점이 2번 차이점으로 볼 수 있다.
각 차이점이 명확하게 눈에 보인다!
let currentValueSubject = CurrentValueSubject<String, Never>("Initial")
// First Subscriber
let cancellable1 = currentValueSubject.sink { value in
print("Subscriber 1 received: \(value)")
}
// send를 통한 값 publish
currentValueSubject.send("Hello") // "Subscriber 1 received: Initial" \n "Subscriber 1 received: Hello"
currentValueSubject.send("World") // "Subscriber 1 received: World"
// Second Subscriber
let cancellable2 = currentValueSubject.sink { value in
print("Subscriber 2 received: \(value)")
}
// send를 통한 값 publish
passthroughSubject.send("Combine")
// "Subscriber 1 received: Combine"
// "Subscriber 2 received: World"
// "Subscriber 2 received: Combine"
길고 길었던 이번 Combine 시리즈 글은 여기까지다.
러닝 커브(Learning Curve)가 높다고 했지만, 막상 정리해 보니 쉽게 글로 옮기느라 시간이 오래 걸렸을 뿐 실전 코드로 작성하는 것은 그리 어렵지 않았다.
아마 이 글을 1탄부터 3탄까지 쭉 따라왔으면 여러분도 나와 같은 생각을 가지지 않을까?
이번에는 간단한 WWDC에서 소개한 예제를 가지고 코드를 작성했는데,
언제가 될지는 모르겠지만 나중에는 실전 프로젝트에서 Combine을 적용한 또 다른 예제 / 혹은 리팩토링 상황이 생기면 다시 이 주제를 가지고 찾아오게 될 거다.
그럼 다음을 기다리며.. 오늘은 여기까지!