Combine과 async/await를 같이 사용하는 건에 관하여

화성에서 온 Combine, 금성에서 온 async/await

Gordon Choi
21 min readJan 3, 2024

여는 말: Swift 앱에서 네트워크 통신 작업을 하려다가

필자가 면접을 보러 다닐 때 가장 많이 받은 질문 중 하나가 “네트워크 통신 구현해보셨나요?” 였다. 그만큼 iOS 개발자, 나아가 개발자의 기본 소양과도 같은 것이기 때문이겠다. 대부분의 서비스는 인터넷과 연결되지 않으면 의미를 잃는다. 데이터의 저장이 됐든, 서비스 자체가 그런 속성을 가지든, 소프트웨어에서 네트워크 통신은 거의 필수적이다. 심지어 그 소프트웨어를 다운로드받을 때도.

네트워크 작업의 특성은 여러 가지가 있지만, 그 중 대표적인 하나를 꼽자면 역시 “시간이 오래 걸린다”는 점이다. 코드 안에 있는 대개의 함수는 실행만 딸깍, 해 주면 바로 결과가 나온다. 하지만 네트워크 통신을 위한 함수는 실행시 네트워크 요청을 보내고, 그 결과가 도착하기 전까지 얼마나 걸릴지 알 수 없다. 결과를 받아올 때까지 무한정 기다릴 수는 없으므로, 앱은 일단 다른 할 일을 한다. 한편 결과값을 수신했을 경우 반드시 그에 따른 후처리를 해 주어야만 한다. 한 마디로 말하면, 비동기 작업이라는 거다.

Swift 차원에서 비동기 작업을 지원하는 방법을 크게 세 가지 정도 꼽아볼 수 있을 것 같다.

  • Completion Handler
  • Combine
  • Swift Concurrency (async/await)

아래로 갈 수록 더 최근에 제시된 방법이다.

새로 나온 걸 써 보고 싶은 것은 인지상정이라고 생각한다. 필자는 개인적으로 개발에 재미를 느끼는 입장에서, 새로운 무언가가 나오는 것을 마치 새 장난감을 받는 것처럼 생각한다. 그렇기 때문에 Swift Concurrency(이하 async/await)를 활용한 비동기 코드도 짜 보고 싶다는 생각이 들었다. 하지만 async/await은 뭔가 다르게 생겼다는 느낌이 들었다. 기존의 RxSwift나 Combine을 기반으로 짠 코드 베이스를 갈아엎어야 한다고 본능적으로 느꼈다. 그래서 일단은 손을 뗐지만, 그럼에도 불구하고 써 보고 싶었다. 조금이라도, 진짜 조금이라도 async/await을 활용해 볼 수 없을까? 어떻게든 둘을 호환해서 쓸 수 있지 않을까?

In a nutshell

Combine과 async/await 바꿔치기

각각의 프레임워크에 대해 모든 것을 설명하기에는 여백이 부족하므로(…), 네트워크 통신에 어떻게 활용되는지만 간단히 짚고 넘어가고자 한다.

Combine

Combine은 애플의 반응형 프로그래밍 프레임워크이다. Publisher를 통해 이벤트를 발신하면 Subscriber가 이를 구독해 변경사항의 전파를 파악하고 반영한다.

Combine에서 필자가 자주 사용하는 네트워크 요청 코드는 다음과 같다. URLSession을 활용해 요청을 보내고 JSON 등의 Raw Data를 받아 와서 반환하는 함수이다.

struct WebService: WebServiceable {
let session: URLSession

init(session: URLSession = URLSession.shared) {
self.session = session
}

private func call(_ request: URLRequest) -> AnyPublisher<Data, FetchingError> {
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw FetchingError.failedToGetHTTPResponse
}

guard (200...399).contains(httpResponse.statusCode) else {
throw FetchingError.invalidNetworkStatusCode(code: httpResponse.statusCode)
}

return data
}
.mapError { error in
if let networkingError = error as? FetchingError {
return networkingError
} else {
return .unknownError(error: error)
}
}
.eraseToAnyPublisher()
}
}

Combine에 대해 배경 지식이 없는 분을 위해, 맨 마지막에 붙는 eraseToAnyPublisher에 관한 애플 공식 문서를 인용하고자 한다.

Use eraseToAnyPublisher() to expose an instance of AnyPublisher to the downstream subscriber, rather than this publisher’s actual type. This form of type erasure preserves abstraction across API boundaries, such as different modules.

거칠게 요약하면, 다른 모듈에서 발신된 데이터를 변형해서 쓰려면 먼저 AnyPublisher로 바꿔 주어야 한다는 뜻이다. Combine을 사용하는 과정에서는 여러 가지의 Publisher 타입이 발생할 수 있는데, 이를 AnyPublisher로 래핑해 줌으로써 다른 코드에서도 원활히 사용할 수 있도록 한다.

async/await

async와 await은 2021년 처음 소개된 Swift Concurrency에서 제시된 비동기 코드 작성 표현이다. 비동기 코드를 동기 코드처럼 쓸 수 있게 해 준다고 한다.상술한 필자의 Combine 코드를 async/await을 활용해 바꾸면 대략 아래와 같다.

extension WebService {
private func call(_ request: URLRequest) async throws -> Data {
// 통신은 사실상 여기서 끝난다
let (data, response) = try await session.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw FetchingError.failedToGetHTTPResponse
}

guard (200...399).contains(httpResponse.statusCode) else {
throw FetchingError.invalidNetworkStatusCode(code: httpResponse.statusCode)
}

return data
}
}

굉장히 놀랐던 게, 통신을 위해서는 실상 한 줄만 작성하면 된다는 것이다. 물론 에러 핸들링을 위해 더 많은 과정이 필요하고, session.data 메서드에서 나오는 에러는 또 추가적인 처리가 필요하긴 하지만. 그래도 async/await이 가지고 있는 “생산성”이라는 녀석이 무엇인지 느낄 수 있었다.

async/await to Combine

그런 생각을 해 봤다. 비동기 통신을 수행할 때 어느 지점에서 async/await과 Combine의 조인 지점을 만들어 준다면 조금씩 코드를 바꿀 수 있지 않을까. 그래서 먼저 async/await을 Combine으로 바꿔 보기로 했다.

단순히 생각했을 때, async/await 함수에서 값이 발신되기를 기다렸다가 값을 받았을 때 Combine의 Future를 사용해 발신하면 된다는 생각이 들었다.

// async/await으로 발신하는 부분
struct AsyncAwaitHandler {
// 성공을 입력하면 1에서 99 사이 숫자를 반환하고, 실패하면 임의로 정해준 에러 투척
func handle(succeeded result: Bool) async throws -> String {
guard result else { throw MyError.anError }
return Array(1...99).map { String($0) }.randomElement()!
}
}

// Combine으로 변환하는 부분
struct AsynchronousBridge {
func asyncAwaitToCombine(succeeds result: Bool) async -> AnyPublisher<String, MyError> {
// 실행 결과가 없을 시 임의의 에러 반환 - 임시 에러 핸들링
guard let executionResult = try? await asyncAwaitHandler.handle(succeeded: result) else {
return Future { promise in
promise(.failure(MyError.anError))
}
.eraseToAnyPublisher()
}

// 결과가 있을 시 Future에 태워서 발신
return Future { promise in
promise(.success(executionResult))
}
.eraseToAnyPublisher()
}
}

WWDC19 Introducing Combine에 나온 내용에 따르면, 반응형 프로그래밍은 시간에 따른 이벤트의 나열을 마치 배열을 다루듯 다룰 수 있다고 소개했다. Array의 Element에 해당하는 것이 Future라고. 그래서 Future를 사용해 값 하나를 발신하는 식으로 이어 볼 수 있다.

단 이렇게 했을 경우, 함수 심볼에도 async가 붙고, 호출부에서도 await이 붙어야 한다. 이는 Swift Concurrency가 제어권을 다루는 방식과 관련이 있다. 단순히 생각했을 때 비동기 작업을 마친 작업 단위는 일종의 콜백과 함께 원래 작업하던 스코프에 결과값을 넘겨 줘야 한다. 하지만 await 키워드가 붙은 메서드는 제어권을 포기하는 과정에서 이를 부모 작업 단위에 직접 넘겨 주는 것이 아니라, 시스템에 그 결정을 위임한다. 한편 await 상태에서는 해당 메서드뿐만 아니라 해당 메서드를 호출한 상위 스코프도 일시정지된다. 그래서 해당 가능성에 대한 표시로, 단지 async 메서드를 불러올 뿐인 메서드도 async 심볼을 붙여 줘야 하는 것이다.

좋다. 값을 발신한다. 그러면 실제 개발 상황을 가정해, 뷰 모델 비슷한 것을 만들고 뷰의 버튼과 뷰모델의 Published 프로퍼티와 연결했다. 버튼을 누르면 레이블의 숫자가 바뀌는 방식. 그런데 Button의 action 파라미터에 함수를 넣었더니?

Button("Call Combine") {
await viewModel.callCombine()
// Cannot pass function of type '() async -> ()'
// to parameter expecting synchronous function type
}

에러가 떴다. 왜인고 하니, Button의 action 부분은 () -> Void 탈출 클로저가 들어가는 부분이라서 그렇다. 즉, 원래는 동기 함수가 들어가는 자리. 하지만 우리가 사용하고자 하는 함수는 비동기 함수다. 어떻게 하면 좋을까?

이럴 때 Task를 활용할 수 있다. 공식 문서에 따르면 Task는 일련의 비동기 작업의 단위라고 한다. Task 인스턴스를 생성하고 수행하고자 하는 비동기 작업들을 넣어 준다면 알아서 수행한다고 한다. cancellation 등의 작업을 위해서는 상위 객체와 영향을 주고받아야 한다고 하지만, 더 자세한 내용에 대해서는 일단 넘어가고자 한다. 중요한 것은, 동기 함수가 원래 들어가야 하는 자리에 Task를 넣으면 안에 async/await 함수를 넣을 수 있다는 것이다.

Button("Call Combine") {
Task {
await viewModel.callCombine()
}
}

에러가 사라졌다. 하지만 이 경우 View가 viewModel에서 Combine 쪽을 호출했는데, 갑자기 await을 붙여 줘야 하는 것이 생뚱맞게 느껴질 수 있다고 생각했다. Task의 위치를 옮겨 주자.

final class ContentViewModel {
func callCombine() {
Task {
await bridge.asyncAwaitToCombine(succeeds: true)
.sink(receiveCompletion: { completion in
print(completion)
}) { [weak self] message in
self?.combineLabel = message
}
.store(in: &cancellables)
}
}
}

struct ContentView {
// ...
Button("Call Combine") {
viewModel.callCombine()
}
// ...
}

훨씬 낫다.

Combine to async/await

발신된 값 하나를 바로 받아와서 형식을 바꾼다, 라는 아이디어를 얻었다. 그렇다면 Combine으로 발신한 값을 하나씩 받아다가 async/await으로 반환하면 어떨까 싶었다. 일단 함수를 async throws로 먼저 만들고 — 에러 핸들링은 중요하다 — , 적당히 값을 return해주면 좋지 않을까 하는 생각이었다.

일단 Combine 퍼블리셔를 반환하는 함수부터 짜 보자.

struct CombinePublisher {
func publish(succeeded result: Bool) -> AnyPublisher<String, MyError> {
return Future { promise in
result
? promise(.success(Array(1...99).map { String($0) }.randomElement()!))
: promise(.failure(MyError.anError))
}
.eraseToAnyPublisher()
}
}

아까처럼 숫자나 에러를 반환하는 함수를 짰다. 그럼 이제 브릿징을 해 주면 되는데…

일단 publish 함수를 그대로 반환할 수는 없다. 함수 자체는 (Bool) -> AnyPublisher<String, MyError> 타입이니까. 그렇다면 그 안에 있는 String 값이나 MyError 값을 뽑아서 사용하면 되지 않을까 했다.

그런데 스트림 안에 있는 값을 뽑으려면 어떻게 해야 하지? 필자는 sink를 활용해 같은 객체에 있는 Published 프로퍼티에 값을 할당하는 정도만 써 봤었다. 이 작업을 위해서 브릿지 인스턴스에 임시로 값을 담을 프로퍼티를 만든다? 좋지 않다고 생각했다. 특히나 브릿지를 구조체로 만든 시점에서, 이를 구현하려면 mutating을 사용해야 했기에 더욱 그렇다. 그렇다고 함수 안에다가 임시 변수를 두고 브릿징을 한다?? Variable “A” captured before being initialized 따위의 오류가 나오고. 머리가 아팠다. 그렇게 해결 방법을 찾던 중, Continuation에 대해 찾아 보게 되었다.

CheckedContinuation 문서에 따르면…

continuation이란 프로그램의 상태에 대한 추상적인 표현이다. UnsafeContinuation과 CheckedContinuation의 두 가지가 있다.

continuation을 비동기 코드 안에서 만들려면, 원래는 withUnsafeContinuation 혹은 withThrowingUnsafeContinuation을 사용하면 된다. continuation에 대해서 처리한 다음 비동기 작업을 재개하고자 한다면, resume 메서드를 사용하면 되는데, 프로그램 상에서 이 비동기 함수가 진행되는 전체 과정 중 resume은 딱 한 번만 불러올 수 있다.

CheckedContinuation은 이러한 resume 작업이 두 번 이상 불려왔거나 불려오지 않아 비동기 작업에 이상이 생기는 경우 이를 런타임에 체크해 로그 메시지로 남긴다. 반면 UnsafeContinuation은 런타임 체크를 강제하지 않아 소폭이나마 성능 우위가 있다. 둘은 기본적으로 로깅을 하냐 안하냐 외에는 같으므로 다른 코드 수정 없이 바꿀 수 있다.

요컨대, 비동기 스트림이 진행되는 중 잠시 멈춰! 를 시전한 다음 일정한 작업을 하고, resume 메서드를 통해 비동기 작업을 재개하면 된다, 라는 것으로 알아들었다.

Continuation을 사용할 때 쓰는 몇 가지 함수가 있었다. 지금 필자의 경우 안전한 코드를 원하고, 에러 핸들링을 할 수 있기를 원하므로 withCheckedThrowingContinuation 메서드를 이용하기로 했다. 실제 적용하는 데 있어서는 Eduardo Domene Junior의 글을 참조했다. 모든 AnyPublisher에 대해 first() 메서드를 사용해 첫 번째 값을 캡처해 해당 값이 값인지 에러인지에 따라 결과로 반환하는 async throws 함수가 반환할 값을 결정한다.

extension AnyPublisher where Failure: Error {
func asyncThrows() async throws -> Output {
// 여기서의 Continuation은 CheckedContinuation.
// Continuation을 인자로 받고 Void를 리턴하는 함수를 인자로 받는다.
// Continuation에 대한 합당한 처리를 말하는 것.
// resume 메서드를 통해 withCheckedThrowingContinuation에서 반환할
// 값을 정해서 반환한다
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = first()
.sink { completion in
switch completion {
case .finished:
break
case .failure(let error):
// 이 시점의 AnyPublisher가 실패를 발신하고 있다면
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
// 성공하고 있다면, 그 값을 반환하면 된다.
continuation.resume(with: .success(value))
}
}
}
}

이제 이 함수를 적용해 보자.

struct AsynchronousBridge {
func combineToAsyncAwait(succeeds result: Bool) async throws -> String {
return try await combinePublisher.publish(succeeded: result).asyncThrows()
}
}

// 에러 핸들링은 뷰 모델에서 적절히 해 준다

struct ContentView {
// ...
Button("Call async/await") {
Task {
await viewModel.callAsyncAwait()
}
}
// ...
}

아까의 교훈을 발판삼아 이번엔 미리 Task로 await 함수를 감싸 주었다.

실행

자아, 실전을 가정해 만든 앱을 보겠다. Combine Label, async/await Label이 있고, 각각의 값을 불러오는 버튼이 있다. 중간에는 Bridge 인스턴스를 통해 Combine으로 발신되는 신호는 async/await으로, async/await으로 반환되는 값은 Combine으로 바꿔 준다. 뷰는 그것까지는 모르고, 아무튼 Combine이랑 async/await을 불러 온다. 일단 무조건 성공하게끔 함수를 짜서, 두 함수 모두 1에서 99 사이의 랜덤한 숫자를 불러 올 것이다. 초기 텍스트는 Loading이라고 써 두었다.

잘 된다! 이걸로 Combine과 async/await을 바꿔 가면서 쓸 수 있게 되었다. 실제 네트워크 통신 등의 비동기 작업을 위해서도 사용할 수 있을 것이라고 생각된다. 아래에 코드를 첨부했다.

닫는 말: 랑데뷰 포인트

개발을 하다 보면 흔히 오래된 코드베이스를 만나곤 한다. 의욕적인 개발자라면 새로운 기술을 적극적으로 사용해서 이를 “고쳐 보고자” 할 수도 있다. 하지만 모든 코드 베이스를 바꾼다는 것은 여러 현실적인 문제에 부딪힌다. 시간은 유한하며, 더 중요한 피처의 개발이나 유지보수에 우선적으로 쓰여야 한다. 한편 iOS의 경우 Deployment Target을 조정하는 문제에 부딪힐 수도 있다. 거기다가 지금 이렇게 다 뜯어고치는 데 성공했다고 치자. 하지만 개발자의 코드는 20년만 지나면 기술 부채가 된다. 이런 작업은 무의미하다는 반대에 부딪힐 것이다. 이것이 개발자를 붙드는 과거의 힘이다.

반면 애플은 Swift를 지속적으로 발전시키고자 하고, 이 과정에서 일견 급진적인 변화 또한 단행해 왔다. 그리고 한편으로는 자신들이 의도하는 방향으로 iOS 개발자를 강하게 유도하고자 한다. 필자는 SwiftUI, Swift Regex, SwiftData 등이 계속 출시되고 홍보되는 것을 보면서 이런 느낌을 많이 받았다. 이것이 미래가 개발자를 잡아끄는 힘이다.

과거와 미래가 끌어당기는 힘 사이에서 균형을 찾고 싶은 개발자로서 필자는 구본신참(舊本新參)의 마인드셋에 대해 생각하곤 한다. 이전의 것에 대해 충분한 존중을 보내면서, 새로운 것에 대해 지속적으로 참조하는 것이다. 이전의 코드베이스가 여전히 남아 있는 이유는, 여전히 그것이 세상에 이익을 가져다 주고 있기 때문이다. 한편 당장의 이익은 20년 뒤의 기술 부채로 돌아올 수 있다. 그래서 새로운 것에 대해 알고 항상 주목해야 대비하고 또 쓸 수 있으며, 궁극적으로 코드와 소프트웨어가 변함없이 세상에 이익을 가져다 주게 할 수 있다.

이런 시선에서 봤을 때 오늘 해 본 작업은, 기존의 것과 새로운 것이 만나는 지점이었다는 측면에서 의미가 있다고 생각한다. 한편 Swift Concurrency에 대해서 좀 더 학습해 볼 생각도 든다. Continuation, Task 등에 대해서 더 깊이 있는 지식을 확보해 활용해볼 수 있을 것 같다.

옛 것과 새 것은 반드시 만나게 된다. Photo by Toa Heftiba from Unsplash

--

--