툴과 개발 환경13 분 읽기

빌드 세팅 한 줄이 테스트 61건을 부팅 중에 죽였습니다

기본 액터 격리를 메인 액터로 두는 세팅은 deinit을 직접 쓰지 않은 클래스에만 isolated deinit을 합성합니다. 아무것도 안 쓴 클래스만 위험합니다. 직관과 반대입니다.

#ios#debugging#verification#build#gotchas
개념 도식: 빌드 세팅 하나가 합성한 isolated deinit이 슬랩 할당 메모리를 free로 반납해 abort하는 경로
글 내용을 요약한 개념 도식.

한 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입니다.

크래시 조건은 셋입니다.

  1. isolated deinit이 동기 fast path로 실행
  2. 현재 태스크가 없는 동기 코드
  3. 그 스레드의 폴백 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가지

  1. 테스트가 죽는 지점이 실행마다 다릅니까? 그건 "그 테스트의 결함"이 아니라 공통 경로의 결함일 가능성이 높습니다. 스택의 맨 아래를 보십시오.
  2. 빌드 성공을 테스트 통과로 세고 있지 않습니까? 부팅 중 죽는 결함은 빌드로 안 드러납니다.
  3. 크래시 리포트 0건을 안전의 증거로 쓰고 있습니까? 설치량이 작으면 0은 아무 뜻도 없습니다.

솔직한 부분

이 글에서 제일 오래 걸린 건 원인 규명이 아니라 **"그래서 프로덕션이 위험한가"**였습니다. 바이너리를 열어 지점을 세는 데까지는 갔는데, 지점 수는 답이 아니었습니다. 12지점 앱은 안전하고 22지점 앱은 위험합니다. 차이는 그 객체가 해제되느냐뿐입니다.

숫자를 세는 것과 위험을 판정하는 것이 다른 일이라는 걸, 두 앱의 결과가 갈리고 나서야 알았습니다.

지금 딱 하나만 해 보세요. 당신 앱 아카이브 바이너리에 otool -tV | grep deinitOnExecutor를 한 번 걸어 보십시오. 지점이 나오면, 그다음 질문은 "그 클래스가 실행 중에 해제되는가"입니다.

관련 글