2025. 3. 24. 17:36ㆍFramework, Library/Combine
[Combine] Combine Operator 완전 정복하기 - Combining Operators
예전 아래 제 글에서 Operator의 개념과 종류들을 소개한 적이 있습니다.그런데 단순히 글과 표로만 정리해서 읽고 넘어가기에는, Combine을 사용하면서 충분히 Operator를 적재적소에 사용하기가 어
mini-min-dev.tistory.com
예전에 살펴봤던 Combining Operator에 이어서, 오늘은 Combine의 Transforming Operator에 대해 살펴보고자 합니다!
*Combine 스터디를 할 때 역할을 나누어서 살펴보다보니, 저의 글로 다시 정리하는게 시간이 걸렸다는 점... 양해 부탁드립니다.
Transforming이라는 이름에 맞게, 무엇인가 어떤 것을 "변형"하는 느낌의 Operator에 해당한다고 이해하면 되는데요.
더 자세하게 설명하자면, Transforming Operator는 "Upstream Publisher로부터 받은 값들을 "변형"해서 Downstream으로 전달하는 Operator"입니다.
map
✔️ map : Upstream Publisher로부터 전달받은 각 요소에 대해 클로저를 사용해 값을 변화해 publish함.

Swift 문법 중 고차함수 map과 유사하게 동작한다고 이해하면 좋습니다.
코드 구성을 살펴보죠.
transform 클로저는 업스트림 Publisher의 Output을 인자로 가지고 있는 것을 확인할 수 있습니다.
이 클로저 (transform)에서 원하는 형태의 타입을 지정해 리턴시켜주면, Publishers.Map<self, T> 타입으로 Publisher가 변환되는 구조이죠.
public func map<Result>(
_ transform: @escaping (Output) -> Result
) -> Publishers.Map<Self, Result> {
return Publishers.Map(upstream: self, transform: transform)
}
조금 더 자세하게 아래 OpenCombine 코드를 뜯어봤을 때, 얻을 수 있는 시사점은 아래와 같은 것들이 있다고 보여지네요.
- 결국 Publishers.Map<Self, T> 는 Publishers.Map<Upstream, Ouput> 구조와 같음을 확인할 수 있습니다.
- Map의 Failure는 Upstream의 Failure을 별도로 처리하지 않고, 그대로 DownStream으로 전달하는 역할에 그칩니다.
- Downstream Subscriber를 구독하고 데이터를 전달하는 역할을 하는 부분은 recreive 메서드 부분입니다.
- receive 메서드 부분 : Upstream 객체가 데이터를 방출한다 -> Inner 객체가 생성되어 transform 클로저를 통해 데이터를 변환한다 -> 변환된 데이터가 Downstream Subscriber에게 전달된다.
extension Publishers {
/// A publisher that transforms all elements from the upstream publisher with
/// a provided closure.
public struct Map<Upstream: Publisher, Output>: Publisher {
public typealias Failure = Upstream.Failure
/// The publisher from which this publisher receives elements.
public let upstream: Upstream
/// The closure that transforms elements from the upstream publisher.
public let transform: (Upstream.Output) -> Output
public init(upstream: Upstream,
transform: @escaping (Upstream.Output) -> Output) {
self.upstream = upstream
self.transform = transform
}
public func receive<Downstream: Subscriber>(subscriber: Downstream)
where Output == Downstream.Input, Downstream.Failure == Upstream.Failure
{
upstream.subscribe(Inner(downstream: subscriber, map: transform))
}
}
그럼 실제 코드는 어떻게 사용하는지 살펴볼게요!
일반적으로 사용하는 숫자 (5, 4, 3, 2, 1)를 로마 숫자 (V IV III II I)로 변환하는 예시 코드를 살펴보겠습니다.
해당 코드는 Int 자료를 담고 있는 배열 numbers를 Publisher로 변환하여 데이터 스트림을 시작하고 있는 상황입니다.
map 연산자는 numbers에서 Publish 되고 있는 각 Int 값을 romanNumeralDict 딕셔너리를 참조하여 해당하는 로마 숫자로 변환하고 있어요.
만약 딕셔너리에 해당 키가 존재하지 않으면 "(unknown)" 문자열을 기본값으로 사용하고 있군요.
스트림에 대한 구독 관계는 sink로 만들어져 있고,
sink 부분에 정의된 print 문에 의해 최종적으로 "V IV III II I (unknown)"이 출력될 것입니다.
핵심은 map Operator에 의해 Int 값이 로마 숫자를 나타내는 String 값으로 변환되어 Publish 되는 부분이죠! map의 역할은 매우 간단합니다.
let numbers = [5, 4, 3, 2, 1, 0]
let romanNumeralDict: [Int : String] = [1:"I", 2:"II", 3:"III", 4:"IV", 5:"V"]
let cancellable = numbers.publisher
.map { romanNumeralDict[$0] ?? "(unknown)" } // map
.sink { print("\($0)", terminator: " ") }
// "V IV III II I (unknown)"
tryMap, compactMap, flatMap
큰 틀에서 Upstream Publisher로부터 전달받은 각 요소에 대해 클로저를 사용해 값을 변화해 publish 한다는 점에서는 동일합니다. 단, 그 이후가 조금씩 차이가 있는 것 같아요.
✔️ tryMap : map과 유사. Upstream Publisher로부터 전달받은 각 요소를 변환하는 과정에서 에러를 던질 수 있음
✔️ compactMap : Upstream Publisher로부터 전달받은 각 요소를 클로저에 전달하고, 해당 클로저가 반환한 값이 nil이 아닌 경우에만 그대로 방출
일단 tryMap부터 살펴보죠.
try라는 키워드에서 알 수 있듯이 크게는 map과 같은 역할을 하지만, map 과정에서 에러를 던질 수 있다는 점에서 차이점을 갖습니다.
아래 tryMap 구조 부분을 살펴보게 되면,
transform 클로저 부분에 throws 키워드로 에러를 던질 수 있게 선언되어 있는 것을 확인하실 수 있습니다!
하나 중요한 포인트가 있다면, 만약 클로저 내부에서 오류를 발생시키면 즉시 데이터 스트림은 종료되고, Down Stream으로 에러가 전달된다는 점이에요!
public func tryMap<Result>(
_ transform: @escaping (Output) throws -> Result
) -> Publishers.TryMap<Upstream, Result> {
return .init(upstream: upstream) { try transform(self.transform($0)) }
}

compactMap의 역할은 딱 간단합니다!
Upstream Publisher에서 방출된 값이 nil일 경우, 그 값을 제거하고 Downstream으로 전달하기 위한 역할이죠.
딱 간단하게 말해서 Upstream의 Optional 타입을 처리하기 위해 사용하는 Operator라고 생각하면 이해가 쏙쏙 될 것 같네요!
*tryMap은 에러가 발생할 때 즉시 데이터 스트림이 종료되지만,
compactMap은 nil을 방출하는 데이터 스트림만 필터링될 뿐, nil이 발생했다고 이후의 데이터 스트림에 영향이 되지 않는다는 점이 차이점입니다.
public func compactMap<ElementOfResult>(
_ transform: @escaping (Output) -> ElementOfResult?
) -> Publishers.CompactMap<Self, ElementOfResult> {
return .init(upstream: self, transform: transform)
}

그래서 아래와 같은 코드 리뷰를 팀원에게 달았던 기억도 있네요!
map 연산자를 사용할 때,
옵셔널을 굳이 처리하는 로직을 담지 않고 / compactMap을 사용해 nil일 경우 Publish가 되지 않도록 애초에 처리할 수 있으니까요!

✔️ flatMap : Upstream Publisher로부터 전달받은 각 요소를 가지고 새로운 Publisher를 생성하고, 해당 Publisher가 방출하는 값을 Downstream으로 publish함.
flatMap이라는 이름에 맞게, flat한 map입니다 (?)
flat은 "평탄하다"는 의미죠.
쉽게 말해, 중첩되어 있는 데이터 스트림을 평탄화 (flatten)하여 처리하는 역할을 하는 것이 flatMap입니다.
이 중첩되어 있는 Publisher는 한 번에 최대로 가질 수 있는 개수를 지정하거나, 무제한으로 지정할 수 있는데 -> 그 역할을 하는 것이 파라미터 maxPublishers입니다.
public func flatMap<Result, Child: Publisher>(
maxPublishers: Subscribers.Demand = .unlimited,
_ transform: @escaping (Output) -> Child
) -> Publishers.FlatMap<Child, Self>
where Result == Child.Output, Failure == Child.Failure {
return .init(
upstream: self,
maxPublishers: maxPublishers,
transform: transform
)
}
무슨 말인지 "엥?" 할 수 있을 것 같아 아래 예시 코드를 살펴보겠습니다.
- WeatherStation 타입을 받는 PassthroughSubject를 통해 Publisher를 발행합니다. (weatherPublisher)
- 이를 flatMap을 통해 URLSession.DataTaskPublisher로 변환한다.
- sink는 이제 초기 Publisher의 Output인 WeatherStation이 아니라, flatMap에서 리턴되는 새로운 Publisher인 URLSession.DataTaskPublisher를 구독합니다.
즉, Publisher가 또 다른 Publisher를 반환할 때 발생하는 중첩을 평탄화(flat) 하게 만들고 (Publisher<Publisher<String, Error>, Error> → Publisher<String, Error> )
네트워크 요청처럼 각각이 Publisher를 반환하는 비동기 작업들을 병렬로 연결할 때 사용할 수 있는 연산자가 flatMap입니다.
public struct WeatherStation {
public let stationID: String
}
var weatherPublisher = PassthroughSubject<WeatherStation, URLError>() // 1
let cancellable = weatherPublisher
.flatMap { station -> URLSession.DataTaskPublisher in // 2
let url = URL(string:"https://weatherapi.example.com/stations/\(station.stationID)/observations/latest")!
return URLSession.shared.dataTaskPublisher(for: url)
}
.sink( // 3
receiveCompletion: { completion in
switch completion {
case .finished:
print("finished")
case .failure(let failure):
print(failure)
}
},
receiveValue: { value in
print(value)
}
)
//출력: 여러 도시의 WeatherStation을 발행
weatherPublisher.send(WeatherStation(stationID: "KSFO")) // San Francisco, CA
weatherPublisher.send(WeatherStation(stationID: "EGLC")) // London, UK
weatherPublisher.send(WeatherStation(stationID: "ZBBB")) // Beijing, CN
scan
✔️ scan : 누적 계산을 수행할 때 사용. 클로저가 반환한 마지막 값과 현재 값을 클로저에 제공하여 업스트림 Publisher의 요소를 반환하는 Operator
scan은 reduce와 유사합니다.
Upstream에서 방출되는 값을 기반으로, 계속해서 누적되는 결과를 계산해 DownStream으로 전달하기 때문이죠.
단 차이점이 있다면, reduce는 최종 누적 값을 방출하고 / scan은 중간 누적 값을 지속해서 방출한다는 점에서 다르다고 이해하면 됩니다.

scan을 사용할 때 알아야하는 내용만 간단하게 정리해보겠습니다.
- initialPoint에는 초기값을 설정합니다.
- Upstream에서 Publish 되는 값에 대해 실행될 클로저는 nextPartialResult 부분에 정의합니다.
- 해당 클로저에서는 기본적으로 이전값 (없으면 정의되어있는 initialResult)을 인자로 받고 새로운 값을 Downstream으로 발행합니다.
public func scan<T>(
_ initialResult: T,
_ nextPartialResult: @escaping (T, Publishers.Sequence<Elements, Failure>.Output) -> T
) -> Publishers.Sequence<[T], Failure>
replaceNil
✔️ replaceNil : Upstream Publisher로부터 전달받은 nil값을 지정된 기본값으로 대체하는 Operator

앞에서 살짝쿵 살펴봤던 compactMap과 nil을 처리한다는 점에서 비슷한데..? 라고 생각할 수도 있지만... 결이 조금 다른 연산자입니다.!
어떤 점에서냐구요...?
- Optional 값을 가진 Publisher에서 nil이 발생할 경우 -> 데이터 스트림이 필터링되지 않고, 지정된 기본값으로 대체됩니다.
- nil이 아닌 값은 옵셔널을 처리하는 것이 아니라, 업스트림에서 방출된 값 그대로 다운스트림으로 전달됩니다.
아주 간단한 예시도 있습니다.
numbers는 1, 3, 5의 Int 값을 포함해 nil까지 함께 담고 있는 Int? 타입의 배열인데요.
replaceNil(with: 0) 연산자를 사용했을 때 -> 일반 Int? 타입은 그 (옵셔널) 값 그대로 / nil 타입은 기본 값으로 지정되어 있는 Optional(0) 값이 Publish되고 있습니다.
let numbers = [1, nil, 3, nil, 5].publisher
numbers
.replaceNil(with: 0)
.sink { print("\($0)", terminator: " ") }
// 출력: "Optional(1) Optional(0) Optional(3) Optional(0) Optional(5)"
'Framework, Library > Combine' 카테고리의 다른 글
[Combine] Combine Operator 완전 정복하기 (4) - Timing and Control Operators (0) | 2025.04.06 |
---|---|
[Combine] Combine Operator 완전 정복하기 (3) - Filtering Operators (0) | 2025.03.25 |
[Combine] Combine Operator 완전 정복하기 (1) - Combining Operators (0) | 2024.12.26 |
[Combine] Cancellable, AnyCancellable 개념 뿌시기 (3) | 2024.12.05 |
[Combine] AnyPublisher와 Type Erasure 개념 뿌시기 (1) | 2024.12.02 |