⭐️Combine / 컴바인⭐️
컴바인,RxSwift 말로는 참 많이 들었다. 드디어 찍먹을 해본다!
https://developer.apple.com/documentation/combine
https://developer.apple.com/videos/play/wwdc2019/722/
What is Combine?
'비동기 및 이벤트 기반 프로그래밍을 위한 프레임워크'
iOS13 이상 버전에서 사용 가능. (cf. RxSwift는 iOS8 이상부터)
Swift 표준 라이브러리에 포함된 프레임워크
Combine의 특징 & 장점
1. 간결한 비동기 코드 작성
이스케이핑 클로저, 컴플리션핸들러와 같은 콜백 기반 코드 대신 선언형 연산자 기반 체이닝 방식으로 비동기 작업 표현 가능
코드의 가독성, 비동기 작업의 순차적 흐름 표현에 용이함.
2. 데이터 흐름 관리
데이터 변환, 필터링 등 다양한 작업을 간단한 연산자를 사용해 처리 가능
-> 명확한 표현, 재사용 가능한 코드 작성 용이
3. 에러 처리
비동기 작업 과정에서 발생하는 에러를 명확하게 처리할 수 있다.
(.replaceError, .sink 등에서)
4. 시간 기반 이벤트 처리
Timer, 애니메이션 등 시간 기반 이벤트 처리 구현이 용이하다.
5. 반응형 프로그래밍
데이터의 변경을 감지하고 자동으로 처리하는 기능 제공
-> 데이터 흐름 추적, 데이터가 변경될 때 자동으로 업데이트 되는 반응형 인터페이스 (스유 특)
우선 처음 접해본 소감으로는 1번이 가장 크게 와닿았다.
이전 시간 이스케이핑 클로저와 비동기 처리를 통해 JSON 데이터를 받아왔던 것을
컴바인을 활용해서 다시 해보자. 얼마나 간결해 지는 지 알 수 있다.(물론 코드가 간결해 지는 거지 내용이 간단하지는..ㅋㅋ)
Publisher와 Subscriber
Combine에서 Publisher, Subscriber는 데이터의 발생과 구독을 관리하는 주요 프로토콜.
Publisher
- 데이터를 발행하는 타입. 데이터 시퀀스를 생성하고 변환해 구독자에게 전달하는 역할
- subscribe(_:) 메서드 요구. (for 구독자에게 데이터 전달)
- 데이터를 발행할 때마다 Subscriber로부터 받은 요청에 맞게 데이터를 전달
Combine의 Publisher 타입들
ex. Just, Future, NotificationCenter.Publisher, URLSession.DataTaskPublisher
Subscriber
- 데이터를 수신하고 처리하는 타입. Publisher에서 발행하는 데이터를 구독해서 처리하는 역할
- Subscriber 프로토콜은 receive(subscription:), receive(_:), receive(completion:)등의 메서드 정의
Combine의 Subscriber 타입들
ex. Subscribers.Sink, Subscribers.Assign, Subscribers.Demand
+ p.s.
Publisher & Operator & Subscriber
컴바인 공부하다가 내용 추가함. 아래 설명이 좀 더 명확한듯
1. Publisher (값을 생성하고)
Publisher는 값을 생성하거나, 특정 이벤트를 발생시키는 객체. 데이터의 소스로서 역할.
ex. 특정 시간 간격마다 값을 방출하는 Timer, 네트워크 요청 결과를 방출하는 URLSession, 사용자 입력 이벤트를 방출하는 UIControl 등이. Publisher는 데이터의 변화를 추적하고 Subscriber에게 전달.
2. Operator (값을 변경하고)
Operator는 Publisher가 방출하는 값에 대한 변형, 필터링, 조합 등의 작업을 수행하는 객체.
Operator는 중간 단계에서 Publisher와 Subscriber 사이에서 데이터를 가공하거나 조작하여 데이터의 흐름을 조작.
ex. 값을 변환하는 map 연산자, 조건에 따라 값을 필터링하는 filter 연산자, 여러 Publisher의 값을 조합하는 combineLatest 연산자 등
3. Subscriber (값을 받는다)
Subscriber는 Publisher로부터 값을 받아 처리하는 객체.
Subscriber는 값을 받으면서 작업을 수행하고, 필요에 따라 최종 결과를 생성하거나 추가 작업을 수행함.
ex. 값을 출력하는 sink Subscriber, 값을 모으는 collect Subscriber 등.
Subscriber는 Publisher에 대한 구독을 시작하고, 값을 수신하면서 원하는 작업을 수행함.
Publisher, Operator, Subscriber는 Combine에서 데이터의 흐름을 표현하고 처리하기 위한 주요 구성 요소임.
Publisher는 데이터의 소스로서 동작하며, Operator는 데이터를 가공하거나 조작하는 중간 단계를 담당하고, Subscriber는 최종 결과를 생성하거나 추가 작업을 수행함. 이들을 조합하여 데이터의 흐름을 구성하고 비동기적인 작업을 처리할 수 있는 것.
간단히 개념적 정의는 이렇고..
컴바인을 활용해서 JSON 데이터를 받아와 디코딩 하는 과정을 먼저 일상생활에 비유해보겠다.
터피는 커피를 굉장히 좋아하는 iOS 개발자다.
요즘 커피 구독 서비스가 핫하길래 한 번 신청해봤다.
이제 터피는 어떤 절차에 따라 커피를 마시게 될까?
1. 매달 원두를 배송해 주는 서비스를 구독한다.
2. 원두 회사에서는 새로운 원두를 만든다.
3. 현관 앞에 원두가 배달된다.
4. 원두의 박스 및 포장 상태가 정상인지 확인한다.
5. 박스를 까서 원두를 확인한다.
6. 원두를 갈아서 커피를 마신다.
7. 원두 구독은 언제든 해지할 수 있다.
코드와 맥락이 완전 일치하지는 않지만, 대략 이런 플로우로 흘러간다고 이해하면 좋겠다.
(이전 json 다운로드 코드와 같은 부분 많음)
MODEL
import SwiftUI
import Combine ⭐️
struct CombineModel: Identifiable, Codable {
let userId: Int
let id: Int
let title: String
let body: String
}
ViewModel
final class DownloadWithCombineViewModel: ObservableObject {
@Published var posts: [CombineModel] = []
//아래에서 다룸
var cancellables = Set<AnyCancellable>()
init() {
getPosts()
}
...
⭐️핵심코드⭐️
func getPosts() {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
URLSession.shared.dataTaskPublisher(for: url)
.subscribe(on: DispatchQueue.global(qos: .background))
.receive(on: DispatchQueue.main)
.tryMap(handleOutput)
.decode(type: [CombineModel].self, decoder: JSONDecoder())
.sink { (completion) in
print("COMPLETION: \(completion)")
switch completion {
case .finished:
print("FINISHED")
case .failure(let error):
print("ERROR OCCURED: \(error.localizedDescription)")
}
} receiveValue: { [weak self] (returnedPosts) in
print("메인스레드인가?:\(Thread.isMainThread)")
print("현재 스레드는?:\(Thread.current)")
self?.posts = returnedPosts
}
.store(in: &cancellables)
}
func handleOutput(output: URLSession.DataTaskPublisher.Output) throws -> Data {
guard
let response = output.response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 300 else {
throw URLError(.badServerResponse)
}
return output.data
}
한 줄 씩 뜯어보기
func getPosts() {
//url 설정
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
// 1. 퍼블리셔를 만든다.(데이터 발행 & 구독자에게 전달하는 역할)
URLSession.shared.dataTaskPublisher(for: url)
// 2. 백그라운드 스레드가 퍼블리셔를 구독한다. (백그라운드에서 작업 수행)
// (이렇게 명시적으로 구현하지 않아도, URLSession.shared.dataTaskPublisher는 기본적으로 백그라운드에서 실행되기는 함
.subscribe(on: DispatchQueue.global(qos: .background))
// 3. 데이터 다운이 완료되면 결과를 메인스레드에서 처리한다.(이전 모든 작업이 지정된 대상 큐에서 처리됨)
// 렌더링과 관련된 데이터 업데이트이기에 꼭 메인스레드에서 처리해야함.(@Published)
.receive(on: DispatchQueue.main)
// 4.데이터의 상태가 괜찮은지 확인한다.(원두 확인) 에러처리
// 클로저를 인자로 받고, 데이터와 응답을 받아 처리하고 처리된 결과를 반환함.
.tryMap { (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 300 else {
throw URLError(.badServerResponse)
}
return data
}
+ 코드를 더 간결하게 한다면
.tryMap(handleOutput)
...
}
func handleOutput(output: URLSession.DataTaskPublisher.Output) throws -> Data {
guard
let response = output.response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 300 else {
//임의로 에러 반환한 것
throw URLError(.badServerResponse)
}
return output.data
}
// 5. 데이터를 내 모델에 맞게 디코딩 한다. (원두를 가는 과정으로 생각_커피를 내리려면 원두를 갈아야 하니까)
.decode(type: [CombineModel].self, decoder: JSONDecoder())
// 6. sink (put the item into my app)
// 커피가 잘 내려졌다면 잔에 담아서 마시자!
.sink { (completion) in
print("COMPLETION: \(completion)")
switch completion {
case .finished:
print("FINISHED")
case .failure(let error):
print("ERROR OCCURED: \(error.localizedDescription)")
}
} receiveValue: { [weak self] (returnedPosts) in
//스레드에 대한 정보 확인용
print("메인스레드인가?:\(Thread.isMainThread)")
print("현재 스레드는?:\(Thread.current)")
self?.posts = returnedPosts
}
.sink 연산자를 사용해서 데이터 다운로드 작업이 완료된 후에 호출되는 클로저를 정의
.sink는 Publisher의 출력을 수신하고 완료 or 에러에 대한 처리를 담당함.
첫 번째 클로저: completion처리
- .finished / .failure에 따라 분기처리 가능
두 번째 클로저: 데이터 처리 (returnedPosts)
- 전달된 데이터를 처리하고 필요한 작업 수행.
혹은? 아래와 같은 방식으로도 처리 가능
//에러가 발생하면 빈 배열로
.replaceError(with: [])
.sink(receiveValue: { [weak self] (returnedPosts) in
self?.posts = returnedPosts
})
//마음에 안들면 구독 취소!
// 필요한 경우 구독을 취소하기
var cancellables = Set<AnyCancellable>()
...
.store(in: &cancellables)
AnyCancellable -> Combine에서 구독을 취소하고 메모리 누수를 방지하기 위해 사용되는 타입.
AnyCancellable 인스턴스를 관리하기 위한 빈 Set를 선언해준 것.
컴바인에서 Publisher와 Subscriber 간의 구독 관계를 설정할 때 sink, assign등의 메서드를 통해 구독을 생성하는데,
이것은 AnyCancellable의 형태로 반환됨.
.store(in: &cancellables)는 반환된 AnyCancellable 인스턴스를 Set에 저장해서 관리하도록 하는 메서드.
(cancellables 세트에 구독 인스턴스를 추가한다고 생각하면 됨).
-> AnyCancellable 인스턴스를 Set에 저장해서 관리하기 때문에
필요시 구독을 취소, 메모리에서 해제할 수 있다.
이는 메모리 누수를 방지하고 Combine의 구독을 관리하는 일반적인 방법임.
이렇게 컴바인을 활용해서 JSON데이터를 받아와서 디코딩까지 해보았다.
결국은 '비동기 처리'를 위한 프레임워크이고, 선언형 프로그래밍인 SwiftUI와 찰떡이라고 느꼈다.
좀 더 많이 사용하고 연습해 봐야 익숙해지겠지만, 컴바인이 대체 뭔데? 라는 의문증을 해소할 수 있어서 유익한 시간이었다 :)
@escaping 클로저, completionHandler를 활용하는 비동기 처리와 Combine을 비교해 봤을 때
컴바인이 훨씬 더 나은가? 그렇지는 않다고 생각한다.
사실 비동기 작업 처리 방식은 거의 동일하기에, 성능도 거의 같다고 볼 수 있다.
다만 Combine은 높은 수준의 추상화를 통해서 데이터의 흐름을 '선언적인 방식'으로 제어할 수 있다는 것이 강점이라는 것.
또한 여러 비동기 작업을 조합,변환할 수 있는 다양한 연산자가 존재해서 코드의 가독성과 유지 보수성 면에서 좋을 수 있다는 것.
어떤 방식을 채택하냐는 상황에 따르는 것이고, 결국 둘 다 잘 할 줄 알아야 된다고 생각한다 😅
개발 세계에서 어떤 프레임워크, 라이브러리, 방식을 쓰는 것이 훨씬 좋아! 이런 거는 잘 없는거 같다.
뭐든 득과 실이 있는듯..
그래도 확실히 컴바인을 잘 활용하면 깔끔하고 효율적인 비동기 처리가 가능해 보여서
많이 써보고 싶다!
'SwiftUI > SwiftUI(Intermediate)' 카테고리의 다른 글
20. @Published / Subscriber / Combine (0) | 2023.06.14 |
---|---|
19. Timer 타이머 (0) | 2023.06.13 |
17. JSON Download with @escaping (0) | 2023.06.12 |
16. Codable, JSON, Encodable, Decodable (0) | 2023.06.12 |
15. mask 마스킹 (0) | 2023.05.26 |