본문 바로가기

iOS/Combine Framework

[Swift] No6. Transforming operator 개념 완전 뿌수기!! | Combine

안녕하세요!

 

이번 포스트는 Combine framework에서 publisher 와 subscriber간 subscription의 값을 바꾸는 operator 공부한 내용을 정리하려고 합니다.

 

주제는 transform 타입의 map, tryMap, flatMap + compactMap(_:)

 

collect, scan.

 

Upstram의 output value가 nil인 경우 대처 가능 한 

replaceNil(with:), replaceEmpty(with:). 

 

주의사항!!

upstream publihser라는 말을 사용하는데 이는 operator보다 위에 있는 publisher의 흐름을 칭합니다.

downstream은 특정 publihser가 값을 방출했을 때 그 하위에 있는 stream 입니다. subscriber 일 수도 있고 다른 publihser 일 수도 있고 operator 일 수도 있습니다.

 

operator에 대해 다루려고 합니다.

 

Error throw 할 경우에 대한 catch 관련은 이전 포스트에서 다뤘습니다. 컴바인을 공부하면서 느낀 점은 publisher와 subscriber의 개념은 간단한데 operator를 많이 알아야 Combine을 잘 활용할 수 있다는 점입니다. 요즘 알고리즘 공부를 하는데 Swift standard library의 여러 함수들과 이름조차 같은 operator가 많이 있어서 낯설지 않게 함수들을 다룰 수 있어 좋은 것 같습니다.

1. What is operator?

 

publisher는 subscriber에게 값을 보낼 수 있습니다. subscriber는 publisher에게 등록(구독)을 반드시 해야 합니다. 구독하게 되면 subscription을 통해 subscription 자신이 가지고 있는 값을 subscriber에게 전달해 줍니다.

 

Operator chain이 있다면 upstream publisher로부터 publish 된 value는 각각의 operator chains를 거쳐 subscriber에게 전달됩니다. operator에서 수행하고 싶은 로직이 실행되며 로직에 따라 값이 변경될 수 있습니다. Operator chains가 끝나면 다른 publiser or subscriber에게, 여러 operator를 거친 쪼물쪼물된 값이 전달됩니다.

 

Operator란 메서드를 의미합니다!!

 

주어진 input의 값을 내가 원하는 형태로 바꿔서 output으로 내보내는 함수 말입니다!! if else구문, subscriber에게 전달해야 할 값이 특정한 기대값에 미치는지의 유무에 따른 filter, 값의 초기값을 부여, 처음 publish되는 값만 전달, 여러 번 값이 publish됬을 때 때 최신값을 받기 등등..

 

그리고 Swift standard library에 속한 함수들과 이름이 같은 함수들이 많은데 실제로 동작 원리와 정말 유사합니다. 하지만 차이점이 있다면? 바로 publisher 반환 유무입니다.

 

2. map(_:) func and map(_:), tryMap(_:), flatMap(_:), compactMap(_:) operator.  What is difference? 

 

https://developer.apple.com/documentation/Swift/Sequence/map(_:)

Swift standard library 고차함수 map(_:)입니다.

 

배열, iterated access가 가능한 sequence에 주로 사용됩니다. for in, forEach 루프를 사용하는 것과 거의 유사합니다.(차이점은 배열을 반환한다는 것?!)

 

(_) -> T 클로저 타입의 transform 매개변수를 받아서 [T] 제너릭 T타입인 배열을 반환합니다. 연속적인 sequence의 각 Element를 Self.Element(클로저 입력값)로 받아 내가 원하는 형태로 변형한 후 Element를 배열로 반환합니다.

 

2-2. map(_:) operator

https://developer.apple.com/documentation/combine/publisher/map(_:)-99evh

Combine framework의 publisher Operator입니다. 마찬가지로 위에서 설명한 고차함수 map과 같은 동작원리를 갖고 있습니다.

각각의 upstream publisher의 각각의 값을 (_) -> T 타입인 클로저로 받습니다.

() -> T

(요기에 upstream publisher의 각각의 output이 담깁니다.) -> T // 위 타입을 자세히 풀어 설명했습니다.

그래서 클로저의 input타입이 Self.Output인 것 같습니다. 그렇게 클로저 타입의 transform을 받은 후에 클로저 내부에 로직을 작성하면서 값을 변형 한 후에 Publishers.Map<Self,T>타입으로 반환합니다.

 

이 점이 고차함수와 다른 점입니다. 

https://developer.apple.com/documentation/combine/publishers/map

즉, publisher를 반환합니다.

 

"Operator"의 특징은 Upstream publihser로부터의 output value를 받은 후에 다시 publisher를 통해 값을 emit 한다는 점입니다. emit을 할 수 있다는 뜻은 다른 operator가 publisher의 Output을 받아서 publisher를 통해 subscriber or 다른 operator or publihser한테 반환할 수 있다는 말입니다. 값을 emit 할 수 있기에 chain이 가능합니다. 

 

다시, 위에서 정의된 고차함수 map(_:)을 보면,, map(_:) operator과의 차이점이 배열 반환 vs publisher 반환임을 명확하게 알 수 있습니다.

 

또 다른 차이점이 존재합니다. 

 

고차함수의 경우 throws를 지원합니다. 주어진 sequence의 각각의 element를 작업할 때 해당 값이 error를 throw 할 때 그 즉시 error를 throw할 수 있습니다. 반면 map(_:) operator는 throws 키워드가 없기 때문에 upstream publisher로부터 emit 될 값을 바꾸려고 할 때 특정 로직에서 에러를 던진다면 throw 하지 못하기에 컴파일 에러가 발생합니다...

 


  
let pub = NotificationCenter.default.publihser(for: .download)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.map { data in
try JSONDecoder().decode(WantsToBeSwiftMaster.self, from: data)
}

 

위의 코드에서 JSONDecoder를 통해 data를 WantsToBeSwiftMaster 구조체(Codable type)로 디코딩하기 위한 로직이 있습니다. 

https://developer.apple.com/documentation/foundation/jsondecoder/2895189-decode

decode는 data를 T.Type로 decode 합니다. 이 과정에서 data와 T.Type가 일치하지 않을 경우 에러를 던집니다. 그러기에 위 코드에서 try를 사용해야 합니다. 하지만 위에서 본 map(_:) operator는 throws가 없기 때문에 에러를 throw 하지 못하고 컴파일 에러를 발생합니다.

 

2-3. tryMap(_:) operator

 

이땐 tryMap(_:)를 사용해야 합니다. 

 

거의 대부분의 operator는 prefix에 try*키워드가 붙여 저 에러를 throws 할 수 있는 operator가 있습니다. try*가 붙지 않은 operator와 throws여부를 뺀다면 동일한 동작을 합니다. tryMap의 경우도 마찬가지입니다. map과 동일하게 루프 처리, 주어진 값을 다른 값으로 반환 등에 사용할 때 좋습니다. 차이점은 throw 여부입니다. tryMap은 에러를 throw 할 수 있습니다.

 

따라서 위의 코드에선 


  
.tryMap { data in
try JSONDecoder().decode(WantsToBeSwiftMaster.self, from: data)
}

tryMap을 붙여야 합니다. 추가적으로 tryMap을 사용할 경우엔 에러를 던지든 말든 upstream's failure 타입이 Swift.Error로 바뀝니다. 이와 관련 내용은 error 처리 포스트에서 다뤘습니다.

 

2-4. decode(_:_:) operator

 

transform 주제에서는 벗어나지만,,

tryMap은 JSONDecoder를 통해 <data, Error>    ->     <WantsToBeSwiftMaster,Error> 타입을 반환합니다. 그리고 JSONDecoder를 지원하는 operator가 있습니다.


  
...
.map { noti in
return ...
}
.decode(WantsToBeSwiftMaster.self, JSONDecoder())

decode(_:_:) operator입니다. URLSession.dataTaskPublisher, Data타입의 PassthroughSubject subject, notificationCenter에서 notification등에서 전달된 data의 값을 Codable타입의 구조체로 디코딩할 수 있습니다.

 

하지만 tryMap과의 다른 점은??? 에러가 났을 때 어떻게 처리할 것인가?..


  
...
.decode(Wants...)
.catch{
return Just(에러처리)
}

 

catch operator를 사용하면 됩니다. 그 대신 단점은? publihser의 subscription에서 에러가 던져졌기 때문에 publihser는 종료하게 됩니다.( 보낼 값이 남아있는데,,,, 더 이상 값을 방출하지 못하고 .finish 호출..) 

 

"아직 publish되야 할 값들이 많이 남아있는데 단 한 개 에러 났다고 남은 데이터를 publish 못 받으면 말이 되나?! 구독까지 했는데!!!!"

 

2-5. flatMap(maxPublihser:_:) operator

 

이때 flatMap(_:)을 사용하면 됩니다. (마찬가지로 에러 처리 포스트에서 다뤘지만 한번 더..) 잠시 flatMap(maxPublihsers:_:)의 특징을 간단히 설명하겠습니다.

 

https://developer.apple.com/documentation/combine/publishers/sequence/flatmap(maxpublishers:_:)-2nvtu

flatMap은 map과 마찬가지로 transform 레이블(매개변수)을 통해 주어진 upstreamPublisher's output value를 받아 transform 합니다. map, tryMap과 마찬가지로 두 번째 매개변수명은 transform이고 () -> P 의 형식을 갖습니다. 이때 P는 Publisher타입!!입니다. 값타입이 아닙니다. 한 가지 특이한 점은 첫 번째 매개변수로 maxPublishers. subscription의 제한을 요구할 수 있습니다.

 

flatMap(maxPublihsers:_:)은 좀 독특합니다. 위 설명에서와 같이 upstream publisher로부터 받은 들을 가지고 새로운 publisher로 만들어 해당 publihser를 통해 값을 전달할 수 있다는 점입니다.

 

새로운 publihser?!

https://developer.apple.com/documentation/combine/publishers/sequence/flatmap(maxpublishers:_:)-2nvtu

Combine에선 publihser, subscriber의 output, failure 타입이 같아야 합니다. 그런데 flatMap은 클로저를 통해 새로운 publisher를 만들어서 emit 할 수 있습니다. flatMap의 upstream의 output, failure타입과 다른 타입을 반환하거나, 같은 타입을 반환할 수 있다는 점입니다.

 

또한 upstream publihser가 여러~ pulbihser일 때 flatMap을 통해 flatten... 여러 개의 output 이 아니라 flatMap을 통해 한 개의 publisher로 반환할 수 있다는 점입니다. 그리고 각각의 publihsers는 값을 방출하는데 maxPublishers의 Demand를 통해 publisher들 중 몇 개만 값을 방출하도록 요청할 수 있습니다. 약간 Publihsers.Merge와 비슷한 것 같은데... 둘의 차이를 좀 더 파악해 봐야겠습니다.  Date: 23.4.5

 

flatMap과 Merge의 차이점을 명확히 알았습니다. flatMap을 사용할 경우에 해당 클로저는 upstream publisher의 값을 받아서 새로운 publisher를 반환한다는 점입니다. 여러 개의 퍼블리셔를 flatMap을 통해 한 개의 새로운 퍼블리셔로 반환하는 반면, Merge는 여러 개의 퍼블리셔를 병합할 때 사용됩니다.

 

flatMap을 통해 다양한 기능을 구현할 수 있습니다. 윗 그림의 밑줄을 통해 upstream의 output 값을 transform 클로저에서 매개변수로 받을 때 해당 값을 가지고 새로운 publihser를 만들어 upstream publihser인 origin publisher와 같이 값을 계속 emit 할 수 있습니다. 때에 따라서 completion을 할 수도 있고, 에러를 발생해 failure를 발생할 수도 있습니다.

 

flatMap을 사용할 경우 에러가 발생했을 때, flatMap에 의해 새로 publisher가 만들어졌다면, 모든 것은 upstream publihser가 아니라 new publihser에서 일어난 일입니다. upstream publihser에는 영향을 미치지 않습니다.

 

다시 말해 upstream publihser는 flatMap을 통해 새로 생성된 new publisher가 끝나든 값을 계속 전달하던 끝난다면 new publisher만 끝나는 것이고 값을 전달한다면 flatMap의 flatten의 성격처럼 여러 publhiser(upstream publihser, flatMap을 통해 생성된 publihser)를 single publihser로 값을 방출할 수 있다는 점입니다.

 

이 성격을 활용해

 

"아직 publish되야 할 값들이 많이 남아있는데 단 한 개 에러 났다고 남은 데이터를 publish 못 받으면 말이 되나?! 구독까지 했는데!!!!"

 

위의 코드에서 catch로 잡았을 때, 여전히 upstream publihser가 구독자에게 보낼 값이 많이 남아있음에도 불구하고 finished 된 경우에 upstream publisher의 completion이 되는 경우에서 벗어날 수 있습니다. 에러가 났으면 new publihser가 처리하고 아니면 flatMap을 통해 subscriber에게 값을 반환한다는 개념입니다.


  
let pub = NotificationCenter.default.publihser(for: .download)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.flatMap { data in
return Just(data)
.decode(WantsToBeSwiftMaster.swift, JSONDecoder())
.catch {
return Just(단 한번 에러에 대한 다른 값 기본값이든 반환!)
}
}

data를 Decodable type의 구조체로 decode 할 때 에러가 나도 flatMap을 통해 새로 정의된 Just publihser를 통해 flatMap의 upstream publisher의 작업에는 영향을 미치지 않게 됩니다.

 

2-6. compactMap(_:) operator

 

그렇다면 compactMap(_:) operator는 무엇인가? upstream publihser의 sequence 한 elements에 대해서 nil이 있는 경우 nil을 제거한 elements만 downstream으로 값을 방출합니다.

 

https://developer.apple.com/documentation/combine/publishers/sequence/compactmap(_:)-2rha0

결국 이 publihser를 반환합니다. 근데 non-nil의 ement만 republihs 합니다! 즉, 값에 nil이 있다면 그 데이터만 제거하고 nil이 아닌 데이터들을 downstream에 emit 합니다.

 

3. collect(), scan(_:_:) operator

3-1. collect() operator

https://developer.apple.com/documentation/combine/publishers/catch/collect()

upstream으로부터 방출된 모~~~ 든 값을 수집합니다. 언제까지? upstream publisher가 finished event를 방출하기 전까지 수집합니다.

Publihsers.Collect publihser는 버퍼가 있나 봅니다.. 그래서 upstream publihser가 finish 이벤트를 보내기 전까지 upstream publihser가 보낸 값들을 버퍼에 담았다가 finish event(값 전달을 다 했다!!) 이벤트 때 비로소 배열로 값을 반환합니다.

 


  
[0,1,2,3,4,5].publisher
.collect()
.sink { print("\($0)") }
// Prints: "[0,1,2,3,4]"

주어진 sequence를 다 emit 하면 바로 sink로 각각의 element를 출력하지 않고 collect의 버퍼에 저장합니다. Publihsers.Sequence 특징은 주어진 sequence를 전부 emit 할 경우 finish 이벤트를 발송하며 종료합니다. 이때 비로소 collect()가 값을 subscriber에게 publish 합니다. with 배열!

 

주의해야 할 점은 upstream publihser finish 이벤트. completion이 발생돼야 비로소 collect가 작동합니다. buffer를 사용하기에 제한이 없습니다. 주의해야 합니다. 제한 없는 많은 양의 emit 값을 버퍼에 저장하려고 할 것이기 때문입니다.

 

3-2. scan(_:_:) operator

https://developer.apple.com/documentation/combine/publishers/sequence/scan(_:_:)-3w0at

scan은 간단하게 초기값과 이전에 방출된 값을 더해 방출합니다. 그리고 방출한 값을 이전 값으로 저장하고 다음에 publish 된 값을 더해서 방출합니다. 그리고 방출한 최근 값을 이전 값으로 저장하고... 의 반복입니다.

 

첫 레이블은 initialResult이고 초기값을 의미합니다. nextPartialResult 다음 부분적인 결과로 클로저를 통해

 

(T, Self.Output)

( T == 이전 값 or 초기 값, Self.Output == upstream publisher로부터 방출된 따끈따끈한 값) -> T 

 

이때 반환된 T는 이전값으로 저장합니다.

 

4. Replacing upstream's output

- replaceNil(with:)

- replaceEmpty(with:)

이 둘의 공통점은 upstream publisher로부터 publish 된 값이 nil을 갖게 된다면 다른 값( 준비해 둔 기본값)으로 대체할 수 있는 operator입니다. 즉 nil이어도 항상 replacing operator를 사용해 항상 값을 downstream subscriber에게 publish 할 수 있습니다.

4-1. replaceNil(with:)

upstream publisher가 nil을 방출한다면 replaceNil(with:)를 통해 준비된 다른 값으로 replace 할 수 있습니다.

https://developer.apple.com/documentation/combine/just/replacenil(with:)


  
["a",nil,"c"].publisher.eraseToAnyPublisher().replaceNil(with: "b").collect().sink{print($0)}
// ["a", "b", "c"]

optional 타입을 반환합니다.

 

하지만 eraseToAnyPublihser()를 사용할 경우에 옵셔널이 해제됩니다?!?!! 버그라고 합니다. 원래는?? 연산자나 compactMap을 통해 옵셔널을 걸러내야 하는데,,

 

4-2. replaceEmpty(with:)

https://developer.apple.com/documentation/combine/publishers/sequence/replaceempty(with:)

upstream publihser로부터 emit 된 값이 비어있을 경우에 replaceEmpty(with:)를 통해 output value를 지정할 수 있습니다. 만약 upstream publihser가 subscriber에게 보낼 값을 갖고 있다면 replaceEmpty(with:)는 실행되지 않습니다.

 


  
let empty = Empty<Int,Never>().print().replaceEmpty(with:1).sink{print($0)}

sink를 통해 Empty publihser에게 구독을 요청하면 Empty publisher는 subscription을 생성하고 subscription은 publihser로부터 값을 받아 여러 operator를 실행하고 subscriber에게 값을 전달..

 

empty이기에 relpaceEmpty(with:)가 작동해서 finish와 함께 1을 반환함을 알 수 있습니다.

 

 

위에서 소개한 operator 중 map, tryMap, flatMap은 제가 정말 많이 사용하고 있는 것 같습니다.

 

틀린 개념 발견 시 댓글로 알려주신다면 정말 감사합니다!!

 

 

참고 자료

https://developer.apple.com/documentation/combine/publishers-trylastwhere-publisher-operators/

https://www.kodeco.com/books/combine-asynchronous-programming-with-swift

 

Combine: Asynchronous Programming with Swift

Master declarative asynchronous programming with Swift using the Combine framework! Writing asynchronous code can be challenging, with a variety of possible interfaces to represent, perform, and consume asynchronous work — delegates, notification center,

www.kodeco.com