Do.

Swift - Result Type 을 반환값으로 써보자 본문

iOS

Swift - Result Type 을 반환값으로 써보자

Hey_Hen 2022. 8. 28. 16:51

Introduce

Result Type은 Swift 5에서 도입되어 SSL(Swift Standart Library)에 포함된 문법입니다. 동작의 수행 결과를 Success 인지 Failure인지 구분해서 나은 가독성과 코드 분기를 제공해줍니다.
(개인 의견: 테스트 코드를 작성해본 사람은 알겠지만, try-catch에서 catch 아래는 코드 커버리지에 포함이 안되어서 미묘하게 킹받는데, 그런 사소한 부분을 해결해 주기도 한다.)
본 글은 Result Type을 반환값으로 쓰는 방법으로 Result Type 자체에 대한 내용은 다루지 않겠습니다.

왜 Return 값으로 Result Type을 쓰고싶었을까?

Data Layer를 작성하다보면, 해당 비즈니스 모델에는 iOS 버전이나 라이브러리를 의존하고 싶지 않은 경우가 있습니다. 기껏 Layer를 구분했는데, 라이브러리를 포함시키게 되면 유지보수나 빌드 속도에 악영향을 미칠 가능성이 높습니다.
비즈니스 모델이 iOS 버전이나 라이브러리에 의존하는 것 만큼 안좋은 선택은 없을 것 같습니다.
따라서 최소한의 의존성을 가지도록 구성하고 싶습니다.
이 얘기는 왜 하냐면, iOS13 타겟 이상이라면 사실 Combine의 Future를 사용할 수 있습니다.
또는 라이브러리에 의존하자면 PromiseKit이나 FutureKit 또는 RxSwift의 Single을 이용할 수 있겠죠.
(2022년에 네이티브한 Future-Promise 문법이 Swift에 없다는 것 자체가 좀 불만 스럽네요)
하지만 앞서 얘기했 듯 Data Layer는 아주 가볍고 민첩해야 합니다. 의존성도 낮아야 하고 말입니다.
그래서 Swift에는 이미 Result Type이 도입되었고 이를 이용해 Combine의 Future처럼 쓰고 싶다는 생각을 했습니다.

그냥 파라미터에서 escaping closure로 쓰면 안될까?

Swift 도서에서 소개된 대로 Result Type은 escaping closure를 활용해서 잘 사용할 수 있습니다. 아래처럼요

func fetchImage(completionHandler: @escaping (Result<Data, Error>) -> Void) {
  urlSession.dataTask(with: URL(string: "https://image.tmdb.org/t/p/w500/kAVRgw7GgK1CfYEJq8ME6EvRIgU.jpg")!) { data, response, error in
    if let error = error {
      completionHandler(.failure(error))
      return
    }

    if let data = data {
      completionHandler(.success(data))
      return
    }
  }
  .resume()
}

URLSession과 같은 비동기 기능에서 Result Type을 꺼내려면 escaping 을 사용하는 수 밖에는 없습니다.
이는 Swift 도서에 소개된 방법 그대로 이지만, 조금 아쉽습니다.
completionHandler 자체를 매번 파라미터로 전달하는 것도 그렇습니다. 이게 생각보다 귀찮거든요 수정에도 그렇고 결과를 받을 때도 조금 불편합니다.

configuration.fetchImage { result in
  switch result {
    case .success(let success):
      ()
    case .failure(let failure):
      ()
  }
}

fetchImage를 호출하고 바로 수행 코드를 작성해줘야 합니다. 물론 이게 편할때도 많습니다. 하지만 그렇게 쓰고 싶지 않을때도 존재합니다.
결론은 그렇게 쓰고 싶지 않을 때도 있는데 자유가 없다 정도입니다. (웃음)

그래서 Return으로 Result Type을 쓰면 어떻게 생겼는데?

configuration
  .fetchImage(name: "영화사진")
  .map { data in
    UIImage(data: data)
  }
  .sink { [weak self] result in
    switch result {
      case .success(let image):
        Task {
          self?.imageView.image = image
        }
      case .failure(let error):
        print("ERROR:\(error)")
  }
}

요렇게 쓰고 싶습니다. (Single이랑 Combine.Future랑 똑같이 생겼움)
지금은 이렇게 썼지만

let fetchResult = configuration.fetchImage(name: "영화사진")

completionHandler를 썼을 때와 다르게 바로 호출하지 않고 이렇게 저장해둘 수 있습니다. 필요한 시점에 sink를 호출해야지 비로서 네트워크 요청을 하게 됩니다.

구현

그래서 어떻게 했냐가 중요할 것 같습니다.
사실 이게 맞게 구현한 건가 잘 모르겠습니다. 이미 promise kit 이나 future kit등 좋은 예제가 있지만 우선 구현은 스스로의 힘으로 해본 뒤 라이브러리를 참고하고 싶었습니다. (밑밥 깔기)
우선 코드를 아래처럼 사용할 수 있어야 합니다.

func fetchImage(name: String) -> Future<Data, Error> {
  return Future<Data, Error> { [unowned self] promise in
    let url = imageService.makeURL(name: name)
    urlSession.dataTask(with: URL(url: url) { data, response, error in
      if let error = error {
        promise(.failure(error))
        return
      }

      if let data = data {
        promise(.success(data))
        return
      }
    }
    .resume()
  }
}

대충 이런 느낌인데, 여기서 눈 여겨 보셔야 할 부분은
1. 파라미터에 escaping closure 대신 아니라 return 이 있습니다.
2. URLSession completion handler 내부에서 그 결과를 리턴값으로 전달해야 합니다.
Future<Data, Error> 뒤에 클로져로 promise가 붙습니다. promise에 succee와 failure를 전달하면 URLSession 블록 바깥으로 값을 전달할 수 있습니다.

우선 promise에 promise(.success(value)) 와 같은 식으로 값을 전달하기 위해서 promise의 타입은 (Result<Data, Error>) -> Void 와 같은 형태일 필요가 있습니다.

그래서 생성자를 다음과 같이 만들어 줬습니다.

typealias Promise<T, E: Error> = (Result<T, E>) -> Void

private var result: (@escaping Promise<T, E>) -> Void

/// Future 생성자
/// - Parameter promise: promise 클로져 안에서 promise를 호출하고 success 또는 failure를 작성할 수 있습니다.
init(promise: @escaping (@escaping Promise<T, E>) -> Void) {
  result = promise
}

생성자를 열고 클로져 인자로 Result<T, E> -> Void 를 내보내야 하는데 풀어쓰면

init(promise: @escaping (@escaping (Result<T, E>) -> Void) -> Void) 과 같은 형태라 typealias로 감싸줬습니다.

그러면 이제 아래처럼 Result Type 자체를 반환값으로 사용할 수 있고, 그 안에서 어떤 값이 전달될 것인지 구성할 수 있습니다.

return Future<Data, Error> { promise in 
    // success
    promise(.success(data))

    // error
    promise(.failure(error))

}

앞서 생성자와 result 멤버 변수가 @escaping (@escaping Promise<T, E>) -> Void 와 같이 escaping이 두개나 붙는 기형적인 형태에 대해서 의아하실 텐데요, 저도 처음에 많이 고민했던 부분입니다.


URLSession의 completion handler는 마찬가지로 escaping clousre 이기 때문에 해당 블록 안에서 값을 탈출 시키려면 탈출되는 코드도 escaping 이어야 하는데요,
첫번째 escaping은 생성자 블록 자체를 Future의 result 멤버 변수로 전달하는데 한번 사용했고, 남는건 (Result<T, E>) -> Void 가 남는데 이건 escaping closure가 아니라서 URLsession의 escaping closure 바깥으로 가져갈 수 없습니다.
그렇기 때문에 escaping 타입 속성이 하나 더 있는거죠

이제 여기까지만 해도 Result Type을 반환값으로 사용하는 것은 목표 달성입니다만, 한번 해보고 싶었던 부분은
transform 연산입니다.

Additional

fetchResult
  .map { data in
    UIImage(data: data)
  }
  .sink { [unowned imageView] result in
    switch result {
      case .success(let image):
        Task {
          imageView.image = image
        }
      case .failure(let error):
        print("ERROR:" , error.localizedDescription)
  }
}

map과 같은 변환 오퍼레이터를 이용하고 싶은 것이죠
map 까지는 타입이 Result<Data, Error> 이고 이후에는 Result<UIImage?, Error> 입니다.

방법을 생각해보면
1. map 이후에는 새로운 Future 타입을 생성
2. 외부에서 transform을 하는 블럭을 받아서 그를 통해 Future 타입 생성

func map<N>(_ transform: @escaping (T) -> N) -> Future<N, E> {
  Future<N, E> { newPromise in
    result { newResult in
      switch newResult {
        case .success(let t):
          newPromise(.success(transform(t)))
        case .failure(let error):
          newPromise(.failure(error))
      }
    }
  }
}

요런식으로 새로운 Future를 작성해볼 수 있을 것 같습니다. 요번 포스팅의 목적 자체가 Return 에 Result Type을 사용해보고 싶은 것이었는데 해당 부분을 해결했기 때문에 이런것도 가능한 것 같습니다.
같은 요령으로 tryMap, compactMap도 만들어 볼 수 있을 것 같습니다.

Sink

sink 구현을 누락했는데요! 별거없습니다.

  /// 저장된 값을 sink를 통해서 가져올 수 있습니다.
  /// - Parameter result: result는 success, failure 두 타입이 있습니다.
  func sink(result: @escaping Promise<T, E>) {
    self.result(result)
  }

Repository

https://github.com/urijan44/ReturnResultKit

Refernece

https://en.wikipedia.org/wiki/Futures_and_promises
https://developer.apple.com/documentation/swift/result

Comments