Combine이 뭐길래?
iOS 13에서 Apple이 선보인 Combine 프레임워크를 처음 접했을 때, "아, 그냥 비동기 처리 도구구나" 하고 넘어가려던 제가 있었습니다. 하지만 실제로 써보니 완전히 다른 이야기더라고요 😅
Combine의 진짜 목적은 비동기 처리가 아닙니다.
반응형 프로그래밍이라는 패러다임을 통해 데이터 흐름과 변화의 전파에 중점을 둔 선언적 API를 제공하는 게 핵심이에요. 왜냐하면 기존의 명령형 코드보다 가독성과 유지보수성을 극대화할 수 있기 때문입니다.
그럼 왜 하필 Apple이 RxSwift가 이미 자리잡고 있는데 Combine을 만들었을까요?
RxSwift는 서드파티 라이브러리라서 별도의 프로젝트 설정이 필요하고, 의존성 관리도 해야 했어요. 반면 Combine은 퍼스트파티 프레임워크로서 Swift 언어와 완벽하게 통합되어 있죠. 빌드 시간도 단축되고, 유지보수 비용도 절감됩니다
핵심요소 5가지프로토콜
Publisher: 데이터의 시작점
Publisher는 시간의 흐름에 따라 값을 방출하는 타입이에요. 이게 왜 중요하냐면, 모든 데이터 흐름의 출발점 역할을 하기 때문입니다.
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 타입이에요. AnyPublisher<String, Never>라고 하면 String 타입의 값을 방출하되, 에러는 절대 발생시키지 않겠다는 의미거든요.
Publisher | Observable |
Value Type | Reference Type(class) |
OutputData(Data Type) | Element(Data Type) |
Failure(error Type) | X |
1. Just
let publisher = Just("Hello Combine!")
구독자들에게 딱 한 번만 값을 보내고 완료 이벤트를 보내요.
왜 이런 걸 쓸까요? 초기값을 설정하거나 테스트 코드 작성할 때 엄청 유용합니다.
2. Future
let futurePublisher = Future<String, Never> { promise in
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
promise(.success("비동기 작업 완료!"))
}
}
신기한 건 대부분의 Publisher가 구조체인데, Future만 클래스예요.
왜 그럴까요? 비동기 작업의 상태를 저장해야 하기 때문입니다.
한 번 결과가 나오면 그 값을 계속 캐싱해서 새로운 구독자들에게도 같은 값을 전달해주거든요.
3. Deferred, Record..
잘안쓰임...
Subscrbier
Publisher가 데이터를 방출한다면, Subscriber는 그 데이터를 받아서 처리하는 역할이에요. 3단계 생명주기를 가집니다:
- receive(subscription:): "구독 시작할게요!"
- receive(_:): "값 받았어요! 더 줄 수 있어요?"
- receive(completion:): "완료됐거나 에러났어요"
하지만 실제로는 직접 구현보다는 sink나 assign을 더 많이 써요.
subscribe를 통해 연결
class practiceSubscriber: Subscriber {
typealias input = type
typealias Failure = type
//1.subscriber에게 publisher를 성공적으로 구독했음을 알려즈고 item요청.
func receive(subscription: Subscription) {
print("구독시작할게용")
subscription.request(.unlimited)
}
// 2. subscriber에게 publisher가 element를 생성햇다고 알려즘/ count
func receive(_ input: String) -> Subscribers.Demand {
print("\(input)")
return .none
}
// 3. subscriber에게 publisher가 정상적으로 또는 오류로 publish를 완료했음을 알림.
func receive(completion: Subscribers.Completion<Never>){}
1. receive(subscription:): subscriber에게 Subscription 인스턴스를 전달해줍니다!
이용해서 subscriber은 publisher의 elements를 요구하거나, 더 이상 값을 받지 않겠다고 할 수도 있다~
Subscription은 subscriber와publisher의 연결을 나타내는것
Demand : subscriber가 publisher에게 subscription을 통해 요청한 아이템 수 몇번값 요청했니?
- unlimited, .max(개수), none(no elements=.max(0))
Demand 시스템은 Publisher가 과도한 데이터를 방출하여 메모리 압박을 일으키는 것을 방지하는 백프레셔 메커니즘입니다.
sink: 가장 범용적인 구독 방법
let cancellable = publisher
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("모든 작업 완료! 🎉")
case .failure(let error):
print("에러 발생: \(error)")
}
},
receiveValue: { value in
print("받은 값: \(value)")
}
)
여기서 중요한 건 Demand 시스템이에요. sink는 자동으로 unlimited 요청을 하는데, 이게 왜 필요할까요? Publisher가 과도한 데이터를 방출해서 메모리 압박을 일으키는 걸 방지하는 백프레셔 메커니즘 때문입니다\
Demand 시스템이 왜 필요할까? 🤔
실제로 겪어본 상황을 예로 들어볼게요. 네트워크에서 이미지를 연속으로 다운로드하는 앱을 만들고 있었는데, Publisher가 초당 100개의 이미지 URL을 방출한다고 생각해보세요.
// 이런 상황이었어요
let imageURLPublisher = Timer.publish(every: 0.01, on: .main, in: .common)
.autoconnect()
.map { _ in generateImageURL() } // 초당 100개 URL 생성
만약 Subscriber가 이 모든 요청을 다 처리하려고 하면 어떻게 될까요?
- 메모리 사용량 급증 📈
- 앱 성능 저하
- 결국 앱 크래시 💥
이런 문제를 해결하기 위해 Combine은 백프레셔 메커니즘을 도입했어요.
Demand의 세 가지 타입
// 1. .unlimited - "다 받을게!"
subscription.request(.unlimited)
// 2. .max(n) - "n개까지만 받을게"
subscription.request(.max(5))
// 3. .none - "지금은 더 안 받을게" (.max(0)과 동일)
return .none
sink는 왜 .unlimited일까?
let cancellable = publisher
.sink { value in
print("받은 값: \(value)")
}
sink를 쓰면 내부적으로 .unlimited 요청을 하는데, 이게 적절한 이유는 대부분의 경우 개발자가 모든 값을 받고 싶어하기 때문이에요.
만약 흐름 제어가 필요하다면 sink 대신 직접 Subscriber를 구현하거나, Operator들을 조합해서 해결하는 게 Combine의 철학이거든요.
3. assign
class MyViewModel {
@Published var text: String = ""
}
let viewModel = MyViewModel()
let cancellable = publisher
.assign(to: \.text, on: viewModel)
key path로 표시된 프로퍼티에 수신된 값을 할당하는 간단한 subscriber.
sink가 간단히 완료와 값에대한 이벤트를 처리한다면 Assign은 Publisher로부터 받은 값을 주어진 instance의 property에 할당할수 있게한다..!!
publisher의 failure type == never일때만. 사용가능!
- object : 프로퍼티를 포함하는 객체, subscriber는 새로운 값을 받을때마다 여기에 할당,
subscriber는 upstream publisher가 subscriber의 receive(completion)이 나오기전까지 강한참조유지하다가 종료응답받으면 그때 nill
- keypath : 할당할 프로퍼티를 나타내는 key-path
assign(to:on:)
to에 값이 할당될 property! On에는 해당프로퍼티를 갖는 instance자리!
그런데 to안에 \.를써줘야한다..왜?그럴까?
assign은 새로운 값을 keyPath에 따라 주어진 인스턴스의 property에 할당하는 것인데 \.가 object의 프로퍼티를 특정하기 위해 사용/
⚠️ 주의사항: assign(to:on:)은 대상 객체에 대한 강한 참조를 유지합니다. 이로 인해 메모리 누수가 발생할 수 있으므로 참조 사이클에 주의해야 합니다.
Scheduler
Scheduler는 작업이 언제, 어떻게 실행될지 제어
- RunLoop: RunLoop와 관련된 스케줄러
- DispatchQueue: GCD 기반 스케줄러
- OperationQueue: Operation 기반 스케줄러
let publisher = Just("Hello")
// 메인 스레드에서 실행
let mainSubscription = publisher
.receive(on: RunLoop.main)
.sink { print("Main thread: \($0)") }
// 백그라운드 스레드에서 실행
let backgroundSubscription = publisher
.receive(on: DispatchQueue.global())
.sink { print("Background: \($0)") }
Subject
Subject는 외부에서 값을 받아 구독자에게 전달할 수 있는 Publisher입니다. 일종의 "양방향 Publisher"라고 생각할 수 있습니다
protocol Subject: AnyObject, Publisher {
func send(_ value: Self.Output)
func send(completion: Subscribers.Completion<Self.Failure>)
}
CurrentValueSubject vs PassthroughSubject
// 현재 값을 저장하고 있는 Subject
let currentValueSubject = CurrentValueSubject<String, Never>("초기값")
print(currentValueSubject.value) // "초기값" 출력
// 값을 그냥 통과시키는 Subject
let passthroughSubject = PassthroughSubject<String, Never>()
// 초기값 없음, 최신값도 저장 안 함
CurrentValueSubject는 최신 값을 버퍼에 유지해서 새로운 구독자가 와도 현재 값을 바로 받을 수 있어요.
반면 PassthroughSubject는 그때그때 흘러가는 값만 전달합니다.
결론: 비동기처리를 하기 위해 콤바인을 하러 왔습니다? XXXXXXXX
'반응형프로그래밍' 카테고리의 다른 글
RxSwift(3)-Filtering Operators & TransForming Operators (0) | 2025.04.29 |
---|---|
RxSwift(2)-Subject (0) | 2025.04.29 |
RxSwift(1)-Observable (1) | 2025.04.28 |
Combine(2)- Operator (1) | 2023.10.08 |
Combine3-Cancellable (0) | 2023.07.09 |