Apple 이 제시하는 콤비네이션 핏자, Combine 살짝 음미해보기

2024. 12. 21. 05:16Apple/Swift

RxSwift 를 10월쯤 작성하고(꽤 많은 시간동안, Swift 와 관련된 글을 쓰지 못했다.), RxSwift 관련 코드를 더 공부할까 파볼까 고민했다. 그치만 Combine 도 궁금했고, 기본적으로 애플에서 제공하는 프레임워크였기에 알아보고 싶었다.

그렇다..나는 건들면 안되는 크나큰 무언가를 건든 것이다..

꽤나 Combine 이 제공하고 다루고 있는 영역이 컸다. 그래서 나는 컴바인, Rx 왕초보니까,, 기본적인 흐름과 역할에 대해 정리하고 공부하려고 한다. 기본 공식문서에서 소개하는 Combine 의 역할은 애플리케이션이 이벤트를 처리할 때, 선언적으로 접근할 수 있게 해준다고 한다.

  선언적 프로그래밍은 목표를 설정하고 목표에 접근하는 방법은 컴퓨터에게 일임한다. 예를 들어, '양복점에 가서 1930년대 스타일의 정장을 만들어주세요.' 라는 것과 비슷하다. 원하는 목적을 말하고, 양복을 만드는 것은 테일러(tailor, 재단사- 즉, 컴퓨터와 같은 입장)에게 맡기는 것이다.

프로그래밍 형태: 명령형 프로그래밍과 선언형 프로그래밍의 차이

우리가 익숙하게 쓰는 명령형프로그래밍은 개발자가 직접 변수 값을 변경하고 프로그램 상태를 직접적으로 관리해준다. 순차적인 실행이며, 제어 구조를 사용하는 프로그래밍이다.

[ 짝수만 필터링해주는 명령형 코드를 원할 때 ]

// 정수 배열 선언
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 짝수를 저장할 빈 배열 초기화
var evenNumbers: [Int] = []

// 배열의 각 요소를 순회하며 짝수인지 확인하고, 짝수인 경우 새로운 배열에 추가
for number in numbers {
    if number % 2 == 0 {
        evenNumbers.append(number)
    }
}

// 결과 출력
print("짝수 배열: \(evenNumbers)")

이렇게 변수를 생성하고 짝수를 따로 저장할 Array(배열) 타입 변수도 초기화해서 만들어 주었다. 배열을 다 검색하면서 numbers 에 들어있는 숫자가 짝수인지 아닌지 조건을 확인하기 위해 if 조건문을 for in loop 내에서 개발자가 하나하나 짜줬다. 컴퓨터에게 명령한 것이다.  반대로, 선언형 프로그래밍을 사용한다면 꽤(?) 간단해진다. 고차함수를 많이 쓰기 때문에 당연히 아는 사람들도 있겠지만, filter 를 쓰면 된다.

// 정수 배열 선언
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// filter 함수를 사용하여 짝수만 필터링 (선언형 스타일)
let evenNumbers = numbers.filter { $0 % 2 == 0 }

// 결과 출력
print("짝수 배열: \(evenNumbers)")

나는 짝수를 원해. filter 해줘 Swift 야~ 하면 filter 함수 내 조건을 저리 주고 바로 evenNumber 상수를 print 를 하면 2, 4,..,10 이 출력될거다.

이렇듯 컴바인을 쓰면 선언적으로 데이터를 구독하고 처리할 수 있다. Rx 와 마찬가지로 코드 가독성을 높이고 비동기적으로 Task 들을 스레드에 분배하고 관리하는 것의 복잡성을 줄일 수 있는 것이다.

 

컴바인이 제공하는 것들은 뭐가 있을까?

무지무지 많고, 타입도 다르고 방대하다.. 그래서 Publisher, Subscriber, Operator 크게 이 세 놈만 잡아보자면,

우선, Publisher 는 사전적 의미로 출판하다, 게시하다, 발행하다의 publish 의 행위자 주체자로 `게시자` 로 의역할 수 있을 거다.

데이터 스트림을 생성하고, 그걸 게시하고 변환하고 조합할 수 있게 해준다.

Subscriber 는 구독하다의 사적적 의미의 수동적 객체로 Publisher 에서 발생한 이벤트를 받아 처리하는 객체다. 또한 데이터 처리 및 구독도 제어한다.

Operator 는 Publisher 에서 방출된 값을 변형하거나 필터링하는 메서드 체인이다.

그럼 구독만 하면 구독취소하고 싶을땐 어떻게 하나? 유튜브도 재미없음 구취하니까~

바로 Cancellable 을 쓴다.

이 이미지는 OpenAI의 DALL·E로 생성되었습니다.(그래서 오타가 있습니다. 프롬프트로 다시 일러줘도 다른 곳을 점점 더 틀리길래..그냥 이걸 쓰네요.)

이런 컴바인은 반응형 프로그래밍, 데이터 스트림, 선언형 프로그래밍 ,비동기 작업 등에 특화되어 있다.

Publisher 와 Subscriber 연결하기

이 두 녀석들은 다 프로토콜 타입이다.

Publisher 는 생성하는 타입이라서 Output, Subscriber 는 수신(받아들이기)하는 프로토콜임으로 Input 을 정의한다.

그럼 이 두 녀석을 연결하려면 Output 타입과 Input 의 타입이 일치해야하는 것은 당연하다. 실패할 가능성도 있기때문에 둘다 Failure 타입도 지정해줘야하고 이 Failure 의 타입도 일치해야한다. 예외가 발생하면 당연히 에러가 발생하듯이 말이다.

protocol Publisher<Output, Failure>
protocol Subscriber<Input, Failure> : CustomCombineIdentifierConvertible

컴바인은 자동으로 Subscriber 를 제공하고 연결된 Publisher 의 Output, Failure 타입을 자동으로 일치시켜 준다.

이미지 로드 기능 (UIKit 베이스)

import UIKit
import Combine

class AsyncViewController: UIViewController {
    private let button: UIButton = {
        var config = UIButton.Configuration.filled()
        config.title = "이미지 로드 (Combine)"
        config.baseBackgroundColor = .systemOrange
        config.baseForegroundColor = .black
        config.cornerStyle = .medium
        config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)
        let button = UIButton(configuration: config)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()

    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.layer.cornerRadius = 10
        imageView.clipsToBounds = true
        imageView.backgroundColor = .systemGray6
        return imageView
    }()

    private let timeLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.text = "걸린 시간: 0.0초"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
        setupConstraints()
        setupActions()
    }

    private func setupView() {
        view.backgroundColor = .white
        view.addSubview(button)
        view.addSubview(imageView)
        view.addSubview(timeLabel)
    }

    private func setupConstraints() {
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -150),

            imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            imageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            imageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8),
            imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor),

            timeLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            timeLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 20)
        ])
    }

    private func setupActions() {
        let imageUrl = URL(string: "https://picsum.photos/300/300")!

        button.publisher(for: .touchUpInside)
            .flatMap { _ in
                let startTime = CFAbsoluteTimeGetCurrent()
                return URLSession.shared.dataTaskPublisher(for: imageUrl)
                    .map { (UIImage(data: $0.data), CFAbsoluteTimeGetCurrent() - startTime) }
                    .replaceError(with: (nil, 0))
                    .receive(on: DispatchQueue.main)
            }
            .sink { [weak self] result in
                guard let self = self else { return }
                let (image, elapsedTime) = result
                self.imageView.image = image
                self.timeLabel.text = String(format: "Combine: %.2f초", elapsedTime)
                print(image != nil ? "Combine: 이미지 로드 성공" : "이미지 로드 실패")
            }
            .store(in: &cancellables)
    }
}

#Preview {
    AsyncViewController()
}

[ DispatchQueue.main.async 사용해서 짰을 때 코드 ]

@objc private func loadImage() {
    let imageUrl = URL(string: "https://picsum.photos/300/300")!
    let startTime = CFAbsoluteTimeGetCurrent()
    URLSession.shared.dataTask(with: imageUrl) { [weak self] data, response, error in
        guard let self = self, let data = data, error == nil else {
            print("이미지 로드 실패: \(error?.localizedDescription ?? "알 수 없는 오류")")
            return
        }
        let image = UIImage(data: data)
        let elapsedTime = CFAbsoluteTimeGetCurrent() - startTime
        DispatchQueue.main.async {
            self.imageView.image = image
            self.timeLabel.text = String(format: "일반: %.2f초", elapsedTime)
            print("일반: 이미지 로드 성공")
        }
    }.resume()
}

 

결과화면

둘 다 동일하게 비동기적으로 이미지를 로딩하는 시간이 랜덤하다. 코드의 분리와 가독성, 유지보수의 차이로 컴바인 프레임워크를 사용하는 것이다...로 정의하기엔 납득이 안갔다.

URLSession 자체가 비동기적으로 실행되고, DispatchQueue 에서 async 비동기 적으로 이미지 로딩하는 방식과 Combine 에서 Publisher 와 sink, flatMap, store 를 사용하는 표면적 방식이다.

 

그럼 Combine 을 안써도 비동기적으로 관리할 수 있는데 왜 컴바인 써??

복잡한 비동기 데이터 흐름을 간결하게 표현하고 확장성 또한 좋아진다고 한다. (나 자신을 납득하기 위해 찾아보는 중이다..)

네트워크 요청, 데이터 필터링, 결과 병합 등등

뿐만 아니라 많은 연산자들 외에도 Convenience Publishers는 기본적인 Publisher 외 더 간단한 작업을 처리할 수 있게 제공된다.

데이터를 특정 방식으로 제공하고 특정 이벤트를 트리거할 때 사용한다.

Convenience Publisher 종류 몇가지만 살펴보자면,,

Just: 단일 값을 발행하고 완료 이벤트를 내보낸다, 한번만 데이터를 발행해야할 때 유용하다. 실패는 절대!! 없다.

보통 어떤 값을 스트림으로 연결하고 싶을 때 사용한다.

Empty : 아무 데이터도 발행하지 않고 완료 이벤트만 발행한다. 명시적으로 데이터가 없음을 나타내거나, 테스트 및 기본값을 설정할 때 사용한다.

Fail: 에러를 발행하고 완료이벤트 발행 한다. 실패를 시뮬레이션 하고 명시적으로 에러 반환시 사용된다.

Future: 정확히 하나의 값 또는 에러를 전달하고 완료되는 녀석이다. Future 는 커스텀 메서드를 호출하고, Result.success 또는 Result.failure 를 반환한다.

Future 코드 예시

I used SwiftLee’s codes as references.

import Foundation
import UIKit
import Combine

/*:
## Future and Promises
- a `Future` delivers exactly one value (or an error) and completes
- ... it's a lightweight version of publishers, useful in contexts where you'd use a closure callback
- ... allows you to call custom methods and return a Result.success or Result.failure
*/

struct Planet {
    let id: Int
    let name: String
}

let planets = [Planet(id: 0, name: "수성"), Planet(id: 1, name: "금성"), Planet(id: 2, name: "지구")]

enum FetchError: Error {
    case planetNotFound
}

func fetchUser(for planetId: Int, completion: (_ result: Result<Planet, FetchError>) -> Void) {
    if let user = planets.first(where: { $0.id == planetId }) {
        completion(Result.success(user))
    } else {
        completion(Result.failure(FetchError.planetNotFound))
    }
}

let fetchUserPublisher = PassthroughSubject<Int, FetchError>()

fetchUserPublisher
    .flatMap { planetId -> Future<Planet, FetchError> in
        Future { promise in
            fetchUser(for: planetId) { (result) in
                switch result {
                case .success(let planet):
                    promise(.success(planet))
                case .failure(let error):
                    promise(.failure(error))
                }
            }
        }
}
.map { planet in planet.name }
.catch { (error) -> Just<String> in
    print("Error occurred: \(error)")
    return Just("Not found")
}
.sink { result in
    print("Planet is \(result)")
}

fetchUserPublisher.send(0)
fetchUserPublisher.send(5)

send 메소드에서 planet의 id 값이 0일땐 수성이 나오지만, id가 5인 Planet 의 이름은 존재하지 않기에 찾을 수 없다는 커스텀 에러가 뜬다.

 

일반적인 코드

func fetchAndMergeData() {
    fetchFirstData { firstData in
        self.fetchSecondData { secondData in
            let mergedData = self.merge(firstData, secondData)
            DispatchQueue.main.async {
                self.updateUI(with: mergedData)
            }
        }
    }
}

func fetchFirstData(completion: @escaping (Data) -> Void) { /* 네트워크 요청 */ }
func fetchSecondData(completion: @escaping (Data) -> Void) { /* 네트워크 요청 */ }
func merge(_ first: Data, _ second: Data) -> Data { /* 병합 로직 */ }
func updateUI(with data: Data) { /* UI 업데이트 */ }

문제점:

  1. 중첩된 클로저(Nested Callback)로 인해 읽기 어려움 (Callback Hell).
  2. 데이터를 처리하는 각 단계가 코드에 흩어져 있어 추적이 어려움.

Combine 방식을 쓴다면?

func fetchAndMergeData() {
    Publishers.Zip(fetchFirstDataPublisher(), fetchSecondDataPublisher())
        .map { self.merge($0.0, $0.1) }
        .receive(on: DispatchQueue.main)
        .sink { self.updateUI(with: $0) }
        .store(in: &cancellables)
}

func fetchFirstDataPublisher() -> AnyPublisher<Data, Error> { /* Publisher 반환 */ }
func fetchSecondDataPublisher() -> AnyPublisher<Data, Error> { /* Publisher 반환 */ }
func merge(_ first: Data, _ second: Data) -> Data { /* 병합 로직 */ }
func updateUI(with data: Data) { /* UI 업데이트 */ }
  1. 데이터 흐름이 명확하게 순서대로 선언되어 있어 읽기 쉽다.
  2. 클로저 중첩 없이 데이터 처리, 변환, UI 업데이트를 체인으로 관리할 수 있다.

 

에러처리도 중요하잖아.

에러처리를 명시적으로 관리할 수 있다.

  • ReplaceError, Catch
    스트림 내 에러 발생시 대체 값 제공하는 Publisher 다.
  • SetFailureType, MapError
    • SetFailureType: Publisher 의 Failure 타입 변형할 때 사용
    • MapError: Upstream Publisher 에서 발생한 에러를 새로운 에러로 변환

일반적인 처리

URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        print("에러 발생: \(error.localizedDescription)")
        return
    }
    if let data = data {
        DispatchQueue.main.async {
            self.updateUI(with: data)
        }
    }
}.resume()

 

  • 위에서 언급했듯 콜백 헬 발생 가능 -> 중첩 클로저 많아지면 가독성도 떨어지고, 코드 유지보수도 어려워짐.
  • 재사용성하기 어려워짐 -> 에러 처리 로직 반복되면 분리 하기 어려운 경우 발생 가능
  • 비동기 작업 흐름 관리 -> 다중으로 비동기 작업을 순차적으로 처리 혹은 결합할 때, 코드가 복잡해질 수 있음.

Combine 의 Error Handling(오류처리) 방식

단순히 에러가 났다고 개발자에게 디버그 창에 출력해주는 것이 아니다. 컴바인에서는 에러일 때 나타내고 싶은 에러 대체값이나 에러여도 어쨌든 완료하고 싶을 때 사용할 수 있는 다양한 대체제들을 제공해준다.

  • 에러를 데이터 흐름 안에서 선언적으로 처리한다.
  • 체인 내에서 변환과 에러 처리를 통합적으로 관리할 수 있다.

일부 에러 핸들링 중 mapError 와 Catch 에 대한 예시코드

import Combine
import Foundation

enum NetworkError: Error {
    case badURL
    case requestFailed
    case decodingError
    case unknown
}

func fetchData(from urlString: String) -> AnyPublisher<Data, NetworkError> {
    guard let url = URL(string: urlString) else {
        return Fail(error: NetworkError.badURL).eraseToAnyPublisher()
    }

    return URLSession.shared.dataTaskPublisher(for: url)
        .map(\.data)
        .mapError { error in
            // URLSession 에러를 NetworkError로 변환
            if let urlError = error as? URLError {
                return .requestFailed
            } else {
                return .unknown
            }
        }
        .eraseToAnyPublisher()
}

// 사용 예
let cancellable = fetchData(from: "https://invalid-url-example")
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("요청 완료")
            case .failure(let error):
                print("에러 발생: \(error)")
            }
        },
        receiveValue: { data in
            print("받은 데이터 크기: \(data.count) bytes")
        }
    )
  • mapError를 사용해 URLSession에서 발생하는 URLError를 NetworkError로 변환한다.
  • 잘못된 URL이면 .badURL, 요청 실패 시 .requestFailed, 알 수 없는 에러는 .unknown으로 변환한다.
  • 이렇게 하면 에러를 구체적으로 정의하여 호출자에게 전달할 수 있다.

Catch 는 예외처리때 쓰던 try - catch 와 비슷한 맥락이다. 이걸 내가 자바랑 C 에서 본 것 같은데..Swift 에서는 do try catch 로 쓰인다. try catch 는 동기 코드에서 특정 블록 에러를 잡아내고, Combine 의 Catch 는 스트림(흐름) 기반 비동기 작업에서 에러 발생 시 대체할 수 있는 흐름을 제공해 스트림을 이어나가는 것이 뽀인트다.

import Combine

let publisher = Fail<String, URLError>(error: URLError(.badServerResponse))
    .catch { error in
        Just("대체 값") // 에러 발생 시 대체 값 방출
    }

let cancellable = publisher.sink(
    receiveCompletion: { completion in
        print("완료 상태: \(completion)")
    },
    receiveValue: { value in
        print("받은 값: \(value)")
    }
)

Scheduler

스케쥴러는 비동기 작업을 수행할 대 코드가 실행될 스레드나 큐를 관리한다. Scheduler는 Combine 작업을 예약하고 실행하는 역할을 담당하고, 데이터 처리 흐름에서의 스레드 관리와 성능최적화를 돕는다.

특정 시점에서 작업이 실행 되어야하며, 실행환경을 제어할 때 쓰는 프로토콜 타입이 바로 Scheduler 다. 작업의 실행순서와 실행위치가 명확해야할 때 필요한 녀석이다. 일반적으로 UI 상태변경은 Main Thread, 데이터 처리 작업은 백그라운드~

(마치 프론트,클라이언트 단이 앞에서 주인공하고, 백엔드 서버가 보이지 않는 곳에서 데이터 통신 등을 맡는 것처럼 말이다.)

종류는 SubscribeOn, Delay, Debounce, throttle 등이 있다.

Subscribe(on:): 업스트림 작업 실행 컨텍스트 변경

e.g. 네트워크 요청이나 데이터처리를 백그라운드 큐에서 실행

receive(on:): 다운스트림 작업 실행 컨텍스트 변경

e.g. UI 업데이트를 메인 스레드에서 실행하도록 설정

 

업스트림Publisher 에서 시작연산자를 통해 전달되는 단계다
-> 데이터 생성, 변환, 필터링 작업을 담당한다.
다운스트림연산자에서 소비자로 전달되는 단계다. 소비자는 여기서 Subscriber 다.
데이터의 최종소비 -> e.g. UI 업데이트, 파일 저장

코드의 확장성

Combine은 데이터 흐름을 명확히 선언하기 때문에 새로운 로직을 추가하거나 확장하는 작업이 쉽다.

  • 데이터 변환이나 추가 작업을 체인 내에 간단히 추가할 수 있다.
  • 데이터 흐름을 직관적으로 확장 가능.
import Combine

let publisher = Just("Hello, Combine!")
    .subscribe(on: DispatchQueue.global(qos: .background)) // 데이터 생성은 백그라운드에서
    .map { $0.uppercased() }
    .receive(on: DispatchQueue.main) // UI 업데이트는 메인 스레드에서
    .sink { value in
        print("Received on main thread: \(value)")
    }

 

데이터 흐름 단계

1. 업스트림: 데이터 발행하고 초기 생성

2. 연산자 단계: 데이터 변환, 필터링, 축적 등 처리

3. 다운스트림 단계: 최종 데이터 소비(e.g. UI 업데이트)

결론: 왜 Combine을 쓰는가?

Combine을 사용하는 이유는 성능보다는 코드의 유지보수성과 확장성, 선언적 데이터 흐름 관리에 있다.

  • 단순한 비동기 작업에서는 Combine이 필요하지 않을 수 있다.
  • 복잡한 데이터 흐름(다중 이벤트 처리, 데이터 변환, 에러 처리)에서는 Combine이 압도적으로 유리하다고 한다.

헉헉,,많이 달려왔다.. Publisher, Subscriber 와의 Pub-Sub 패턴도 있고, SwiftUI 프레임워크와 MVVM 아키텍처 환경에서 Combine 을 도입한 프로젝트 코드나 개념들은 2탄에서 다뤄봐야겠다. 항상 글을 쓸 때 다음 시간에.. 와 같은 느낌으로 글을 썼는데, 연결되지 않은 경우가 다수였다. (...반성...)

다뤄보지 못한 Combine (다음에 꼬옥 다뤄볼 Combine 주제)

  1. AnyPublisher
  2. Cancellable
  3. AnyCancellable
  4. SwiftUI 환경에서 Combine 써보기!

생각보다 RxSwift 가벼운 개념 공부했을 때 보다 Combine 개념 공부하는 것이 시간이 더 많이 들어가고, 이해하기에도 어려웠던 것 같다. 그래서 유튜브 영상도 보고 예시 코드도 보고, 안보던 강의도 보게 해주고(?), 채찍피티의 힘도 빌렸다..

너무 긴 것 같아서 나중에 ToC(Table of Contents) 를 달아야만 할 것 같다..


공부한 기간: 2-3일 하루 2시간 안팎

글쓰기 시간: 약 3시간

참고자료