야매 iOS

[iOS/Swift] Combine 알아보기 iii

the Cosmos 2023. 2. 25. 15:13

Transforming Publishers

Combine에서 값들은 publisher에 의해 subscriber에게 전달된다.

대부분의 경우에선 publisher가 방출한 값을 subscriber가 원하는 형태로 변환하는 과정을 거쳐 subscriber에게 전달된다.

Combine은 변환하는 연산자를 제공한다

  • map (들어온 값을 필요한 형태로 변환)
  • error(catch error or handle error)
  • publisher으로부터 받을 값의 수를 제한하는 것

Publisher들과 연산자들을 chaining해서 복잡한 비동기 코드를 operation list로 표현할 수 있다.

let dataTask = URLSession.shared.dataTaskPublisher(for: url)
	.retry(1)
	.map({ $0.data })
	.decode(type: Temp.self, decoder: JSONDecoder())
	.map ({ $0.name })
	.replaceError(with: "anonymous")
	.assign(to: \\.text, on: label)
/*
1. API 요청
2. 실패시 한번 더 요청
3. 응답에서 data 추출
4. Data를 원하는 타입으로 변환
5. 타입에서 필요한 값 추출
6. 문제가 생겼다면 정의한 값 전달
7. 라벨의 text 프로퍼티에 값을 넣어줌
*/

코드를 이해하지 못하는 게 정상

그냥 completionHandler를 사용해서 처리하는 방식과 비교했을 때 어떤 작업을 수행하는지 한눈에 알아볼 수 있음.

자주 사용하는 변환 연산자 Publisher에 적용하기

let label = UILabel()

[1,2,3].publisher
    .sink(receiveValue: { int in
        label.text = "value is \\(int)"
    })

위의 코드는 publisher로부터 방출된 값을 sink 메서드 내부에서 문자열로 바꿔 UILabel에서 보여지도록 했다.

위의 코드는 아무런 문제가 없다.

하지만 왠면하면 subscriber(sink or assign) 내부에서 값을 가공하거나 변환하는 것은 지양하는 것이 좋다.

아래와 같은 코드로 개선할 수 있다.

[1,2,3].publisher
    .map({
        return "value is \\($0)"
    })
    .sink(receiveValue: { text in
        label.text = text
    })

Publisher의 map은 swift 배열의 map과 비슷한 기능을 한다. 하지만 차이점은

  • Publisher의 map은 시간이 지남에 따라 비동기적으로 값을 방출한다.
  • Array의 map은 모든 값을 한번에 전달한다
  • Publisher map 메서드의 output은 새로운 Publisher
  • Array 메서드의 output은 새로운 Array

Map

[1,2,3].publisher // Publishers.Sequence<[Int], Never>

// map을 적용하니 Publisher의 Element가 변경됐다
[1,2,3].publisher
    .map({ 
        return "value is \\($0)"
    }) // Publishers.Sequence<[String], Never>
// URLSession.DataTaskPublisher
let dataTaskPublisher = URLSession.shared.dataTaskPublisher(for: url)

//Publishers.Map<URLSession.DataTaskPublisher, Data>
let mappedPublisher = dataTaskPublisher
	.map({ response in
		return response.data
	})

첫 번째 코드와 두 번째 코드에서 map을 사용했을 때 타입을 비교해보면 조금 다르다.

첫 번째 코드에서 map을 사용하면 Publishers.Sequence의 Element 타입만 변경되었다.

두 번째 코드에서 map을 사용하면 Publishers.Map 안에 또 하나의 Publisher가 존재한다(URLSession.DataTaskPublisher)

Why?

  1. 여러 타입의 Publishers는 Publisher 프로토콜을 준수한다.
  2. Publisher 프로토콜은 map을 포함한 여러 연산자들이 존재한다
  3. Publishers는 필요에 따라 Publisher 프로토콜의 메서드를 override할 수 있다.

Combine의 여러 built in 연산자들은 Publisher 프로토콜 내부에 정의되어 있다.

Publisher Protocol’s ‘map’ method ”the map(_:) operator returns an instance of Publishers.Map

dataTaskPublisher에 map 메서드를 사용했을 땐 위의 설명처럼 Publishers.Map이 생성되었지만 Publishers.Sequence에선 그렇지 않았다.

Publishers.Sequence의 map 메서드를 보면 Publishers.Map을 반환하지 않고 Publishers.Sequence<[T], Failure>를 반환한다.

map, flatMap, compactMap의 차이점 이해하기

map, flatMap, compactMap은 Swift의 Sequence와 Collection 타입에서 지원하는 메서드다.

Publisher에서의 map, flatMap, compactMap도 거의 비슷하게 동작한다.

Combine에서 map, flatMap, compactMap을 언제 사용하는 것이 좋은지 알아보자

compactMap

let array = ["one", "2", "three", "4", "5"]
	.compactMap({ Int($0) })

// 출력 [2,4,5]

일반 배열에 compactMap을 사용하면 nil인 값을 버리고 array의 타입도 옵셔널이 아니다

  • 위의 예시에서 map을 썼다면 array의 타입은 [Int?]
  • compactMap을 썼기에 array의 타입은 [Int]

Combine의 compactMap 또한 유사하게 동작한다.

["one", "2", "three", "4", "5"].publisher
	.compactMap({ Int($0) })
	.sink(receiveValue: {
		print($0)
	}
/* 출력
2
4
5
*/

장점

  • nil 체크를 하지 않아도 되고 guard문을 사용하지 않아도 된다.

단점

  • nil을 다른 값으로 바꿔주거나 nil에 대한 추가적인 처리가 필요할 땐 사용하기 어렵다.

nil에 대한 추가 처리?

["one", "2", "three", "4", "5"].publisher
	.map({ Int($0) })
	.replaceNil(with: 0)
	.sink(receiveValue: {
		print($0)
	}
/* 출력
0
2
0
4
5
*/

replaceNil 연산자를 이용해 nil에 대한 기본값을 설정해준다.

  • nil을 필터링 할 수 있고 nil이 아닌 값으로 대체할 수 있다
  • replaceNil은 nil을 기본값으로 대체하는 것이기 때문에 output 타입은 옵셔널이다.

compactMap vs replaceNil

nil 값을 버려도 된다면?

→ compactMap

nil을 다른 값으로 바꿔줘야 한다면?

→ replaceNil

flatMap

flatMap을 말로 설명하긴 너무 어렵다. 하지만 예시를 본다면 바로 이해할 수 있다.

let numbers = [1,2,3,4,5]

let mapped = numbers.map { Array(repeating: $0, count: 2) }
// [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]]
let flatMapped = numbers.flatMap { Array(repeating: $0, count: 2)
// [1, 1, 2, 2, 3, 3, 4, 4, 5, 5]

flatMap은 transformation이 sequence 또는 collection을 생성할 때 한 레벨을 없애준다.

  • 배열의 요소로 존재하는 배열을 flatten해서 1차원 배열을 반환한다.
  • nesting의 레벨을 1 낮춰준다.
    • 말로 설명하기 어려우니 한번 실험해보면 바로 이해가 될 것이다.

Combine의 flatMap

Publisher의 Ouput이 새로운 Publisher일 때 주로 사용한다.

var url = URL(string: "<https://www.google.com>")!
var cancellables = Set()

// sequency publisher가 path를 방출한다
["/", "/images", "/maps"].publisher
		// map을 적용한 후의 타입 : Publishers.Sequence<[URLSession.DataTaskPublisher], Never>
    .map({ path in
        let temp = url.appending(path: path)
				// dataTaskPublisher를 방출한다.
        return URLSession.shared.dataTaskPublisher(for: url)
    })
		// sink 안에 dataTask의 result가 아닌 publisher가 전달된다.
    .sink(receiveValue: { completion in
        print("completed \\(completion)")
    })
    .store(in: &cancellables)

이 문제는 flatMap을 사용하면 해결된다.

var url = URL(string: "<https://www.google.com>")!
var cancellables = Set()

// sequency publisher가 path를 방출한다
["/", "/images", "/maps"].publisher
    .flatMap({ path in
        let temp = url.appending(path: path)
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .sink(receiveCompletion: { completion in
        print("Completed with \\(completion)")
    },
        receiveValue: { value in
        print("completed \\(value)")
    })
    .store(in: &cancellables)

Swift의 Array에 flatMap을 적용했을 때 요소로 있던 배열이 한 레벨 낮아지는 것을 볼 수 있었다.

Combine도 Publisher가 다른 Publisher를 wrapping 한 것을 한 겹 벗겨낼 수 있다.

setFailureType

위의 예시는 iOS 14 이후부터는 정상적으로 동작하지만 iOS13에선 제대로 동작하지 않을 수 있다.

iOS 13에선 flatMap을 사용하는 publisher와 flatMap 내부에서 생성되는 publisher 간의 Error 타입이 동일하지 않으면 정상적으로 동작하지 않는다.

  • Sequence Publisher의 에러 타입은 Never
  • DataTaskPublisher의 에러 타입은 URLError

그러므로 Sequence Publisher의 에러 타입을 URLError로 바꾸거나 DataTaskPublisher의 에러 타입을 Never로 바꿔야 한다.

네트워킹에 대해 URLError 정보를 알아야 처리할 수 있는 것들이 있기 때문에 Sequence Publisher의 에러 타입을 URLError로 변경하는 것이 좋아 보인다.

에러 타입을 변경해주는 연산자가 바로 setFailureType

["/", "/images", "/maps"].publisher
		/*
		Publishers.Sequence<[String], URLError>
		setFailureType을 사용해 에러 타입이 변경된 것을 볼 수 있다.
		*/
    .setFailureType(to: URLError.self)
    .flatMap({ path in
        let temp = url.appending(path: path)
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .sink(receiveCompletion: { completion in
        print("Completed with \\(completion)")
    },
        receiveValue: { value in
        print("completed \\(value)")
    })
    .store(in: &cancellables)

flatMap이 생성하는 active publisher의 수 제한하기

flatMap을 사용하면 값을 받아서 새로운 publisher를 생성한 후 그 publisher의 결과값을 sink로 전달한다.

어떤 상황에선 위와 같은 상황이 달갑지 않을 수 있다.

  • 유저의 동작에 따라 API 요청을 보낸다고 가정해보자
  • 유저가 빠르고 반복적으로 어떤 동작을 하고 코드가 이를 적절히 대응하지 못하면 여러 API 콜이 동시에 실행될 수 있다.

BackPressure

  • subscriber가 몇 개의 값을 publisher로부터 받고 싶은지 설정하는 것
  • publisher 또한 chain에서 자신보다 위에 있는 publisher로부터 값을 얼마나 받고 싶은지 제한할 수 있다.

flatMap은 maxPublisher 인자를 통해 backPressure 기능을 지원한다.

[1,2,3].publisher
	.print()
	.flatMap({ int in
		return Array(repeating: int, count: 2).publisher
	})
	.sink(receiveValue: { value in
		print("got \\(value)")
	})

/* Output
receive subscription: ([1, 2, 3])
request unlimited
receive value: (1)
got : 1
got : 1
receive value: (2)
got : 2
got : 2
receive value: (3)
got : 3
got : 3
receive finished
*/

결과값으로 여러 줄이 나왔지만 지금 눈여겨 봐야 하는 항목은 첫 2줄이다.

receive subscription: ([1, 2, 3])
request unlimited

publisher가 특정 시점에 구독되었고 요청이 무제한으로 이뤄졌다고 나왔다.

  • publisher는 생성하고 싶은 아이템을 마음대로 만들어낼 수 있다.

MaxPublishers

flatMap의 maxPublisher 인자를 사용해 최대로 몇개의 값을 받을 것인지 지정할 수 있다.

  • flatMap의 maxPublisher의 기본값은 .unlimited
[1,2,3].publisher
    .print()
    .flatMap(maxPublishers: .max(1), { int in
        return Array(repeating: int, count: 2).publisher
    })
    .sink(receiveValue: { value in
        print("got \\(value)")
    })
    .store(in: &cancellables)

/*
receive subscription: ([1, 2, 3])
request max: (1)
receive value: (1)
got 1
got 1
request max: (1)
receive value: (2)
got 2
got 2
request max: (1)
receive value: (3)
got 3
got 3
request max: (1)
receive finished
*/

maxPublisher를 설정하면 새로운 publisher를 생성하기 전까지 upstream publisher로부터 새로운 값을 받아오지 않는다.

  • upstream publisher가 종료되고 completion 이벤트를 보낼 때까지 이 행동은 유지된다.

위의 예시로 flatMap이 제공하는 maxPublisher가 어떤 기능을 하는지 온전히 체감하기 어렵다.

// without maxPublisher
[1,2,3].publisher
    .print()
    .flatMap({ _ in
        let url = URL(string: "<https://picsum.photos/1080/620>")!
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .map {
        $0.data
    }
    .sink(receiveCompletion: {
        print("completed in \\($0)")
    }, receiveValue: {
        print("newImage : \\(String(describing: UIImage(data: $0)))")
    })
    .store(in: &cancellables)

/*
receive subscription: ([1, 2, 3])
request unlimited
receive value: (1)
receive value: (2)
receive value: (3)
receive finished
newImage : Optional()
newImage : Optional()
newImage : Optional()
completed in finished
*/
[1,2,3].publisher
    .print()
    .flatMap(maxPublishers: .max(1), { _ in
        let url = URL(string: "<https://picsum.photos/1080/620>")!
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .map {
        $0.data
    }
    .sink(receiveCompletion: {
        print("completed in \\($0)")
    }, receiveValue: {
        print("newImage : \\(String(describing: UIImage(data: $0)))")
    })
    .store(in: &cancellables)
/*
receive subscription: ([1, 2, 3])
request max: (1)
receive value: (1)
newImage : Optional()
request max: (1)
receive value: (2)
newImage : Optional()
request max: (1)
receive value: (3)
receive finished
newImage : Optional()
completed in finished
*/

첫 번째 코드의 출력을 보면 upstream에 모든 값이 방출되고 그에 따라 새로운 dataTaskPublisher가 생성된다.

반면 두 번째 코드의 출력을 보면 upstream에서 maxPublisher로 지정한 수만큼 값을 받아와 dataTaskPublisher를 생성하고 그 다음 값을 받아온다.

BackPressure 기법을 쓴다면 upstream에서 값을 방출하는 시간보다 이를 처리해 새로운 publisher를 생성하는 시간이 더 오래 걸릴 수 있다.

그럴 때 upstream publisher는

  • upstream publisher 그 값들을 모두 쌓을 수 있다
  • upstream publisher는 해당 값을버릴 수 있다.

이는 publisher가 어떻게 구현되었는지에 따라 다르다.

실패 가능한 연산자

Combine에는 try로 시작하는 여러 연산자가 있다

  • tryMap, tryCompactMap, …
enum MyError: Error {
    case outOfBounds
}

[1,2,3].publisher
    .tryMap({
				// 3보다 큰 값이 들어오면 error
        guard $0 < 3 else { throw MyError.outOfBounds}
        return $0 * 2
    })
    .sink(receiveCompletion: { completion in
        print(completion)
    }, receiveValue: { val in
        print(val)
    })
/*
2
4
failure(__lldb_expr_43.MyError.outOfBounds)
*/

모든 publisher는 complete와 error를 한번만 방출할 수 있다.

  • error가 던져진 후 publisher는 새로운 값을 방출할 수 없다.

에러를 던져야 하는 상황이 오면 try로 시작하는 연산자를 찾아봐라

커스텀 연산자 정의하기

Combine은 여러 built-in 연산자들이 존재한다.

하지만 어떤 상황에선 개발자가 원하는 연산자가 built-in 연산자로 존재하지 않을 수 있다.

→ 그럴 땐 개발자의 니즈에 맞게 커스텀 연산자를 정의할 수 있다.

["/", "/images", "/maps"].publisher
    .setFailureType(to: URLError.self) // iOS 13용
    .flatMap({ path in
        let temp = url.appending(path: path)
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .sink(receiveCompletion: { completion in
        print("Completed with \\(completion)")
    },
        receiveValue: { value in
        print("completed \\(value)")
    })
    .store(in: &cancellables)

setFailureType과 flatMap은 강력하게 결합되어 있고 가독성이 조금 떨어진다.

그리고 setFailureType은 iOS13에서만 필요하기 때문에 그 위의 버전에선 사용하지 않아도 된다.

→ Custom Operator를 만들어보자

extension Publisher where Output == String, Failure == Never {
    func toURLSessionDataTask(baseURL: URL) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLError> {
        if #available(iOS 14, *) {
            return self
                .flatMap({ path -> URLSession.DataTaskPublisher in
                    let url = baseURL.appending(path: path)
                    return URLSession.shared.dataTaskPublisher(for: url)
                })
                .eraseToAnyPublisher()
        }
        return self
            .setFailureType(to: URLError.self)
            .flatMap({ path -> URLSession.DataTaskPublisher in
                let url = baseURL.appending(path: path)
                return URLSession.shared.dataTaskPublisher(for: url)
            }) 
						/* Publisers.FlatMap<P, Publishers.SetFailureType<Self, URLError>>
	          setFailType과 FlatMap을 적용하면 타입이 위와 같이 변한다. 이 타입은 가독성을 해치고 사실 중요하지 않다
						중요한 것은 반환하는 타입이다.
						*/
						.eraseToAnyPublisher()
						/* AnyPublisher<URLSession.DataTaskPublisher.Output, URLError>
						위 operator를 사용해 불필요하고 가독성 떨어지는 타입 정보를 지운다.
						*/
    }
}

Combine의 모든 연산자들은 Publisher의 extension으로 존재한다.

  • extension은 Output 타입과 Failure 타입에 제한을 둘 수 있다.

eraseToAnyPublisher

  • publisher의 타입 정보를 지우고 AnyPublisher로 wrap한다.

커스텀 연산자를 제대로 만들면 많은 편리함을 선물해줄 수 있지만 다른 개발자에게 혼란을 안겨줄 수도 있다.

이런 측면을 고려해서 만드는 것이 좋다.