"원규님, 고객분이 앱이 켜지질 안는다는데요?" 😇
신규 기능은 추가하지 않고, 다음 배포를 위해 간단한 변경점만 추가한 버전에서 갑자기 의문의 VoC 가 들어오기 시작합니다...
내용은, "오늘 아침부터 갑자기 앱이 켜지지 않는다." 였습니다. 아마 업데이트 이후 무언가 잘못된 모양인 거 같아요.
하지만, 아무리 테스트 버전으로 빌드해서 돌려봐도 현상이 재현되지 않습니다...
VoC 의 내용또한 불분명합니다. 정확한 재현 경로를 파악하기 어려움을 느꼈어요.
사용하고 있는 유저 세션 기록 서비스인 Firebase 와 DataDog 의 최근 2일간 모든 오류 보고를 찾아봤지만, 충돌 보고서나, 의심되는 오류가 감지된 퍼널 자체가 기록되지 않고 있었습니다..
도대체 알 수 없는 오류에 대한 티켓을 재현 경로 정보 부족으로 해결 불가로 기록하고 종료하려던 그 순간,
동일한 문의가 한건 더 들어오게 됩니다.
DataDog 과 서버 로그를 통해 분석한 결과, 문제를 제기한 모든 유저의 공통점은 IOS 를 사용중이라는 점을 확인 할 수 있었습니다.
Mac에서 IOS 실기기에 붙어 stdout 를 보며 앱의 초기화 과정을 분석하니, Flutter render engine 관련 warning 을 하나 찾을 수 있었습니다.
혹시, IOS 에서 skia 엔진이 impalla 로 교체되며 충돌이 일어나는건가 싶어 lnfo.plist 에 FLTEnableImpeller 값을 false 로 변경해 빌드해 보았지만 여전히 테스트 환경에서는 어떠한 문제도 없이 잘 돌아가는 앱을 볼 수 있었습니다.
별 수 없이 리뷰를 요청하고 이 변경점으로 문제가 해결되길 기도했습니다.
렌더 엔진 충돌건은 원인이 아닌것으로 판별, 그럼 다시 접속 로그를 보며 제보된 유저들의 공통점을 확인 할 수 있는지 자료를 조사해봅니다..
접속 위치, 접속 시간, 문제가 발생 한 기종명들은 모두 일치하지 않았습니다.
머리를 싸매고 조금 더 파악한 결과, 문제를 보고한 모든 고객분들은 마지막 접속 기록에서 IOS의 버전이 최신 IOS 17 버전이 아닌 IOS16 이였던 점을 알 수 있었습니다!
마지막 접속기록이 채 40시간이 지나지 않았으니, 지금도 동일한 os 버전을 유지하고 있을것이라는 가정을 세운 후, 회사의 모든 동료분들 중, IOS 업데이트를 아직 하지 않은 분이 있는지 찾기 시작했습니다.
다행이 타 개발팀에서 사용중인 테스트 시료가 아직 업데이트를 하지 않아 IOS 15 버전을 유지중인것을 확인,
이 시료를 가져와 프로덕트의 최신 버전을 설치한 수 실행시켜보니, 다행이 실행하자 마자 바로 프로그램의 충돌이 발생 하는것을 볼 수 있었습니다!
다행이다... 이제 재현경로와 크래시 로그를 확보했으니, 문제 해결만 하면 됩니다!
IOS 의 크래시 로그는 Android 와 Web과는 다른게 보다 밑단의 로우(raw) 데이터를 제공해줍니다.
저도 최근에 arm 아키택처를 공부하지 않았다면. 무슨 일이 일어나고 있는지 알 수 없었을건데요,
제공된 크래시 로그의 일부분입니다.
로그를 천천히 분석해보면,
첫번째 문단에서는 실행 환경, 실행된 프로그램 정보등을 보여주고 있습니다.
첫번째 문단
Incident Identifier: 52D4DA86-E36E-4A1B-9D34-F40F34B78462 Hardware Model: iPhone13,4 Process: Runner [11335] Path: /private/var/containers/Bundle/Application/6BA54962-48F4-4155-B6B7-752A16B20B08/Runner.app/Runner Identifier: ------------------- Version: 1.0.44 (115) AppStoreTools: 15E204 AppVariant: 1:iPhone13,4:15 Beta: YES Code Type: ARM-64 (Native) Role: Foreground Parent Process: launchd [1] Coalition: -------------------
iPhone 13,4 는 우리가 부르는 아이폰 12 프로맥스의 model identifier 모델 식별자를 의미합니다.
사용하고 있는 cpu 코어의 타입이 ARM-64 아키택처를 사용중이라는것을 확인 할 수 있고,
저희 프로그램은 launchd 라는 PID(Process ID) 1 을 가지는 프로세스에서 호출되었음을 확인 할 수 있습니다.
luachD 는 간단히 설명하면, IOS, MacOS 의 초기화, 운영체제 서비스를 관리하는 데몬입니다.
https://en.wikipedia.org/wiki/Launchd
어플리케이션의 호출 또한 해당 프로세스가 관리중이라는것을 확인 할 수 있겠네요.
또한 어플리케이션이 충돌하기 직전 Foreground 상태였음을 확인 할 수 있습니다.
두번째 문단
Date/Time: 2024-03-28 15:16:06.6836 +0900 Launch Time: 2024-03-28 15:16:06.3779 +0900 OS Version: iPhone OS 15.4.1 (19E258) Release Type: User Baseband Version: 2.53.01 Report Version: 104
두번째 문단에서는 충돌 당시의 디바이스 로컬 시간과 프로그램이 시작한 시간, OS 버전을 볼수 있습니다.
거의 0.5초도 걸리지 못하고 프로그램이 충돌했음을 알 수 있네요.
세번째 문단
Exception Type: EXC_BAD_ACCESS (SIGSEGV) Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000000 Exception Codes: 0x0000000000000001, 0x0000000000000000 VM Region Info: 0 is not in any region. Bytes before following region: 4375396352 REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL UNUSED SPACE AT START ---> __TEXT 104cb4000-104cb8000 [ 16K] r-x/r-x SM=COW ...er.app/Runner Exception Note: EXC_CORPSE_NOTIFY Termination Reason: SIGNAL 11 Segmentation fault: 11 Terminating Process: exc handler [11335] Triggered by Thread: 0
세번째 문단부터는 직접적인 충돌 원인을 볼 수 있습니다.
EXC_BAD_ACCESS (SIGSEGV) 는,
OS 에서 지정해주는 메모리 세그멘테이션을 위배(SIGSEGV = Signal Segmentation Violation)하는 잘못된 주소의 접근(EXC_BAD_ACCESS)을 시도했음을 의미합니다.
OS 가 할당해주는 메모리 구조에서, 해당 프로그램에게 할당된 메모리 구간을 넘어서는 주소를 참조, 쓰기 요청을 할 때 발생하는 예외입니다.
로그를 더 자세히 읽어봅시다!
두번째 줄에서는 Exception 의 Subtype, 즉 더 상세한 오류의 원인을 설명합니다.
KERN_INVALID_ADDRESS 는 참조중인 가상 주소가 페이지 테이블에 없거나, 접근 권한이 없음을 이야기합니다.
뒤에는 실제로 참조하려 시도했던 주소를 보여주네요 0x0000000000000000
어 근데 포인터의 값이 좀 이상합니다? 저렇게 주소가 다 0일 경우가 있을까요?
맞습니다. 0x0000000000000000 은 흔히 널 포인터 Null Pointer 로 사용되는 주소입니다.
이 시점에 문제의 원인을 절반 이상 추측 할 수 있었습니다.
앱의 초기화 과정에서 IOS 에서 제공하는 어떤 함수가 필요한데, 이 함수는 포인터로서 먼저 선언되고, 후에 실제 주소를 역참조로서 기록한뒤 사용하는 측에서는 포인터를 참조해 사용할 것으로 생각됩니다.
하지만, 포인터만 생성되고 역참조를 통한 초기화가 어떤 이유에서인지 정상적으로 진행되지 못해, 초기화 되지 못한 값은 NullPtr의 값에 적혀있는 코드를 CPU 가 실행하려고 해서 난 오류입니다.
이런 과정이 일어나려면 Dart 나 Java 같은 메모리를 VM 이 메니지먼트해주는 환경에서는 발생하지 않을꺼고, 그 밑단의 UnManagement 환경에서 발생할 것이라 추측할 수 있습니다.
swift 혹은 그 밑단의 영역에서 발생한 오류겠군요.
마지막으로, 이러한 오류는 0번 스래드, 즉 메인 스래드에서 발생했음을 확인 할 수 있습니다.
이제 충돌 보고서의 밑을 좀더 읽어보겠습니다.
각 스래드의 스택 트레이스가 적혀있는데요, 충돌이 발생한 0번 스래드 이외의 정보들은 큰 의미가 없기 때문에, 0번 스래드만 읽어보겠습니다.
바로 문제의 원인을 파악 할 수 있겠죠?
DatadogCore 의 DatadogContextProvider.__allocation_init() 이라는 함수를 실행하다가 참조한 함수의 포인터가 nullPtr 이였던 오류였습니다.
크레시 로그의 마지막에는 충돌이 발생한 시점, CPU 레지스터의 저장된 상태를 볼 수 있는데요,
아이폰의 CPU 가 충돌 직전 어떤 일을 실행하려던 시점인지를 확인 할 수 있습니다.
기본적으로 x0번 레지스터부터, x28번 레지스터는 어셈블리 명령어의 입력, 출력으로 사용됩니다.
여기에 저장되어있는 값들의 실제 의미는, 디버거를 붙여서 실제로 수행하는것을 보기 전까진 알기 힘들기 때문에, 우리는 실행 맥락을 담고있는 레지스터들에 집중해보겠습니다.
lr 레지스터는 Link Resister 를 의미합니다.
이 레지스터는 함수를 호출 할 때, 반환 주소를 저장하는데 사용합니다.
보다 자세히 설명하자면, lr 은 함수의 동작이 끝난 뒤 돌아갈 주소를 저장합니다.
반대로 말하면, 지금 함수를 호출한 코드의 영역임을 알 수 있겠네요.
저 위치에 작성된 코드가 지금 문제의 원인임을 알 수 있습니다.
마지막으로 보아야하는 레지스터는 pc 입니다.
pc 는 ProgramCounter 를 의미하고, 현재 실행중인 명령어의 주소를 의미합니다.
0x00...00 인 nullPtr 값을 가지고 있죠? 직전 위치에서 잘못된 위치로 실행 맥락이 이동된 상황입니다.
일단은 이 시점에서 Datadog SDK 가 문제의 원인임을 확인 할 수 있으므로, 급한대로 해당 SDK 를 비활성화 한 배포버전을 급하게 출시하며 문제의 표면적인 현상을 해결 할 수 있었습니다.
하지만, 정말 어디서 충돌이 발생했는지 조금 더 찾아볼까요?
실제 Xcode 의 충돌 당시 스택 트레이스입니다.
동일하게 nullPtr exception 이 발생하는것을 알 수 있죠?
이러한 오류가 발생하게 된 맥락을 9번째 호출지점인, DatadogSdkPlugin 의 초기화 코드부터 쫒아가보겠습니다.
DatadogSDKPlugin 의 handle 이란 메서드 안에서 초기화 코드가 실행되고 있군요,
그렇다면, initialize 코드를 좀더 보겠습니다.
Datadog core 의 초기화 과정에서,
타겟 환경과, 현재 OS 등을 확인 한 뒤,
DataDogContextProvider 라는걸 생성하다가 실패했군요.
넘겨받는 인자들을 보아하니, 현재 프로세스가 실행되는 환경관련 정보를 제공하는 클래스 인것 같습니다.
결국 오류가 난 부분은 NWPathMonitor 의 .init() 메서드입니다.
https://developer.apple.com/documentation/network/nwpathmonitor
문서를 보아하니, 네트워크 변경사항을 모니터링하고 대응하는 관찰자 클래스이군요.
Xcode 15 버전부터 최소 배포대상을 IOS 12 이상으로 올렸기 때문에 발생하는 오류인 것 같습니다.
(말도 안하고 바꿔버리는 애플....)
https://developer.apple.com/forums/thread/737672
너무 막막한 VoC 부터, 정보 수집, 원인 추정 후 해결까지 빠른시간 안에 진행하는 경험은
제 문제 해결 능력을 향상시키는데 큰 도움이 되었다고 느꼈습니다.
물론 상당한 압박을 느꼈지만요...
또한, 이러한 문제를 해결하기 위해선 프레임워크 밑단의 로우 레벨 지식들을 미리 미리 공부해 두었던게 큰 도움이 되었다는 사실 또한 다시 한번 느끼게 되었습니다.
저의 경험을 공유하며 이 글을 읽으시는 여러분들도 프레임워크를 어떻게 사용할 까에 집중할 뿐만이 아니라, 보다 더 밑단의 지식을 공부하실 필요성을 느낄 수 있었으면 좋겠습니다.
Xcode15에서 minimum deployment target을 주어진 옵션에서 선택하려고 하면 12.0이 가장 낮지만 직접 입력하면 11.0 심지어 9.0도 가능하지 않나요?
플러터라 약간 다른가요?