[WWDC] Efficiency awaits: Background Tasks in SwiftUI

marisol👩🏻‍💻·2023년 5월 16일

SwiftUI의 새 API로 모든 Apple 플랫폼에서 Swift Concurrency를 이용해 백그라운드 태스크를 일관적으로 처리할 수 있다.

1️⃣ Stormy: A storm photos app

먼저 "Stormy"라는 샘플 앱을 살펴볼 예정이다. Stormy 앱은 폭풍우 치는 날 하늘 사진을 찍는 앱으로, 백그라운드 태스크를 이용한다.
두번째로 백그라운드 태스크를 앱에서 활용하는 방법과 백그라운드 태스크가 동작하는 과정을 살펴본다.
그리고 SwiftUI의 새 API로 백그라운드 태스크를 다루는 법을 알아보고,
마지막으로 백그운라운드 태스크 처리가 Swift Concurrency로 얼마나 쉬워졌는지 알아본다.

새로운 API는 watchOS, iOS, tvOS, Mac Catalyst와 위젯 및 Mac에서 실행하는 iOS앱까지 모두 통합하여 사용할 수 있다.
즉, 백그라운드 태스크를 처리할 때 한 플랫폼에서 배운 개념과 패턴을 다른 곳에도 적용할 수 있다.
Swift Concurrency를 활용하는 새 API는 깊이 중첩된 completion handler와 콜백 및 종종 부작용을 일으켰던 가변 상태의 필요성을 줄일 수 있다.
Swift Concurrency의 네이티브 작업 취소는 앱이 시간에 맞춰 작업을 완료하도록 도움으로써 시스템이 백그라운드에서 앱을 종료하는 것을 피한다.

폭풍우 치는 날을 좋아하는 사람들을 위해 Stormy라는 앱을 만들려고 한다.
Stormy 앱은 폭풍우가 치면 하늘 사진을 찍으라고 알려주는 앱이다.
폭풍우가 치는 날이면 앱이 정오에 알림을 보내서 하늘 사진을 찍으라고 요청한다.

사용자는 알림을 탭하고 하늘 사진을 찍어서 프로필에 올린다.
그리고 이 사진을 백그라운드에서 업로드할 것이다.

업로드가 완료되면 앱에서 다시 알림을 보낸다.

백그라운드 태스크가 어떻게 진행되는지 살펴보자.
왼쪽 막대가 포그라운드 앱의 런타임을 나타내고,
중간 막대는 백그라운드 앱의 런타임이고,
오른쪽 막대는 시스템 앱 런타임이다.

사용자가 처음 앱을 포그라운드로 실행하면, 정오에 새로고침 태스크를 예약할 첫번째 기회를 얻는다.

사용자가 앱에서 나가서 앱이 일시정지되면, 시스템은 예약한 시간에 백그라운드에서 앱을 깨워야 한다는 것을 기억한다.

정오에 앱을 예약했기 때문에 시스템은 정오에 백그라운드에서 앱을 깨우고, 백그라운드 앱 새로고침 작업을 보낸다.
이 백그라운드 런타임에 우리는 폭풍우가 치는지 알아내야 한다. 날이 흐리다면 사용자에게 알림을 보낸다.

우선 날씨 서비스에 네트워크 요청을 보내서 현재 날씨를 확인해야 한다.

백그라운드에 예약된 URLSession으로 앱은 일시 정지 상태로도 네트워크 요청이 완료될 때까지 기다릴 수 있다.

백그라운드 네트워크 요청으로 날씨 데이터를 받으면, 새로운 URLSession 백그라운드 태스크로 앱에 백그라운드 실행 시간이 다시 주어진다.

요청한 날씨 데이터를 얻으면, 앱은 밖에 폭풍우가 치는지 판단하고 사진을 찍으라는 알림을 보낼 것인지 결정한다.

URLsession 태스크가 완료되었으므로 시스템은 다시 한 번 앱을 일시 정지(suspend) 시킨다.

2️⃣ Background on Background Tasks

백그라운드 태스크의 작동 방식을 더 자세히 알아보자.

우선 앱을 새로고침하는 백그라운드 작업의 수명 주기부터 살펴보자. 여기를 확대해보면

먼저 시스템이 앱을 깨우고, 앱 새로고침 백그라운드 태스크를 보낸다.
그 다음 백그라운드 상태에서 네트워크 요청을 보내 폭풍우가 치는지 확인한다.

여기서 이상적인 상황은, 우리의 네트워크 요청이 새로고침을 위해 앱에 할당된 런타임 안에 모두 완료되는 것이다.
네트워크 응답을 받으면 알림을 즉시 표시하려 한다.
알림이 게시되면 앱 새로고침 중에 필요한 건 모두 마쳤으므로 시스템이 다시 앱을 일시 정지시킨다.

하지만 날씨 데이터에 대한 네트워크 요청이 제때 안 끝나면 어떻게 될까?

앱이 태스크를 수행할 백그라운드 런타임을 모두 소비했다면, 시스템이 앱에 시간이 부족하다고 알려서 이 상황에 대비할 기회를 준다.

런타임이 만료될 때까지 앱이 백그라운드 태스크를 완료하지 못하면 앱은 시스템에 의해 중단되고 다음 백그라운드 태스크를 위해 throttling을 일으킨다.

3️⃣ SwiftUI API in practice

이제 SwiftUI의 BackgroundTask API가 Stormy 개발에 어떻게 도움이 되는지 살펴보자.

하단에 작성한 코드는 다음날 정오에 앱의 백그라운드 새로 고침을 예약하는 함수이다.
먼저 내일 정오를 나타내는 날짜를 만들고,
백그라운드에서 앱을 새로고침하는 요청을 만든다.
가장 빠른 시간을 다음 날 정오로 설정하고 스케줄러에 제출한다.
시스템은 이것을 받고 다음날 정오에 앱을 깨운다.

사용자가 처음 앱을 열고 폭풍우가 치면 정오에 알리라고 요청할 때 이 함수를 실행시키려고 한다.

예약한 백그라운드 태스크에 해당 핸들러를 등록하려면, 새 백그라운드 태스크 Scene modifier를 이용해야 한다.
앱이 백그라운드 태스크를 받으면 백그라운드 태스크와 일치하는 modifier에 등록된 모든 블록이 실행된다.

이 경우에는 appReferesh를 사용해서 원하는 날짜에 백그라운드의 실행 시간을 미리 예약해서 앱에 제공할 수 있도록 했다.

백그라운드 태스크 modifier에서 request handler와 같은 식별자를 사용하면 앱이 해당 태스크를 받을 때 시스템이 호출할 핸들러를 식별할 수 있다.
내일로 다시 예약이 됐는지 확실히 확인하기 위해 좀 전에 위에 작성한 scheduleAppRefresh 함수로 백그라운드 런타임을 내일 정오로 다시 예약해서 백그라운드 태스크를 시작하려고 한다.

정오의 백그라운드 런타임이 순환하기 때문에 바깥 날씨를 확인하는 네트워크 요청을 만들고 swift의 await 키워드로 결과를 기다린다.
네트워크 요청이 반환되고 실제로 밖에 폭풍우가 친다면 하늘 사진을 업로드하라는 알림을 사용자에게 보내길 기다린다.
클로저의 본문이 리턴되면 시스템이 앱에 할당한 백그라운드 태스크가 완료된 것으로 표시되어
시스템이 앱을 다시 일시 정지할 수 있다.

여기에서 Swift Concurrency를 사용하면 작업이 완료됐을 때 명시적인 콜백 없이 백그라운드에서 장기 실행 태스크를 수행할 수 있다.

notifyForPhoto async 함수를 활용하면 UserNotificationCenter에서 찾을 수 있는 비동기 알림 추가 메서드로 간단하게 비동기 작업에서 Swift Concurrency를 사용할 수 있다.

4️⃣ Swift Concurrency

마지막으로 Swift Concurrency의 async와 await가 백그라운드 태스크 처리에서 얼마나 편리한지 알아보자.

그동안 위에서 참조한 isStormy 함수를 작성해보자.
이 async 함수는 바깥 날씨를 확인하는 네트워크 요청을 처리해야 한다.

가장 먼저 URLSession.share를 사용하고, 날씨 데이터 요청을 인스턴스화 한다.
Swift Concurrency를 채택한 URLSession은 비동기 컨텍스트로 대기할 수 있는 네트워크에서 데이터를 다운로드하는 메서드를 갖고 있다.
네트워크 응답이 들어오면 날씨 데이터를 읽고 결과를 반환한다.

하지만 아까처럼 런타임이 만료되기 전에 앱이 네트워크 요청을 완료하지 못하면 어떻게 될까?

이 경우에는 URLsession을 백그라운드 세션으로 설정했는지 확인하고, URLSession 백그라운드 태스크를 이용해서 앱에 시작 이벤트를 보내는지 살펴봐야 한다.

코드로 돌아가보면 아까 URLSession.shared를 사용했는데,

대신 URLSession을 만들 때 백그라운드 설정에서 sessionSendsLaunchEvents 속성을 true로 설정해야 한다.
이 코드는 앱이 suspend 된 경우에도 네트워크 요청을 실행하고, 요청이 완료되면 앱을 깨워서 URLSession 백그라운드 태스크를 수행하라고 시스템에 알린다.

이건 특히 watchOS에서 중요한데, watchOS 백그라운드에서 실행되는 앱의 네트워크 요청은 백그라운드 URLSession으로 요청되어야 한다.

백그라운드 태스크의 런타임이 만료되면 백그라운드 태스크 modifier에 제공된 클로저를 실행하는 비동기 태스크를 시스템에서 취소한다.
-> 백그라운드 런타임이 만료될 때 여기서 이루어진 네트워크 요청도 취소된다는 뜻.
이 취소에 응답하고 처리하려면 Swift Concurrency에 내장된 함수인 withTaskCancellationHandler를 사용하면 된다.

결과를 직접 대기하기보다, withTaskCancellationHandler 호출에 다운로드태스크를 넣고 그 결과도 함께 대기하라고 한다.
withTaskCancellationHandler에 전달된 첫번째 블록은 우리가 실행하려고 대기하는 비동기 프로시저다.
두 번째 클로저는 작업이 취소될 때 실행되는 코드이다.
여기에서 즉각적인 네트워크 요청이 실행 시간 만료로 취소되면 백그라운드 다운로드 태스크로 네트워크 요청 재개 호출을 한다.
앱이 suspend된 상태에서도 백그라운드 다운로드를 계속 진행시킨다.

이 코드는 양쪽 모두 같은 URLSession을 사용해서 네트워크 요청을 두 번 보내지 않으며, URLSession은 진행중인 모든 중복 요청을 제거한다.

0개의 댓글