"앱이 죽었다"는 한 문장이지만 그 뒤에는 서로 완전히 다른 메커니즘 다섯 개가 있다. 어떤 것은 우리 코드가 잘못된 메모리를 건드려서, 어떤 것은 OS가 자원 한도를 이유로, 어떤 것은 사용자가 직접 죽인 것이다. 대응 방법이 각각 다르므로 판독이 먼저다. 이 챕터는 크래시 리포트를 열었을 때 어느 갈래인지 3초 안에 판정하고, 그 판정이 왜 그렇게 나오는지를 커널 수준에서 설명하는 것을 목표로 한다.
Q1. 앱이 사라지는 방식은 몇 가지이며 어떻게 구분하는가?
크게 다섯 갈래다. (1) 우리 코드의 오류 — 잘못된 메모리 접근·언어 예외·강제 언래핑. (2) OS의 자원 회수 — Jetsam 메모리 한도, 발열, 디스크 부족. (3) OS의 계약 위반 제재 — 워치독, 백그라운드 태스크 시간 초과, 서스펜드 중 락 미해제. (4) 사용자 강제 종료 — 앱 스위처에서 스와이프. (5) 정상 종료 — 시스템의 정리 목적 종료. 판정 순서는 항상 같다. Exception Type을 먼저 보고, 그것이 EXC_CRASH (SIGKILL)이면 반드시 Termination Reason을 본다. (1)은 스택트레이스가 원인을 가리키고, (3) 정책 종료에서는 스택이 원인이 아니다. (2) 자원 회수는 갈린다 — 메모리 한도 초과에서는 스택이 무의미하지만 CPU·디스크쓰기·웨이크업 초과에서는 오히려 유용하다(Q5).
CS 원리
구분의 뿌리는 누가 종료를 결정했는가다. 유닉스 계열 OS에서 프로세스가 사라지는 경로는 근본적으로 둘뿐이다.
- 프로세스 자신이 정의되지 않은 동작을 저질러 하드웨어·커널이 트랩을 발생시킨 경우 — 잘못된 주소 접근(
SIGSEGV/SIGBUS), 잘못된 명령어(SIGILL),abort()호출(SIGABRT). 여기서는 문제를 일으킨 명령어의 위치가 곧 원인이므로 스택트레이스가 결정적 증거다. - 외부(커널·다른 프로세스)가 정책적으로 죽인 경우 —
SIGKILL. 이 시그널은 핸들러를 등록할 수 없고 무시할 수도 없다. 프로세스는 자신이 죽는다는 사실조차 알지 못하고 즉시 사라진다.
이 이분법에서 실무적으로 가장 중요한 결론이 나온다. SIGKILL로 죽은 앱은 스스로 크래시 리포트를 남길 수 없다. 핸들러가 돌 기회가 없기 때문이다. 그래서 (2)·(3) 계열 종료는 앱이 아니라 OS가 대신 기록하며, 그 기록의 핵심 필드가 Termination Reason이다 — "내가 왜 이 프로세스를 죽였는지"에 대한 커널의 진술서인 셈이다.
또 하나 짚을 것은 스택트레이스의 의미가 갈래마다 다르다는 점이다. (1)에서 스택은 인과의 종점이다. (3) 정책 종료와 (2) 중 메모리 한도 초과에서 스택은 그저 죽는 순간 무엇을 하고 있었는지일 뿐이며, 원인은 이미 한참 전에 만들어져 있다. 메모리 한도를 넘긴 이유는 죽기 직전 함수가 아니라 30초 전에 디코딩한 이미지에 있다.
다만 (2) 안에서도 갈린다. CPU·디스크쓰기·웨이크업 한도 초과는 그 순간 실제로 자원을 태우고 있던 코드가 찍히므로 스택이 상당히 유용하다(Q5). "자원 종료면 스택이 무의미하다"고 뭉뚱그리면 정작 고칠 수 있는 단서를 버리게 된다. 이 차이를 모르면 크래시 리포트를 반복적으로 오독한다.
EXC_CRASH (SIGKILL)이 보이면 스택 읽기를 멈추고 Termination Reason으로 내려가야 한다. 이 한 단계가 판독의 절반이다.iOS에서는
Apple은 Exception Type별 문서를 개별 페이지로 제공한다. 실무에서 마주치는 빈도 순으로 정리하면 이렇다.
| Exception Type | 갈래 | 대표 원인 | 스택이 원인? |
|---|---|---|---|
EXC_BAD_ACCESS (SIGSEGV) | 우리 코드 | 해제된 객체 접근, 잘못된 포인터 | 예 |
EXC_BAD_ACCESS (SIGBUS) | 우리 코드 | 정렬 위반, 매핑되지 않은 파일 영역 접근 | 예 |
EXC_CRASH (SIGABRT) | 우리 코드 | 잡히지 않은 ObjC/C++ 예외, abort() | 예 |
EXC_BREAKPOINT (SIGTRAP) | 우리 코드 | Swift 런타임 트랩 — 강제 언래핑, 배열 범위 초과, 정수 오버플로 | 예 |
EXC_GUARD | 우리 코드 | 보호된 파일 디스크립터 조작 | 예 |
EXC_RESOURCE | OS 자원 | CPU·메모리·디스크쓰기·웨이크업 한도 초과 | 서브타입에 따라 다름 — CPU·IO·WAKEUPS는 유용, MEMORY는 아님 (Q5) |
EXC_CRASH (SIGKILL) | OS 정책 | Termination Reason을 봐야 안다 | 아니오 |
Swift 개발자에게 특히 중요한 것이 EXC_BREAKPOINT (SIGTRAP)이다. 이름이 "브레이크포인트"라 디버거 문제로 오해하기 쉬운데, 실제로는 Swift 런타임이 안전성 위반을 감지하고 의도적으로 트랩을 실행한 것이다. nil 강제 언래핑, 배열 인덱스 초과, Int 오버플로, fatalError·precondition 위반이 전부 여기로 온다. P0 05장에서 본 Optional의 안전성이 런타임에서 이렇게 강제된다.
또한 EXC_GUARD는 진단이 어려운 버그를 조기에 드러내려고 Apple이 일부러 만든 장치다. 예컨대 Core Data가 쓰는 SQLite 파일의 파일 디스크립터를 앱이 실수로 close()하면, 원래는 한참 뒤에 엉뚱한 곳에서 알 수 없는 크래시가 났다. 파일 디스크립터를 "guarded"로 표시해두면 잘못 건드리는 그 순간 터지므로 원인 지점이 명확해진다.
실험 · 도구
각 종료 유형을 의도적으로 재현해보면 리포트가 어떻게 달라지는지 몸으로 익힐 수 있다. 실기기에서, 디버거를 떼고 실행해야 한다 — 디버거가 붙어 있으면 시그널을 디버거가 가로채서 리포트가 생성되지 않는다.
import Foundation
enum CrashKind: String, CaseIterable {
case forceUnwrap, arrayBounds, objcException, badAccess, abortCall, memoryHog
func trigger() {
switch self {
case .forceUnwrap:
// EXC_BREAKPOINT (SIGTRAP) — Swift 런타임 트랩
let s: String? = nil
print(s!)
case .arrayBounds:
// EXC_BREAKPOINT (SIGTRAP) — 같은 계열
let a = [1, 2, 3]
print(a[99])
case .objcException:
// EXC_CRASH (SIGABRT) — 잡히지 않은 ObjC 예외 → abort()
NSException(name: .genericException, reason: "실험용", userInfo: nil).raise()
case .badAccess:
// EXC_BAD_ACCESS (SIGSEGV) — 명백히 잘못된 주소
let p = UnsafeMutablePointer<Int>(bitPattern: 0x1)!
p.pointee = 42
case .abortCall:
// EXC_CRASH (SIGABRT) — 직접 호출
abort()
case .memoryHog:
// EXC_RESOURCE (MEMORY) 후 Jetsam SIGKILL — 스택은 원인이 아니다
var blocks: [Data] = []
while true { blocks.append(Data(count: 64 * 1024 * 1024)) }
}
}
}
재현 후 리포트를 확인하는 경로는 셋이다.
# 1) 기기에서 직접 — 설정 › 개인정보 보호 및 보안 › 분석 및 향상 › 분석 데이터
# 파일명이 앱 이름으로 시작하는 .ips 파일이 크래시 리포트다.
# 2) Xcode — Window › Devices and Simulators › View Device Logs
# 자동 심볼화되어 함수명까지 보인다.
# 3) 커맨드라인 — 기기 로그에서 종료 기록만 추린다
# ⚠️ log show 에는 --device 가 없다. 기기 로그는 collect 로 받아 아카이브를 show 한다.
log collect --device --last 1h --output device.logarchive
log show device.logarchive --style compact \
--predicate 'eventMessage CONTAINS "termination" OR eventMessage CONTAINS "jetsam"'
# 실시간으로 보려면 stream 에 --device 를 준다
log stream --device --style compact \
--predicate 'process == "runningboardd" OR process == "SpringBoard"'
memoryHog 케이스가 특히 교육적이다. 리포트를 열면 스택 최상단이 Data.init(count:)인데, 이건 원인이 아니라 마지막 한 방울일 뿐이다. 진짜 원인은 "왜 이만큼 쌓였는가"이고 그 답은 스택 어디에도 없다.
프로젝트 적용
팀에서 크래시를 분류할 때 쓸 판정 순서를 문서로 못 박아두면 오독이 크게 준다.
Exception Type이EXC_CRASH (SIGKILL)인가? → 예라면 스택을 읽지 말고Termination Reason으로. (Q4)EXC_RESOURCE인가? →Exception Note에NON-FATAL CONDITION이 있는지 확인. (Q5)EXC_BREAKPOINT (SIGTRAP)인가? → Swift 런타임 트랩. 강제 언래핑·인덱스·오버플로를 의심하고 인앱 프레임 최상단을 본다.EXC_CRASH (SIGABRT)인가? →Last Exception Backtrace섹션이 있으면 그쪽이 진짜 원인 스택이다. 없으면 언어 예외가 아닌abort()다.- 그 외 → 크래시 스레드의 스택을 위에서부터 읽되 첫 인앱 프레임을 찾는다.
대시보드 관점에서는 이 분류를 태그로 만들어 두는 것이 유용하다(18장 Q5). termination_kind=code_error | resource | policy | user 정도의 축이 있으면, "우리 크래시율의 절반이 사실은 메모리 한도"라는 사실이 한눈에 보인다. 대응 팀도 갈린다 — 코드 오류는 기능 담당자가, 자원·정책 종료는 성능 담당자가 본다.
"크래시 리포트가 있으면 스택트레이스를 보면 된다"는 절반만 맞다. SIGKILL 계열에서는 스택이 원인과 무관하고, 그걸 모르고 스택 최상단 함수를 고치면 아무것도 달라지지 않는다. "사용자가 앱을 스와이프해서 종료한 것도 크래시로 집계된다"도 틀렸다 — 사용자 강제 종료는 정상 종료 경로로 처리되어 일반적으로 크래시로 잡히지 않는다(오히려 이 때문에 워치독 종료를 사용자 종료로 오인하는 반대 방향 문제가 생긴다). 또 "EXC_BREAKPOINT는 디버거 때문에 생긴다"도 흔한 오해다 — 릴리스 빌드의 실사용자 기기에서도 발생하며, 그건 Swift 런타임이 안전성 위반에 대해 의도적으로 실행한 트랩이다.
사람이 회사에서 사라지는 경우를 생각해보자.
첫째, 본인이 사고를 쳤다. 넘어져서 다쳤다거나, 규칙을 어겨서 그 자리에서 문제가 생겼다. 이 경우엔 사고 난 자리를 보면 원인을 알 수 있다. 이게 SIGSEGV나 SIGTRAP 같은 크래시다.
둘째, 회사가 내보냈다. "책상을 너무 많이 차지해서"(메모리 초과), "회의에서 정해진 시간 안에 대답 못 해서"(워치독), "퇴근하면서 창고 열쇠를 안 돌려줘서"(락 미해제). 이때 내보내지는 순간에 그 사람이 뭘 하고 있었는지는 아무 의미가 없다. 복도를 걷고 있었을 수도 있다. 원인은 훨씬 전에 만들어졌다.
그런데 크래시 리포트는 사라지는 순간의 사진을 찍는다. 첫째 경우엔 그 사진이 곧 증거다. 둘째 경우엔 사진에 복도가 찍혀 있다고 "복도가 문제였다"고 하면 완전히 틀린 결론이다.
그래서 사진을 보기 전에 퇴사 사유서(Termination Reason)를 먼저 읽어야 한다. 회사가 내보낸 경우엔 거기에 이유가 적혀 있다.
꼬리 질문
사용자가 앱 스위처에서 스와이프로 종료한 것과 워치독 종료를 구분하기 어려운 이유는?
둘 다 프로세스가 자기 의사와 무관하게 갑자기 사라진다는 점에서 같고, 앱 입장에서는 어느 쪽이든 실행 중이던 상태가 그대로 증발한다. 앱이 남긴 흔적만 놓고 보면 "지난번에 정상 종료 기록 없이 끊겼다"라는 사실뿐이라 구분 재료가 부족하다.
OS 수준에서는 구분이 된다. 사용자 강제 종료는 시스템이 정상 종료 경로로 처리하고 워치독 종료는 0x8badf00d가 붙은 리포트를 만든다. 문제는 앱이 이 정보를 자기 다음 실행 때 직접 읽을 수 없다는 것이다. 그래서 서드파티 SDK는 "직전 실행이 비정상이었다"를 휴리스틱으로 추정하게 되고, 그 휴리스틱에 "사용자가 수동 종료하지 않았음" 같은 조건을 여러 개 걸어 오탐을 줄인다.
실무적으로 신뢰도 높은 구분 수단은 MetricKit이다. MXAppExitMetric은 종료 유형을 포그라운드/백그라운드별로 분류해 OS가 직접 집계한 값을 주므로 추정이 아니다. 다만 하루 한 번 집계되어 오므로 개별 사건 추적에는 못 쓴다 — 이 상보성이 Q7의 주제다.
Swift의 fatalError·precondition·assert는 각각 어떤 Exception Type으로 나타나는가?
셋 다 조건 위반 시 EXC_BREAKPOINT (SIGTRAP)으로 나타난다. Swift 런타임이 프로그램을 계속 진행시키는 것이 위험하다고 판단할 때 실행하는 트랩 명령어가 같기 때문이다.
차이는 어떤 최적화 레벨에서 살아남는가다. assert는 디버그 빌드에서만 검사되고 릴리스(-O)에서는 조건 자체가 제거된다. precondition은 릴리스에서도 살아남고 -Ounchecked에서만 제거된다. fatalError는 조건이 없는 무조건 중단이라 어떤 최적화에서도 제거되지 않는다.
실무적 함의는 이렇다 — 릴리스 크래시 리포트에서 SIGTRAP을 보면 assert는 용의선상에서 빠지고, precondition·fatalError·강제 언래핑·배열 범위 초과·정수 오버플로가 남는다. 이 중 어느 것인지는 스택의 인앱 프레임과, 리포트에 함께 실리는 종료 메시지(Fatal error: Unexpectedly found nil… 같은)로 좁힌다.
참고로 -Ounchecked는 이 검사들을 없애 성능을 얻지만, 위반 시 크래시 대신 정의되지 않은 동작이 되므로 크래시가 사라지는 게 아니라 더 나쁜 형태로 바뀐다.
assert는 연습 때만 달아두고 실전에선 뗀다. precondition은 실전에도 달려 있다. fatalError는 아예 뗄 수 없게 용접돼 있다.EXC_CRASH (SIGABRT) 리포트에서 Last Exception Backtrace가 따로 있는 이유는?
Objective-C 예외는 던져진 시점과 프로세스가 죽는 시점이 다르기 때문이다. 예외가 던져지면 런타임이 핸들러를 찾아 스택을 거슬러 올라가고, 아무도 잡지 않으면 최상위 핸들러가 abort()를 호출한다. 그런데 abort()가 불린 시점의 스택은 이미 예외 처리 기계장치의 프레임(__cxa_throw, abort 등)으로 채워져 있어서, 정작 예외를 던진 우리 코드가 어디였는지는 그 스택에 없다.
그래서 시스템은 예외가 던져진 순간의 스택을 따로 캡처해 Last Exception Backtrace로 남긴다. 이게 진짜 원인 스택이다. 크래시 스레드 스택은 "어떻게 죽었나"를, Last Exception Backtrace는 "왜 죽었나"를 말해준다.
실무 규칙은 단순하다 — SIGABRT 리포트를 열면 Last Exception Backtrace부터 본다. 이 섹션이 없으면 언어 예외가 아니라 직접적인 abort()(또는 다른 프로세스가 보낸 SIGABRT)라는 뜻이므로 크래시 스레드 스택으로 돌아간다.
Swift 전용 코드에서는 이 섹션이 잘 안 보이는데, Swift 에러는 예외 메커니즘을 쓰지 않기 때문이다(P0 05장). Swift 앱에서 SIGABRT가 났다면 대개 브리징된 Foundation·UIKit 내부에서 ObjC 예외가 났거나 C++ 예외가 원인이다.
Q2. 크래시 리포터는 왜 크래시 순간에 리포트를 전송하지 않는가?
크래시 순간의 프로세스는 거의 아무것도 안전하게 할 수 없는 상태이기 때문이다. 시그널 핸들러는 프로그램 흐름을 임의 지점에서 가로채므로, 그 순간 힙 락이 잡혀 있을 수 있고 힙 자체가 손상됐을 수 있다. 여기서 malloc을 부르면 이미 잡힌 락을 다시 잡으려다 교착하거나 즉사한다. 네트워크 전송은 메모리 할당·락·스레드·Objective-C 런타임을 전부 요구하므로 전부 금지 목록이다. 그래서 크래시 리포터는 핸들러 안에서는 write() 같은 async-signal-safe 저수준 호출로 디스크에 기록만 하고, 전송은 다음 앱 실행 때 정상 상태에서 한다.
CS 원리
시그널 핸들러의 제약은 재진입성(reentrancy) 문제다. 시그널은 프로세스의 실행 흐름을 임의의 명령어 경계에서 가로챈다. 즉 핸들러가 시작될 때, 중단된 코드가 어떤 상태에 있었는지 알 수 없다.
구체적으로 무엇이 위험한지 보자. malloc은 내부에 전역 자료구조와 그것을 보호하는 락이 있다.
- 스레드가
malloc을 호출해 힙 락을 획득했다. - 락을 쥔 채로 자유 리스트를 수정하던 중 바로 그 순간
SIGSEGV가 발생했다. - 같은 스레드에서 시그널 핸들러가 실행된다.
- 핸들러가
malloc을 호출한다 → 이미 자기가 쥐고 있는 락을 다시 잡으려 한다.
결과는 교착이거나, 재귀 락 감지에 의한 즉시 중단이다. 자유 리스트가 반쯤 수정된 상태였다면 힙 자체가 일관되지 않아 어떤 할당도 안전하지 않다.
그래서 POSIX는 async-signal-safe 함수 목록을 정의한다. 이 목록의 함수들은 "언제 어디서 중단당해도, 그리고 그 중단된 자기 자신 안에서 다시 호출돼도 안전하게 동작한다"는 성질을 보장한다. 목록은 놀랄 만큼 짧다 — write, read, open, close, _exit, signal 같은 저수준 시스템 호출이 대부분이다.
snprintf조차 내부에서 로케일 락을 잡아 금지다. 그래서 리포터는 16진수 변환 유틸을 직접 구현해 쓴다.실제 프로덕션 크래시 리포터의 규칙을 보면 제약의 실체가 선명하다. KSCrash가 내부 코드 리뷰용으로 명문화한 규칙은 이렇다.
| 금지 | 이유 |
|---|---|
malloc / calloc / free / new | 힙 락 재진입 — 대신 사전 할당 버퍼나 스택 사용 |
| 뮤텍스 · 세마포어 | 교착 가능 |
Objective-C 메서드 호출 · @synchronized | ObjC 런타임이 시그널 컨텍스트와 근본적으로 비호환 |
snprintf / sprintf / vsnprintf | Apple libc 구현이 내부적으로 로케일 락(FLOCKFILE·__current_locale())을 잡는다 |
| Foundation · dispatch · stdio | 위 전부에 의존 |
snprintf가 금지라는 점이 이 제약의 가혹함을 잘 보여준다. 문자열 포매팅처럼 순수해 보이는 함수조차 내부에서 락을 잡기 때문에 쓸 수 없고, 크래시 리포터는 16진수·10진수 변환 유틸을 직접 구현해서 쓴다.
iOS에서는
이 제약이 iOS 크래시 리포터의 아키텍처를 결정한다. 전형적인 구조는 이렇다.
- 앱 시작 시(사전 준비) — 리포트를 쓸 버퍼를 미리 할당하고, 기기 정보·앱 버전 같은 고정 데이터를 미리 문자열로 만들어 둔다. 핸들러 안에서는 만들 수 없으므로 미리 만든다.
- 크래시 순간(핸들러) — 스택을 언와인딩해 주소 목록을 얻고, 사전 할당 버퍼에 채워
write()로 파일에 쓴다. 심볼화도, JSON 직렬화도, 압축도 하지 않는다. - 다음 실행 시(정상 상태) — 디스크의 원시 리포트를 읽어 파싱·심볼화·직렬화하고 네트워크로 보낸다.
여기서 iOS 특유의 결과가 둘 나온다.
- 크래시 후 앱을 다시 켜지 않으면 리포트는 영원히 안 온다. 크래시가 너무 심각해서 사용자가 앱을 삭제해버리면 그 크래시는 집계되지 않는다. 즉 가장 치명적인 크래시일수록 과소 집계되는 편향이 있다.
SIGKILL에는 이 구조 전체가 무력하다. 핸들러를 등록할 수 없으므로 1단계에서 준비한 것이 아무 소용이 없다. Jetsam·워치독 종료가 별도 메커니즘으로 다뤄지는 근본 이유다.
실험 · 도구
"핸들러 안에서 malloc을 부르면 위험하다"는 말은 추상적이다. 직접 재현해보면 확실해진다.
import Darwin
// ⚠️ 학습용이다. 실제 앱에 넣지 말 것.
func installUnsafeHandler() {
signal(SIGSEGV) { sig in
// ❌ 여기서 하면 안 되는 것들 — 전부 힙/락/ObjC에 의존한다
// print("crashed") ← stdio + 락
// let s = "crash: \(sig)" ← Swift String = 힙 할당
// NSLog("crashed") ← Foundation + ObjC 런타임
// URLSession.shared.dataTask(...) ← 스레드 + 할당 + 락
// ✅ 이것만 안전하다 — write(2)는 async-signal-safe
let msg = "CRASH\n"
msg.withCString { p in
_ = write(STDERR_FILENO, p, strlen(p))
}
_exit(sig) // exit(3)이 아니라 _exit(2). exit는 atexit 핸들러를 돌린다.
}
}
더 결정적인 검증은 실제 사례를 읽는 것이다. Sentry의 iOS SDK에서 보고된 이슈 #8134는 이 함정이 실전에서 어떻게 터지는지 보여준다 — handleSignal 안에서 지연 초기화되는 _Thread_local 변수를 읽었는데, 그 지연 초기화가 내부적으로 malloc을 호출했다. 그 결과 _os_unfair_lock_recursive_abort로 프로세스가 중단됐고, 원래 크래시는 리포트에 남지 않았다. 이 실패 모드는 Q6에서 따로 다룬다.
메인 스레드 밖에서 실행되는 코드가 async-signal-safe한지 확인할 때 쓰는 도구는 이렇다.
# 핸들러가 어떤 심볼을 호출하는지 정적으로 확인 — malloc/objc/stdio 흔적을 찾는다
nm -u YourCrashHandler.o | grep -E 'malloc|free|objc_|printf|dispatch_'
# 런타임 검증: Xcode Scheme › Diagnostics › Malloc Stack Logging 을 끄고
# Thread Sanitizer 로 락 재진입을 관찰한다 (핸들러 경로를 반복 실행하는 테스트와 함께)
프로젝트 적용
대부분의 팀은 크래시 리포터를 직접 만들지 않는다. 그래도 이 원리를 알아야 하는 이유는 우리 코드가 크래시 경로에 끼어드는 경우가 있기 때문이다.
NSSetUncaughtExceptionHandler를 직접 설치하지 않는다. 여기서 로그를 남기거나 서버로 보내려는 시도가 흔한데, 이 핸들러는 SDK가 설치한 것을 덮어써서 크래시 리포트를 통째로 유실시킬 수 있다. 꼭 필요하면 기존 핸들러를 저장했다가 마지막에 호출해야 한다.- 크래시 직전에 뭔가 저장하려 하지 않는다. "죽기 전에 사용자 데이터만 저장하자"는 발상은 위험하다 — 힙이 손상된 상태에서 파일을 쓰면 멀쩡한 저장 파일까지 깨뜨릴 수 있다. 중요한 상태는 크래시 시점이 아니라 평상시에 원자적으로 저장해둬야 한다(P0 10장).
- 종료 직전 정리 로직은
applicationWillTerminate에 의존하지 않는다. 이 콜백은 정상 종료 경로에서만 불리고 크래시·SIGKILL에서는 불리지 않는다.
또 하나 실무 규칙 — 리포트가 안 올라오는 문제를 디버깅할 때 디버거를 떼야 한다. Xcode가 붙어 있으면 디버거가 시그널을 먼저 가로채므로 리포터의 핸들러가 실행되지 않는다. "내 기기에서는 크래시 리포트가 안 생긴다"의 십중팔구가 이것이다.
"크래시 리포터가 크래시 순간에 서버로 전송한다"는 틀렸다. 전송은 거의 항상 다음 실행에 일어난다. 그래서 "크래시가 났는데 대시보드에 없다"는 정상일 수 있고, 사용자가 앱을 다시 열어야 올라온다. "핸들러 안에서 NSLog 정도는 괜찮다"도 틀렸다 — Foundation과 ObjC 런타임에 의존하므로 금지 목록의 핵심이다. 또 "Swift로 짜면 안전하다"도 오해다. Swift의 String 보간, 배열 생성, 클로저 캡처가 전부 힙 할당을 유발하므로 오히려 C보다 실수하기 쉽다. 마지막으로 "async-signal-safe 목록에 없어도 실제로는 대개 잘 돌더라"는 가장 위험한 태도다 — 이 버그는 확률적으로만 재현되고, 하필 재현되는 순간이 이미 크래시가 난 순간이라 증거가 남지 않는다.
비행기 사고를 떠올려보자. 사고가 나는 순간에 조종사가 본사에 전화를 걸어 상세 보고를 하지는 않는다. 그럴 상황이 아니다.
대신 블랙박스가 있다. 블랙박스는 아주 단순하게, 아주 튼튼한 방식으로 기록만 한다. 분석도 안 하고, 요약도 안 하고, 어디로 보내지도 않는다. 그냥 적는다. 그리고 나중에 안전한 상황에서 꺼내서 읽고 분석한다.
왜 그럴까? 사고 순간엔 기내의 모든 것을 믿을 수 없기 때문이다. 전기가 나갔을 수도 있고, 계기판이 고장 났을 수도 있다. 이때 "복잡한 장비를 써서 보고서를 만들자"고 하면 그 장비 자체가 고장 나서 아무 기록도 안 남을 수 있다.
크래시 리포터도 똑같다. 크래시 순간의 앱은 메모리가 어떻게 망가졌는지 알 수 없는 상태다. 여기서 문자열을 만들고(메모리 필요), 네트워크를 열고(더 많은 메모리와 스레드 필요) 하려 들면, 기록하려다 기록도 못 하고 사라진다. 그래서 가장 단순한 방법으로 파일에 적어두고, 다음에 앱이 정상적으로 켜졌을 때 그걸 읽어서 보낸다.
꼬리 질문
사전 할당 버퍼를 쓴다면 크기는 어떻게 정해야 하며, 부족하면 어떻게 되는가?
크기는 최악의 경우를 가정해 고정한다. 스레드 수 × 스레드당 최대 프레임 수 × 프레임당 바이트가 대략의 상한이고, 여기에 레지스터 상태와 바이너리 이미지 목록이 더해진다. 실무 리포터들은 수백 KB 수준의 버퍼를 앱 시작 시 잡아둔다.
부족하면 잘라내는 것 외에 선택지가 없다. 핸들러 안에서는 버퍼를 늘릴 수 없기 때문이다(malloc 금지). 그래서 설계는 "부족하면 실패"가 아니라 "부족하면 중요도 순으로 잘라 담는다"가 된다 — 크래시 스레드의 스택을 먼저 채우고, 남으면 다른 스레드를, 그래도 남으면 부가 정보를 담는 식이다.
여기에 미묘한 문제가 하나 더 있다. 스택 오버플로로 인한 크래시에서는 스택 공간 자체가 없어서 핸들러가 실행될 자리도 없다. 그래서 리포터는 sigaltstack()으로 시그널 전용 대체 스택을 미리 등록해둔다. 이것도 사전 할당의 일종이고, 등록하지 않으면 스택 오버플로 크래시는 아예 기록되지 못한다.
다음 실행 때 전송한다면, 크래시 직후 사용자가 앱을 삭제한 경우의 통계 편향은 얼마나 심각한가?
방향이 명확하다 — 심각한 크래시일수록 과소 집계된다. 앱을 열자마자 죽는 크래시(초기화 실패, 마이그레이션 손상)는 사용자가 두세 번 시도하다 삭제할 가능성이 높고, 삭제하면 그 크래시 리포트는 영영 전송되지 않는다. 반면 가끔 나는 크래시는 사용자가 계속 앱을 쓰므로 다음 실행에서 정상적으로 올라온다.
이 편향이 실무에서 만드는 착시는 이렇다 — 대시보드 상위에 뜨는 크래시가 "가장 심각한" 크래시가 아니다. 상위권은 "많이 나면서도 사용자가 앱을 계속 쓰게 만드는" 크래시이고, 진짜 치명적인 것은 건수가 적어 보인다.
보정 방법은 다른 축의 지표와 교차 확인하는 것이다. 크래시 리포트 건수만 보지 말고, (a) MetricKit의 MXAppExitMetric처럼 OS가 집계한 종료 통계, (b) 세션 시작 대비 정상 진입률, (c) 릴리스별 이탈률·삭제율을 함께 본다. 앱 시작 크래시가 있으면 크래시 건수는 낮은데 세션 진입률이 떨어지고 이탈이 는다.
또한 신규 릴리스에서는 크래시-프리 세션율보다 크래시-프리 사용자율이 이 편향에 덜 민감하다.
Objective-C 예외 핸들러(NSSetUncaughtExceptionHandler)는 시그널 핸들러와 무엇이 다르고, 왜 둘 다 필요한가?
실행 시점과 프로세스 상태가 다르다. 예외 핸들러는 아직 정상 상태에서 실행된다 — ObjC 런타임이 스택을 거슬러 올라가다 핸들러를 못 찾아서 최상위 핸들러를 부른 것이고, 이 시점에 힙은 멀쩡하고 락도 잡혀 있지 않다. 그래서 여기서는 Foundation을 써도 되고 메모리를 할당해도 된다. NSException 객체에서 name·reason·callStackSymbols를 꺼내 읽을 수 있다.
반면 시그널 핸들러는 비정상 상태에서 실행되며 앞서 본 모든 제약을 받는다.
둘 다 필요한 이유는 잡는 대상이 다르기 때문이다. 예외 핸들러는 ObjC/C++ 언어 예외만 잡고, 메모리 접근 위반이나 Swift 런타임 트랩은 못 잡는다. 시그널 핸들러는 그 반대다. 게다가 순서가 겹치는 구간도 있다 — 잡히지 않은 ObjC 예외는 예외 핸들러가 먼저 불리고, 그 다음 abort()가 SIGABRT를 만들어 시그널 핸들러까지 불린다. 그래서 리포터는 같은 크래시를 두 번 기록하지 않도록 중복 방지 플래그를 둔다.
실무 주의점은 핸들러 체이닝이다. NSSetUncaughtExceptionHandler는 하나만 등록되므로, 우리가 나중에 등록하면 SDK 것을 덮어쓴다. 반드시 기존 핸들러를 NSGetUncaughtExceptionHandler()로 저장했다가 우리 일이 끝난 뒤 호출해야 한다.
Q3. 시그널 핸들러와 Mach exception 핸들러는 무엇이 다른가?
계층이 다르다. Mach exception이 먼저이고 더 낮은 층이다. 하드웨어 트랩이 발생하면 XNU는 먼저 Mach 예외 포트로 메시지를 보내고, 아무도 처리하지 않으면 그제서야 BSD 시그널로 변환해 프로세스에 전달한다. Mach 방식은 별도 스레드에서 처리되므로 크래시 난 스레드의 스택을 온전히 볼 수 있고 async-signal-safe 제약도 훨씬 느슨해 유리하다. 그런데 iOS에서는 예외 메시지에 안전하게 응답하는 데 필요한 API가 완전히 공개돼 있지 않아, 잘못 쓰면 프로세스가 잠기거나 시스템 크래시 리포터로 전달되지 않는다. 그래서 실무 리포터는 대개 시그널 기반을 택하거나 둘을 조합한다.
CS 원리
XNU는 이름 그대로 Mach 마이크로커널과 BSD 층의 결합이다(P0 03장). 예외 처리에서 이 이중 구조가 그대로 드러난다.
CPU가 잘못된 메모리 접근 같은 트랩을 일으키면 커널이 받는다. 이때 커널의 처리 순서는 이렇다.
- Mach 예외 전달 — 커널이 예외 포트로 메시지를 보낸다. 포트는 스레드 → 태스크(프로세스) → 호스트 순으로 계층적으로 조회되며, 각 층에 핸들러가 등록돼 있을 수 있다.
- 핸들러가 메시지를 받고 "처리했다"고 응답하면 — 커널은 문제가 해결됐다고 보고 스레드 실행을 재개하거나 종료를 진행한다.
- 아무도 처리하지 않으면 — 커널이 BSD 시그널로 변환해(
EXC_BAD_ACCESS→SIGSEGV등) 프로세스에 전달한다. 여기서 비로소signal()로 등록한 핸들러가 실행된다.
이 순서에서 두 방식의 성질 차이가 전부 나온다.
| Mach exception | BSD signal | |
|---|---|---|
| 계층 | 먼저 (더 낮음) | 나중 (Mach 미처리 시) |
| 실행 컨텍스트 | 별도 스레드가 메시지를 수신 | 크래시 난 그 스레드에서 실행 |
| 크래시 스레드 스택 | 그대로 보존 (읽기만 함) | 핸들러 프레임이 얹힘 |
| async-signal-safe 제약 | 느슨함 (정상 스레드니까) | 매우 엄격 |
| 스택 오버플로 대응 | 문제없음 (다른 스택) | sigaltstack 필요 |
| iOS에서의 안전성 | 필요 API 미공개 | 공개 API로 충분 |
Mach 방식의 결정적 장점은 "환자를 건드리지 않고 옆에서 관찰한다"는 점이다. 크래시 난 스레드는 정지된 채로 있고, 핸들러 스레드가 그 스레드의 레지스터와 스택을 읽는다. 그래서 힙 락 재진입 문제 자체가 발생하지 않고, 스택에 핸들러 프레임이 섞이지도 않는다.
iOS에서는
Mach 방식이 기술적으로 우월한데도 iOS 서드파티 리포터가 그대로 쓰지 못하는 이유는 API 공개 범위에 있다. Apple의 시스템 크래시 리포터는 Mach 예외 메커니즘을 쓰지만, 서드파티가 예외 메시지를 받아 안전하게 응답하는 데 필요한 API가 iOS에서 완전히 공개돼 있지 않다. 이 때문에 두 가지 실패가 가능하다.
- 프로세스가 잠긴다 — 예외 메시지에 제대로 응답하지 못하면 커널이 응답을 기다리며 프로세스가 멈춘 채로 남는다. 사용자 눈에는 "앱이 얼어붙은" 상태다.
- 시스템 리포터로 전달되지 않는다 — 우리가 예외 포트를 가로챈 뒤 원래 핸들러(시스템)로 넘기지 못하면, Apple의 크래시 리포트가 생성되지 않는다. 즉 Xcode Organizer와 기기의 분석 데이터에서 그 크래시가 사라진다.
두 번째가 실무에서 특히 아프다. 서드파티 SDK 대시보드에는 크래시가 보이는데 Apple 쪽에는 안 보이거나 그 반대인 상황이 생기고, 어느 쪽 숫자를 믿어야 하는지 판단이 어려워진다.
그래서 현실의 선택지는 대체로 셋이다.
- 시그널 기반만 사용 — 안전하고 공개 API만 쓴다. 대신 async-signal-safe 제약을 정면으로 감당해야 하고, 스택 오버플로 대응에
sigaltstack이 필요하다. - Mach 기반을 쓰되 조심스럽게 체이닝 — 원래 예외 포트를 저장했다가 처리 후 반드시 넘긴다. 구현 난이도가 높다.
- 둘을 조합 — Mach로 잡되 실패 시 시그널로 폴백하거나, 유형별로 나눠 담당시킨다.
덧붙여 디버거가 붙어 있으면 둘 다 무력화된다. 디버거는 태스크의 예외 포트를 자기가 차지하므로 우리 핸들러까지 메시지가 오지 않는다. 앞서 말한 "디버거를 떼고 테스트하라"의 커널 수준 이유가 이것이다.
실험 · 도구
예외 포트가 계층적으로 조회된다는 사실은 직접 확인해볼 수 있다.
import Darwin
import MachO
// 현재 태스크에 등록된 예외 포트를 조회한다 — 디버거를 붙였을 때와 뗐을 때를 비교해보자
func dumpExceptionPorts() {
var masks = [exception_mask_t](repeating: 0, count: Int(EXC_TYPES_COUNT))
var ports = [mach_port_t](repeating: 0, count: Int(EXC_TYPES_COUNT))
var behaviors = [exception_behavior_t](repeating: 0, count: Int(EXC_TYPES_COUNT))
var flavors = [thread_state_flavor_t](repeating: 0, count: Int(EXC_TYPES_COUNT))
var count: mach_msg_type_number_t = mach_msg_type_number_t(EXC_TYPES_COUNT)
let kr = task_get_exception_ports(
mach_task_self_,
exception_mask_t(EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION |
EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT),
&masks, &count, &ports, &behaviors, &flavors)
guard kr == KERN_SUCCESS else { print("조회 실패: \(kr)"); return }
print("등록된 예외 포트 \(count)개")
for i in 0..<Int(count) {
// port가 0이 아니면 누군가(디버거 또는 SDK)가 그 예외를 가로채고 있다는 뜻
print(String(format: " mask=0x%x port=0x%x behavior=%d",
masks[i], ports[i], behaviors[i]))
}
}
확인 포인트는 명확하다 — Xcode 디버거를 붙이고 실행하면 포트가 잡혀 있고, 디버거 없이(기기에서 직접 앱 아이콘으로) 실행하면 비어 있거나 SDK가 잡은 포트만 보인다. 이 차이가 "디버거를 붙이면 크래시 리포트가 안 생기는" 이유를 눈으로 보여준다.
어느 방식으로 잡혔는지는 리포트에서도 단서가 나온다.
# Apple 크래시 리포트의 Exception Note 필드
# EXC_CORPSE_NOTIFY → Mach 예외 경로로 시스템이 "시체(corpse)"를 만들어 리포트한 것
# 이 노트가 있으면 시스템 리포터가 Mach 층에서 정보를 수집했다는 뜻이다.
# ⚠️ iOS 15+ 의 .ips 는 JSON 이다. "Exception Type" 같은 필드명 문자열이 없으므로
# grep 이 아니라 jq 로 파싱해야 한다. (첫 줄은 메타 헤더, 둘째 줄이 본문)
jq -s '.[1] | {type: .exception.type, signal: .exception.signal,
subtype: .exception.subtype, termination: .termination}' \
~/Downloads/YourApp-crash.ips
# Console.app 이나 Xcode 가 "번역"해 보여주는 텍스트에서는 grep 이 통한다
grep -A2 "Exception Type" translated-report.txt
프로젝트 적용
대부분의 팀에서 이 지식이 실제로 쓰이는 지점은 SDK를 여러 개 붙였을 때의 충돌 진단이다.
- 크래시 리포팅 SDK를 둘 이상 붙이지 않는다. 둘 다 예외 포트나 시그널 핸들러를 잡으려 하면 나중에 초기화된 쪽이 이기거나, 체이닝이 어긋나 양쪽 다 리포트를 못 남기는 상황이 된다. A/B 비교를 위해 잠시 둘을 붙이는 경우가 있는데, 그 기간의 데이터는 신뢰할 수 없다는 점을 알고 있어야 한다.
- Apple 쪽 숫자와 SDK 숫자를 정기적으로 대조한다. Xcode Organizer의 크래시와 SDK 대시보드의 크래시가 크게 어긋나면 예외 포트 체이닝이 깨졌을 가능성이 높다.
- "앱이 얼어붙었다"는 제보를 워치독으로만 단정하지 않는다. 예외 처리가 잘못 얽히면 프로세스가 응답 없이 남을 수 있고, 이건 메인스레드 블로킹과 증상이 같지만 원인이 전혀 다르다.
"Mach 예외가 시그널보다 상위 개념"이라는 표현은 방향이 반대다. Mach 예외가 더 낮은 층이고 먼저 발생하며, 시그널은 Mach에서 처리되지 않았을 때 만들어지는 상위 계층의 폴백 메커니즘이다. "Mach 방식을 쓰면 async-signal-safe를 신경 안 써도 된다"도 절반만 맞다 — 핸들러 스레드는 정상 상태지만, 그 스레드가 크래시 난 스레드가 쥐고 있던 락을 필요로 하면 여전히 교착한다(예: 크래시 난 스레드가 malloc 락을 쥔 채 죽었는데 핸들러가 malloc을 호출). 또 "서드파티가 Mach 예외를 쓰면 안 된다"고 단정하는 것도 부정확하다 — 실제로 쓰는 리포터가 있고, 다만 안전한 응답과 체이닝이 어렵다는 것이 요점이다.
건물에서 사고가 났을 때 알리는 방법이 두 가지 있다고 해보자.
첫 번째는 인터폰(Mach 예외)이다. 사고가 나면 관리실로 바로 연결된다. 관리실 직원은 사고 현장 밖에 있는 사람이라 침착하게 상황을 기록할 수 있고, 현장은 손대지 않은 채 그대로 남는다. 훨씬 좋은 방식이다.
문제는 이 인터폰이 관리실 전용 회선이라는 것이다. 외부 업체가 중간에 끼어들어 받으려면 특별한 장비가 필요한데, 그 장비 사양이 완전히 공개돼 있지 않다. 잘못 끼어들면 인터폰이 끊겨서 관리실이 사고를 아예 모르게 되거나, 통화가 안 끊겨서 회선이 먹통이 된다.
두 번째는 현장에 있던 사람이 직접 소리치는 것(시그널)이다. 누구나 들을 수 있어서 외부 업체도 쓸 수 있다. 대신 소리치는 사람이 사고 현장 안에 있는 사람이라 제약이 크다 — 다치지 않은 손으로만, 아주 짧게, 정해진 몇 마디만 할 수 있다.
그래서 외부 업체는 대개 두 번째를 택한다. 불편하지만 확실히 쓸 수 있고, 관리실 인터폰을 망가뜨리지 않기 때문이다.
꼬리 질문
Mach 방식이 크래시 스레드의 스택을 온전히 보존한다는 것이 실제 리포트에서는 어떤 차이로 나타나는가?
시그널 방식에서는 크래시 스레드의 스택 최상단에 핸들러 자신의 프레임이 얹힌다. 리포트를 보면 _sigtramp, 그리고 리포터의 핸들러 함수들이 실제 크래시 지점 위에 쌓여 있어서, 사람이 읽을 때 몇 프레임을 건너뛰어야 진짜 크래시 지점인지 판단해야 한다. 자동 그룹핑에도 영향을 주는데, 리포터 버전이 바뀌어 프레임 구성이 달라지면 지문이 흔들릴 수 있다(18장 Q6).
Mach 방식에서는 핸들러가 다른 스레드에 있으므로 크래시 스레드 스택이 사고 순간 그대로다. 최상단이 곧 크래시 지점이다.
더 중요한 차이는 레지스터 상태다. 시그널 핸들러는 ucontext를 통해 중단 시점 레지스터를 받을 수 있지만 플랫폼별 처리가 필요하고 누락되기 쉽다. Mach 예외 메시지는 thread_get_state()로 정지된 스레드의 상태를 직접 읽을 수 있어 더 정확하고 완전하다. EXC_BAD_ACCESS에서 어떤 주소에 접근하려다 죽었는지(fault address)를 정확히 얻는 데 이 차이가 드러난다.
그리고 스택 오버플로에서는 차이가 결정적이다 — 시그널 방식은 대체 스택을 미리 마련하지 않으면 아예 기록을 못 남기지만, Mach 방식은 애초에 다른 스레드라 문제가 없다.
크래시 난 스레드가 락을 쥔 채 죽었다면 Mach 핸들러 스레드도 위험한 이유는?
락은 프로세스 전체가 공유하는 자원이기 때문이다. 스레드 A가 malloc 락을 쥐고 힙을 수정하던 중 크래시로 정지했다면, 그 락은 영원히 풀리지 않는다 — A는 재개되지 않을 것이므로 해제 코드가 실행될 일이 없다.
이제 Mach 핸들러 스레드 B가 리포트를 만들면서 메모리를 할당하려 하면, B는 A가 쥔 락을 기다리며 영구히 블록된다. 재진입 교착이 아니라 일반적인 교착이지만 결과는 같다 — 리포트가 안 나오고 프로세스가 멈춘다.
즉 Mach 방식이 완화해주는 것은 재진입 문제이지 공유 자원 문제가 아니다. 그래서 견고한 Mach 기반 리포터도 결국 핸들러 경로에서 할당을 피하고 사전 할당 버퍼를 쓴다. "Mach를 쓰면 마음껏 Foundation을 써도 된다"는 잘못된 안심이다.
더 나아가면 힙이 손상됐을 가능성도 남는다. A가 자유 리스트를 반쯤 고쳐놓고 죽었다면 락과 무관하게 그 힙에서 할당하는 것 자체가 위험하다.
실무적으로 견고한 리포터들은 그래서 "할당하지 않는다"를 방식과 무관한 기본 원칙으로 둔다.
예외 포트를 가로챈 뒤 시스템으로 넘기지 않으면 Apple 쪽 크래시 통계는 어떻게 되는가?
해당 크래시가 Apple 쪽에서는 존재하지 않게 된다. Xcode Organizer의 Crashes 패널에도, 기기의 "분석 데이터"에도, App Store Connect의 지표에도 나타나지 않는다. 우리 SDK 대시보드에만 보인다.
이게 만드는 실무 문제는 셋이다. 첫째, 교차 검증이 불가능해진다. SDK가 놓친 크래시가 있는지 확인할 기준선이 사라진다. 둘째, Apple의 회귀 감지(특정 OS 버전에서 크래시 급증 등)에서 우리 앱이 빠진다. 셋째, 지원 요청 시 불리하다 — Apple에 버그 리포트를 낼 때 시스템 크래시 리포트가 표준 증빙인데 그게 없다.
반대 방향의 실패도 있다. 시스템으로는 넘겼는데 우리가 응답을 잘못 해서 커널이 계속 기다리면, 프로세스가 종료되지도 리포트가 생성되지도 않은 채 좀비처럼 남는다.
그래서 체이닝 구현의 정석은 "가로채기 전에 원래 포트를 반드시 저장하고, 우리 일이 끝나면 그 포트로 메시지를 그대로 전달한다"이다. 이건 NSSetUncaughtExceptionHandler의 체이닝(Q2)과 정확히 같은 패턴이고, 모든 전역 훅에 공통되는 규칙이다.
Q4. Termination Reason 코드는 무엇을 말해주는가?
EXC_CRASH (SIGKILL)은 "OS가 죽였다"까지만 말해주고, 왜 죽였는지는 Termination Reason에 있다. Apple은 이 코드들을 문서로 공개해뒀고 대부분 16진수로 영어 단어를 흉내낸 말장난이라 외우기 쉽다 — 0x8badf00d("ate bad food", 워치독), 0xdead10cc("dead lock", 서스펜드 중 락 미해제), 0xc00010ff("cool off", 발열), 0xbaddd15c("bad disc", 디스크 공간 회수), 0xbaadca11("bad call", VoIP 응답 실패). 각 코드는 서로 완전히 다른 대응을 요구하므로, 이 필드를 읽지 않으면 엉뚱한 곳을 고치게 된다.
CS 원리
여기서 작동하는 원리는 정책적 종료에는 근거가 남아야 한다는 것이다. 프로세스가 스스로 잘못해서 죽으면 증거(스택·레지스터)가 프로세스 안에 남지만, 외부가 정책적으로 죽인 경우엔 프로세스 안에 아무 증거도 없다. 그래서 죽인 쪽이 이유를 기록해야 한다.
XNU에서 이 역할을 하는 것이 termination namespace + code 쌍이다. 네임스페이스는 "누가 죽였는가"(예: RUNNINGBOARD, JETSAM, SPRINGBOARD)를, 코드는 "왜 죽였는가"를 나타낸다. 리포트에는 이렇게 찍힌다.
Exception Type: EXC_CRASH (SIGKILL)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note: EXC_CORPSE_NOTIFY
Termination Reason: Namespace RUNNINGBOARD, Code 0xdead10cc
Exception Codes가 전부 0인 것에 주목할 만하다 — 메모리 접근 위반이 아니므로 fault address 같은 것이 없다. 정보가 전부 Termination Reason에 있다는 신호다.
iOS에서는
Apple이 문서화한 코드표에서 iOS 실무 빈도 상위만 발췌하면 이렇다(문서에는 0xd00d2bad, watchOS 전용 0xc51bad01~03 등이 더 있다). 이 표는 외워두면 크래시 판독 속도가 크게 달라진다.
| 코드 | 읽는 법 | 의미 | 대응 |
|---|---|---|---|
0x8badf00d | ate bad food | 워치독 종료 — 메인스레드를 너무 오래 막았다 | 20장 참조. 동기 I/O·네트워킹 제거 |
0xdead10cc | dead lock | 서스펜드 중 파일·SQLite 락을 쥐고 있었다 | 백그라운드 진입 전 락 해제. beginBackgroundTask를 쓰기 시작 전에 요청 |
0xc00010ff | cool off | 발열로 종료 | CPU 사용량 감축. 열 상태 알림 구독 |
0xbaddd15c | bad disc | 디스크 공간 회수를 위해 캐시 삭제 목적 종료 | 디스크 쓰기 최소화, 파일 생명주기 관리 |
0xbaadca11 | bad call | VoIP 앱이 알림에 대해 호출 보고를 안 함 | CallKit 보고 누락 수정 |
0x2182bad2 | — | 백그라운드 태스크가 시간 내 미완료 | 작업 축소 또는 분할 |
0x2182bad3 | — | 백그라운드 URL 세션 미완료 | 전송량 축소 |
0x2182bad4 | — | 백그라운드 페치 미완료 | 페치 작업 축소 |
0xbad22222 | — | VoIP 앱이 너무 자주 재개됨 | 재개 빈도 조절 |
실무에서 특히 자주 오해받는 것이 0xdead10cc다. 이름 때문에 "교착 상태(deadlock) 때문에 죽었다"로 읽기 쉬운데, 실제 의미는 "앱이 서스펜드되는 시점에 파일 락이나 SQLite 락을 쥐고 있었다"이다. 다른 프로세스(예: 같은 App Group 컨테이너를 쓰는 익스텐션)가 그 파일에 접근하지 못하게 되므로 시스템이 앱을 죽인다.
Apple이 제시하는 해법도 구체적이다 — 메인스레드에서 beginBackgroundTask로 추가 실행 시간을 요청하되, 파일 쓰기를 시작하기 훨씬 전에 요청해야 한다. 쓰기가 끝나고 락을 놓을 시간까지 확보해야 하기 때문이다. 앱 익스텐션에서는 대신 ProcessInfo.performExpiringActivity를 쓴다.
워치독 허용치는 추측할 필요가 없다 — 리포트가 직접 알려준다. Termination Description에 그 건에 적용된 허용치와 실제 소요 시간이 초 단위로 인쇄된다. Apple 문서의 예시가 그대로 보여준다.
scene-create watchdog transgression: application<com.example.MyCoolApp>:667
exhausted real (wall clock) time allowance of 19.97 seconds
| WatchdogEvent: scene-create
| "Elapsed total CPU time (seconds): 15.290 (user 15.290, system 0.000), 28% CPU",
| "Elapsed application CPU time (seconds): 0.367, 1% CPU"
여기서 19.97 seconds가 이 리포트에 적용된 허용치다. 그러니 리포트를 받았다면 먼저 이 줄을 읽는다.
주의할 것은 이 값이 모든 상황에 적용되는 고정 임계가 아니라는 점이다. Apple은 전환 종류별 일반 허용치 표를 문서화하지 않았고, 널리 인용되는 "런치 20초 / scene-update 10초"는 관측값에서 나온 근사다. 실제 값은 기기·OS 버전·시스템 부하에 따라 달라진다. 그래서 고정 숫자에 맞춘 설계는 위험하고, 개별 리포트의 값은 신뢰할 수 있다.
실험 · 도구
0xdead10cc는 재현이 까다로워 보이지만 원리를 알면 간단하다.
import UIKit
// ⚠️ 재현 실험용. 실제 앱에서 이렇게 하면 안 된다.
final class DeadlockReproducer {
private var handle: FileHandle?
/// 공유 컨테이너 파일에 락을 걸고 놓지 않은 채 백그라운드로 간다
func reproduce() {
let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.shop")!
.appendingPathComponent("shared.db")
handle = try? FileHandle(forWritingTo: url)
// 배타적 락 획득 후 의도적으로 해제하지 않는다
flock(handle!.fileDescriptor, LOCK_EX)
// 이 상태에서 홈으로 나가 앱이 서스펜드되면 → 0xdead10cc
}
/// ✅ 올바른 패턴 — 백그라운드 진입 전에 반드시 놓는다
func correct() {
var taskID = UIBackgroundTaskIdentifier.invalid
// 쓰기를 "시작하기 전에" 시간을 확보한다. 끝난 뒤에 요청하면 늦다.
taskID = UIApplication.shared.beginBackgroundTask(withName: "db-write") {
// 만료 핸들러: 시간이 다 되면 여기서라도 반드시 락을 놓는다
self.releaseLock()
UIApplication.shared.endBackgroundTask(taskID)
taskID = .invalid
}
defer {
releaseLock()
if taskID != .invalid {
UIApplication.shared.endBackgroundTask(taskID)
taskID = .invalid
}
}
performWrite()
}
private func releaseLock() {
guard let h = handle else { return }
flock(h.fileDescriptor, LOCK_UN)
try? h.close()
handle = nil
}
private func performWrite() { /* … */ }
}
재현 절차는 이렇다 — 실기기에서 앱을 실행하고 reproduce()를 호출한 뒤 홈 화면으로 나간다. 몇 분 대기하면 앱이 종료되고, 설정 › 분석 데이터에서 Termination Reason: Namespace RUNNINGBOARD, Code 0xdead10cc를 확인할 수 있다.
기기 로그로 실시간 관찰하는 방법도 있다.
# 종료 결정을 내리는 주체(runningboardd)의 로그를 본다
log stream --predicate 'process == "runningboardd"' --style compact --level info
# 이미 지나간 종료 기록만 추린다
log show --last 30m --predicate \
'eventMessage CONTAINS "0xdead10cc" OR eventMessage CONTAINS "8badf00d"' --style compact
프로젝트 적용
이 코드표를 실제로 활용하는 방법은 분류를 자동화해 대시보드 축으로 만드는 것이다.
/// Termination Reason 코드를 대응 팀·대응 방법으로 매핑한다
enum TerminationCode: UInt32 {
case watchdog = 0x8badf00d
case lockHeldOnSuspend = 0xdead10cc
case thermal = 0xc00010ff
case diskSpace = 0xbaddd15c
case voipNoCall = 0xbaadca11
case bgTaskTimeout = 0x2182bad2
case bgURLSession = 0x2182bad3
case bgFetch = 0x2182bad4
/// 스택트레이스가 원인을 가리키는가 — 판독 방법을 가른다
var stackIsCause: Bool { false } // SIGKILL 계열은 전부 아니다
var owner: String {
switch self {
case .watchdog, .thermal: return "성능"
case .lockHeldOnSuspend, .bgTaskTimeout,
.bgURLSession, .bgFetch: return "백그라운드 동작"
case .diskSpace: return "저장소"
case .voipNoCall: return "통화"
}
}
var firstCheck: String {
switch self {
case .watchdog: return "메인스레드 동기 I/O·네트워킹 (20장)"
case .lockHeldOnSuspend: return "백그라운드 진입 시 파일·SQLite 락 해제 여부"
case .thermal: return "지속적 고CPU 작업, 열 상태 알림 구독 여부"
case .diskSpace: return "캐시 디렉터리 크기, 파일 정리 주기"
case .voipNoCall: return "CallKit 보고 누락"
case .bgTaskTimeout, .bgURLSession, .bgFetch:
return "백그라운드 작업 소요 시간과 분할"
}
}
}
운영 규칙 두 가지를 덧붙이면 좋다.
- 이 코드들을 "크래시"와 같은 목록에 섞어두지 않는다. 원인 유형도, 대응 팀도, 판독 방법도 다르다. 대시보드를 분리하면 "우리 크래시율이 나쁘다"가 아니라 "우리는 워치독이 문제다"라는 실행 가능한 결론이 나온다.
0xdead10cc는 App Group을 쓰는 앱에서 특히 확인한다. 익스텐션과 본 앱이 같은 파일을 공유하면 발생 확률이 크게 오르는데(24장), 익스텐션 쪽 코드가 원인인 경우가 많아 본 앱만 봐서는 못 찾는다.
0xdead10cc를 "교착 상태 때문"으로 읽는 것이 가장 흔한 오독이다. 실제로는 서스펜드 시점에 파일·DB 락을 쥐고 있었다는 뜻이고, 앱 내부에 교착이 전혀 없어도 발생한다. "워치독 시간은 20초"처럼 일반 임계로 단정하는 것도 위험하다 — Apple은 전환 종류별 허용치 표를 문서화하지 않았고 상황마다 다르다. 다만 개별 리포트에는 그 건의 허용치가 초 단위로 인쇄되므로 그 값은 읽으면 된다. 또 "0xc00010ff(발열)는 사용자 환경 탓이라 우리가 할 게 없다"도 틀렸다 — Apple은 열 상태 API로 감지해 작업량을 줄이라고 안내하며, 실제로 지속적 고부하가 원인인 경우가 많다. 마지막으로 "SIGKILL이니 Termination Reason만 보면 되고 스택은 무의미하다"도 지나친 단순화다 — 스택이 원인은 아니지만 "어느 코드 경로에서 백그라운드로 갔는지" 같은 정황은 준다.
도서관에서 쫓겨나는 상황을 떠올려보자. 사서가 "나가세요"라고만 하면 왜인지 알 수 없다. 그런데 퇴장 사유서에 코드가 적혀 있다.
- ate bad food — 계산대에서 허용된 시간을 넘겨 멍하니 있어서 뒷사람이 밀렸다 (워치독)
- dead lock — 나가면서 열람실 열쇠를 반납 안 했다. 다음 사람이 못 들어간다 (서스펜드 중 락)
- cool off — 노트북이 너무 뜨거워져서 (발열)
- bad disc — 사물함을 다 차지해서 정리해야 했다 (디스크 공간)
재밌는 건 이 코드들이 16진수로 영어 단어를 쓴 말장난이라는 점이다. 0x8badf00d를 소리내어 읽으면 "ate bad food"(상한 음식을 먹었다)다. 배탈이 나서 못 움직이는 상태를 뜻하는 셈이니, 메인스레드가 막혀서 응답을 못 하는 상황과 잘 맞는다.
중요한 건 사유마다 고칠 곳이 완전히 다르다는 것이다. "열쇠 반납 안 함"으로 쫓겨났는데 "계산을 더 빨리 하자"고 연습해봐야 아무 소용이 없다. 그래서 쫓겨난 순간의 모습(스택)보다 사유서(Termination Reason)를 먼저 읽어야 한다.
꼬리 질문
0xdead10cc를 피하려고 beginBackgroundTask를 쓰기 시작 전에 요청해야 하는 이유는?
beginBackgroundTask는 지금부터 추가 실행 시간을 달라는 요청이지, 이미 시작된 작업을 소급해서 보호해주지 않는다. 쓰기를 시작한 뒤에 요청하면 그 사이에 앱이 서스펜드될 수 있고, 하필 그 순간 락을 쥐고 있으면 종료된다.
더 중요한 이유는 락을 놓는 데도 시간이 필요하다는 것이다. 확보한 시간은 "쓰기를 끝내는 시간"이 아니라 "쓰기를 끝내고 파일을 닫고 락을 해제할 때까지"를 다 덮어야 한다. 쓰기 직후에 요청하면 남은 시간이 마무리에 부족할 수 있다.
그리고 만료 핸들러 설계가 핵심이다. beginBackgroundTask의 만료 핸들러는 시간이 다 됐을 때 시스템이 마지막으로 주는 기회이므로, 여기서 반드시 락을 해제하고 endBackgroundTask를 호출해야 한다. 만료 핸들러에서 endBackgroundTask를 호출하지 않으면 그 자체로 앱이 종료된다.
앱 익스텐션에는 beginBackgroundTask가 없으므로 ProcessInfo.processInfo.performExpiringActivity(withReason:using:)를 쓴다. 익스텐션이 공유 컨테이너 DB를 건드리는 구조라면 이 처리가 특히 중요하다.
리포트에 허용치가 찍히는데도 그 숫자에 맞춰 최적화하면 안 되는 이유는?
리포트의 값은 그 한 건에 적용된 값이지 보편 임계가 아니기 때문이다. 같은 scene-create라도 기기 성능·OS 버전·시스템 부하에 따라 다른 값이 적용될 수 있고, Apple은 전환 종류별 일반 허용치 표를 문서화하지 않았다. 한 리포트에서 19.97초를 봤다고 "20초까지는 안전"이라고 일반화하면 다른 기기에서 그대로 무너진다.
대신 세워야 할 목표는 "메인스레드에서 블로킹 작업을 하지 않는다"는 구조적 규칙이다. 이건 임계값과 무관하게 항상 옳고, 워치독뿐 아니라 행·히치까지 함께 해결한다.
측정 목표를 굳이 숫자로 잡는다면 Apple이 문서화한 값을 쓰는 편이 낫다 — 행 리포팅 임계 250 ms, 행 리포트 백트레이스 캡처 1초 같은 값들이다(20장). 이 값들은 워치독 한계보다 훨씬 엄격하므로, 이걸 지키면 워치독은 자동으로 안전권에 들어온다.
또 하나의 함의는 느린 기기에서 테스트해야 한다는 것이다. 최신 기기에서 3초 걸리는 런치가 구형 기기에서는 훨씬 오래 걸릴 수 있고, 워치독은 그 기기 기준으로 판정한다.
발열 종료(0xc00010ff)를 앱이 미리 감지해 대응할 방법이 있는가?
있다. iOS는 ProcessInfo.processInfo.thermalState로 현재 열 상태를 nominal / fair / serious / critical 네 단계로 제공하고, ProcessInfo.thermalStateDidChangeNotification으로 변화를 알려준다. 종료는 critical에서도 한참 버틴 뒤에 일어나므로, serious 단계부터 작업량을 줄이면 대개 피할 수 있다.
실무에서 줄일 대상은 대개 정해져 있다 — 지속적인 위치 추적 정확도, 백그라운드 동기화 주기, 애니메이션 프레임률, 영상·이미지 처리 해상도, 네트워크 폴링 빈도. 게임이나 카메라 앱에서는 렌더 해상도나 프레임률을 낮추는 것이 표준 대응이다.
주의할 점은 열 상태가 우리 앱만의 결과가 아니라는 것이다. 다른 앱이나 충전, 직사광선 때문일 수도 있으므로 "우리 앱이 원인"이라고 단정하면 안 된다. 그래도 대응은 해야 하는데, 이미 뜨거운 상태에서 우리가 부하를 더하면 종료 대상이 우리가 되기 때문이다.
테스트는 Xcode의 Devices and Simulators에서 열 상태 device condition을 걸어 시뮬레이션할 수 있다. 실제로 기기를 뜨겁게 만들 필요가 없다.
Q5. EXC_RESOURCE에 NON-FATAL CONDITION이 붙는 것은 무슨 뜻인가?
크래시 리포트가 생성됐지만 앱은 죽지 않았다는 뜻이다. EXC_RESOURCE는 프로세스가 CPU·메모리·디스크쓰기·웨이크업 한도를 넘었을 때 발생하는데, Exception Note에 NON-FATAL CONDITION이 있으면 OS가 경고만 하고 프로세스는 계속 실행시킨 것이다. 그래서 이 리포트는 "크래시"가 아니라 조기 경보로 읽어야 한다 — 특히 MEMORY 서브타입은 곧 있을 Jetsam 종료의 전조다. 이걸 크래시 목록에 섞어두면 "죽지도 않은 것이 크래시율을 올린다"는 혼란이 생기고, 반대로 무시하면 공짜로 주어진 예고를 버리는 것이 된다.
CS 원리
운영체제가 자원 한도를 다루는 방식에는 두 가지 층이 있다.
- 소프트 리밋(soft limit) — 넘으면 경고한다. 프로세스에게 스스로 교정할 기회를 준다.
- 하드 리밋(hard limit) — 넘으면 종료한다. 협상은 없다.
NON-FATAL은 크래시가 아니라 공짜로 주어진 예고다. 크래시율에 섞지도, 버리지도 않는다.이 구분이 존재하는 이유는 즉시 죽이는 것이 항상 최선은 아니기 때문이다. 일시적 스파이크로 죽이면 정상 앱까지 불안정해지고, 반대로 아무 신호 없이 방치하다 갑자기 죽이면 앱이 대비할 방법이 없다. 그래서 두 단계를 둔다.
EXC_RESOURCE는 이 소프트 리밋 층의 알림 메커니즘이다. 특이한 점은 알림을 "예외"라는 형태로 전달한다는 것인데, 그래서 크래시 리포트 인프라를 그대로 재사용할 수 있다 — 스택트레이스가 함께 캡처되므로 "한도를 넘긴 그 순간 무슨 코드가 돌고 있었는지"를 알 수 있다.
다만 여기에도 앞서 본 함정이 그대로 적용된다. 메모리 한도를 넘긴 순간의 스택은 마지막 할당 지점일 뿐 축적의 원인이 아니다. CPU·웨이크업 서브타입에서는 반대로 스택이 상당히 유용한데, 그 시점에 실제로 CPU를 태우고 있던 코드가 찍히기 때문이다.
iOS에서는
Apple 문서가 정의하는 서브타입은 넷이다.
| Exception Subtype | 의미 | 스택이 유용한가 | 대표 원인 |
|---|---|---|---|
CPU / CPU_FATAL | 단시간에 CPU를 과도하게 사용 | 매우 유용 | 무한 루프, 폴링, 과도한 파싱 |
MEMORY | 시스템이 부과한 메모리 한도 초과 | 거의 무용 | 이미지 원본 디코딩, 캐시 누수 |
IO | 단시간에 과도한 디스크 쓰기 | 유용 | 로그 남발, 캐시 과다 기록 |
WAKEUPS | 초당 웨이크업 횟수 과다 (배터리 소모) | 유용 | 스레드 간 통신 API 과다 호출 |
CPU와 CPU_FATAL의 차이가 소프트/하드 리밋 구분과 정확히 대응한다. CPU는 대개 NON-FATAL CONDITION과 함께 오고, CPU_FATAL은 실제 종료를 동반한다. 후자는 주로 앱 익스텐션처럼 CPU 예산이 엄격한 환경에서 나타난다.
WAKEUPS는 진단이 흥미롭다. Apple 문서는 이 예외가 스레드 간 통신 API를 예상보다 자주 호출할 때 발생한다고 설명하면서, "통신이 워낙 잦기 때문에 크래시 리포트에는 대개 비슷한 백트레이스를 가진 백그라운드 스레드가 여러 개 나타난다"고 덧붙인다. 즉 서로 닮은 스레드가 여러 개 보이는 것 자체가 단서다.
MEMORY 서브타입이 실무적으로 가장 중요하다. 이건 P0 01장에서 다룬 per-process 메모리 한도의 경고 단계이고, 문서도 "종료로 이어지는 전조일 수 있다"고 명시한다. 그런데 많은 팀이 이 리포트를 노이즈로 취급해 필터링해버린다 — Jetsam 종료를 미리 알려주는 유일한 신호를 버리는 셈이다.
실험 · 도구
리포트에서 이 구분을 자동으로 뽑아내면 대시보드 분류가 쉬워진다.
# .ips 는 JSON 이므로 jq 로 EXC_RESOURCE 관련 필드를 뽑는다
jq -s '.[1].exception' YourApp-*.ips
# { "type": "EXC_RESOURCE", "subtype": "MEMORY", ... }
# Console/Xcode 가 번역해 보여주는 텍스트라면 grep 이 통한다
# 전형적인 비치명 메모리 경고 출력:
# Exception Type: EXC_RESOURCE
# Exception Subtype: MEMORY
# Exception Note: NON-FATAL CONDITION
# Exception Message: Memory limit exceeded (Limit 1400 MB)
#
# 전형적인 치명적 CPU 초과 (익스텐션 등):
# Exception Type: EXC_RESOURCE
# Exception Subtype: CPU_FATAL
# Exception Message: CPU consumption exceeded (Limit 80% over 60s)
앱 안에서 자기 메모리 사용량을 관찰해 한도 접근을 감지할 수도 있다.
import Foundation
import os // os_proc_available_memory() 는 os 모듈에 있다
/// 현재 프로세스의 실제 메모리 footprint (Jetsam이 보는 값에 가장 가깝다)
func currentFootprintBytes() -> UInt64? {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size)
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard kr == KERN_SUCCESS else { return nil }
return info.phys_footprint // ← resident_size가 아니라 phys_footprint를 봐야 한다
}
/// 시스템이 이 프로세스에 부여한 대략적 한도
func memoryLimitBytes() -> UInt64 {
// 정확한 한도를 조회하는 공개 API는 없다.
// os_proc_available_memory()로 "남은 양"을 얻어 역산하는 것이 현실적이다.
UInt64(os_proc_available_memory()) + (currentFootprintBytes() ?? 0)
}
// 사용 예: 큰 작업 전에 여유를 확인하고 작업 규모를 조절한다
let available = os_proc_available_memory()
print("남은 메모리: \(available / 1024 / 1024) MB")
if available < 200 * 1024 * 1024 {
// 이미지 다운샘플 비율을 높이거나 캐시를 비운다
}
여기서 resident_size가 아니라 phys_footprint를 봐야 한다는 점이 중요하다. Jetsam이 판정에 쓰는 값이 footprint이고, 여기에는 P0 01장에서 본 압축된 메모리도 포함된다. resident_size만 보면 압축분이 빠져 실제보다 낮게 보인다.
프로젝트 적용
운영 규칙 셋으로 정리된다.
NON-FATAL리포트를 크래시 지표에서 분리하되 버리지는 않는다. 별도 지표(예: "메모리 경고율")로 추적하면 Jetsam 종료가 늘기 전에 회귀를 잡을 수 있다.MEMORY경고는 스택이 아니라 "직전에 무엇을 했는가"로 조사한다. breadcrumb의 화면 전환 기록(18장 Q4)과 결합하면 "특정 화면 진입 후 경고가 몰린다"가 보인다. 이게 스택보다 훨씬 유용하다.CPU·WAKEUPS경고는 스택을 그대로 활용한다. 특히WAKEUPS에서 비슷한 백그라운드 스레드가 여러 개 보이면 타이머·폴링 루프를 의심한다.
덧붙여 메모리 한도는 기기와 익스텐션 종류에 따라 크게 다르다. 본 앱은 기기 RAM에 비례해 늘어나지만 익스텐션은 훨씬 낮은 고정값에 가깝다(24장). 그래서 "본 앱에서 잘 되던 이미지 처리가 공유 익스텐션에서 죽는" 상황이 흔하고, 이때 EXC_RESOURCE (MEMORY)가 먼저 뜨는 경우가 많다.
"크래시 리포트가 있으면 앱이 죽은 것"은 EXC_RESOURCE에서 틀린다. NON-FATAL CONDITION이면 앱은 계속 돌았다. 이걸 모르고 크래시율에 합산하면 지표가 오염되고, 반대로 "죽지도 않았으니 무시"하면 Jetsam의 예고편을 버리는 것이다. "EXC_RESOURCE (MEMORY)의 스택을 고치면 해결된다"도 흔한 오독이다 — 그 스택은 한도를 넘기는 마지막 할당일 뿐이라 대개 무해한 코드이고, 진짜 원인은 그 이전의 축적이다. 또 앱 메모리를 resident_size로 재는 것도 부정확하다 — 시스템은 압축분이 포함된 footprint로 판정한다.
도서관 사물함에 짐을 넣는데 무게 제한이 있다고 해보자.
제한을 넘으면 두 가지 일이 일어날 수 있다. 하나는 경고장이다. "짐이 너무 많습니다"라는 쪽지가 붙지만 짐은 그대로 두고 나는 계속 도서관을 쓴다. 다른 하나는 퇴장이다. 짐과 함께 쫓겨난다.
NON-FATAL CONDITION은 경고장이다. 쪽지가 왔다는 건 "다음엔 진짜 쫓겨난다"는 뜻이라 아주 유용한 정보다. 그런데 많은 사람이 "어차피 안 쫓겨났잖아" 하고 쪽지를 버린다. 그러다 어느 날 쫓겨나고는 "아무 예고도 없었다"고 말한다. 예고는 있었다.
또 하나 재밌는 건 쪽지에 찍힌 시각이다. 쪽지는 "마지막으로 짐을 넣은 순간"에 붙는다. 그 마지막 짐이 양말 한 켤레였다고 해서 양말이 문제인 건 아니다. 이미 30분 전에 넣은 캐리어 두 개가 진짜 원인이다. 쪽지에 적힌 "양말"만 보고 양말을 뺀다고 해결되지 않는다.
꼬리 질문
EXC_RESOURCE (MEMORY) 경고를 받고도 앱이 계속 사는데, Jetsam은 언제 실제로 죽이는가?
두 축이 따로 돈다. 하나는 per-process 한도다 — 프로세스가 자기 몫의 footprint 상한을 넘으면 시스템 전체에 여유가 있어도 그 프로세스만 죽는다(P0 01장). EXC_RESOURCE (MEMORY)는 대개 이 상한 근처에서 나오는 경고이고, 계속 늘려가면 결국 per-process-limit 사유로 종료된다.
다른 하나는 시스템 전체 메모리 압박이다. 이건 우리 앱의 사용량과 무관하게 발생할 수 있고, Jetsam이 우선순위 밴드(포그라운드 > 백그라운드 > 유휴)에 따라 희생자를 고른다. 우리 앱이 백그라운드에 있으면 상한 근처가 아니어도 죽을 수 있다.
그래서 "경고를 받았지만 안 죽었다"는 상황은 대개 스파이크가 잠깐이었고 곧 내려간 경우다. 반대로 경고 없이 죽는 경우도 있는데, 백그라운드에서 시스템 압박에 의해 선택된 경우다.
실무적으로는 경고 발생률과 종료율을 함께 추적하는 것이 옳다. 경고만 늘고 종료가 안 늘면 스파이크 문제(한 번에 크게 잡았다 놓음), 둘 다 늘면 기저 사용량 자체가 올라간 것이다.
WAKEUPS 예외에서 "비슷한 백트레이스의 스레드가 여러 개"가 왜 단서가 되는가?
웨이크업은 스레드가 잠들었다 깨어나는 사건이다. 이게 잦으면 CPU가 저전력 상태로 내려가지 못해 배터리를 크게 소모한다. 그래서 OS가 초당 웨이크업 횟수에 한도를 둔다.
문제를 일으키는 전형적 패턴은 스레드 간 통신이다. 한 스레드가 다른 스레드에 작업을 넘기면 받는 쪽이 깨어나야 하고, 이게 초당 수백 번 반복되면 한도를 넘는다. 그런데 이 패턴은 대개 같은 코드 경로가 여러 스레드에서 동시에 도는 형태로 나타난다 — 워커 풀, 병렬 처리, 반복 실행되는 타이머 기반 작업 같은 것들이다.
그래서 크래시 리포트를 열었을 때 백그라운드 스레드 5~10개가 거의 같은 스택을 갖고 있으면 그 스택이 곧 범인이다. 하나짜리 문제라면 스레드 하나만 튀겠지만, 웨이크업 문제는 구조적으로 여러 스레드가 같은 일을 반복하는 형태이기 때문이다.
조사 방향은 (a) 큐에 작업을 잘게 쪼개 넣고 있지 않은지, (b) 타이머 주기가 지나치게 짧지 않은지, (c) 완료 콜백이 매번 다른 큐로 홉하고 있지 않은지다. 특히 (c)는 무심코 만들기 쉽다 — 결과 하나를 넘기려고 큐를 두세 번 건너뛰면 웨이크업이 배로 는다.
phys_footprint와 resident_size가 다른 이유와, 어느 쪽을 계측해야 하는가?
resident_size는 물리 RAM에 실제로 상주 중인 페이지의 크기다. 전통적 유닉스 개념(RSS)이고, 여기에는 다른 프로세스와 공유하는 페이지도 포함되며 압축된 페이지는 빠진다.
phys_footprint는 시스템이 이 프로세스에 귀속시키는 메모리다. dirty 페이지와 압축된 메모리를 포함하고, 공유 페이지는 적절히 배분해 계산한다. P0 01장에서 본 대로 iOS는 앱의 dirty 메모리를 디스크로 스왑하지 않고 압축하는데, 압축된 메모리는 RAM 안에 남아 있으므로 여전히 우리 몫이다.
차이가 실무에서 드러나는 순간은 이렇다 — 앱이 큰 캐시를 만들고 한동안 쓰지 않으면 커널이 그 페이지들을 압축한다. resident_size는 뚝 떨어지고, 개발자는 "메모리를 잘 관리했다"고 착각한다. 그런데 phys_footprint는 거의 그대로이고, Jetsam은 이 값으로 판정하므로 여전히 위험하다.
결론은 명확하다 — 계측도 판정도 phys_footprint로 해야 한다. Xcode 메모리 게이지와 Instruments의 Allocations도 이 값에 맞춘 지표를 보여주고, os_proc_available_memory()도 같은 기준으로 남은 양을 알려준다.
phys_footprint)를 잰다.Q6. 크래시 리포터가 원래 크래시를 가리는 일은 왜 일어나는가?
리포터의 시그널 핸들러가 async-signal-safe하지 않은 코드를 실행하다 자기가 먼저 죽으면, 프로세스는 리포터의 크래시로 종료된다. 그러면 리포트에는 원래 크래시가 아니라 리포터가 죽은 지점이 남는다. 실제 사례가 있다 — sentry-cocoa의 시그널 핸들러가 지연 초기화되는 _Thread_local 변수를 읽었고, 그 초기화가 malloc을 재진입해 _os_unfair_lock_recursive_abort로 중단됐다. 증상은 최악이다. 대시보드에는 똑같은 크래시가 잔뜩 쌓이는데 그게 전부 리포터의 스택이라 원인을 알 수 없다. 판별법은 스택 최상단이 리포터·libsystem 심볼로만 채워져 있고 우리 코드가 안 보이는 패턴이다.
CS 원리
이건 관측자 효과의 극단적 형태다. 18장 Q7에서 관측 도구가 대상의 성능을 바꾼다는 얘기를 했는데, 여기서는 관측 도구가 관측 대상 자체를 파괴한다.
메커니즘은 단순하다.
- 원래 크래시(예:
SIGSEGV)가 발생하고 리포터의 핸들러가 호출된다. - 핸들러가 안전하지 않은 호출(할당·락·ObjC)을 한다.
- 그 호출이 실패하거나 교착하거나
abort()를 유발한다. - 프로세스는 2차 크래시로 종료된다. 시스템 크래시 리포트에는 2차 크래시가 기록된다.
- 원래 크래시의 정보는 어디에도 남지 않는다.
여기서 특히 고약한 점은 실패가 결정적이지 않다는 것이다. 힙 락이 잡혀 있는지 여부는 크래시가 발생한 정확한 타이밍에 달렸으므로, 같은 버그라도 어떤 사용자에게는 정상 리포트가 나오고 어떤 사용자에게는 리포터가 죽는다. 재현이 극도로 어렵고 통계적으로만 드러난다.
더 근본적인 문제도 있다. 원래 크래시가 힙 손상이었다면 — 예를 들어 use-after-free나 버퍼 오버플로로 힙 메타데이터가 깨진 상태 — 리포터가 아무리 조심해도 할당 관련 동작은 위험하다. 즉 가장 심각한 종류의 버그일수록 리포터가 실패할 확률이 높다. 통계 편향이 여기서 또 발생한다.
iOS에서는
실제 사례를 보는 것이 가장 빠르다. sentry-cocoa 이슈 #8134의 구조는 이렇다.
- 시그널 핸들러
handleSignal안에서_Thread_local변수를 읽었다. 코드만 보면 그냥 변수 읽기라 안전해 보인다. - 그런데 C/C++의 스레드 로컬 변수는 첫 접근 시 지연 초기화될 수 있고, 그 초기화 경로가 내부적으로
malloc을 호출한다. - 크래시 시점에 힙 락이 잡혀 있었다면 이
malloc이 이미 자기가 쥔 락을 다시 잡으려 하고, libsystem이 이를 감지해_os_unfair_lock_recursive_abort로 프로세스를 중단시킨다. - 결과 — 원래 크래시가 가려진다.
이 사례가 교훈적인 이유는 "변수 하나 읽는 것"조차 안전하지 않을 수 있다는 점이다. 소스 코드 수준에서는 할당이 전혀 보이지 않는다. 위험은 컴파일러와 런타임이 삽입한 코드에 숨어 있다.
Swift에서는 이런 함정이 더 많다. 다음은 전부 보이지 않는 힙 할당을 유발한다.
| 겉보기 | 실제로 일어나는 일 |
|---|---|
"crash: \(sig)" | String 보간 → 힙 할당 |
[1, 2, 3] | Array 생성 → 힙 할당 |
| 이스케이핑 클로저 캡처 | 컨텍스트 박스 → 힙 할당 (P0 05장) |
기존 String을 Data로 변환 | 버퍼 복사 → 힙 할당 |
프로토콜 타입(any P)에 큰 값 담기 | 박싱 → 힙 할당 (P0 05장) |
lazy var 최초 접근 | 지연 초기화 → 할당 가능 |
전역 let 최초 접근 | swift_once → 초기화 코드 실행 |
마지막 두 줄이 sentry 사례와 정확히 같은 패턴이다. "이미 초기화됐겠지"라는 가정이 틀리는 순간 위험한 코드가 실행된다.
실험 · 도구
내가 쓰는 SDK가 이 문제를 겪고 있는지 판별하는 법은 리포트의 패턴을 보는 것이다.
// 🚩 리포터가 죽은 리포트의 전형적 패턴
Exception Type: EXC_CRASH (SIGABRT)
Thread 0 Crashed:
0 libsystem_kernel.dylib __pthread_kill + 8
1 libsystem_pthread.dylib pthread_kill + 268
2 libsystem_c.dylib abort + 180
3 libsystem_platform.dylib _os_unfair_lock_recursive_abort + 36 ← 락 재진입
4 libsystem_malloc.dylib _malloc_zone_malloc_instrumented + 264 ← 핸들러 안의 malloc
5 libsystem_c.dylib tlv_get_addr + 100 ← 스레드 로컬 지연 초기화
6 SentryCrash handleSignal + 152 ← 리포터의 핸들러
7 libsystem_platform.dylib _sigtramp + 56 ← 여기부터 위가 2차 크래시
8 ??? ??? ← 원래 크래시는 여기 아래에
9 ??? ??? 있었는데 잘려 있다
// 판별 신호 3가지
// (1) _sigtramp 위쪽이 전부 시스템·리포터 심볼이고 우리 코드가 없다
// (2) recursive_abort / malloc / tlv_get_addr 조합이 보인다
// (3) 같은 스택의 이슈가 대시보드 상위에 대량으로 쌓여 있다
대시보드에서 이 패턴을 자동으로 찾는 방법도 있다.
# 인앱 프레임이 하나도 없는 이슈를 찾는다 — 리포터 자기 크래시의 강력한 신호
# 대부분의 백엔드가 "in-app frame 없음" 필터를 지원한다.
# 로컬 .ips 더미를 jq 로 분석한다 (.ips 는 JSON — 첫 줄 메타, 둘째 줄 본문)
# 1) 재진입 abort 흔적이 있는 리포트 수
for f in *.ips; do
jq -s -e '.[1].threads[]?.frames[]?.symbol? // empty
| select(test("_os_unfair_lock_recursive_abort"))' "$f" >/dev/null 2>&1 \
&& echo "$f"
done | wc -l
# 2) 크래시 스레드에 우리 바이너리 프레임이 하나도 없는 리포트 수
for f in *.ips; do
jq -s -e --arg app "YourAppName" '
.[1] as $r
| ($r.usedImages | map(.name) | index($app)) as $idx
| $r.threads[] | select(.triggered == true)
| [ .frames[].imageIndex ] | index($idx) | not' "$f" >/dev/null 2>&1 \
&& echo "$f"
done | wc -l
# → 이 비율이 갑자기 오르면 리포터 실패 또는 심볼화 문제 (21장 Q3)
발견했을 때의 대응은 SDK 업데이트가 1순위다. 이런 버그는 리포터 벤더가 고쳐야 하고, 대개 이미 알려져 있다. 임시 완화책으로는 문제가 되는 자동 계측 기능을 끄거나, 리포터를 다른 구현으로 교체하는 방법이 있다.
프로젝트 적용
우리 코드가 크래시 경로에 끼어들지 않게 하는 것이 실무의 핵심이다.
- 시그널 핸들러를 직접 설치하지 않는다. 정말 필요하다면 기존 핸들러를
sigaction으로 저장했다가 체이닝하고, 핸들러 본문은write()와 사전 계산된 상수 문자열만 쓴다. NSSetUncaughtExceptionHandler도 마찬가지로 체이닝한다. (Q2)- 크래시 리포팅 SDK를 두 개 이상 붙이지 않는다. (Q3) 서로의 핸들러를 덮어써 양쪽 다 실패할 수 있다.
- SDK 버전을 고정해두고 방치하지 않는다. 이 종류의 버그는 조용히 데이터를 망가뜨리므로, 릴리스 노트에 크래시 핸들러 관련 수정이 있으면 우선순위를 높여 반영한다.
그리고 지표로 감시하는 방법이 있다 — "인앱 프레임이 없는 크래시의 비율"을 추적한다. 이 비율이 갑자기 오르면 리포터 문제이거나 심볼화 문제(20장)다. 둘 다 조용히 진행되므로 능동적으로 보지 않으면 몇 달을 놓친다.
"크래시 리포트가 많이 쌓이는 건 최소한 리포터는 잘 동작한다는 뜻"이라는 안심이 가장 위험하다. 리포터가 자기 크래시를 대량으로 보고하는 상황이 정확히 그렇게 보인다. "우리는 SDK를 쓰니까 이 문제와 무관하다"도 틀렸다 — 사례 자체가 널리 쓰이는 SDK에서 나왔다. 또 "핸들러 안에서 변수만 읽으면 안전하다"도 반례가 있다 — 스레드 로컬·전역 lazy는 첫 접근에 초기화 코드가 돌 수 있다. 마지막으로 "이 문제는 드물다"는 인식도 조심해야 한다. 힙 손상 계열 버그에서 확률이 크게 오르므로, 하필 가장 고치고 싶은 버그에서 증거가 사라진다.
사고 현장에 블랙박스를 확인하러 간 조사관이 있다고 하자. 조사관은 현장에 들어가서 기록을 남겨야 한다.
그런데 이 조사관이 현장에서 커피를 타 마시는 습관이 있다. 평소엔 아무 문제 없다. 그런데 하필 사고가 전기 누전이었다면? 조사관이 커피포트를 켜는 순간 감전당한다.
결과가 최악이다. 사고 기록에 남는 건 "조사관이 감전됨"이고, 원래 사고가 무엇이었는지는 아무 데도 안 남는다. 게다가 이런 일이 여러 현장에서 반복되면, 사고 통계에 "감전 사고"만 잔뜩 쌓인다. 실제로는 전부 다른 원인의 사고였는데.
더 얄궂은 건, 제일 심각한 사고일수록 이 일이 잘 일어난다는 점이다. 현장이 엉망일수록 조사관이 다칠 확률이 높으니까. 그래서 정말 알고 싶은 사고의 기록이 가장 잘 사라진다.
그래서 조사관에게는 규칙이 있다 — 현장에서는 아무것도 만지지 말고, 미리 챙겨온 수첩에 연필로만 적어라. 이게 async-signal-safe 규칙이다.
꼬리 질문
2차 크래시가 발생해도 원래 크래시 정보를 살릴 방법이 있는가?
일부는 가능하다. 핵심 아이디어는 핸들러가 위험한 작업을 하기 전에, 최소한의 정보를 먼저 디스크에 써두는 것이다. 시그널 번호, fault address, 크래시 스레드의 프로그램 카운터 정도는 write() 한 번으로 남길 수 있다. 이후 단계에서 리포터가 죽어도 이 최소 기록은 남고, 다음 실행에서 "직전에 이런 크래시가 있었다"를 알 수 있다.
두 번째 방법은 핸들러 진입 자체를 기록하는 것이다. 핸들러 시작 시 플래그 파일을 만들고 정상 완료 시 지운다. 다음 실행에서 플래그가 남아 있으면 "핸들러가 완주하지 못했다"는 것을 알 수 있고, 이 카운트가 곧 리포터 실패율이 된다.
세 번째는 단계별 진행 표시다. 핸들러의 각 단계에 번호를 붙여 write()로 갱신하면, 다음 실행에서 "몇 단계까지 갔다가 죽었는지"를 알 수 있어 리포터 버그 자체를 디버깅할 수 있다.
다만 이 모두는 리포터 구현 쪽 얘기이지 앱 개발자가 할 수 있는 일은 아니다. 앱 쪽에서 할 수 있는 건 "인앱 프레임 없는 크래시 비율"을 감시해 문제가 있다는 사실을 아는 것까지다.
Swift 코드에서 "할당이 없는 코드"를 어떻게 확신할 수 있는가?
완전히 확신하기는 어렵고, 이것이 시그널 핸들러를 Swift로 작성하지 않는 실무적 이유다. 그래도 검증 수단은 몇 가지 있다.
정적 확인: 컴파일된 오브젝트 파일에서 할당 심볼 참조를 찾는다. nm -u로 미정의 심볼을 뽑아 swift_allocObject, malloc, swift_retain, objc_msgSend 같은 이름이 있는지 본다. 있으면 그 경로에 할당이나 런타임 호출이 있다는 뜻이다.
swiftc -O -c Handler.swift -o Handler.o
nm -u Handler.o | grep -E 'swift_alloc|swift_retain|malloc|objc_msgSend|_tlv_'SIL 확인: swiftc -emit-sil -O로 중간 표현을 보면 alloc_box, alloc_ref 같은 명령이 그대로 보인다. 어느 소스 줄이 할당을 유발했는지까지 추적할 수 있다.
구조적 회피: 애초에 핸들러 본문을 C로 작성하고 Swift에서는 등록만 한다. C는 무엇이 할당인지 소스에서 명확히 보이므로 검증 비용이 훨씬 낮다. 실제 크래시 리포터들이 핵심 경로를 C로 유지하는 이유가 이것이다.
덧붙여 Swift의 @_noAllocation 같은 실험적 속성이나 임베디드 Swift 모드가 이 영역을 다루고 있지만, 일반 앱 개발에서 의존할 만큼 안정된 도구는 아직 아니다.
"인앱 프레임 없는 크래시 비율"이 오르는 원인 중 리포터 문제가 아닌 것은?
가장 흔한 대안 원인은 심볼화 실패다(20장). dSYM이 없으면 우리 코드 프레임이 함수명 대신 주소로 남고, 백엔드가 "인앱"으로 분류하지 못해 프레임이 전부 시스템 것처럼 보인다. 이 경우 스택에 0x… 형태 주소가 많이 보이므로 구분된다.
두 번째는 시스템 프레임에서 발생하는 진짜 크래시다. UIKit·Core Data 내부에서 우리가 넘긴 잘못된 인자 때문에 크래시가 나면, 스택 상단이 전부 시스템 프레임이고 우리 코드는 훨씬 아래에 있거나 비동기 경계에서 잘려 없을 수 있다. 이건 실제 버그이고 리포터 문제가 아니다.
세 번째는 Swift 비동기 스택 절단이다(18장 Q6). await를 넘나든 뒤 재개 지점에서 크래시하면 원래 호출자 스택이 없어서 인앱 프레임이 얇아진다.
네 번째는 SIGKILL 계열 종료다. 스택 자체가 원인과 무관하므로 인앱 프레임이 없거나 무의미할 수 있다 — 이 경우는 Termination Reason으로 즉시 구분된다.
구분 순서는 이렇다 — (1) Termination Reason이 있나? → SIGKILL 계열. (2) 주소가 심볼화 안 됐나? → dSYM 문제. (3) _sigtramp + recursive_abort 조합인가? → 리포터 문제. (4) 나머지는 실제 시스템 프레임 크래시.
Q7. MetricKit과 자체 크래시 리포팅은 왜 둘 다 필요한가?
서로의 맹점을 정확히 메우기 때문이다. 자체 리포터는 즉시·상세하지만 자기가 죽으면 아무것도 못 남기고(Q6), SIGKILL 계열 종료는 원리적으로 못 잡는다(Q1). MetricKit은 OS가 직접 집계하므로 우리 코드가 죽어도 기록되고 워치독·Jetsam 종료까지 MXAppExitMetric으로 분류해주지만, 메트릭은 하루 한 번 집계되어 오고 실기기에서만 동작하며 breadcrumb 같은 앱 맥락이 없다. 요약하면 MetricKit은 "정확한 전체 통계", 자체 리포터는 "즉시 파악 가능한 개별 사건"이다. 둘 중 하나만 보면 반드시 왜곡된다.
CS 원리
이건 신뢰의 경계(trust boundary) 문제다. 관측 주체가 관측 대상 안에 있으면 대상이 무너질 때 관측도 함께 무너진다. 밖에 있으면 대상이 무너져도 관측이 살아남지만, 대신 안에서만 알 수 있는 정보는 얻을 수 없다.
| 자체 리포터 (안) | MetricKit (밖) | |
|---|---|---|
| 관측 주체 | 앱 프로세스 자신 | 운영체제 |
| 앱이 즉사해도 기록? | 아니오 | 예 |
SIGKILL 계열 포착 | 불가 (추정만) | 가능 |
| 앱 내부 맥락 (breadcrumb·태그) | 풍부 | 없음 |
| 지연 | 다음 실행 시 | 진단 즉시 / 메트릭 하루 1회 |
| 커스터마이징 | 자유 | 고정 스키마 |
| 시뮬레이터 | 동작 | 실기기만 |
여기서 핵심은 "둘 다 부분적으로 눈이 멀었다"는 것이다. 어느 쪽도 완전하지 않으므로, 둘의 숫자가 어긋날 때 어느 쪽이 무엇에 눈이 먼지 알고 판단해야 한다.
iOS에서는
MetricKit이 제공하는 것을 정확히 알아두면 역할 분담이 명확해진다.
- 진단(diagnostic) 5종 —
MXCrashDiagnostic(크래시),MXCPUExceptionDiagnostic(CPU 예외),MXHangDiagnostic(행),MXDiskWriteExceptionDiagnostic(디스크 쓰기 예외), 그리고MXAppLaunchDiagnostic(느린 실행, iOS 16+). iOS 15 이상에서는 즉시 전달된다. - 메트릭 — 실행 시간·응답성·메모리·디스크·배터리 등. 직전 24시간치를 하루 최대 한 번 전달한다.
MXAppExitMetric— 포그라운드/백그라운드별 종료 유형 통계. 워치독·메모리 자원 한도·강제 종료를 OS가 직접 분류해준다. Q1에서 말한 "사용자 강제 종료와 워치독 구분"의 정답이 여기 있다.
사용법 자체는 간단하고, Apple은 구독·수신 호출이 앱 런치 같은 성능 민감 코드에서도 안전하다고 명시한다.
import MetricKit
final class AppMetrics: NSObject, MXMetricManagerSubscriber {
static let shared = AppMetrics()
func start() {
MXMetricManager.shared.add(self) // 런치 경로에서 호출해도 안전하다고 문서에 명시
}
// 하루 최대 1회 — 직전 24시간 집계
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// 종료 유형 분포 — 자체 리포터가 못 보는 SIGKILL 계열이 여기 있다
if let exits = payload.applicationExitMetrics {
let fg = exits.foregroundExitData
upload(metric: "exit.fg.watchdog", fg.cumulativeAppWatchdogExitCount)
upload(metric: "exit.fg.memory", fg.cumulativeMemoryResourceLimitExitCount)
upload(metric: "exit.fg.badAccess", fg.cumulativeBadAccessExitCount)
upload(metric: "exit.fg.abnormal", fg.cumulativeAbnormalExitCount)
upload(metric: "exit.fg.normal", fg.cumulativeNormalAppExitCount)
let bg = exits.backgroundExitData
upload(metric: "exit.bg.memory", bg.cumulativeMemoryResourceLimitExitCount)
upload(metric: "exit.bg.taskTimeout", bg.cumulativeBackgroundTaskAssertionTimeoutExitCount)
upload(metric: "exit.bg.cpuLimit", bg.cumulativeCPUResourceLimitExitCount)
}
// 실행 시간 히스토그램 — 실사용자 기기 분포 (18장 Q7의 콜드 스타트 검증)
if let launch = payload.applicationLaunchMetrics {
uploadHistogram("launch.timeToFirstDraw", launch.histogrammedTimeToFirstDraw)
}
}
}
// iOS 15+ 에서는 즉시 전달
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
payload.crashDiagnostics?.forEach { forward($0.jsonRepresentation()) }
payload.hangDiagnostics?.forEach { forward($0.jsonRepresentation()) } // 20장
payload.cpuExceptionDiagnostics?.forEach { forward($0.jsonRepresentation()) }
if #available(iOS 16.0, *) {
// 느린 콜드 스타트의 콜스택 — 18장 Q7·26장 Q3 과 직접 연결된다
payload.appLaunchDiagnostics?.forEach { forward($0.jsonRepresentation()) }
}
}
}
private func upload(metric: String, _ value: Int) { /* … */ }
private func uploadHistogram(_ name: String, _ h: MXHistogram<UnitDuration>) { /* … */ }
private func forward(_ json: Data) { /* 자체 백엔드로 전달 */ }
}
중요한 실무 포인트 — MetricKit 페이로드를 그냥 두면 아무 데도 안 간다. Apple은 데이터를 앱에 전달만 하고, 그걸 서버로 보내 집계하는 것은 우리 몫이다. Xcode Organizer가 별도로 보여주는 값이 있지만 그건 사용자 동의 기반의 Apple 자체 수집이고, MetricKit 콜백과는 다른 경로다.
실험 · 도구
MetricKit은 실기기에서만 동작하고 하루 한 번 오므로 개발 중 검증이 까다롭다. Xcode가 페이로드 시뮬레이션 기능을 제공한다.
# Xcode 메뉴: Debug › Simulate MetricKit Payloads
# → didReceive(_ payloads:) 콜백이 즉시 호출된다.
# ⚠️ 실기기에서 실행 중이어야 하고, 시뮬레이터에서는 동작하지 않는다.
# 실제 페이로드가 오는지 확인하려면 앱을 하루 이상 실기기에서 사용한 뒤
# 콜백에 로그를 심어 확인한다.
log stream --predicate 'subsystem == "com.example.shop" AND category == "metrickit"' \
--style compact
둘의 숫자를 대조하는 것이 이 챕터의 실전 활용이다. 다음 표를 팀 운영 지표로 만들어두면 유용하다.
| 상황 | 자체 리포터 | MetricKit | 해석 |
|---|---|---|---|
| 정상 | 100건 | 105건 | 차이가 작으면 건강. 소폭 차이는 미전송·집계 지연 |
| SDK가 크래시 유실 | 20건 | 100건 | 리포터 실패(Q6) 또는 예외 포트 충돌(Q3) 의심 |
| SIGKILL 계열 다수 | 10건 | 종료 200건 | Jetsam·워치독이 주원인. 크래시가 아니라 성능 문제 |
| 리포터 자기 크래시 | 500건 | 50건 | 리포터가 자기 크래시를 대량 보고 중 |
프로젝트 적용
실무 구성은 이렇게 정리된다.
- 자체 리포터는 개별 사건 조사용. breadcrumb·태그·사용자 맥락이 붙어 있어 "이 사용자가 왜 실패했나"에 답한다. 즉시성이 필요한 알람도 여기서 건다.
- MetricKit은 기준선(ground truth)용. "우리가 실제로 얼마나 죽고 있나"의 정답에 가장 가깝다. 자체 리포터 숫자를 검증하는 데 쓴다.
MXAppExitMetric은 크래시 대시보드와 분리해 별도 지표로. 워치독·메모리 종료는 크래시가 아니라 성능 문제이고 대응 팀이 다르다(Q4).- 두 숫자의 비율을 지표로 만든다.
자체 리포터 건수 / MetricKit 크래시 건수가 1에서 크게 벗어나면 계측 자체에 문제가 생긴 것이다.
주의할 점 하나 — MetricKit 페이로드는 사용자 동의나 시스템 정책에 따라 안 올 수 있고, 특정 iOS 버전에서 전달이 누락되는 사례도 보고된 적이 있다. 그래서 MetricKit도 "완벽한 기준선"은 아니고, 둘이 서로를 검증하는 관계로 두는 것이 맞다.
"MetricKit을 붙이면 크래시 리포팅 SDK가 필요 없다"는 틀렸다. MetricKit에는 breadcrumb·태그·사용자 맥락이 없고 메트릭은 하루 한 번이라 즉시 대응이 불가능하다. 반대로 "SDK가 있으니 MetricKit은 중복"도 틀렸다 — SIGKILL 계열 종료와 리포터 자신의 실패는 원리적으로 SDK가 볼 수 없는 영역이다. "MetricKit 데이터는 자동으로 Apple에 올라가 Organizer에서 보인다"도 오해다 — MetricKit 콜백 데이터는 우리가 직접 서버로 보내야 하며, Organizer의 값은 별개 경로다. 또 "MetricKit 진단은 하루 한 번"이라고 뭉뚱그리는 것도 부정확하다 — 진단은 iOS 15부터 즉시, 메트릭이 하루 한 번이다.
가게에서 사고가 났을 때 기록이 두 가지 있다고 해보자.
하나는 직원이 쓰는 일지다. 아주 자세하다. "3시 15분에 손님이 이런 걸 물어봤고, 그때 저쪽에서 소리가 났고…" 사고 직전 상황까지 다 적혀 있어서 원인을 파악하기 좋다. 그런데 문제가 있다 — 직원이 다치면 일지도 못 쓴다. 그리고 가게가 통째로 정전되면 아무것도 못 적는다.
다른 하나는 건물 관리사무소의 기록이다. "3층 202호, 8월 14일, 정전으로 영업 중단." 간단하고 무미건조하다. 대신 가게 안에서 무슨 일이 있었든 반드시 남는다. 직원이 다쳐도, 정전이 나도 관리사무소는 밖에 있으니까.
여기서 중요한 게 있다. 관리사무소 기록은 한 달에 한 번 정산해서 온다. 지금 당장 뭐가 잘못됐는지는 알 수 없다. 반면 직원 일지는 그날 바로 볼 수 있다.
그래서 둘 다 필요하다. "지금 뭐가 문제지?"는 일지로, "우리 가게 진짜 사고율이 얼마지?"는 관리사무소 기록으로 답한다. 그리고 두 숫자가 크게 다르면 — 일지에는 10건인데 관리사무소엔 100건이면 — 일지 쓰는 사람이 자꾸 다치고 있다는 뜻이다.
꼬리 질문
MetricKit 메트릭이 하루 한 번인데도 유용한 이유는?
메트릭의 용도가 즉시 대응이 아니라 기준선과 추세이기 때문이다. "지금 장애가 났나"는 자체 리포터와 알람이 답하고, MetricKit은 "지난 릴리스 대비 실제로 나아졌나"에 답한다. 이 질문은 애초에 하루 단위 이하의 해상도가 필요 없다.
또한 하루 집계라는 점이 오히려 안정성을 준다. 실시간 지표는 표본이 적을 때 크게 흔들리지만, 24시간 집계는 노이즈가 눌린 값이라 릴리스 간 비교에 적합하다.
결정적으로 MetricKit 메트릭은 OS가 모든 실행을 세어서 만든 값이다. 자체 계측은 "앱이 살아서 보고할 수 있었던 경우"만 세므로 분모 자체가 편향돼 있다(Q2의 삭제 편향). MetricKit의 MXAppExitMetric은 정상 종료까지 포함한 전체 종료를 세므로 정확한 분모를 준다.
실무에서 이걸 활용하는 방법은 릴리스별로 비정상 종료 / 전체 종료 비율을 그려보는 것이다. 자체 크래시율이 개선된 것처럼 보이는데 이 비율이 그대로면, 개선이 아니라 보고가 줄어든 것이다.
MXCrashDiagnostic이 있는데도 서드파티 SDK를 쓰는 이유가 맥락 말고 또 있는가?
몇 가지 더 있다. 첫째, 지원 OS 범위다. MetricKit 진단은 iOS 14 이상에서 의미 있게 쓸 수 있고 즉시 전달은 iOS 15부터다. 하위 버전을 지원하는 앱은 그 구간을 다른 수단으로 메워야 한다.
둘째, 그룹핑과 워크플로다. MetricKit은 진단 페이로드를 주기만 하고 이슈 그룹핑(18장 Q6), 회귀 감지, 담당자 배정, 알림 같은 운영 기능은 전혀 제공하지 않는다. 그걸 직접 구현하면 결국 크래시 리포팅 백엔드를 만드는 셈이 된다.
셋째, 심볼화 파이프라인이다. MetricKit 페이로드는 심볼화되지 않은 주소를 주므로 dSYM과 대조하는 처리를 우리가 붙여야 한다(20장).
넷째, 플랫폼 통일이다. Android와 iOS를 한 대시보드에서 보려면 공통 SDK가 필요하다.
다섯째, 릴리스 건강도 연계다. 크래시-프리 세션율 같은 지표는 세션 추적과 결합돼야 나오는데 MetricKit만으로는 세션 개념이 없다.
정리하면 MetricKit은 데이터 소스이지 제품이 아니다. 좋은 구성은 MetricKit을 소스 중 하나로 삼아 자체 백엔드나 SDK로 흘려보내는 것이다.
두 소스의 숫자가 어긋날 때 어느 쪽을 기준으로 삼아야 하는가?
질문에 따라 다르다. "실제로 얼마나 죽었나"는 MetricKit이 기준이다. OS가 세므로 앱의 보고 능력과 무관하고, 정상 종료까지 포함해 분모가 정확하다.
"왜 죽었나"는 자체 리포터가 기준이다. 스택·breadcrumb·태그가 붙어 있어야 원인을 좁힐 수 있다.
그런데 실무에서 더 중요한 것은 어긋남 자체를 신호로 읽는 것이다. 어긋남의 방향이 진단을 준다.
- 자체 << MetricKit → 리포터가 못 잡고 있다.
SIGKILL계열이 많거나(성능 문제), 리포터가 실패 중이거나(Q6), 예외 포트 충돌(Q3). - 자체 >> MetricKit → 리포터가 자기 크래시를 세고 있거나, 실제 크래시가 아닌 것을 크래시로 집계 중이다.
- 둘 다 낮은데 사용자 불만이 많음 → 크래시가 아니라 행이 문제다(20장). 행은 앱이 죽지 않으므로 어느 크래시 지표에도 안 잡힌다.
마지막 항목이 특히 중요하다. "크래시율은 좋은데 평점이 나쁘다"의 가장 흔한 답이 행이고, 그래서 다음 챕터가 행과 응답성이다.