한 iOS 앱의 테스트 스위트가 부팅 중에 SIGABRT로 죽어서 61건 중 한 건도 못 돌았습니다.
처음에는 특정 테스트의 결함처럼 보였습니다. 죽는 지점이 실행마다 달랐기 때문입니다.
앱 코드 결함이 아니었습니다. 빌드 세팅 + 런타임 결함이었습니다.
질문 하나 드리겠습니다. 당신 테스트가 죽는 지점이 실행마다 다르다면, 당신은 그것을 "플래키 테스트"라고 부르고 넘어갑니까?
1. 스택
<ViewModel>.__deallocating_deinit
→ swift_task_deinitOnExecutorMainActorBackDeploy
→ swift_task_deinitOnExecutorImpl
→ TaskLocal::StopLookupScope::~StopLookupScope()
→ ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED
→ abort방아쇠는 기본 액터 격리를 메인 액터로 두는 빌드 세팅 하나입니다.
2. 아무것도 안 쓴 클래스만 위험합니다
이 세팅이 있으면 컴파일러가 deinit을 직접 쓰지 않은 메인 액터 클래스에만 isolated deinit을 합성합니다.
| 클래스 형태 (기본 격리 = 메인 액터) | isolated deinit 합성 |
|---|---|
| deinit을 안 씀(암시적) | 예 |
deinit {} / nonisolated deinit {} 명시 |
아니오 |
isolated deinit {} 명시 |
예 |
nonisolated 클래스 |
아니오 |
세팅 없음(@MainActor만 붙여도) |
아니오 |
직관과 반대입니다. 보통은 "뭔가를 쓴 쪽"을 의심하는데, 여기서는 아무것도 안 쓴 쪽이 위험합니다. 그래서 코드를 아무리 읽어도 범인이 안 보입니다. 범인은 그 자리에 없는 코드입니다.
3. 기계적 원인
런타임 동시성 라이브러리를 디스어셈블해 확인했습니다.
현재 태스크가 없으면 스레드 폴백 task-local 리스트를 쓰면서 stop 노드를 태스크 슬랩
할당기로 잡는데, 소멸자는 그것을 **free()**로 반납합니다. malloc 불변식 위반이라 무조건
abort입니다.
크래시 조건은 셋입니다.
- isolated deinit이 동기 fast path로 실행
- 현재 태스크가 없는 동기 코드
- 그 스레드의 폴백 task-local 리스트에 살아 있는 바인딩
③을 테스트 프레임워크가 만듭니다. 테스트 메서드마다 오류 관찰용 task-local을 걸고, 그 프레임이 크래시 스택에 그대로 남아 있습니다. 그래서 **"메인 액터 객체를 해제하는 테스트는 전부 죽는다"**가 되고, 죽는 지점이 매번 달라 특정 테스트의 결함처럼 보입니다.
20줄 최소 재현이 있습니다(task-local을 동기 withValue로 걸고 그 안에서 암시적 deinit 클래스를
해제). 세팅을 빼면 안 죽습니다. macOS 호스트에서는 같은 소스가 안 죽는데 그 이유는 규명하지
못했습니다.
4. 제가 처음에 한 주장은 미검증이었습니다
⚠️ "61건 전부 통과"는 처음엔 미검증 주장이었습니다.
그때 확인한 것은 오브젝트 코드 대조와 ** TEST BUILD SUCCEEDED **까지였습니다. 시뮬레이터가
다른 작업 부하로 막혀 스위트를 못 돌렸습니다. 하루 뒤에 실제로 돌려 확인했습니다.
Executed 61 tests, with 0 failures, 로그에 abort 0건.
빌드가 성공했다는 것과 테스트가 통과한다는 것은 다릅니다. 특히 이 결함은 부팅 중 죽어서 빌드로는 절대 안 드러납니다. 같은 배치의 0건을 실행하고 통과라고 찍힌 이야기와 헤더 타이머 때문에 무엇을 해도 통과하던 이야기가 같은 계열입니다. 초록불이 거짓말하는 세 가지 방식입니다. 검증 단계가 실제로 검증하지 않던 이야기와 폴백이 판을 초록으로 유지하던 이야기가 그 계열의 앞글입니다.
5. 진단하면서 밟은 함정들
- ⚠️ 서명을 끄고 돌리면 양상이 달라집니다. 개별 테스트가 abort하고 일부는 통과해서 "특정 테스트 결함"으로 오진합니다. 서명을 켜야 진짜 스택이 나옵니다.
- ⚠️ 빌드가 실패한 뒤의 테스트 결과 XML은 근거가 아닙니다. 낡은 결과가 남아 있습니다. 같은 주에 다른 앱에서도 이 함정으로 "로케일 관련 실패"를 한 번 오진했습니다.
- ⚠️ 격리 오용을 먼저 의심하게 되는데 아닙니다. 앱도 테스트도 메인 액터일 수 있습니다.
- ⚠️ 배포 타깃 차이도 아닙니다. 갈리는 것은 그 빌드 세팅 하나입니다.
하면 안 되는 것: isolated deinit 명시(경로를 유지해 계속 죽습니다) · 배포 타깃 상향(효과
없음) · isolated deinit을 끄는 컴파일러 플래그(존재하지 않습니다).
6. 당신이라면 무엇을 고치시겠습니까
- (a) 그 빌드 세팅을 끈다
- (b) 문제되는 클래스마다 한 줄씩 넣는다
- (c) 테스트를 건너뛴다
(a)가 제일 빨라 보입니다. 커맨드라인으로 덮어 빌드하면 에러 0개로 통과합니다 — 예전 기록에 있던 "대량 컴파일 에러"는 사실이 아니었습니다.
그런데 그 앱은 언어 모드가 낮아 동시성 진단이 거의 없습니다. 세팅을 끄면 메인 액터로 직렬화돼 있던 타입이 경고 하나 없이 nonisolated가 됩니다. 즉 (a)는 컴파일은 되는데 격리 의미를 조용히 바꿉니다.
답은 (b)입니다.
7. 조치는 한 줄입니다
테스트가 직접 생성하는 클래스에:
nonisolated deinit {}빈 본문이라 동작 변화가 없습니다. 해제 시 메인 액터로 홉하지 않을 뿐이고, 뷰 상태 객체는 어차피 메인 스레드에서 해제됩니다. 한 앱은 뷰모델 4개에 넣어 오브젝트 코드의 isolated deinit 지점이 16 → 12로 줄었습니다.
8. 프로덕션 위험은 바이너리로 판정합니다
해제되지 않는 객체의 isolated deinit은 절대 실행되지 않으므로 조건 ①이 성립하지 않습니다. 그래서 "몇 지점이냐"가 아니라 **"그 지점의 객체가 해제되느냐"**가 판정입니다.
otool -tV <app 바이너리> | grep deinitOnExecutor | awk '{print $1}' # 호출 주소
nm -n <dSYM 안 DWARF 바이너리> | awk '$2=="t"||$2=="T"{print $1,$3}'
# 주소별로 직전 심볼을 이분 탐색 → 디맹글해서 소유 클래스 확정⚠️ IPA 안의 앱 바이너리는 완전히 스트립돼 텍스트 심볼이 1개입니다. 반드시 아카이브의 dSYM으로 심볼을 붙입니다.
결과가 두 앱에서 갈렸습니다.
- 앱 하나는 위험 없음. 12지점이 전부 싱글턴 9개 + private init 싱글턴 + 앱 델리게이트 + 인스턴스화되지 않는 리소스 번들 클래스였습니다. 해제되는 것이 없습니다.
- 다른 앱은 실제 위험이었습니다(라이브). 22지점인데 성질이 다릅니다. 환경 객체가 실행 중 교체되면서 자기와 소유한 서비스들이 해제되고, 뷰모델 3개는 뷰 상태, 로그인 델리게이트는 로그인마다, 카메라 코디네이터는 뷰마다 해제됩니다. 17클래스에 한 줄씩 넣어 22 → 7 지점을 아카이브 바이너리로 실측 확인했습니다.
9. 크래시 데이터로는 확인이 안 됐습니다
스토어 분석 리포트 API에 크래시 리포트가 있습니다(예전에 "없다"고 적어 둔 것이 틀렸습니다). 다만 실측에서 6개 앱 전부 인스턴스 0개였습니다.
우리 설치량에서는 아무것도 내놓지 않으므로 0은 "크래시 없음"의 증거가 아닙니다. 분모가 없는 0입니다.
절차는 600초 벽을 피해 쪼갭니다.
xcrun simctl bootstatus <udid> -b
xcodebuild … build-for-testing
xcodebuild … test-without-building -parallel-testing-enabled NO자가진단 3가지
- 테스트가 죽는 지점이 실행마다 다릅니까? 그건 "그 테스트의 결함"이 아니라 공통 경로의 결함일 가능성이 높습니다. 스택의 맨 아래를 보십시오.
- 빌드 성공을 테스트 통과로 세고 있지 않습니까? 부팅 중 죽는 결함은 빌드로 안 드러납니다.
- 크래시 리포트 0건을 안전의 증거로 쓰고 있습니까? 설치량이 작으면 0은 아무 뜻도 없습니다.
솔직한 부분
이 글에서 제일 오래 걸린 건 원인 규명이 아니라 **"그래서 프로덕션이 위험한가"**였습니다. 바이너리를 열어 지점을 세는 데까지는 갔는데, 지점 수는 답이 아니었습니다. 12지점 앱은 안전하고 22지점 앱은 위험합니다. 차이는 그 객체가 해제되느냐뿐입니다.
숫자를 세는 것과 위험을 판정하는 것이 다른 일이라는 걸, 두 앱의 결과가 갈리고 나서야 알았습니다.
지금 딱 하나만 해 보세요. 당신 앱 아카이브 바이너리에 otool -tV | grep deinitOnExecutor를
한 번 걸어 보십시오. 지점이 나오면, 그다음 질문은 "그 클래스가 실행 중에 해제되는가"입니다.