SwiftUI + Combine 공부를 시작했습니다.... 몇 년 전에 하긴 했었는데 기억이 하나도 안 나서 다시 시작했습니다.
패스트캠퍼스 30개 프로젝트로 배우는 iOS 앱 개발 강의 참고해서 공부했습니다.
Part 5. Advanded 강의 중 Ch02. 코로나19예방접종센터조회 앱 강의를 보면서 리마인드했습니다.
강의에서 사용하는 데이터가 지금은 없어서 공공데이터 - 한국보훈복지의료공단_보훈병원 위탁병원 정보 사용했습니다.
Combine: Customize handling of asynchronous events by combining event-processing operators
이벤트 처리 연산자를 결합을 해서 비동기 이벤트 처리를 커스텀마이징 할 수 있습니다.
Combine declares
publishers to expose values that can change over time, and subscribers to receive those values from the publishers
Publisher가 시간이 지남에 따라 변경될 수 있는 값을 노출을 하고 subscribers는 이러한 퍼블리셔로부터 해당 값을 받도록 선언합니다.
-> observable이 onNext하고 observer가 그걸 subscribe 한다고 생각하면 RxSwift랑 유사하다고 할 수 있겠습니다.
By adopting Combine, you'll make your code easier to read and maintain, by centralizing your event-processing code and eliminating troublesome techniques like nested closures and convention-based callbacks.
Combine 을 채택하면! 이벤트 처리 코드를 중앙집중화하고, 중첩된 closure, convention 기반 callback과 같은 번거로운 기술들을 제어해서 코드를 보다 쉽게 읽고 유지관리 할 수 있습니다.
코드를 보다 쉽게 읽고 유지관리 할 수 있다 -> RxSwift의 장점 중 하나로, 이러한 장점을 기본 프레임워크만으로 가능한 게 Combine! 그렇다면 다른 점을 알아봅시다.
CocoaSDK 내에는 수많은 비동기 인터페이스들이 있습니다. (IBTarget / IBAction, Notification center 등등...) 이들은 클로저나 completion block을 받는 API입니다. 각각은 여러가지 목적이나 사용성을 가지죠. 특정 클로저에 도달하는 값을 다른 블록으로 넘겨 핸들링 한다거나 이들을 묶고 연결해야 할 때는 사용성이 별로입니다. 이러한 사용성 개선을 위해서 이들을 모두 새로운 개념으로 대체하는 대신 공통점을 관통하는 개념을 만들고자 한 것이 Combine입니다. Combine은 시간이 지남에 따라 발생하는 값 처리를 위한 통합된 선언적 API인데요. 비동기적으로 발생하는 각종 액션들을(Notification, urlReqeust, callback 등) 시간이 지남에 따라 발생하는 값으로 보고 이걸 처리할 수 있도록 정리한 것입니다.
combine의 핵심 요소는 3가지로 RxSwift의 핵심 요소와 묶어서 볼 수 있습니다.
combine -- RxSwift
(1) Publishers -- Observables
(2) Subscribers -- Observer
(3) Operators -- Operators
(4) Subject -- Subject
(5) Cancellable -- Disposable
(6) Subscribe(on:) -- SubscribeOn(_ :) (얘넨 스케줄러. 즉 스레드핸들링을 하는 함수임)
public protocol Publisher {}
struct AnyPublisher: Publisher {}
class Observable: ObservableType {}
Publisher는 protocol입니다. AnyPublisher는 Publisher protocol를 채택한 구조체니까 클래스인 Observable 매칭되는건 AnyPublisher라고 볼 수 있습니다.
AnyPublisher value type. Observable reference type.
Publisher는 값과 오류가 어떻게 생성되는지 정의합니다. Output(Data Type)과 Faliure(Error Type) 을 이미 갖고 있습니다.
associatedtype Output
associatedtype Failure: Error
AnyPublisher<String, Error>
AnyPublisher<String, Never> // 에러 발생할 수가 없는 대신 Failure대신 Naver 사용 가능
반면 Observable은 Element(Data Type)만 받을 뿐, 별도의 에러타입은 받지 않습니다.
class Observable<Element>
Observable<Result<String, Error>>
Observable<String> // Error가 없다고 해서 에러를 발생할 수 없다는 의미는 아니겠지옹
다만 Element에 Result타입을 주입한다면 Publisher는 유사한 형태로 표현할 수 있겠지용.
정리하자면, Combine의 스트림 생성은 Publisher, RxSwift는 Observable로 할 수 있습니다. 다만, Combine의 경우는 Error Type을 정의해 주어야 하죠.
RxSwift에서 제공하는 대부분의 Operator는 컴바인에도 존재하고 명칭이랑 기능도 유사합니다.


try가 붙고 안붙고의 차이점이 있을까?
있습니다!! 뭐냐면, try가 붙으면 에러를 보다 쉽게 핸들링 할 수 있는 operator들이라는 거죠.
func map<T>(_ transform: (Output) -> T) -> Just<T>
func tryMap>T>(_ transform: (Output) throws -> T) -> Result<T, Error>.Publisher
데이터 타입뿐만 아니라 맵 내부에서 발생할 수 있는 에러 타입을 핸들링 할 수 있게 도와줍니다.

묶을 수 있는 시퀀스 개수에 따라 다른 Sturct명을 가집니다.
4개를 초과하는 시퀀스를 묶어야 할 때는 여러개의 Publisherd로 분할한 다음에 다시 분할한 것을 묶어야 하는 작업을 해야 합니다. 아무래도 컴바인이 더 번거로운듯...
Combine / RxSwift
PassthroughSubject / PublishSubject
X / ReplaySubject
CurrentValueSubject / BehaviorSubject
class PassthroughSubject<Output, Failure> {
public init()
}
class PublishSubject<Emlement> {
override init()
}
class CurrentValueSubject<Output, Failure> {
public init(_ value: Output)
}
class BehaviorSubject<Element> {
init(value: Element) {
}
둘 다 시퀀스 종료하는 개념입니다.
deinit 시점에 Publisher에 대해서 auto cancell이 일어납니다. 다른 점은 disposeBag이 없다는 것.
// Combine
let cancellables = Set<Cancellable>()
Just(1)
.sink {
print($0)
}
.store(in: $cancellables)
// RxSwift
let disposeBag = DisposeBag()
Observable.just(1)
.subscribe(onNext: {
print($0)
})
.disposed(by: disposeBag)
disposeBag과 같이 Dispose꾸러미와 같은 느낌을 주고 싶을 땐 AnyCancellable을 Set로 선언하고 store라는 함수를 통해 cancellables에 넣어 주면 됩니다.

observeOn 은 해당 코드 다음부터 즉, 바로 아래의 시퀀스부터 작동합니다. subscribeOn은 위치에 구애받지 않고 전체적인 옵저버블의 스레드를 변화시킵니다.
Combine에도 동일한 이름의 subscribeOn 함수가 있습니다.
Just(1)
.subscribe(on: DispatchQueue.main)
.map { _ in
implements()
}
.sink { ... }
RxSwift와 다른 점은 함수가 위치한 데 따라서 다르게 동작한다는 것입니다. 코드에 있는 것은 Combine 코드인데 만약 subscribe가 map 밑에 있거나 한다면 RxSwift처럼 동작하지 않을 것입니다.
RxSwift의 subscribeOn은 up Stream, down Stream 상관없이 영향을 미치지만, up Stream 대해서만 작동한다는 것입니다.

코드의 양이 적다고 해서 비슷한 질적 비교의 절대값이 되진 않지만, 같은 내용의 코드라면 양이 줄어들수록 가독성이 높아지고 버그가 숨어들 틈이 적어질 수 있습니다.

앱의 용량은 더욱 줄어듭니다. 외부라이브러리를 사용하지 않은 이유 때문입니다.
애플 엔지니어의 말: 사용적인 면에서 둘이 비슷해 보이지만 메모리 모델은 많이 다르다. 애초에 컴바인은 퍼포먼스를 고려해 디자인되었다는 점.
// network.swift
func getHospitalList() -> AnyPublisher<[Hospital], URLError> {
guard let url = api.getHospitalListComponents().url else {
return Fail(error: URLError(.badURL)).eraseToAnyPublisher() }
var request = URLRequest(url: url)
request.setValue(SecretKey.key.rawValue, forHTTPHeaderField: "Authorization")
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpRepsonse = response as? HTTPURLResponse else {
throw URLError(.unknown)
}
print("httpResonse status code: \(httpRepsonse.statusCode)")
switch httpRepsonse.statusCode {
case 200..<300:
return data
case 400..<500:
throw URLError(.clientCertificateRejected)
case 500..<599:
throw URLError(.badServerResponse)
default:
throw URLError(.unknown)
}
}
.decode(type: HospitalAPIResponse.self, decoder: JSONDecoder())
.map { $0.data }
.mapError { $0 as! URLError }
.eraseToAnyPublisher()
}
URLSession과 Combine을 활용하여 네트워크 함수를 구현합니다.
비동기 네트워크 요청을 Publisher 형태로 반환하여 ViewModel에서 쉽게 바인딩 할 수 있도록 합니다.
func getHospitalList() -> AnyPublisher<[Hospital], URLError>
성공 시 [Hospital] 배열을 방출하고 실패 시 URLError를 방출하는 AnyPublisher 타입을 반환합니다. 이를 통해 ViewModel에서 sink를 사용해 결과를 구독할 수 있습니다.
guard let url = api.getHospitalListComponents().url else {
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
}
URL 생성 및 유효성 검사를 통해 URL 생성에 실패하면 네트워크 요청을 진행하지 않고 Fail Publisher를 반환하여 에러를 방출합니다.
session.dataTaskPublisher(for: request)
URLSession의 dataTaskPublisher를 사용해 비동기 네트워크 요청을 Publisher 형태로 처리합니다.
.mapError { $0 as! URLError }
.eraseToAnyPublisher()
실제 필요한 병원 배열(data)만 추출한 이후 발생한 에러를 URLError 타입으로 통일하고 외부에서는 구체적인 구현을 알 수 없도록 AnyPublisher로 타입을 지운 뒤 반환합니다.
// viewModel.swift
final class SelectRegionViewModel: ObservableObject {
@Published var hospitals = [Hospital.RegionSmall: [Hospital]]()
private var cancellables = Set<AnyCancellable>()
init(hospitalNetwork: HospitalNetwork = HospitalNetwork()) {
hospitalNetwork.getHospitalList()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: {[weak self] in
guard case .failure(let error) = $0 else { return }
print(error.localizedDescription)
self?.hospitals = [Hospital.RegionSmall: [Hospital]]()
},
receiveValue: {[weak self] hospitals in
self?.hospitals = Dictionary(grouping: hospitals) { $0.region_small }
})
.store(in: &cancellables) // disposed(by: disposeBag)
}
}
SwiftUI 화면에서 병원 데이터를 표시하기 위해 구현된 ObservableObject 기반의 ViewModel입니다. 받아온 병원 목록을 View에 전달하는 역할을 합니다.
final class SelectRegionViewModel: ObservableObject {
@Published var hospitals = [Hospital.RegionSmall: [Hospital]]()
ObservableObject를 채택하여 SwiftUI View와 상태를 연결합니다.
@Published 프로퍼티를 통해 hospitals 값이 변경되면 View가 자동으로 다시 렌더링됩니다.
private var cancellables = Set<AnyCancellable>()
Combie 구독 객체를 저장하는 컨테이너로 ViewModel이 해제될 때 자동으로 네트워크 구독도 함께 해지되어 메모리 누수를 방지합니다. (위에서 말한 RxSwift의 DisposeBag과 같은 역할이죠.)
.receive(on: DispatchQueue.main)
네트워크 요청은 백그라운스 스레드에서 실행되므로, UI와 연결된 @Published 값을 안전하게 변경하기 위해 메인 스레드로 전달합니다.
receiveCompletion: { [weak self] in
guard case .failure(let error) = $0 else { return }
print(error.localizedDescription)
self?.hospitals = [:]
}
네트워크 요청 실패 시 에러 로그를 출력합니다
receiveValue: { [weak self] hospitals in
self?.hospitals = Dictionary(grouping: hospitals) { $0.region_small }
}
병원 데이터를 지역 별로 그룹화합니다. 서버에서 받은 병원 array를 region_small 값을 기준으로 Dictionary(goruping:)을 사용해 지역별로 그룹화합니다.
.store(in: &cancellables)
Combine 구독을 cancellables에 저장합니다. ViewModel이 해제되면 자동으로 네트워크 구독도 함께 종료되죠.
| 1 | 2 | 3 |
|---|---|---|
![]() | ![]() | ![]() |