[WWDC-2020] Why is my app getting killed?

2021. 11. 9. 13:57iOS/WWDC

이 글은 WWDC 2020 영상을 정리하여 작성한 글입니다.

원본 영상은 링크를 참고하여 주세요.

Why is my app getting killed?

오늘은 제목 그대로 알아볼 수 있듯이 응용프로그램이 백그라운드에서 종료될 수 있는 여러 가지 이유와 이에 대해 개발자가 할 수 있는 일에 대해 알아보는 시간을 갖도록 하겠습니다.

앱이 일반적인 foreground 상황에서 crash 가 발생하는 것을 원하지 않는 것 처럼 background에서 crash가 발생하는 것 또한 좋지 않은 결과일 수 있습니다.

세션에서는 다음의 예시를 들어 설명하고 있습니다.

예를들어 할일을 정리하는 To-Do list 앱이 있다고 생각해 봅시다. 

사용자는 사야할 식료품 리스트를 보며 Pineapple을 사야겠다는 생각을 하여 화면에 입력하였습니다. 

하지만 잠시 홈화면으로 나가야만 했고 이후 다시 앱을 실행하였습니다.

하지만, 앱은 다시 처음부터 시작하는 상황이 발생합니다. 사용자는 저장을 하지 않은 채로 말이죠.

Save 를 하는데 실패한 개발자 ..!

이렇게 된 이유는 바로 앱이 백그라운드 상태에서 종료되었기 때문입니다. 왜 백그라운드에서는 앱이 종료될까요? 세션에서는 이렇게 백그라운드에서 앱이 종료되는 일반적인 상황에 대해 다음과 같이 정리하였습니다.

iOS 14(이 영상은 2020년 영상입니다.) 이전에는 위 사유 중 두 가지에서 발생한 경우에만 신호를 받을 수 있었습니다. 하지만 iOS 14부터 어떠한 이유로 인해 백그라운드 종료가 얼마나 자주 발생하는지 확인할 수 있는 새로운 API가 있습니다.

MXBackgroundExitData이라는 것입니다. 이 API는 각 종료된 유형의 count 값을 제공합니다.

또한, 사용자가 앱 전환기에서 임의로 앱을 종료한 상황에 대해서도 이러한 count 값을 제공합니다. 따라서 앱이 왜 죽었는지 알 수가 있는 것이겠죠

Crashes

Crashes는 가장 간단한 종료 유형에 속합니다. 일반적으로 segmentation faults(이게 왜 일어나는 거였더라..), 잘못된 동작과 asserts 문으로 인해 발생합니다. 이러한 이벤트들은 Crash 로그를 생성하게 되고 자동으로 Xcode Organizer에 표시됩니다. 이러한 문제를 해결하는 방법은 WWDC-2018 Understanding Crashes and Crash Logs 세션을 참조하면 됩니다.

또한, Xcode Organizer를 통한 방법 이 외에도 MetricKit의 새로운 방법으로 장치에서 직접 programmatic 하게 정보를 가져오는 기능이 추가 되었습니다.

MXCrashDiagnostic라는 객체는 충돌과 기타 종료들을 추적하는데 필요한 모든 항목이 포함되어 있습니다. 이러한 보고서를 얻는 방법은 What's New in Metric Kit 세션을 참조하면 됩니다.

Watchdog

또 다른 일반적인 종료는 감시 이벤트입니다.

이것은 실행, 백그라운드 진입, 포그라운드 진입과 같은 주요 앱 전환 시에 오랜 지연 시간에 대한 것입니다. 이러한 전환의 경우 20초의 단위로 시간제한이 존재합니다만, 디버거가 연결되어 있는 동안에는 이러한 종료가 발생하지 않습니다.

그리고 이러한 감시는 보통 deadlock 상태, 무한루프 상태, 그리고 끝나지 않는 동기 작업과 같은 심각한 문제들을 나타냅니다. 이러한 경우 MXCrashDiagnostic를 통해 진단 보고서를 받을 수 있습니다. call graph가 내부에 있고, 이것은 앱이 막혀있는 곳을 보여줄 것입니다.

CPU resource limit

앱이 백그라운드 실행에 의존하는 경우에 CPU 사용 제한이 매우 엄격합니다. 만약 백그라운드에서 CPU에 대한 부하가 계속 높다면 시스템에서는 energy exception 보고서라는 것을 생성하고, 지속적인 작업이 충분히 오래 진행된 경우에 시스템이 앱을 종료합니다. CPU resource exception 로그는 Xcode Organizer와 MXCPUExceptionDiagnostic를 통해 사용할 수 있습니다. 이 보고서는 종료 시점에 앱이 정확히 어떤 작업을 수행했는지 식별하기 위한 호출 스택이 포함되어 있습니다.

만약 코드에 버그가 있다면 간단하게 수정할 수 있겠지만, 백그라운드에서 매우 집중적인 작업을 수행해야 하는 경우 백그라운드 처리 작업으로 작업을 이동하는 것을 고려하는 것이 좋습니다.

이러한 "백그라운드 처리 작업"은 CPU 리소스에 대한 제한 없이 장치가 밤새 충전되는 동안에 몇 분간의 런타임을 제공하도록 할 것입니다.

Memory footprint exceeded

과도한 CPU 사용량을 피해야 하듯, 메모리 사용량도 제어하는 것이 중요합니다.

만약, 이 녹색 사각형으로 표시된 앱의 메모리가 너무 커진다면 앱 에서 사용할 수 있는 메모리 공간 제한을 초과함에 따라 시스템이 메모리를 올바르게 종료합니다. 이러한 현상은 Instruments와 Xcode Memory Debugger를 통해 앱에서 초과하는 메모리 사용의 원인을 추적하는데 도움이 될 것입니다.

이러한 제한은 장치마다 다른데 구형 기기일수록 낮은 값을 갖습니다. 만약 iPhone 6s 이전의 장치들을 타깃으로 한다면 200 mb 이하를 유지하는 것을 목표로 해야 할 것입니다.

Memory pressure exit (Jetsam)

이제 메모리의 초과에 따른 종료와는 달리, 또 다른 종류의 메모리와 연관된 종료 방식을 가지고 있습니다. Jetsam이라고 알려진 이러한 방식은 압박을 통한 종료(?)이고, 이는 가장 일반적인 백그라운드 종료입니다.

만약 이런 일이 일어난 다면, 반드시 개발자의 잘못은 아닙니다. 단지, 시스템이 포그라운드 상태의 애플리케이션과 음악, 내비게이션 앱과 같이 백그라운드에서 실행 중인 다른 애플리케이션을 위해 메모리를 확보해야 하기 때문입니다.

따라서 포그라운드 앱(파란색 블록)이 너무 커지게 되면 백그라운드에 있는 앱(초록색 블록)이 종료됩니다. 따라서 앱이 백그라운드로 이동할 때는 메모리 사용 공간을 최대한 작게 축소하여 종료 속도를 직접 줄일 수 있습니다. 백그라운드에 들어갈 때 50mb 미만을 줄일 수 있다면 좋습니다. 작으면 더 좋고요!

이를 위해 어떤 것을 할 수 있을까요? 먼저 캐시를 비우거나 디스크에서 다시 읽을 수 있는 리소스를 비우는 것도 고려할 수 있겠습니다. 이러한 부분들은 항상 원래 상태로 되돌릴 수 있습니다.

그리고 자신의 앱이 다른 앱들을 죽일 수 있다는 것도 고려해야 합니다. (포그라운드도 적당히 써 버릇 하자!)

그러나 50mb 미만의 리소스를 사용한다고 하더라도, 이러한 Jetsam의 위험을 완전히 제거할 수 없습니다. 그리고 그것이 일어날 때를 예측하긴 더더욱 어렵습니다.

만약 바로 다음 사용자의 작업이 엄청난 메모리가 필요한 경우 앱이 백그라운드에 들어간 지 단 몇 초만이라도 발생할 수 있습니다. 예를 들어 카메라 앱을 실행시키고 빠르게 수십 장의 사진을 찍는 것과 같이 말이죠.

이럴 때는 어떻게 해야 할까요? 

앱은 백그라운드 상태가 되는 즉시 앱의 상태를 유지시키도록 해야만 합니다. 그다음에 앱이 실행될 때 사용자가 중지했던 위치로 돌아가도록 합니다. 위와 같이 텍스트 필드를 편집하는 상태에서 다시 화면으로 돌아온다면 다시 그 상태가 유지되는 것을 예상할 것이고, 미디어 재생 중이었다면, 재생 위치를 복원해야 할 것입니다.

이러한 유지는 UIKit의 내장된 State Restoration(상태 복원) API를 통해 많은 부담을 덜 수 있을 것입니다. 이러한 장치들을 앱 전반적으로 사용했다면 사용자는 앱이 백그라운드에서 종료되었다는 것을 깨닫지 못할 것입니다.

Background task timeout

Jetsam 이후, 두 번째로 일반적인 종료 이유는 백그라운드 작업 시간 초과입니다. 포그라운드에서 백그라운드로 이동할 때 UIApplication.beginBackgroundTask를 호출하여 중요한 작업을 완료하기 위해 이동하기 전 추가적인 런타임을 얻을 수 있습니다.

그리고 작업을 마치면 endBackgroundTask를 호출합니다.

만약 endBackgroundTask를 호출하지 않는다면, 시스템은 이러한 timeout 된 앱을 종료하게 됩니다. 그리고 이 종료는 앱이 일시 중지된 지 30초 후에만 발생합니다. 이러한 종료는 상태 복원을 하지 않는다면, 유저에게 좌절감(?)을 줄 수 있습니다.

각각의 태스크를 30초짜리 퓨즈가 달린 다이너마이트의 막대기로 생각해 본다면, 앱이 백그라운드에 들어간 시점은 퓨즈가 켜지는 것이라고 생각할 수 있습니다. 그리고 30초 이내에 모든 작업이 완료된다면, 프로그램은 종료되지 않고 정상적으로 일시 중단됩니다.

그리고 이러한 종료가 발생한다면, 충돌 로그가 보이진 않지만, MXBackgroundExitData를 통해 해당 종료에 대한 빈도수에 대해 통계를 볼 수 있습니다.

좋은 소식은 이러한 것들은 백그라운드 작업을 신중하게 처리함으로써 예방할 수 있다는 것입니다.

예방하는 방법은 UIKit의 API로 새롭게 명명된 것을 사용하는 것입니다.

이 API는 앱에서 종료되지 않는 많은 백그라운드 작업 중 특정 작업을 분리할 수 있도록 해주기 때문에 유용합니다. 이러한 종료가 디버거에서는 재현이 되지 않지만, iOS 13.4 이후부터 작업이 매우 지연되었을 때 콘솔 메시지가 출력되도록 하였습니다.

이러한 메시지는 앱이 포그라운드 상태에 있더라도 발생할 수 있습니다. 따라서 앱을 디버깅하는 동안 이러한 메시지가 나타난다면 call 들을 확인하여 beginBackgroundTask와 매칭 되도록 endBackgroundTask를 호출하고 있는지 확인해야 합니다.

또 다른 도움이 될 수 있는 것은 expiration handler입니다. 이 핸들러를 제공할 수 있도록 모든 호출들이 beginBackgroundTask로 시작하는 것이 좋습니다. 그리고 핸들러 내부에 endBackgroundTask를 호출하는 것이 안전합니다. 여기서 주의할 점은 핸들러는 몇 초 정도로 밖에 시간이 없기 때문에 비용이 많이 드는 작업을 시작하지 않도록 하여야 합니다.

expiration handler를 앱의 종료에 대한 안전망으로 생각하면 됩니다. 그리고 이 장소에서만 endBackgroundTask를 호출하여야 하는 것은 아니지만, 작업이 종료된 경우 endBackgroundTask를 호출하여야 합니다. 이를 통해 장치가 더 빠르게 절전 모드로 전환되고 배터리 수명을 유지할 수 있습니다.

MXBackgroundExitData를 통해 확인하여 앱에서는 종료가 발생하고 있지만, 디버거에서 문제를 재현할 수 없는 경우에는 호출되는 expiration handler 확인하기 위해 원격적으로 측정할 수 있는 장치를 추가하는 것이 유용할 수 있습니다. 이를 위해 먼저 로그 핸들러를 만들어 봅시다.

그런 다음 expiration handler에 진입(Entered)했음을 나타내는 이벤트 표지판을 놓습니다.

그리고 필요한 정리 작업들을 수행시켜 앱을 안전하게 일시 중단합니다.

마지막으로, 핸들러를 나올(Exited) 때도 표지판을 놓습니다.

이러한 표지판이 나타나는 빈도를 확인하려면 MXMetricsPayload 내부에서 표지판의 개수를 확인할 수 있습니다. 그리고 앱이 expiration handler를 통과하였는지 여부를 나타낼 수도 있습니다. 

위 예에서는 놓인 표지판의 count 값을 확인했을 때 "Entered" 표지판의 수가 "Exited" 표지판의 수보다 더 많다는 점을 생각해보면 위에서 DatabaseExpirationHandler가 중단이 되거나 어딘가에서 막혀있다는 것을 알 수 있습니다.

Check backgroundTimeRemaining

또한 시작하려 하는 태스크에 있어서 각별히 신경 써야 하는 점은 애플리케이션이 이미 백그라운드에 있을 때, 5초 이내 남은 시점에서 작업을 시작하면 expiration handler가 호출되지 않기 때문에 이를 주의해야 합니다. 따라서 5초 미만을 하한으로 하고 작업이 완료되는 데 걸리 는 시간을 체크할 수 있습니다.

그리고 이것이 백그라운드 시간이 얼마나 남았는지 비교해보고 충분히 남아 있다면 beginBackgroundTask를 호출하는 것이 안전합니다. 만약 시간이 충분하지 않는다면, 백그라운드 처리 작업(장치 충전 시)으로 지정할 수 있습니다.

다음으로 주의해야 할 사항은 UIBackgroundTaskIdentifiers를 저장하는 방법 입니다. 만약 인스턴스 변수에 이를 저장할 경우 문제가 발생하기 쉽습니다.

예를들어 사용자가 beginDataExport 버튼을 눌러 백그라운드 작업을 시작하게 했다면, 몇 초 후 데이터 내보내기가 완료 되고 완료 핸들러가  나타납니다. 만약 사용자가 이 버튼을 여러번 누른다면 여러 백그라운드 작업이 발생할 수 있습니다. 

이럴경우 인스턴스 변수는 한번에 하나의 변수만 저장할 수 있으므로 가장 최근의 태스크에 대한 식별자만을 제외하고 나머지는 모두 유실됩니다. 그러나 다행이도 이러한 버그는 피하기 쉽습니다. 

가장 쉬운 해결책은 인스턴스 변수 대신 로컬 변수를 사용하여 UIBackgroundTaskIdentifier를 저장하는 것 입니다.

스위프트 에서 이러한 로컬 변수는 closure 의 형태로 capture 되므로 completion block과 expiration block에서 엑세스할 수 있습니다. 이러한 방식은 beginDataExport를 호출할 때마다 별도의 메모리 공간에서 식별자를 추적하여 유실을 방지할 수 있습니다.

이러한 방식으로 앱 내에서 beginBackgroundTask와 endBackgroundTask의 사용을 주의깊게 체크하면 Background task timeout에 의한 종료 위험을 제거할 수 있습니다.

여기까지 [WWDC-2020] Why is my app getting killed? 새션을 정리해보았습니다.

감사합니다.