user@namudi: ~

Combine은 왜 써야 할까? (Swift Concurrency와 비교)

>2025.01.28.

Combine은 왜 써?

UIKit에서 반응형 UI를 구현하려면 Combine을 사용해야 한다는데, 왜 써야하는 걸까요? Combine은 복잡한 비동기 처리를 손쉽게 할 수 있는 프레임워크 아닌가요? 우리 앱은 복잡한 비동기 처리가 없을 것 같은데, 없더라도 사용해야 할 이유가 있을까요?

비동기 프로그래밍이 뭔가요?

Combine에 대해 공부하다 보면 비동기, 콜백 함수, 델리게이트 등등 많이 듣는 단어들입니다. 이 비동기 프로그래밍이란 무엇이고 스레드란 무엇인지 간단하게 짚고 넘어가 보겠습니다.

동기와 비동기

먼저 동기와 비동기의 개념을 프로그래밍에서 간단하게 설명하자면,
동기적으로 동작한다는 건, 코드가 반드시 작성된 순서 그대로 실행된다는 것이다.
func onLoadSync(_ sender: Any) {
	guard let url = URL(string: imageURL) else { return }
	guard let data = try? Data(contentOf: url) else { return }
	
	let image = UIImage(data: data)
	imageView.image = image
}
비동기적으로 동작한다는건, 꼭 작성된 순서대로 실행되는 것은 아니라는 것이다.
func onLoadAsync(_ sender: Any) {
    guard let url = URL(string: imageURL) else { return }
    
    DispatchQueue.global().async {
        guard let data = try? Data(contentsOf: url) else { return }
        let image = UIImage(data: data)
        
        DispatchQueue.main.async {
            self.imageView.image = image
        }
    }
}
동기는 동일한 기찻길에 놓인 열차들이라고 할 수 있다. 앞의 열차가 꾸물거리거나 멈춰 있으면 뒤의 열차가 나아갈 수가 없다.
비동기 방식은, 필요에 따라 이동이 느리거나 자주 서는 열차를 다른 선로에 배치하는 것입니다. 이렇게 하면 뒤의 열차가 막히지 않게 됩니다.

FRP (Functional Reactive Programming)

Combine을 이해하기 위해서는 FRP에 대한 이해가 필요합니다.
Combine과 Rx 모두 FRP인데, 결국 Functional Reactive Programming이라는 하나의 패러다임에 부합합니다.
따라서 FRP의 등장 배경을 아는 것이 Combine이나 Rx의 등장 배경을 아는 것과 동일합니다.

1. 패러다임이란?

패러다임: 한 시대의 사람들이 견해나 사고를 근본적으로 규정하고 있는 인식의 체계
오랜 시간이 지나며 프로그래밍이 발전해 왔고, 그동안 여러 가지 프로그래밍 기법이 등장해 왔습니다. 그리고 당시의 개발자들이 공통적으로 동의할 만한 논리들을 기반으로 새로운 방식이 등장했습니다.
오늘날에는 프로그램이 실행될 때 하나의 프로그램만 실행되는 것이 아니라, 여러 개의 프로그램이 동시에 실행되는 것이 당연해진 시대가 되었습니다. 그러다 보니 A프로그램과 B프로그램이 같이 실행되고 있을 때에, 한 프로그램이 다른 프로그램의 실행에 영향을 끼치면 안된다.라는 의견에 대부분의 개발자들이 동의하게 됩니다.
그럼 과연 프로그래밍에 있어서 영향을 끼친다. 는 말이 무슨 말일까?
위의 사진처럼 다양한 프로그램이 하나의 데이터를 동시에 읽거나 쓰는 경우가 존재한다고 했을 때 문제가 생길 수 있고, 이런 상황을 A프로그램과 B프로그램이 같이 실행되고 있을 때에, 한 프로그램이 다른 프로그램의 실행에 영향을 끼친다. 고 할 수 있다.
var data: Int = 0
 
func addAndGet(add: Int) -> Int {
	data += add
	return data
}
위의 코드를 보면, 이렇게 하나의 데이터를 하나의 프로그램(메서드)에서만 사용하면 아무런 문제가 없다. 하지만 여러 메서드에서 해당 데이터에 접근하는 경우엔 문제가 발생할 수 있다. 정확하게 말하자면 읽기만 했을때는 아무런 문제가 없지만 쓰기를 했을 때에 문제가 생긴다.
여러 메서드를 동기적으로 실행해 동시에 접근하는 것을 막을 수도 있겠지만, 이 문제를 해결하는 가장 쉬운 방법은 데이터를 Immutable(불변)하게 바꿔서 아예 쓰기를 못하게 하는 것입니다. 예를 들면 변수가 아닌 상수로 선언하는 것과 같이 말입니다.
영향을 끼치는 경우 를 하나 더 보자면, 흔히 Side Effect라고 말하는 현상도 발생할 수 있다.
var data: Int = 10
 
func addAndGet(add: Int) -> Int {
	return data + add
}
 
addAndGet(add: 1) // 11
data = 20
addAndGet(add: 1) // 21
분명히 같은 Input을 가진 메서드를 실행했는데, 두 실행 결과가 다르게 나오는 상황이 발생합니다. 이런 식으로 외부 변수인 data 로 인해서 같은 Input이지만 다른 Output이 나오는 상황을 Side Effect가 발생한다고 말합니다.

2. 함수형 프로그래밍의 등장

그럼 어떻게 하면 Side Effect를 방지할 수 있을까요? 위의 예시에서 외부 변수를 사용하지 않으면 됩니다.
func addAndGet(base: Int, add: Int) -> Int {
	return base + add
}
 
addAndGet(base: 10, add: 1) // 11
addAndGet(base: 20, add: 1) // 21
이런 식으로 함수의 Output이 오로지 Input에 의해서 결정되는 함수를 사용하게 되면 Side Effect부터 자유로워질 수 있고, 이러한 함수를 사용해서 프로그래밍하는 것이 오늘날의 프로그래밍에서 중요한 부분이 됩니다.
이러한 함수를 순수 함수, Pure Function이라고 한다.
설명을 덧붙이자면, 외부 변수를 메서드 내부에서 사용하더라도 그 변수가 불변하다면, 그 또한 Pure Function이라고 부른다. 만약에 위의 예시에서 data가 let으로 선언되었다면 변하지 않을테니 순수 함수로 볼 수 있다.
그리고 이러한 순수 함수를 이용해 프로그래밍을 하는 여러 가지 기법들이 제안되는데, 그중 하나가 결합을 활용한 프로그래밍입니다.
func getTen() -> Int {
	return 10
}
 
func addTwo(n1: Int, n2: Int) -> Int {
	return n1 + n2
}
 
func output(value: Int) {
	print("\(value)"
}
 
output(value: addTwo(n1: getTen(), n2: getTen())) // 20
위의 코드처럼 함수형 프로그래밍은 Pure Function, 그리고 함수 자체를 타입으로 취급할 수 있는 1급 객체의 특성을 가지고 여러 개의 함수를 조합해서 사용할 수 있는 결합(Composition)을 통해서 복잡한 프로그래밍을 할 수 있게 됩니다.
이런 게 가능한 이유는 Pure Function으로 인해 Side Effect로부터 자유로워졌기 때문입니다.
그래서 결과적으로 기존의 데이터 중심 프로그래밍이 데이터의 변화에 집중하는 것이 아닌 Input이 Output으로 변하는 과정에 집중하게 되고, 이를 선언적 프로그래밍이라고 부르기도 합니다. 함수형 프로그래밍은 이러한 선언적 프로그래밍의 한 형태입니다.
결국은 데이터 자체를 변화시키지 않고 어떤 Output을 만들어내는 프로그래밍으로 인해 불변함을 유지할 수 있게 했고 A프로그램과 B프로그램이 같이 실행되고 있을 때에, 한 프로그램이 다른 프로그램의 실행에 영향을 끼치면 안된다.라는 원칙을 지킬 수 있게 됩니다.
함수형 프로그래밍의 특성을 정리하자면 다음과 같다:
  • 선언적(Declarative): "어떻게" 하는지가 아니라 "무엇을" 할지를 명시한다.
  • 불변성(Immutability): 데이터의 상태 변경을 최소화한다.
  • 순수 함수(Pure Functions): Side Effect 없이 입력에 대해 항상 같은 출력을 반환한다.
  • 고차 함수(Higher-Order Functions): 함수를 인자로 받거나 반환할 수 있다.

3. 기존의 비동기 처리 방식과 반응형 프로그래밍

URL값을 통해 서버에서 어떤 String 값을 받아오는 순수 함수 메서드가 있다고 가정해보자.
func getText1(from url: URL) -> String {
	return try! String(contentOf: url)
}
아마 이런 모양일 것입니다. URL을 가지고 String 값을 받아오는데 오직 Input에 의해서만 Output이 결정되는 순수 함수의 특성을 가지고 있습니다.
하지만 이 코드의 문제점은 실제로 서버에서 데이터를 받아오는데 시간이 걸린다는 것입니다.
결국은 서버에서 데이터를 가져올 때까지 기다려야 하는데, A프로그램과 B프로그램이 같이 실행되고 있을 때에, 한 프로그램이 다른 프로그램의 실행에 영향을 끼치면 안된다.라는 원칙에 위배되는 것입니다.
네트워킹을 하겠다고 UI가 아예 멈춰버리면 안 되는 것처럼 말입니다. Progress View를 띄워 네트워킹 중이라는 것을 표시할 수도 있는데, 이 Progress View를 띄워 로딩바가 움직이는 것조차도 UI 업데이트에 포함되기 때문에, 이 또한 비동기적으로 동작해야 한다는 것입니다.
그리고 비동기 메서드를 작성할 때에는 위처럼 return을 하는 메서드를 작성할 수 없습니다. 왜냐하면 비동기 작업은 언제 끝날지 모르기 때문에 값을 return하는 타이밍을 특정 지을 수 없기 때문입니다.
그래서 비동기 작업은 콜백, 델리게이트와 컴플리션 핸들러의 @escaping 클로저를 이용해 처리하도록 해왔습니다.
// Callback Async
func getText3(from url: URL, result: @escaping (String) -> Void) {
	URLSession().dataTask(with: url) { (data, response, error) in
		let text = String(data: data!, encoding: .utf8)
		result(text!)
	}.resume()
}
// Delegate Async
var delegate: ((String) -> Void)? = nil
 
func getText4(from: url: URL) {
	URLSession().dataTask(with: url) { (data, response, error) in
		let text = String(data: data!, encoding: .utf8)
		delegate!(text!)
	}.resume()
}
우리가 함수를 사용하는 방식이 Input을 가지고 Output을 return 해주는 방식인데, 비동기 처리에서는 이런 방식을 사용할 수 없습니다. 그렇기 때문에 비동기 처리를 원래 쓰던 함수의 모양인 Input → Output 방식으로 사용할 수 있는 방법은 없을까를 고민하다가 나온 패러다임이 바로 Reactive Programming입니다.
Reactive Programming: Async한 상황에서 이 Async한 데이터를 어떻게 처리할 것인가? How: Stream 이라는 것을 만들어서 연결시키고 데이터를 Stream에 흘려보내자.
이는 단순히 아이디어이자 패러다임이기 때문에 구현할 수 있는 방식은 다양하고, 그 구현 방식 중 하나가 RxSwift입니다.
그럼 예제를 통해 Reactive Programming이란 무엇인지 한 번 살펴보겠습니다.
  1. 배달 앱에서 여러 사람의 메뉴를 주문 시 메뉴의 개수를 추가하게 되면 메뉴의 숫자가 페이지의 새로고침 없이 반영되는 것처럼 특정 부분이 실시간으로 변하게 되는 것
  2. 핸드폰으로 동영상을 시청하고 있는데 세로로 보고 있다가 가로로 화면을 돌릴 때 이를 실시간으로 관찰(Observe)하고 있다가 화면을 전환하게 하는 것
이렇게 사용자의 입력을 받을 때마다 즉각적으로 반응하기 위해선, 값을 지속적으로 관찰(Observe)해야 하고, 값에 변화가 있을 때마다 특정 연산이 수행되어야 합니다. 이러한 관찰 패턴을 Observer 또는 Observation 디자인 패턴이라고 하며 RP(Reactive Programming)에서는 해당 ‘Stream에 구독(Subscribe)한다’라고 표현합니다.
즉, 반응형 프로그래밍(RP, Reactive Programming)이란 데이터 스트림 또는 데이터의 변화에 따라 코드가 자동으로 반응하는 프로그래밍 패러다임이다. 이 패러다임에서는 데이터의 변경 사항을 감지하고 이에 따라 연속적으로 반응하는 방식으로 프로그램을 작성한다.
여기에서 나오는 각 단어들을 정리하면 다음과 같다.
1. 데이터 스트림(Data Stream)
  • 이벤트 스트림, 값의 흐름 등과 같이 시간에 따라 연속적으로 발생하는 데이터의 흐름을 나타냄.
  • 이러한 데이터 스트림은 사용자 입력, 센서 데이터, 외부 API의 응답 등 다양한 소스에서 나올 수 있음.
2. 옵저버(Observer) 패턴
  • 데이터의 변화를 감시하고, 변화에 따라 특정 작업을 수행하는 디자인 패턴.
  • 변화가 일어나면 옵저버(또는 구독자)는 해당 변화에 반응하여 알림을 받고, 필요한 작업을 수행.
3. 스트림의 변환과 조작(Transforming and Manipulating Streams)
  • 데이터 스트림을 조작하여 필터링, 매핑, 결합, 변환 등을 수행하여 새로운 스트림을 생성하는 작업.
  • 이를 통해 데이터 스트림을 효과적으로 처리하고 필요한 형태로 가공.
4. 바인딩(Binding)
  • 데이터의 변화와 이에 따른 작업의 연결을 나타냄.
  • 데이터와 UI 요소, 또는 데이터와 작업 사이의 연결을 설정하여, 데이터의 변경이 발생하면 이에 맞춰 UI나 다른 작업을 자동으로 업데이트.

Swift Concurrency(Async/Await)의 등장

여기까지 읽었을 때, 아마 Swift Concurrency를 사용해 보았다면 Async한 동작을 Sync하게 작성하고 읽을 수 있다는 장점은 Swift Concurrency로도 충분히 가능한 것 아닐까? 란 생각이 들 수 있다.
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
        } else if let data = data {
            completion(.success(data))
        }
    }.resume()
}
실제로도 Completion Handler를 사용한 위의 비동기 함수를 Swift Concurrency를 사용하면,
func fetchData() async throws -> Data {
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}
이와 같이 리턴 값을 받을 수 있도록 할 수 있다. 이는 await로 된 부분이 잠재적 일시 중단 지점(Potential Suspension Point)으로 지정되기 때문에 async로 선언된 이 함수가 완료될 때까지 이 지점에서 일시 중단되기 때문이다. 따라서 네트워크 요청을 했을 때에는 데이터를 다 가져올 때(비동기 작업)까지 await 지점에서 대기하며, 비동기 작업이 끝나면 값을 return하게 되는 것이다. 즉, 코드상으로 Sync하게 동작하는 것처럼 보이게 만들 수 있는 것이다.
이렇게만 보면 굳이 복잡한 FRP를 사용하지 않더라도 Swift Concurrency만으로 복잡한 비동기 처리를 해결할 수 있을 것처럼 보인다. 게다가 Swift Concurrency는 기존의 GCD 방식에 비해서 스레드 관리 측면에서도 유리하기 때문에 더더욱 Rx나 Combine을 사용할 이유가 없는 것처럼 보인다.

그래서 Combine은 왜 써야하는가?

특징Swift ConcurrencyCombine
주요 목적비동기 코드의 간소화비동기 이벤트 스트림 처리
구문async/awaitPublisher/Subscriber
취소 매커니즘Task.cancel()AnyCancellable
에러 처리try-catch에러 타입 정의 및 처리
여러 작업 조합Task GroupCombine 연산자 (zip, merge 등)
반응형 프로그래밍제한적 지원강력한 지원
데이터 스트림 변환제한적다양한 연산자 제공
메모리 관리자동 (ARC)자동 (ARC)
백프레셔 처리제한적기본 제공
기존 Apple 프레임워크와의 통합제한적광범위한 통합
학습 곡선상대적으로 낮음상대적으로 높음
UI 업데이트와의 연계직접 구현 필요@Published 등으로 쉽게 구현
복잡한 비동기 흐름 제어제한적강력한 지원
정리해보자면, Swift Concurrency는 async/await를 사용해 비동기 코드를 동기 코드처럼 작성할 수 있게 해준다. Combine은 Publisher와 Subscriber 모델을 사용해 데이터 스트림을 처리할 수 있게 해준다.
Combine과 Swift Concurrency의 가장 큰 차이는 반응형 프로그래밍에 있다고 할 수 있다. 데이터 스트림의 변환과 조합을 통해 복잡한 비동기 작업 흐름도 유연하게 처리할 수 있다. 또한 @Published 속성 래퍼 등을 활용해 데이터 변경을 UI 업데이트와 쉽게 연결할 수도 있다. 반면에 Swift Concurrency에서는 이런 기능들을 직접적으로 제공해주지는 않는다.
결론을 내려보자면, 단순한 네트워킹 작업은 Swift Concurrency를 이용해 코드의 가독성을 높일 수 있고 복잡한 네트워크 요청(비동기 작업)이나 UIKit 기반의 앱에서 반응형 UI를 구현하려고 할 때에나 SwiftUI에서 복잡한 비동기 처리를 UI에 반응형으로 보여주고 싶을 때에는 Combine을 사용하는 것이 더 유용하다고 할 수 있겠다. Swift Concurrency와 Combine 모두 각각의 장단점이 있기 때문에 둘 중 한 개를 선택해 사용한다기보다 각각의 단점을 상호 보완하면서 사용하는 방식이 더 좋지 않을까 감히 생각해본다.

참고자료