이번 포스트는 Combine 프레임워크를 사용 중인 프로젝트 환경에서 Swift Concurrency 문법을 도입해 본 도입기에 관한 내용이다.
Swift에서 제공하는 Combine과 Swift Concurrency 모두 비동기 작업을 좀 더 편리하게 다룰 수 있는 유용한 도구이다. 그렇다면 왜 필자는 잘 사용 중인 Combine 프레임워크 환경에서 Swift Concurrency를 도입하게 된 것일까?
빠른 이해를 돕기 위해 아래는 현재 작업 중인 프로젝트와 비슷한 상황을 가정하여 간단하게 구현한 예시이다.
import SwiftUI
struct SimpleList: View {
@StateObject private var viewModel: SimpleListViewModel = SimpleListViewModel()
var body: some View {
List {
ForEach(viewModel.numbers, id: \.self) { number in
Text("\(number)")
.padding([.top, .bottom], 30)
}
.listSectionSeparator(.visible)
}
.listStyle(.plain)
.refreshable {
viewModel.refresh()
}
}
}
import Combine
import Foundation
final class SimpleListViewModel: ObservableObject {
@Published var numbers: [Int] = Array(0..<11)
private var cancellables = Set<AnyCancellable>()
func refresh() {
fetchNewData()
.sink(receiveValue: { [weak self] in
self?.numbers = $0
})
.store(in: &cancellables)
}
private func fetchNewData() -> AnyPublisher<[Int], Never> {
let newData = Array(11..<20)
return Just(newData)
.delay(for: 2, scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
View 이름 그대로 정말 간단한 형태의 SwiftUI의 List
로 각 행은 0부터 10까지의 숫자를 표시하고 있다.
문제가 된 부분은 바로 List
에 사용된 refreshable(action:)
modifier 였는데, refreshable(action:)
은 iOS 15 버전에서 등장한 modifier로 말그대로 List
에 표시 중인 데이터를 새로고침하는 역할로 이미 시중에 서비스 중인 앱에서 많이 봤던 기능일 것이다.
위의 코드에서는 action
파라미터로 전달된 클로저를 통해 ViewModel의 refresh()
를 호출 하도록 하고 있으며, 위의 코드를 그대로 실행하면 아래와 같이 동작하게 된다.
😳 ...?
이미 눈치를 챘겠지만 리스트가 완전히 업데이트 되기도 전에 indicator가 사라지는 약간 당황스러운 경험을 하게 되었다.
애플공식 문서에서 refreshable(action:)
의 정의와 설명을 찾아보고 빠르게 원인을 찾을 수 있었다. 역시 교과서 만큼 좋은게 없는듯
먼저 refreshable(action:)
의 정의는 아래와 같이 async
키워드를 통해 비동기로 실행할 수 있는 함수를 action
파라미터를 통해 전달 받고 있었다.
nonisolated
func refreshable(action: @escaping () async -> Void) -> some View
그리고 애플공식 문서 하단 Discussion에는 아래와 같이 action
파라미터로 전달된 함수가 비동기로 동작할 때, 그 기간동안 indicator가 유지된다고 나와 있었다.
The indicator remains visible for the duration of the refresh, which runs asynchronously
원인은 즉, ViewModel의 refresh()
함수 자체는 비동기로 실행되지 않으니 함수 요청 후 함수가 종료됨과 동시에 indicator도 함께 사라진 것이 원인이라고 생각 되었다.
위에 언급한 것과 같이 새로고침 되는 동안 indicator를 유지 하기 위해서는 refresh()
함수를 비동기로 만들어야 했고, 그러기 위해서 Swift Concurrency 도입이 필요하다고 생각되어 가장 먼저 실행에 옮긴 것이 ViewModel의 refresh()
함수를 Swift Concurrency로 async
하게 만드는 것이었다.
refresh()
함수를 비동기로 만들면서 가장 중요했던 점은 Publisher가 값을 방출할 때까지 await
키워드를 사용하여 Suspension Point를 만드는 것이 중요했다. 이것을 구현하기 위해서 Publisher에서 값이 방출될 때 기존에 sink(receiveValue:)
를 사용하는 것이 아닌 아래 코드와 같이 values
프로퍼티를 이용하여 Publisher가 값을 방출할 때까지 기다린 후 방출한 값을 가져올 수 있었다.
import Combine
import Foundation
final class SimpleListViewModel: ObservableObject {
@Published var numbers: [Int] = Array(0..<11)
private var cancellables = Set<AnyCancellable>()
func refresh() async {
for await numbers in fetchNewData().values {
DispatchQueue.main.async {
self.numbers = numbers
}
}
}
private func fetchNewData() -> AnyPublisher<[Int], Never> {
let newData = Array(11..<20)
return Just(newData)
.delay(for: 2, scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
위의 코드에서는 Publisher가 에러를 방출하지 않지만 만약 에러 방출이 가능한 Publisher를 사용한다면 아래와 같이 do-catch
를 통해 에러 핸들링이 가능하다.
func refresh() async {
//Publisher가 에러를 발생할 가능성이 있는 경우
do {
for try await numbers in fetchNewData().values {
DispatchQueue.main.async {
self.numbers = numbers
}
}
} catch {
//Todo
}
}
import SwiftUI
struct SimpleList: View {
@StateObject private var viewModel: SimpleListViewModel = SimpleListViewModel()
var body: some View {
List {
ForEach(viewModel.numbers, id: \.self) { number in
Text("\(number)")
.padding([.top, .bottom], 30)
}
.listSectionSeparator(.visible)
}
.listStyle(.plain)
.refreshable {
await viewModel.refresh()
}
}
}
이제 위의 코드대로 실행해 보면 refresh가 진행되는 동안 indicator가 유지되는 모습을 확인할 수 있었다.
프로젝트에서 Combine 프레임워크 기반으로 구현 하면서 위와 같은 상황에서 어쩔 수 없이 Swift Concurrency를 도입하게 되었는데 Swift Concurrency의 가장 큰 장점은 비동기 코드를 마치 동기 코드처럼 한 줄에 작성이 가능하여 코드의 가독성을 향상 시킬 수 있다는 점, 그리고 그로 인한 코드 양 감소가 가장 큰 장점이라 생각된다.