앱이 죽지 않아도 사용자는 떠난다. 19장이 "앱이 사라지는 경우"를 다뤘다면 이 챕터는 앱이 멀쩡히 살아 있는데 반응하지 않는 경우를 다룬다. 크래시율이 좋은데 평점이 나쁜 앱의 십중팔구가 여기에 걸려 있고, 어떤 크래시 지표에도 잡히지 않기 때문에 조용히 방치되기 쉽다. 다행히 Apple은 이 영역의 정의와 임계값을 문서로 명시해뒀다 — 이 챕터의 숫자는 추측이 아니라 인용이다.
Q1. 행과 히치는 무엇이 다른가?
둘 다 "느리다"의 증상이지만 어떤 상호작용에서 나타나는지가 다르다. 행(hang)은 이산적 상호작용(버튼 탭, 키 입력)에서 반응이 늦는 것이고, 히치(hitch)는 연속적 상호작용(스크롤, 드래그, 애니메이션)에서 움직임이 끊기는 것이다. 결정적으로 자릿수가 다르다 — Apple 문서 기준 행은 이산 상호작용에서 50~100 ms부터 인지되기 시작하지만, 히치는 리프레시 간격 하나(8~16 ms)만 늦어도 눈에 띈다. 원인은 겹친다 — 메인스레드를 오래 잡으면 어느 쪽으로 나타날지는 그때 사용자가 무엇을 하고 있었는가에 달렸다.
CS 원리
인간의 지각이 두 상호작용에 대해 완전히 다른 민감도를 갖기 때문이다.
- 이산 상호작용 — 원인(탭)과 결과(화면 변화)가 한 번씩 있다. 사람은 이 사이의 절대 지연을 느낀다. 지연이 일정하기만 하면 어느 정도까지는 "누르면 반응한다"로 받아들인다.
- 연속 상호작용 — 손가락이 움직이는 동안 화면이 계속 따라와야 한다. 사람은 절대 지연보다 지연의 변동에 훨씬 민감하다. 화면이 손가락보다 50 ms 뒤처지더라도 일정하게 뒤처지면 자연스럽게 느껴지지만, 갑자기 한 프레임 멈췄다 뛰면 즉시 거슬린다.
Apple 문서가 이 차이를 직접 설명한다 — 아이콘을 드래그할 때 손가락 방향 전환과 아이콘 방향 전환 사이의 50 ms 지연은 찾아보면 알아챌 수 있지만 그 지연이 일정하게 유지되고 아이콘이 부드럽게 움직이면 명확히 드러나지 않는다. 반면 지연이 더 작더라도 일정하지 않으면 아이콘이 가끔 멈췄다 뛰는 것처럼 보인다.
그래서 두 문제의 목표 함수가 다르다. 행은 지연의 크기를 줄이는 문제이고, 히치는 지연의 분산을 줄이는 문제다. 평균 프레임 시간이 개선됐는데 히치가 늘어나는 상황이 실제로 가능하다.
| 행 (hang) | 히치 (hitch) | |
|---|---|---|
| 상호작용 | 이산 — 탭, 키 입력 | 연속 — 스크롤, 드래그, 애니메이션 |
| 인지 임계 | 50~100 ms 이상 | 단일 리프레시 간격(8~16 ms) |
| 사람이 민감한 것 | 절대 지연 | 지연의 변동 |
| 주 발생 위치 | 메인 런루프 | 메인스레드(commit) 또는 렌더 서버(render) |
| Apple 리포팅 임계 | 메인 런루프 무응답 250 ms | ms/s 단위 히치율 |
| 다른 이름 | freeze, spin | frame drop (정확히 같지는 않음) |
iOS에서는
Apple은 히치를 "프레임이 예상보다 늦게 화면에 나타나는 모든 경우"로 정의하고, 두 종류로 나눈다.
- commit hitch — 메인스레드가 이벤트 처리나 커밋을 제때 못 끝냈다.
- render hitch — 렌더 서버가 레이어 트리를 제때 준비·실행하지 못했다.
여기서 실무적으로 중요한 문장이 나온다 — Apple 문서는 "프레임 드롭은 늦은 프레임에 대한 시스템의 대응 중 하나일 뿐이며, 모든 히치가 프레임 드롭을 유발하지는 않는다"고 명시한다. 즉 "드롭 프레임 수"만 보는 계측은 히치를 과소평가한다.
둘의 관계도 문서에 명확히 정리돼 있다. 응답하지 않는 메인스레드는 행과 히치를 둘 다 유발할 수 있고, 어느 쪽으로 나타나는지는 그 작업이 어떤 사용자 상호작용과 겹쳤는가에 달렸다. 그래서 Apple은 실용적인 지침을 준다 — 행 탐지 도구는 메인스레드 무응답을 전부 잡으므로 commit hitch의 주요 원인도 함께 잡는다. 조사 중인 영역의 행을 다 고치면 히치도 상당수 사라진다.
이건 우선순위 결정에 직접 쓸 수 있는 지침이다. 행부터 고치는 것이 히치까지 함께 줄이는 지름길이다.
실험 · 도구
같은 블로킹을 두 상호작용에서 실행해 증상이 어떻게 달라지는지 직접 본다.
import SwiftUI
struct BlockingDemo: View {
@State private var counter = 0
@State private var blockMs: Double = 300
var body: some View {
VStack {
Slider(value: $blockMs, in: 0...2000, step: 50)
Text("블로킹: \(Int(blockMs)) ms")
// (A) 이산 상호작용 — 탭했을 때 반응이 늦는다 → 행
Button("탭하면 메인스레드를 막는다 (\(counter))") {
block(ms: blockMs)
counter += 1
}
// (B) 연속 상호작용 — 스크롤 중 셀이 그려질 때 막힌다 → 히치
List(0..<500, id: \.self) { row in
Text("row \(row)")
.onAppear {
// 셀이 보일 때마다 아주 짧게 막는다.
// 250 ms 임계에 한참 못 미치는 16 ms 도 스크롤에서는 즉시 보인다.
block(ms: 16)
}
}
}
}
/// 메인스레드를 실제로 점유한다 — sleep이 아니라 CPU를 태워야 현실적이다
private func block(ms: Double) {
let deadline = ContinuousClock.now + .milliseconds(Int(ms))
var x = 0
while ContinuousClock.now < deadline { x &+= 1 }
_ = x
}
}
확인 포인트가 핵심이다.
- (A)에서 300 ms를 걸면 탭 반응이 늦다는 게 느껴지지만 "고장"으로 보이지는 않는다. 그런데 이 값은 이미 Apple의 행 리포팅 임계 250 ms를 넘었다.
- (B)에서 16 ms만 걸어도 스크롤이 눈에 띄게 끊긴다. 250 ms의 1/15인데도 그렇다.
Instruments로 두 증상을 각각 확인한다.
# 행 관찰 — Instruments의 Hangs 계측기. 임계값을 250 ms보다 낮게 조정할 수 있다.
# Instruments › Time Profiler 템플릿에 Hangs 계측기 추가
# 히치 관찰 — Animation Hitches 계측기
# 커밋 히치와 렌더 히치를 구분해 보여준다
# 기기에서 직접 — 설정 › 개발자 › (Hang Detection 관련 항목)
# 개발 중 기기 사용 도중 행이 감지되면 알림을 받을 수 있다
프로젝트 적용
두 문제를 같은 백로그에 섞지 않는 것이 실무의 출발점이다. 원인은 겹칠 수 있지만 측정 도구·지표·목표가 다르다.
- 행 — Xcode Organizer의 행률, Instruments의 Hangs 계측기,
MXHangDiagnostic. 목표는 "메인스레드에서 블로킹 작업 제거". - 히치 — Organizer의 히치율, Animation Hitches 계측기. 목표는 "프레임 예산 안에서 커밋·렌더 완료".
그리고 Apple의 지침대로 행을 먼저 잡는다. 행은 원인이 대체로 명확하고(동기 I/O·네트워킹·큰 파싱) 고치면 commit hitch가 딸려 줄어든다. 반면 render hitch는 레이어 구조·오프스크린 렌더링·이미지 디코딩 같은 별개 영역이라(25장) 조사 비용이 크다.
또 하나 팀 규칙으로 정할 만한 것 — "스크롤 중 실행되는 코드에는 250 ms 기준을 적용하지 않는다". 셀 구성, onAppear, 레이아웃 계산은 프레임 예산(8~16 ms) 기준으로 봐야 하고, 여기서 몇 십 ms는 이미 재앙이다.
"행과 히치는 같은 말"이라는 인식이 가장 흔하다. 임계값이 한 자릿수 대 세 자릿수로 다르므로 같은 기준으로 관리하면 히치는 영원히 못 잡는다. "프레임 드롭 수를 세면 히치를 측정한 것"도 부정확하다 — Apple 문서가 모든 히치가 프레임 드롭을 유발하지는 않는다고 명시한다. "히치는 GPU 문제, 행은 CPU 문제"라는 구분도 틀렸다 — commit hitch는 메인스레드(CPU) 문제이고 행과 원인이 같다. 마지막으로 "60fps만 유지하면 히치는 없다"도 오해다. 평균 프레임률이 60이어도 가끔 한 프레임이 크게 늦으면 사용자는 즉시 느낀다 — 평균은 히치를 숨긴다.
두 가지 상황을 떠올려보자.
첫째, 엘리베이터 버튼을 눌렀다. 버튼을 누르고 불이 켜지기까지 0.3초가 걸린다면? 조금 답답하지만 "눌렸구나" 하고 넘어간다. 1초가 걸리면 다시 누른다. 여기서 중요한 건 얼마나 오래 기다렸나다. 이게 행이다.
둘째, 창밖으로 지나가는 풍경을 본다. 기차가 일정한 속도로 달리면 편안하다. 그런데 0.02초씩 툭툭 끊기면 바로 멀미가 난다. 0.02초는 엘리베이터 버튼에서라면 아무도 못 느낄 시간인데, 움직이는 걸 볼 땐 즉시 거슬린다. 이게 히치다.
그래서 같은 "0.05초 멈춤"이라도 버튼을 눌렀을 땐 아무도 모르고, 스크롤 중엔 다 안다. 원인은 똑같이 "누군가 잠깐 딴짓을 했다"인데, 그때 사용자가 뭘 하고 있었느냐에 따라 이름이 달라지는 것이다.
그리고 재밌는 사실 하나 — 기차가 계속 조금 느리게 가는 건 괜찮다. 문제는 속도가 들쭉날쭉한 것이다. 평균 속도만 재면 "정상"으로 나오는데 타는 사람은 계속 멀미를 한다.
꼬리 질문
평균 프레임률이 좋은데 히치가 많은 상황은 어떻게 발생하는가?
평균은 분산 정보를 버리는 통계이기 때문이다. 1초 동안 120 Hz 기준 120 프레임이 나와야 하는데, 118 프레임이 8.3 ms에 정확히 나오고 2 프레임이 100 ms씩 늦었다면 평균 프레임률은 여전히 매우 높다. 그런데 사용자는 1초에 두 번 화면이 멈췄다고 느낀다.
이게 Apple이 히치율을 ms/s 단위로 정의한 이유다 — "프레임이 몇 개 나왔나"가 아니라 "전체 시간 중 몇 밀리초가 멈춤이었나"를 센다. 이 정의는 드물지만 큰 멈춤을 제대로 반영한다.
실무적 함의는 명확하다 — 성능 지표를 평균으로 보지 말고 분포로 봐야 한다. 프레임 시간의 p95·p99, 또는 히치율처럼 애초에 꼬리를 반영하도록 설계된 지표를 쓴다. 18장 Q1에서 "수집 시점에 평균으로 접으면 축이 사라진다"고 한 것과 정확히 같은 문제다.
덧붙여 화면 리프레시 레이트가 가변이라는 점도 평균을 왜곡한다. 움직임이 없을 때 시스템이 리프레시 레이트를 낮추므로, 유휴 구간이 섞이면 평균 프레임률이 실제 부드러움과 무관해진다.
render hitch는 메인스레드와 무관한데 왜 앱 개발자가 고쳐야 하는가?
렌더 서버가 별도 프로세스이긴 하지만 앱을 대신해서 일하기 때문이다. Apple 문서도 이 점을 명시한다 — 렌더 서버가 별개 프로세스여도 앱을 위해 작업하며, 그 작업이 너무 복잡하면 제때 못 끝낸다.
즉 렌더 서버가 처리해야 할 작업량을 결정하는 것은 앱이다. 레이어를 몇 개 만들었는지, 투명도와 마스크가 몇 겹인지, 그림자와 코너 반경이 오프스크린 렌더링을 유발하는지, 블러가 걸려 있는지가 전부 앱의 뷰 구성에서 나온다.
그래서 render hitch를 고치는 방법은 메인스레드 작업을 줄이는 것과 완전히 다르다 — 레이어 트리를 단순화하고, 오프스크린 렌더링을 유발하는 효과를 줄이고, 큰 이미지를 미리 적절한 크기로 준비하는 쪽이다. 이 내용은 25장에서 다룬다.
진단 관점에서는 Animation Hitches 계측기가 commit과 render를 구분해 보여준다는 점이 결정적이다. 구분 없이 "스크롤이 끊긴다"만 보고 메인스레드를 최적화하면, 원인이 렌더 쪽일 때 아무리 고쳐도 안 나아진다.
이산 상호작용에서 "일정한 지연"이 왜 "변동하는 짧은 지연"보다 견딜 만한가?
사람이 인과 관계를 학습하기 때문이다. 버튼을 누르면 항상 0.2초 뒤에 반응한다는 규칙성이 있으면, 뇌가 그 지연을 예측에 포함시키고 "누른 것이 먹혔다"는 확신을 유지한다. 지연은 느껴지지만 불확실성은 없다.
반면 어떤 때는 즉시, 어떤 때는 0.5초 뒤에 반응하면 규칙성이 없어서 매번 "먹혔나?"를 확인해야 한다. 이 불확실성이 실제 지연 시간보다 더 큰 불쾌감을 만들고, 중복 입력(같은 버튼 여러 번 누르기)을 유발한다. 중복 입력은 그 자체로 버그의 원인이 되기도 한다 — 결제 버튼을 두 번 누르는 상황이 대표적이다(P0 16장).
UI 설계 관점의 대응은 즉시 피드백이다. 실제 작업이 오래 걸리더라도 탭 즉시 시각적 반응(버튼 눌림 상태, 로딩 표시)을 주면 인과 관계가 복원된다. 실제 지연은 그대로인데 체감이 크게 개선된다.
연속 상호작용에서는 이 전략이 통하지 않는다. 손가락과 화면이 실시간으로 붙어 있어야 하므로 "기다리라는 표시"로 대체할 수 없다. 그래서 히치는 진짜로 빠르게 만드는 수밖에 없다.
Q2. 메인 런루프는 메인 디스패치 큐와 무엇이 다른가?
런루프가 토대이고, 메인 디스패치 큐는 그 위의 클라이언트 하나다. Apple 문서가 명확히 못 박는다 — 메인 디스패치 큐, 메인 NSOperationQueue, 메인 액터는 모두 메인스레드에 일을 제출하는 방법이고, 메인스레드에 관한 한 런루프가 모든 것의 토대다. 이들은 런루프의 클라이언트이며, 런루프는 이들 외의 출처(터치 이벤트, 타이머, CFRunLoopSource)에서 온 일도 실행한다. 실무적 귀결이 중요하다 — 메인 디스패치 큐에 넣은 긴 작업은 곧 메인 런루프의 긴 작업이 되고, 그러면 타이머든 터치 이벤트든 다른 클라이언트의 일도 전부 막힌다.
CS 원리
런루프는 이벤트 기반 프로그램의 표준 구조다. Apple 문서가 제시하는 단순화된 구현이 본질을 다 보여준다.
class RunLoop {
var stopped = false
func run() {
repeat {
if let work = workSet.fetchNextWorkItem() {
processWork(work) // 일이 있으면 처리하고
} else {
sleepUntilNewWorkArrives() // 없으면 잔다
}
} while(!stopped)
}
}
여기서 세 가지가 따라 나온다.
- 단일 스레드에서 순차 실행 —
processWork는 한 번에 하나씩만 실행된다. 그래서 UI 갱신이 스레드 안전 문제 없이 직렬화된다. 이것이 "UI는 메인스레드에서만"의 근본 이유다. - 잠들 수 있다 — 할 일이 없으면 스레드가 잔다. 잠든 동안 CPU를 쓰지 않으므로 배터리가 산다. 건강한 런루프는 대부분의 시간을 자면서 보낸다.
- 선점이 없다 — 실행 중인 작업이 끝나야 다음 작업을 꺼낸다. OS 스케줄러가 스레드를 선점할 수는 있어도, 런루프가 작업 하나를 중간에 끊고 다른 작업으로 넘어가지는 않는다.
세 번째가 행의 근본 원인이다. 이벤트가 들어와도 현재 작업이 끝나기 전에는 처리되지 않는다. Apple 문서 표현으로는 — 런루프가 이미 작업을 처리 중이면 이전 작업 처리가 끝날 때까지 새 이벤트를 다룰 수 없다.
iOS에서는
이 계층 구조가 실무에서 만드는 결과 셋을 짚어야 한다.
(1) 격리가 없다. "우리 팀 코드는 메인 액터를 쓰고 저쪽은 메인 큐를 쓰니 서로 영향이 없다"는 착각이 가능한데, 전혀 그렇지 않다. 같은 런루프를 공유하므로 어느 쪽이든 오래 잡으면 전부 멈춘다. 행을 조사할 때 "우리가 메인 큐에 넣은 작업"만 보면 안 되는 이유다.
(2) 타이머가 늦는 이유가 설명된다. P0 07장 Q6에서 "Timer는 요청한 시간에 정확히 실행되지 않는다"고 했는데, 그 메커니즘이 여기 있다 — 타이머가 발화하면 콜백을 런루프에 제출할 뿐이고, 런루프가 다른 작업을 처리 중이면 그 작업이 끝나야 실행된다. 타이머 정확도는 런루프 혼잡도의 함수다.
(3) 런루프 모드가 개입한다. 런루프는 모드 개념이 있어서, 스크롤 중에는 UITrackingRunLoopMode로 전환되며 .default 모드에만 등록된 소스는 실행되지 않는다. 그래서 스크롤 중에 타이머가 멈추는 현상이 생기고, .common 모드에 등록해야 해결된다. 이건 런루프가 단순 큐가 아니라 모드별 소스 집합을 갖는 구조임을 보여준다.
실험 · 도구
런루프가 실제로 잠들고 깨어나는 것을 관찰할 수 있다.
import Foundation
import CoreFoundation
/// 메인 런루프의 각 단계에 옵저버를 달아 busy/asleep 전환을 관찰한다
func observeMainRunLoop() {
var busyStart = ContinuousClock.now
let observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
CFRunLoopActivity.allActivities.rawValue,
true, // repeats
0 // order
) { _, activity in
switch activity {
case .beforeWaiting:
// 런루프가 잠들기 직전 — busy 구간이 여기서 끝난다
let elapsed = ContinuousClock.now - busyStart
let ms = Double(elapsed.components.attoseconds) / 1e15
+ Double(elapsed.components.seconds) * 1000
if ms > 250 {
// Apple이 리포팅 임계로 쓰는 값과 같은 기준
print(String(format: "⚠️ busy 구간 %.0f ms — 행으로 리포트될 수 있음", ms))
}
case .afterWaiting:
// 런루프가 깨어남 — busy 구간 시작
busyStart = ContinuousClock.now
default:
break
}
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
}
이 코드가 하는 일이 시스템의 행 탐지와 원리적으로 같다. Apple 문서 표현대로 행 리포팅은 런루프의 "waiting for events" 구간 두 개 사이의 길이(=busy 구간)를 본다. 위 옵저버가 재는 것이 정확히 그 값이다.
런루프 모드가 소스를 가른다는 것도 실험으로 확인할 수 있다.
// (A) 기본 모드에만 등록 — 스크롤 중에는 멈춘다
let t1 = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
print("A tick")
}
// (B) common 모드에 등록 — 스크롤 중에도 계속 돈다
let t2 = Timer(timeInterval: 0.1, repeats: true) { _ in
print("B tick")
}
RunLoop.main.add(t2, forMode: .common)
// 스크롤을 시작하면 A는 조용해지고 B만 계속 출력된다.
// scheduledTimer 는 .default 모드에 등록하기 때문이다.
프로젝트 적용
이 구조 이해가 실무 판단을 바꾸는 지점은 셋이다.
- "메인 큐에 짧게 여러 번 넣으면 괜찮다"는 부분적으로만 맞다. 각 블록이 짧으면 사이사이에 이벤트가 처리될 수 있어 행은 피한다. 하지만 총량이 많으면 런루프가 계속 깨어 있어 배터리와 히치에 영향을 준다. 쪼개는 것은 행 대책이지 성능 대책이 아니다.
- 타이머 기반 폴링은 런루프 부하로 이어진다. 19장 Q5의
WAKEUPS예외와 직결된다. 짧은 주기 타이머 여러 개보다 이벤트 기반 갱신이 낫다. - 스크롤 중 동작해야 하는 타이머는
.common모드에 등록한다. 반대로 스크롤 중에는 멈춰야 하는 무거운 갱신은 기본 모드에 두는 것이 오히려 유용하다 — 런루프 모드를 의도적으로 활용하는 것이다.
"메인 큐 = 메인 런루프"는 틀렸다. 메인 큐는 런루프의 클라이언트 중 하나이고, 런루프는 메인 큐 외의 출처에서 온 일도 실행한다. "메인 액터를 쓰면 GCD와 격리된다"도 틀렸다 — 둘 다 결국 같은 메인스레드에서 실행되므로 서로를 막는다. "런루프가 CPU를 계속 쓴다"도 오해다 — 할 일이 없으면 잠들어서 CPU를 쓰지 않는다. 반대로 런루프가 계속 깨어 있다면 그 자체가 문제 신호다. 또 "scheduledTimer는 항상 동작한다"도 함정이다 — .default 모드에 등록되므로 스크롤 중에는 발화하지 않는다.
은행 창구가 딱 하나인 지점을 떠올려보자. 이 창구가 메인스레드다.
손님이 창구에 오는 경로는 여러 가지다. 번호표를 뽑고 온 사람(메인 큐), 예약하고 온 사람(메인 액터), 정기 방문 고객(타이머), 그냥 문 열고 들어온 사람(터치 이벤트). 경로는 다르지만 결국 같은 창구 하나를 거친다.
그래서 어떤 손님 하나가 창구에서 30분을 쓰면, 번호표를 뽑았든 예약을 했든 모두가 30분을 기다린다. "나는 예약했으니까 따로 처리되겠지"는 통하지 않는다.
그리고 이 창구 직원은 손님이 없으면 쉰다. 계속 서 있는 게 아니라 자리에 앉아 쉬다가, 누가 오면 일어난다. 이게 좋은 상태다. 만약 직원이 하루 종일 서서 뭔가를 하고 있다면 — 손님이 없는데도 — 그건 쓸데없는 일을 계속 시키고 있다는 뜻이고, 체력(배터리)이 금방 닳는다.
마지막으로 재밌는 규칙이 하나 있다. "바쁜 시간대(스크롤 중)에는 정기 방문 고객을 안 받는다"는 규칙이다. 그래서 평소엔 잘 오던 정기 고객이 바쁜 시간엔 안 나타난다. 이게 타이머가 스크롤 중에 멈추는 이유이고, "이 손님은 바빠도 받아주세요"라고 따로 등록(.common)해야 한다.
꼬리 질문
메인 액터가 GCD 메인 큐와 다른 점은 실행 스레드 말고 무엇인가?
실행되는 스레드는 같지만 모델과 보장이 다르다. 첫째, 컴파일 타임 검사다. @MainActor는 타입 시스템의 일부라 컴파일러가 "이 코드는 메인에서만 실행된다"를 검증한다. DispatchQueue.main.async는 런타임 호출이므로 빼먹어도 컴파일이 통과하고 실행 중에야 문제가 드러난다.
둘째, 재진입성이다. @MainActor 함수 안에서 await하면 그 지점에서 메인 액터를 놓아준다 — 다른 메인 액터 작업이 끼어들 수 있다. 반면 DispatchQueue.main.sync는 놓아주지 않고 블록한다. 그래서 액터 모델에서는 "await 전후로 상태가 바뀌었을 수 있다"는 가정을 해야 한다(P0 02장의 actor 재진입 논의).
셋째, 교착 특성이다. 메인스레드에서 DispatchQueue.main.sync를 호출하면 즉시 교착한다(P0 02장 Q7). 메인 액터에서 await하는 것은 교착하지 않는다 — 스레드를 블록하지 않고 정지하기 때문이다.
다만 행 관점에서는 차이가 없다. 메인 액터 함수가 await 없이 200 ms를 계산하면 메인스레드를 200 ms 잡은 것이고, 런루프는 그동안 아무것도 못 한다. 액터 모델은 데이터 경쟁을 막아주지 응답성을 보장하지 않는다 — 이게 Q6의 주제다.
런루프가 "대부분 자고 있어야 한다"면, 앱이 유휴 상태인데도 계속 깨어 있는 원인은 무엇인가?
대표적 원인이 다섯 가지다.
(1) 짧은 주기 타이머. 0.1초 타이머 하나면 초당 10번 깨운다. 여러 화면에 각자 타이머를 두면 금세 수십 번이 된다.
(2) CADisplayLink를 끄지 않음. 이건 화면 리프레시마다 깨우므로 초당 60~120번이다. 애니메이션이 끝났는데 무효화하지 않으면 계속 돈다.
(3) 폴링 루프. "값이 바뀌었나" 확인하려고 주기적으로 도는 코드. 이벤트 기반으로 바꾸면 사라진다.
(4) 무한 반복 애니메이션. 화면 밖에 있거나 가려진 뷰의 애니메이션이 계속 돌면 렌더 루프가 멈추지 않는다.
(5) 네트워크 재시도 루프. 연결이 안 되는 상황에서 짧은 간격으로 재시도하면 계속 깨어난다. 지수 백오프가 필요한 이유다.
확인 방법은 Instruments의 Time Profiler를 유휴 상태에서 돌려보는 것이다. 아무것도 안 하는데 메인스레드에 샘플이 잡히면 그 스택이 범인이다. 또는 19장 Q5의 EXC_RESOURCE (WAKEUPS)가 이미 경고를 보내고 있을 수 있다.
영향은 배터리에 직접적이다 — CPU가 저전력 상태로 내려갈 기회를 계속 뺏기기 때문이다.
런루프 모드를 의도적으로 활용하는 실무 패턴에는 무엇이 있는가?
가장 유용한 패턴은 "스크롤 중에는 무거운 일을 미루기"다. 이미지 디코딩이나 프리페치를 .default 모드에만 등록하면, 스크롤이 시작되어 UITrackingRunLoopMode로 전환되는 동안 자동으로 실행되지 않는다. 스크롤이 멈추면 다시 실행된다. 조건 분기를 직접 쓰지 않고도 런루프가 우선순위를 대신 관리해주는 셈이다.
두 번째는 반대 방향이다 — 스크롤 중에도 반드시 돌아야 하는 것은 .common에 등록한다. 진행 표시기 애니메이션, 비디오 재생 타이머, 실시간 시계가 여기 해당한다. Timer.scheduledTimer가 .default에 등록된다는 사실 때문에 이 버그는 매우 흔하다.
세 번째는 모달 표시 중 동작 제어다. macOS에서는 모달 패널이 별도 모드를 쓰므로 배경 작업을 자연스럽게 멈출 수 있다. iOS에서는 덜 쓰이지만 개념은 같다.
주의할 점은 이 기법이 명시적이지 않아 읽는 사람이 놓치기 쉽다는 것이다. "왜 스크롤하면 이미지 로딩이 멈추지?"라는 질문이 나올 수 있으므로, 모드 등록으로 동작을 제어했다면 주석으로 의도를 남기는 편이 좋다. 최근에는 명시적인 우선순위·취소 모델(Task 우선순위, UICollectionView 프리페치 API)로 같은 목적을 달성하는 쪽이 읽기 쉽다.
Q3. 시스템은 행을 어떻게 감지하는가?
Apple의 행 탐지는 메인 런루프의 busy 구간 길이를 잰다. 정확히는 런루프가 "이벤트 대기(waiting for events)" 상태로 들어가는 두 시점 사이의 구간이고, 이 구간 동안 런루프는 새 이벤트를 처리할 수 없다. 대부분의 Apple 개발자 도구는 이 구간이 250 ms를 넘으면 리포팅을 시작하며, Instruments의 Hangs 계측기는 더 낮은 임계도 선택할 수 있다. 그리고 메인스레드가 1초 이상 무응답이면 시스템이 앱을 샘플링해 백트레이스 프로파일까지 캡처한다. 핵심은 이것이 프록시 측정이라는 점이다 — 사용자가 겪는 실제 지연이 아니라 런루프 상태를 대신 본다.
CS 원리
측정하고 싶은 것과 측정할 수 있는 것이 다를 때 쓰는 것이 프록시 지표다.
진짜로 재고 싶은 것은 "사용자가 물리적으로 화면을 터치한 순간부터 화면 픽셀이 바뀔 때까지"다. 그런데 이 값은 앱 프로세스 안에서 잴 수 없다 — 시작점은 하드웨어 이벤트이고 끝점은 디스플레이 갱신이라 둘 다 앱 밖이다.
대신 잴 수 있는 것이 메인 런루프가 얼마나 오래 바빴는가다. Apple이 이걸 프록시로 고른 근거도 문서에 있다 — 다른 단계들(하드웨어 인식, OS 전달, 렌더링)은 시간이 상당히 일정하므로, 전체 지연이 임계를 넘었다면 거의 항상 메인스레드 작업이 너무 오래 걸린 것이다.
프록시가 좋은 프록시가 되려면 두 조건이 필요하다.
- 상관관계가 높을 것 — 런루프 busy 구간이 길면 실제 지연도 길다. ✅
- 측정 비용이 낮을 것 — 런루프 상태 관찰은 옵저버 하나로 가능하다. ✅
대신 프록시의 한계도 명확하다. Apple이 직접 명시한다 — 행 리포팅은 메인 런루프의 시간만 잰다. 이벤트가 런루프에 도달하기 전의 전달 시간, 그리고 메인스레드가 UI 갱신을 끝낸 뒤 화면이 그려지기까지의 시간이 정상 동작에서도 10~50 ms를 더한다. 즉 측정값은 사용자 체감보다 항상 낙관적이다.
iOS에서는
탐지 계층이 여러 개이고 각각 임계와 산출물이 다르다.
| 계층 | 임계 | 산출물 | 어디서 보나 |
|---|---|---|---|
| Instruments Hangs 계측기 | 조정 가능 (250 ms 이하도 가능) | 실시간 구간 표시 + 스택 | 개발 중 |
| 기기 온디바이스 행 감지 | 기본 임계 | 사용 중 알림 | 개발자 기기 |
| 시스템 행 리포트 | 메인스레드 1초 이상 | 백트레이스 프로파일 | Xcode Organizer |
| Organizer 행률 | 250 ms 초과 구간만 합산 | 시간당 무응답 초 | Xcode Organizer |
MXHangDiagnostic | — | 행 시점 콜스택 | 앱이 직접 수신 |
여기서 250 ms와 1초의 역할이 다르다는 점이 중요하다.
- 250 ms — 집계의 기준이다. 이보다 짧은 무응답은 행률에 포함되지 않는다.
- 1초 — 진단 캡처의 기준이다. 메인스레드가 1초 이상 무응답이면 시스템이 앱을 샘플링해 어디서 시간을 쓰고 있는지 백트레이스 프로파일을 뜬다. 이 리포트는 사용자가 개발자와 데이터 공유에 동의한 경우 익명으로 수집되고, Organizer가 유사한 백트레이스끼리 묶어 보여준다.
즉 250~1000 ms 구간의 행은 "있었다"는 사실은 집계되지만 "어디서였는지"는 모른다. 이게 실무에서 자주 겪는 상황이다 — 행률은 나쁜데 행 리포트 목록은 비어 있는 경우. 짧은 행이 많이 쌓이고 있다는 뜻이므로, 이때는 Instruments로 직접 재현해 임계를 낮춰 잡아야 한다.
Organizer의 행 리포트는 함수 호출별로 전체 행 시간의 몇 퍼센트를 차지하는지 내림차순으로 보여준다. 개별 리포트를 열면 메인스레드 샘플 스택과 함께 iOS 버전, 기기 모델, 수신 로그 수, 14일 추세, 총 행 시간이 나온다.
실험 · 도구
Q2에서 만든 런루프 옵저버를 확장하면 시스템과 같은 방식으로 행을 자체 계측할 수 있다.
import Foundation
import CoreFoundation
import OSLog
import os // OSAllocatedUnfairLock (iOS 16+) 은 os 모듈에 있다
/// 시스템의 행 탐지와 같은 원리로 busy 구간을 재고, 임계 초과 시 스택을 뜬다
final class HangMonitor {
private let log = Logger(subsystem: "com.example.shop", category: "hang")
private var observer: CFRunLoopObserver?
/// 감시 스레드가 이 값을 읽어 "메인이 언제부터 바쁜지" 판단한다
private let busySince = OSAllocatedUnfairLock<ContinuousClock.Instant?>(initialState: nil)
func start(threshold: Duration = .milliseconds(250)) {
// 1) 메인 런루프에 옵저버를 달아 busy 시작/끝을 기록
observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault, CFRunLoopActivity.allActivities.rawValue, true, 0
) { [weak self] _, activity in
guard let self else { return }
switch activity {
case .afterWaiting: self.busySince.withLock { $0 = ContinuousClock.now }
case .beforeWaiting: self.busySince.withLock { $0 = nil }
default: break
}
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
// 2) 별도 스레드에서 감시 — 메인이 막혀 있으므로 메인에서는 감지할 수 없다
let watchdog = Thread { [weak self] in
while let self, !Thread.current.isCancelled {
Thread.sleep(forTimeInterval: 0.05)
let since = self.busySince.withLock { $0 }
guard let since else { continue }
let elapsed = ContinuousClock.now - since
if elapsed > threshold {
// 여기서 메인스레드의 스택을 떠야 원인을 알 수 있다
self.log.error("행 감지: \(elapsed, privacy: .public)")
self.captureMainThreadBacktrace()
// 같은 행을 반복 보고하지 않도록 이 구간은 한 번만 리포트
self.busySince.withLock { $0 = nil }
}
}
}
watchdog.qualityOfService = .utility
watchdog.start()
}
private func captureMainThreadBacktrace() {
// 실제 구현은 thread_suspend + thread_get_state 로 메인스레드 스택을 언와인딩한다.
// 19장에서 본 제약이 여기에도 적용된다 — 메인이 락을 쥔 채 막혀 있으면
// 이 경로에서 할당하는 순간 감시 스레드까지 함께 멈춘다.
}
}
이 코드가 드러내는 중요한 사실 두 가지가 있다.
- 행 감지는 메인스레드에서 할 수 없다. 메인이 막혀 있는 것이 문제인데 감지 코드도 메인에 있으면 그 코드도 함께 막힌다. 반드시 별도 스레드가 필요하다.
- 스택을 뜨는 순간 19장의 제약이 돌아온다. 메인스레드를 정지시키고 스택을 읽는데, 하필 메인이
malloc락을 쥔 채 막혀 있고 감시 스레드가 할당을 하면 둘 다 멈춘다. 행 모니터가 행을 만드는 상황이다.
프로젝트 적용
실무에서 이 지식을 쓰는 방식은 계층별 역할 분담이다.
- 개발 단계 — Instruments Hangs 계측기를 임계를 낮춰(예: 100 ms) 켜고 주요 플로우를 조작해본다. 250 ms 기준으로만 보면 히치를 유발하는 작은 블로킹을 놓친다.
- QA 단계 — 기기의 온디바이스 행 감지를 켜고 실사용 시나리오를 돌린다. 알림이 뜨는 지점을 기록한다.
- 프로덕션 — Organizer 행률로 회귀를 감시하고, 1초 이상 행은 Organizer 행 리포트로 원인을 좁힌다.
MXHangDiagnostic을 우리 백엔드로 흘려보내면 자체 대시보드에서도 볼 수 있다(19장 Q7).
회귀 감시에서 중요한 것은 기기 모델별 필터링이다. Apple도 명시하듯 한 하드웨어에서 완벽히 동작하는 코드가 다른 하드웨어에서는 행을 일으킬 수 있다. 전체 평균만 보면 구형 기기에서만 나타나는 행이 묻힌다. Organizer는 중앙값과 90퍼센타일을 함께 보여주는데, 90퍼센타일이 나쁘면 특정 기기·상황에 문제가 몰려 있다는 신호다.
"250 ms 아래면 안전하다"는 틀렸다. 250 ms는 리포팅 임계이지 인지 임계가 아니다. Apple 문서 기준 이산 상호작용에서는 50~100 ms부터 인지되고, 스크롤 중이라면 16 ms도 보인다. "행률이 0이면 응답성 문제가 없다"도 틀렸다 — 250 ms 미만의 잦은 블로킹은 행률에 안 잡히지만 히치로는 다 보인다. "행 리포트가 없으면 행이 없다"도 오해다 — 백트레이스 캡처는 1초 이상일 때만 일어나므로 250 ms~1초 구간의 행은 집계만 되고 리포트가 없다. 마지막으로 행 탐지 값이 사용자 체감과 같다고 보면 안 된다 — Apple이 명시하듯 이벤트 전달과 렌더링이 정상 상태에서도 10~50 ms를 더한다.
앞에서 은행 창구 이야기를 했다. 이제 창구 직원이 얼마나 바빴는지 재는 방법을 생각해보자.
진짜 재고 싶은 건 "손님이 문을 열고 들어와서 볼일을 마치고 나갈 때까지"다. 그런데 그건 재기 어렵다 — 문 앞에 카메라를 달아야 하고, 나가는 시점도 애매하다.
대신 쉽게 잴 수 있는 게 있다. 직원이 자리에 앉아 쉬는 시간이다. 직원은 손님이 없으면 앉아 쉰다. 그러니까 "앉았다가 다시 앉을 때까지의 시간"을 재면 얼마나 바빴는지 알 수 있다. 이게 런루프의 busy 구간이다.
규칙은 두 개다. 0.25초 넘게 못 앉으면 "바빴다"고 장부에 적는다. 1초 넘게 못 앉으면 그때는 감독관이 와서 사진을 찍는다 — "이 직원이 지금 정확히 무슨 일을 하고 있었나"를 남기는 것이다.
그래서 이런 일이 생긴다. 장부에는 "바빴던 시간 많음"이라고 적혀 있는데 사진은 한 장도 없다. 0.25~1초짜리 바쁨이 계속 반복된 것이다. 이럴 땐 감독관을 기다리지 말고 직접 가서 관찰해야 한다.
그리고 이 측정에는 한계가 있다. 직원이 앉은 순간부터 손님이 밖으로 나가기까지 문을 여닫는 시간이 또 걸린다. 그래서 장부에 적힌 시간은 손님이 실제로 느낀 것보다 항상 조금 짧다.
꼬리 질문
250~1000 ms 구간의 행이 많은데 리포트가 없을 때 원인을 어떻게 좁히는가?
세 단계로 접근한다.
1) 범위 좁히기. Organizer 행률을 앱 버전·기기 모델·iOS 버전으로 필터링해 어떤 조합에서 나쁜지 본다. 특정 버전부터 나빠졌다면 그 사이의 변경 목록이 후보다. 특정 기기에서만 나쁘다면 성능 한계 문제일 가능성이 높다.
2) 재현 후 임계 낮추기. 좁혀진 시나리오를 Instruments의 Hangs 계측기로 돌리되 임계를 100 ms 이하로 내린다. 프로덕션에서 250 ms였던 것이 개발 기기에서는 더 빠를 수 있으므로, 같은 코드 경로를 잡으려면 임계를 내려야 한다. Time Profiler를 함께 켜면 그 구간에서 무엇이 CPU를 쓰는지 같이 보인다.
3) 느린 기기에서 확인. 최신 기기에서 150 ms인 작업이 구형에서는 400 ms가 되어 프로덕션 행률에 잡힌다. 지원 기기 중 가장 느린 모델로 테스트하는 것이 가장 확실하다.
보조 수단으로 자체 행 모니터(위 실험 코드)를 임계 250 ms로 넣어 breadcrumb과 함께 기록하면, "어느 화면에서 주로 발생하는지"를 프로덕션에서 알 수 있다. 스택을 못 떠도 화면 이름만 알면 재현 범위가 크게 줄어든다.
자체 행 모니터가 메인스레드 스택을 뜰 때 위험한 이유와 완화 방법은?
위험의 핵심은 메인스레드를 정지시킨 상태에서 작업해야 한다는 점이다. 스택을 읽으려면 thread_suspend로 메인을 멈추고 레지스터와 스택 메모리를 읽는데, 그 사이에 감시 스레드가 메인이 쥐고 있는 락을 필요로 하면 교착한다. 19장 Q6에서 본 것과 같은 구조다.
가장 흔한 사례가 malloc이다. 메인이 힙 락을 쥔 채 오래 걸리는 작업을 하고 있고, 감시 스레드가 스택 심볼을 문자열로 만들려고 할당하면 그대로 멈춘다. 앱 전체가 얼어붙는다.
완화 방법은 셋이다. (1) 정지 구간을 최소화한다 — 정지 상태에서는 원시 주소만 배열로 복사하고, 심볼화·문자열화는 메인을 재개시킨 뒤에 한다. (2) 사전 할당 버퍼를 쓴다 — 정지 구간에서 할당하지 않는다. (3) 타임아웃을 둔다 — 정지 후 일정 시간 안에 못 끝내면 포기하고 재개시킨다.
더 안전한 대안은 직접 뜨지 않는 것이다. MXHangDiagnostic은 OS가 캡처한 콜스택을 주므로 우리가 메인을 건드릴 필요가 없다. 프로덕션에서는 이쪽이 훨씬 안전하고, 자체 모니터는 "행이 있었다 + 그때 어느 화면이었나" 정도만 기록하는 게 적절하다.
행 탐지가 "메인 런루프 시간만 잰다"는 한계는 어떤 문제를 놓치게 하는가?
세 가지를 놓친다.
(1) 이벤트 전달 지연. 시스템이 터치 이벤트를 앱에 전달하기 전 단계에서 지연이 생기면 런루프는 아직 바쁘지 않으므로 잡히지 않는다. 시스템 전체가 부하를 받거나 다른 프로세스가 CPU를 독점하는 상황에서 발생한다.
(2) 렌더 단계 지연. 메인스레드가 커밋을 제때 끝내 런루프가 잠들었는데 렌더 서버가 늦으면, 행 지표는 깨끗한데 사용자는 늦은 화면을 본다. 이건 render hitch로 별도 계측된다(Q1, 25장).
(3) 논리적 대기. 메인스레드가 막히지 않고 로딩 스피너를 돌리며 네트워크 응답을 5초 기다리는 상황은 행이 아니다. 런루프는 정상적으로 잠들고 깨어난다. 그런데 사용자 입장에서는 "5초 동안 아무것도 못 했다"는 점에서 체감이 나쁠 수 있다.
세 번째가 특히 중요한데, 기술 지표가 전부 초록불인데 사용자 만족도가 낮은 전형적 원인이기 때문이다. 이 영역은 행 지표가 아니라 제품 지표(화면 진입부터 콘텐츠 표시까지의 시간, 이탈률)로 봐야 한다.
그래서 응답성 측정은 세 층으로 구성하는 것이 맞다 — 런루프 행(기술), 렌더 히치(기술), 그리고 사용자 작업 완료 시간(제품).
Q4. 행률은 어떻게 정의되며 무엇을 놓치는가?
Xcode Organizer의 행률은 "앱이 무응답인 시간(초) ÷ 사용 시간(시간)"이며, 250 ms를 초과하는 구간만 센다. 단위가 "시간당 초"라는 점이 중요하다 — 발생 횟수가 아니라 누적 시간이라 짧고 잦은 행과 길고 드문 행이 같은 값으로 나올 수 있다. Organizer는 중앙값과 90퍼센타일을 함께 제공하는데, 둘의 격차가 곧 "일부 사용자·기기에만 몰려 있는가"를 말해준다. 놓치는 것은 명확하다 — 250 ms 미만의 잦은 블로킹, 렌더 쪽 지연, 그리고 메인스레드는 안 막혔는데 사용자는 기다리는 논리적 대기.
CS 원리
"시간당 무응답 초"라는 정의는 비율 지표(rate metric)의 전형이다. 이런 지표의 성질을 알아야 오독을 피한다.
- 분모가 사용 시간이다 — 사용자가 앱을 오래 쓸수록 분모가 커진다. 그래서 세션이 긴 앱과 짧은 앱을 절대값으로 비교할 수 없다. 또 앱을 많이 쓰는 헤비 유저가 행을 많이 겪어도 분모도 함께 커져 값이 희석된다.
- 강도와 빈도가 섞인다 — 10초짜리 행 1회와 1초짜리 행 10회가 같은 값이다. 사용자 체감은 완전히 다른데도 지표는 구분하지 못한다.
- 절단(truncation)이 있다 — 250 ms 미만은 아예 0으로 취급된다. 240 ms짜리 블로킹이 초당 세 번 일어나도 행률은 0이다.
세 번째 성질 때문에 행률과 히치율을 함께 봐야 한다. 250 ms 미만의 반복 블로킹은 행률에는 안 잡히지만 스크롤 중이라면 히치율에 그대로 드러난다.
중앙값과 90퍼센타일을 함께 주는 이유도 여기서 나온다. 평균 하나로는 "모두가 조금씩 겪는가"와 "일부가 심하게 겪는가"를 구분할 수 없다. 중앙값이 낮고 90퍼센타일이 높으면 후자이고, 이때 조사 방향은 "어떤 기기·어떤 상황"이 된다.
iOS에서는
Organizer가 제공하는 응답성 관련 지표를 정리하면 이렇다.
| 지표 | 정의 | 제공 플랫폼 |
|---|---|---|
| 행률 (Hang Rate) | 시간당 무응답 초, 250 ms 초과 구간만 | iOS · macOS · visionOS |
| 행 리포트 | 1초 이상 행의 백트레이스, 유사 스택끼리 그룹핑 | iOS · iPadOS만 |
| 히치율 (Hitch Rate) | ms/s, 애니메이션 종류별 | iOS · iPadOS만 |
히치율의 현행 등급 기준은 Apple 문서에 명시돼 있다.
| 히치율 | 평가 |
|---|---|
| ≤ 10 ms/s | 좋음 |
| ≤ 25 ms/s | 경고 |
| ≤ 50 ms/s | 심각 |
| > 50 ms/s | 즉시 조치 |
이 등급은 현행 문서 기준이다. 과거 Apple Tech Talk(Explore UI animation hitches and the render loop)에서는 5 ms/s 미만이 좋음, 5~10은 인지 가능, 10 초과는 즉시 조치라는 훨씬 엄격한 기준을 제시했다. 두 자료가 서로 다른 값을 말한다. 인터넷에는 낡은 쪽 수치가 여전히 널리 인용되고 있으므로, 팀 기준을 정할 때는 어느 자료의 값인지 명시해야 한다.
또 하나 실무적으로 중요한 것 — 행 리포트와 히치 지표는 iOS·iPadOS에서만 제공된다. visionOS는 RealityKit Trace를, tvOS는 별도 방법을 써야 한다.
실험 · 도구
같은 행률이 서로 다른 사용자 경험을 뜻할 수 있다는 것을 계산으로 확인해보자.
import Foundation
struct HangEvent { let durationMs: Double }
/// Organizer 정의: 250 ms 초과 구간만 합산, 시간당 초 단위
func hangRate(events: [HangEvent], usageHours: Double) -> Double {
let counted = events.filter { $0.durationMs > 250 }
let totalSeconds = counted.reduce(0) { $0 + $1.durationMs } / 1000
return totalSeconds / usageHours
}
let usage = 1.0 // 1시간 사용
// (A) 길고 드문 행 — 사용자가 "앱이 멈췄다"고 느낀다
let rare = [HangEvent(durationMs: 6000), HangEvent(durationMs: 4000)]
// (B) 짧고 잦은 행 — 사용자가 "앱이 굼뜨다"고 느낀다
let frequent = (0..<20).map { _ in HangEvent(durationMs: 500) }
// (C) 임계 바로 아래로 아주 잦은 블로킹 — 사용자는 스크롤이 계속 끊긴다고 느낀다
let subThreshold = (0..<500).map { _ in HangEvent(durationMs: 240) }
print(String(format: "A 길고 드문: %.1f 초/시간", hangRate(events: rare, usageHours: usage)))
print(String(format: "B 짧고 잦은: %.1f 초/시간", hangRate(events: frequent, usageHours: usage)))
print(String(format: "C 임계 바로 아래: %.1f 초/시간", hangRate(events: subThreshold, usageHours: usage)))
// A 길고 드문: 10.0 초/시간
// B 짧고 잦은: 10.0 초/시간 ← A와 값이 같지만 체감은 전혀 다르다
// C 임계 바로 아래: 0.0 초/시간 ← 총 120초를 막았는데 지표는 0이다
(C)가 이 지표의 가장 중요한 사각지대다. 1시간 중 총 2분을 메인스레드가 막혀 있었는데 행률은 0이다. 각 구간이 250 ms를 안 넘었기 때문이다. 그런데 이 앱은 스크롤할 때마다 끊길 것이고, 그건 히치율에 나타난다.
그래서 대시보드는 둘을 나란히 놓아야 한다.
# 확인 순서
# 1) 행률이 나쁜가? → 긴 블로킹이 있다. Organizer 행 리포트로 스택 확인
# 2) 행률은 좋은데 히치율이 나쁜가?
# → 짧은 블로킹이 잦거나(commit hitch), 렌더 쪽 문제(render hitch)
# → Animation Hitches 계측기로 commit/render 구분
# 3) 둘 다 좋은데 사용자 불만이 있는가?
# → 논리적 대기(로딩)일 가능성. 제품 지표로 넘어간다
프로젝트 적용
행률을 팀 목표로 쓸 때의 실무 규칙 넷.
- 절대값 목표보다 회귀 감시로 쓴다. "행률 5초/시간 이하" 같은 목표는 앱마다 사용 패턴이 달라 의미가 흐리다. 대신 릴리스 간 변화를 본다 — 이전 버전 대비 20% 이상 악화되면 조사한다.
- 중앙값과 90퍼센타일을 항상 함께 본다. 90퍼센타일만 나빠지면 특정 기기·상황 문제다. 둘 다 나빠지면 전역적 회귀다.
- 기기 모델 필터를 기본으로 건다. 지원 기기 중 가장 느린 모델의 행률을 별도 지표로 둔다. 여기서 먼저 터지고, 여기서 고치면 전체가 좋아진다.
- 히치율을 짝으로 둔다. 행률만 보면 250 ms 미만 문제가 통째로 보이지 않는다.
그리고 조사 절차를 문서로 정해두면 좋다. Apple이 제시하는 순서가 그대로 쓸 만하다 — (1) 기기·버전 등 차원으로 필터링해 특정 조합의 문제인지 확인, (2) 문제가 처음 나타난 앱 버전을 찾고 버전 관리로 그 사이 변경을 좁힘, (3) 좁혀진 영역을 집중 테스트해 재현, (4) 재현되면 Instruments로 원인 확정.
"행률이 0이면 응답성 문제가 없다"가 가장 위험한 오독이다. 250 ms 미만 블로킹은 아무리 많아도 0으로 나온다. "행률이 같으면 사용자 경험도 비슷하다"도 틀렸다 — 10초 1회와 0.5초 20회가 같은 값이지만 체감은 전혀 다르다. "히치율 5 ms/s 이하가 기준"이라고 단정하는 것도 조심해야 한다 — 그건 과거 Tech Talk 기준이고 현행 문서는 10 ms/s 이하를 좋음으로 본다. 어느 쪽을 쓰든 출처를 명시해야 팀 안에서 기준이 갈리지 않는다. 또 "행 리포트가 모든 플랫폼에서 온다"도 틀렸다 — 행 리포트와 히치 지표는 iOS·iPadOS 전용이다.
학교에서 지각 시간을 기록한다고 해보자. 규칙은 이렇다 — 5분 넘게 늦은 경우만 장부에 적고, 한 달 동안 늦은 시간을 다 더한다.
여기서 세 학생을 비교해보자.
- A는 한 달에 두 번, 각각 한 시간씩 늦었다. 총 120분.
- B는 스무 번, 각각 6분씩 늦었다. 총 120분.
- C는 매일 4분씩 늦었다. 총 80분인데 장부에는 0분이다. 한 번도 5분을 안 넘겼으니까.
장부만 보면 A와 B가 똑같이 나쁘고 C는 완벽하다. 그런데 실제로는? A는 "가끔 크게 사고 치는 애", B는 "늘 조금씩 늦는 애", C는 "매일 늦는데 기록에 안 남는 애"다. 셋 다 다른 문제다.
특히 C가 무섭다. 선생님은 "C는 문제없음"이라고 생각하는데, 같은 반 친구들은 매일 C 때문에 조회가 늦게 시작된다고 느낀다. 이런 학생을 찾으려면 다른 장부(히치율)를 봐야 한다.
꼬리 질문
중앙값 행률은 좋은데 90퍼센타일만 나쁘면 무엇을 의심해야 하는가?
문제가 일부 사용자·기기·상황에 집중돼 있다는 신호다. 후보를 좁히는 순서는 이렇다.
(1) 기기 성능. 가장 흔한 원인이다. 구형 기기나 저사양 모델에서만 임계를 넘는 경우다. Organizer의 기기 필터로 즉시 확인된다.
(2) 데이터 규모. 사용자마다 로컬 데이터 양이 다르다. 대화 10개인 사용자와 10만 개인 사용자는 같은 코드에서 전혀 다른 시간을 쓴다. 목록 정렬·필터링·마이그레이션이 데이터 크기에 선형 이상으로 반응하면 헤비 유저만 행을 겪는다.
(3) 네트워크 환경. 메인스레드에 동기 네트워킹이 남아 있으면 회선 상태에 따라 지연이 극단적으로 갈린다. 사무실에서는 절대 재현되지 않는 전형적 케이스다.
(4) 저장 공간 부족. 디스크가 거의 찬 기기에서는 파일 I/O가 크게 느려진다.
(5) 특정 기능 사용자. 일부만 쓰는 기능(대용량 첨부, 특정 설정)이 원인이면 그 기능 사용자만 나쁘다.
구분하는 실용적 방법은 90퍼센타일이 나쁜 사용자군의 다른 특성을 보는 것이다. 기기 모델 분포가 치우쳐 있으면 (1), 사용 기간이 길면 (2)일 가능성이 높다.
행률을 릴리스 간 비교할 때 주의할 교란 요인은?
네 가지가 있다.
(1) 채택 곡선. 새 릴리스 초기에는 얼리 어답터가 먼저 업데이트하는데, 이들은 대체로 최신 기기를 쓴다. 그래서 릴리스 직후 행률이 좋아 보였다가 채택이 퍼지면서 나빠지는 착시가 생긴다. 충분한 채택률에 도달한 뒤 비교해야 한다.
(2) OS 버전 분포 변화. iOS 메이저 업데이트 직후에는 OS 자체의 성능 특성이 바뀌어 앱 코드와 무관하게 지표가 움직인다.
(3) 사용 패턴 변화. 새 기능을 넣으면 사용자가 다른 화면에 더 오래 머문다. 분모(사용 시간)와 문제 발생 화면의 분포가 동시에 바뀌면 비교가 어려워진다.
(4) 계절성. 프로모션이나 이벤트로 신규 사용자가 몰리면 기기 분포와 데이터 규모 분포가 통째로 달라진다.
대응은 계층화 비교다. 전체 값만 보지 말고 기기 모델과 OS 버전을 고정한 부분집합끼리 비교하면 교란이 크게 준다. Organizer의 필터가 정확히 이 용도다.
250 ms 미만 블로킹이 많은 앱을 개선했는지 어떻게 증명하는가?
행률로는 증명할 수 없으므로 다른 지표를 기준으로 삼아야 한다. 세 가지 선택지가 있다.
(1) 히치율. 250 ms 미만 블로킹이 스크롤 중에 일어나면 commit hitch로 계측된다. 개선 전후 히치율 비교가 가장 직접적인 증거다. 다만 스크롤이 아닌 상황의 블로킹은 여기에도 안 잡힌다.
(2) 자체 계측. Q3의 런루프 옵저버로 임계를 낮춰(예: 50 ms) busy 구간 분포를 직접 수집한다. 히스토그램으로 보내면 "50~100 ms 구간이 몇 % 줄었다"를 정량화할 수 있다. 이건 18장 Q1에서 말한 "차원을 살려 보내기"의 실제 적용이다.
(3) 제품 지표. 화면 진입부터 상호작용 가능까지의 시간, 스크롤 완료율, 이탈률. 기술 지표가 개선됐는데 이쪽이 안 움직이면 사용자가 체감하지 못한 개선일 수 있다.
실무적으로는 (2)를 넣어두는 것이 가장 확실하다. Apple이 주는 지표는 임계가 고정돼 있어 우리가 정한 목표를 측정해주지 않기 때문이다. 다만 자체 계측 자체가 오버헤드를 만들 수 있으므로(18장 Q7) 샘플링을 걸고 릴리스 후 일정 기간만 켜는 방식이 안전하다.
Q5. 메인스레드를 막는 코드는 어디에 숨어 있는가?
대부분 동기 API인 줄 모르고 쓰는 것들에 숨어 있다. Apple이 직접 지목한 대표 사례가 있다 — String(contentsOf:)·Data(contentsOf:)처럼 URL을 받는 이니셜라이저에 https URL을 넘기면 암묵적으로 동기 네트워크 요청이 일어난다. SCNetworkReachability는 기본이 동기이고, BSD DNS 함수(gethostbyname 등)는 메인스레드에서 절대 안전하지 않다. 여기에 파일 I/O, 대용량 JSON 디코딩, Core Data 라이트웨이트 마이그레이션, 이미지 원본 디코딩, 정규식 백트래킹, 그리고 DispatchQueue.sync·NSLock으로 백그라운드 결과를 기다리는 패턴이 더해진다. 공통점은 하나다 — 호출부만 봐서는 오래 걸릴 것 같지 않다.
CS 원리
이 문제의 본질은 추상화가 비용을 숨긴다는 것이다. 좋은 API는 세부를 감추는데, 감춰진 세부 중에 "네트워크를 다녀온다"가 섞여 있으면 호출부의 겉모습과 실제 비용이 크게 어긋난다.
String(contentsOf: url)이 정확히 그렇다. 시그니처만 보면 "URL에서 문자열을 만든다"이고, 로컬 파일이면 빠르다. 그런데 같은 함수에 https URL을 주면 DNS 조회 → TCP 연결 → TLS 핸드셰이크 → HTTP 요청 → 응답 수신이 전부 동기로 일어난다(P0 04장의 전 과정이 한 줄 안에 들어 있다). 네트워크가 나쁘면 수십 초가 될 수 있다.
더 고약한 성질이 있다. 이런 코드는 개발 환경에서 절대 문제를 드러내지 않는다. Apple도 이 점을 명시한다 — 동기 네트워킹 문제는 네트워크 환경에 크게 좌우되므로, 연결이 좋은 사무실에서만 테스트하면 이런 문제를 결코 보지 못한다. 앱을 배포하고 온갖 네트워크 환경의 사용자에게 도달해야 비로소 흔해진다.
즉 이 버그들은 구조적으로 늦게 발견된다. 코드 리뷰에서 놓치고, 개발 중 안 걸리고, QA에서 안 나오고, 프로덕션 행률로만 드러난다.
iOS에서는
Apple 문서가 직접 지목한 것부터 정리한다.
| 숨은 블로킹 | 겉보기 | 실제 |
|---|---|---|
String(contentsOf:) / Data(contentsOf:) with https | 문자열/데이터 생성 | 동기 네트워크 왕복 전체 |
SCNetworkReachability | 연결 상태 확인 | 기본이 동기 — 가벼워 보이는 함수가 워치독을 유발 |
gethostbyname / gethostbyaddr | 이름 조회 | 메인스레드에서 절대 안전하지 않다 |
getaddrinfo / getnameinfo | 주소 변환 | AI_NUMERICHOST/NI_NUMERICHOST로 IP만 다룰 때만 안전 |
| URL을 받는 다른 이니셜라이저들 | 객체 생성 | 같은 방식으로 동기 요청 |
여기에 실무에서 흔한 것들을 더하면 이렇다.
- 파일 I/O —
Data(contentsOf: 로컬파일),FileManager의 디렉터리 열거, 대용량 파일 쓰기. P0 10장의 주제가 그대로 행의 원인이 된다. - 대용량 디코딩 — 수 MB JSON을
JSONDecoder로 메인에서 파싱. 크기에 비례해 시간이 는다. - Core Data 라이트웨이트 마이그레이션 — Apple이 워치독 문서에서 직접 예로 든다. 큰 스토어를 동기로 마이그레이션하면 앱 시작이 통째로 막힌다.
- 이미지 원본 디코딩 — P0 08장 Q4. 셀에서 큰 이미지를 그리는 순간 디코딩이 메인에서 일어난다.
- 동기화 프리미티브로 기다리기 —
DispatchQueue.sync,DispatchSemaphore.wait(),NSLock.lock(). "비동기 함수를 동기처럼 쓰려고" 세마포어를 쓰는 패턴이 특히 위험하다. - 과도한 오토레이아웃 제약 — 제약 수가 많으면 해 찾기 비용이 비선형으로 는다.
- 정규식 백트래킹 — 입력에 따라 지수적으로 폭발할 수 있다.
실험 · 도구
메인스레드 블로킹을 개발 중에 즉시 잡는 방법이 몇 가지 있다. 프로덕션 행률을 기다리지 않는 것이 핵심이다.
import Foundation
import os
// ── (1) 디버그 빌드에서 메인스레드 I/O를 즉시 잡는 어서션 ──
@inline(__always)
func assertNotMainThread(_ what: String,
function: String = #function, line: Int = #line) {
#if DEBUG
if Thread.isMainThread {
// 여기서 멈추면 스택에 호출 경로가 그대로 남는다
fatalError("메인스레드에서 \(what) 호출됨 — \(function):\(line)")
}
#endif
}
// 위험한 호출을 감싸는 얇은 래퍼를 만들어 두면 팀 전체가 실수를 못 한다
enum SafeIO {
static func read(_ url: URL) throws -> Data {
assertNotMainThread("파일 읽기")
return try Data(contentsOf: url)
}
}
// ── (2) 위험한 API 차단 — 언어 기능만으로는 안 된다 ──
//
// ⚠️ 흔히 시도하는 방법인데 통하지 않는다:
// extension Data { @available(*, unavailable) init(dangerouslySyncContentsOf: URL) }
// 이건 **레이블이 다른 새 이니셜라이저**를 정의할 뿐이고,
// 기존 Foundation API 인 Data(contentsOf:) 에는 아무 영향이 없다.
// Swift 에서 남의 모듈 API 를 소급해 unavailable 로 만들 수단은 없다.
//
// 실제로 통하는 방법은 셋이다:
// (a) SwiftLint custom rule 로 호출 자체를 에러로 승격
// (b) 아래 (6) 처럼 CI 에서 grep 으로 차단
// (c) 모듈 경계를 두고 Foundation 직접 사용을 막은 뒤 SafeIO 만 노출
//
// (a) SwiftLint 예시 — .swiftlint.yml
// custom_rules:
// no_sync_contents_of:
// regex: '(Data|String)\(contentsOf:'
// message: "동기 I/O·네트워킹. URLSession 또는 SafeIO 를 쓸 것"
// severity: error
도구 기반 탐지도 병행한다.
# (3) Xcode Scheme › Run › Diagnostics
# ☑︎ Main Thread Checker — UIKit/AppKit API를 메인 밖에서 부르면 잡는다 (반대 방향)
#
# (4) Instruments — Time Profiler + Hangs 계측기
# 임계를 100 ms 이하로 낮추고 주요 플로우를 조작한다
#
# (5) 심볼릭 브레이크포인트로 동기 네트워킹 호출을 직접 잡는다
# Xcode › Breakpoint Navigator › + › Symbolic Breakpoint
# Symbol: getaddrinfo
# Condition: (없음)
# → 메인스레드에서 걸리면 스택에서 호출자를 바로 확인할 수 있다
# (6) 정적 검사 — 위험 API 사용을 CI에서 차단
grep -rn --include='*.swift' \
-e 'Data(contentsOf:' -e 'String(contentsOf:' \
-e 'SCNetworkReachability' -e 'gethostbyname' \
-e 'DispatchSemaphore' \
Sources/ | grep -v '// allow-sync:'
(6)이 특히 실용적이다. 이 API들은 정당한 용도가 드물기 때문에 CI에서 막고 예외만 주석으로 허용하는 방식이 잘 동작한다.
프로젝트 적용
Apple이 워치독 문서에서 제시하는 대응 원칙이 그대로 행에도 적용된다 — UI에 필수적이지 않은 장시간 코드는 전부 백그라운드로 옮긴다. 구체적 선택지는 이렇다.
- 네트워킹은
URLSession으로 비동기 실행. Apple은 이것을 최선의 해법으로 명시한다 — 스레드를 신경 쓰지 않고 안전하게 네트워크에 접근할 수 있기 때문이다. SCNetworkReachability대신NWPathMonitor. 경로 변경을 우리가 넘긴 큐로 전달하므로 메인 밖에서 안전하게 처리된다.- DNS를 직접 해석하지 않는다. Apple은 대부분의 상황에서 권장하지 않으며, 꼭 필요하면
CFHost나<dns_sd.h>의 비동기 API를 쓰라고 안내한다. - 정말 비동기화가 어려우면 보조 스레드에서 동기로. 동기 네트워킹을 가정하는 큰 레거시 코드베이스라면, 비동기로 재작성하는 대신 별도 스레드에서 동기 실행해 워치독을 피하는 것이 현실적이다.
- 시스템 프레임워크에 비동기 대안이 있는지 먼저 확인한다. 동기 로더 대신 비동기 로더가 제공되는 경우가 많다.
그리고 테스트 환경을 나쁘게 만드는 것이 중요하다. Apple도 Xcode의 네트워크 조건 시뮬레이션을 권한다. 사무실 와이파이에서만 테스트하면 이 종류의 버그는 영원히 안 보인다.
"파일 URL이니 Data(contentsOf:)는 안전하다"는 절반만 맞다. 로컬 파일이어도 대용량이면 여전히 오래 걸리고, 파일이 iCloud나 외부 저장소에 있으면 네트워크가 개입할 수 있다. "reachability 확인은 가벼운 호출"도 틀렸다 — Apple이 겉보기에 무해한 함수가 워치독 종료를 유발할 수 있다고 직접 경고한다. "세마포어로 비동기를 동기로 바꾸는 건 임시방편일 뿐"이라는 인식도 위험하다 — 그 임시방편이 메인스레드에서 실행되면 곧바로 행이고, 심하면 교착한다. 마지막으로 "느린 코드는 프로파일러로 찾으면 된다"도 한계가 있다 — 동기 네트워킹은 CPU를 안 쓰고 기다리므로 CPU 프로파일러에서 눈에 안 띈다. 이럴 땐 Hangs 계측기나 스레드 상태를 봐야 한다.
식당에서 주문을 받는 직원이 있다. 이 직원의 일은 주문을 받아서 주방에 넘기는 것이고, 빨리빨리 해야 줄이 안 밀린다.
그런데 메뉴판에 이런 항목이 있다. "오늘의 특선". 손님이 이걸 시키면 직원은 "잠시만요" 하고 시장에 재료를 사러 간다. 메뉴판에는 그런 얘기가 한 줄도 없다. 다른 메뉴와 똑같이 생겼다.
가게 바로 앞에 시장이 있으면 30초면 돌아온다. 그래서 가게에서 테스트할 땐 아무 문제가 없다. 그런데 손님 중에는 시장이 먼 동네에 사는 사람도 있고, 그날따라 길이 막힐 수도 있다. 그러면 직원이 30분간 자리를 비우고, 그 사이 줄 선 모든 손님이 아무것도 못 한다.
Data(contentsOf: https주소)가 정확히 이 메뉴다. 이름만 보면 "데이터를 읽는다"인데, 실제로는 인터넷 반대편까지 다녀온다.
그래서 두 가지를 해야 한다. 하나는 메뉴판에서 그 항목을 지우는 것(위험한 API를 못 쓰게 막기). 다른 하나는 일부러 먼 동네에서 테스트해보는 것(느린 네트워크 시뮬레이션)이다. 가게 앞에서만 시험하면 영원히 모른다.
꼬리 질문
동기 네트워킹이 CPU 프로파일러에 안 잡히는 이유와, 그럼 어떻게 찾는가?
동기 네트워크 대기는 스레드가 블록된 상태로 응답을 기다리는 것이라 CPU를 거의 쓰지 않는다. Time Profiler는 기본적으로 실행 중인 스레드를 샘플링하므로, 자고 있는 스레드는 샘플에 잡히지 않거나 아주 적게 잡힌다. 그래서 "프로파일링했는데 아무것도 안 나온다. 그런데 앱은 멈춰 있다"는 상황이 된다.
찾는 방법은 셋이다.
(1) Hangs 계측기. CPU 사용 여부와 무관하게 런루프가 바쁜 시간을 재므로 블록된 대기도 그대로 잡힌다. 이게 가장 직접적이다.
(2) Time Profiler의 스레드 상태 옵션. "Record Waiting Threads" 계열 설정을 켜면 대기 중인 스레드도 샘플에 포함되어 어디서 기다리는지 보인다.
(3) 심볼릭 브레이크포인트. getaddrinfo, connect, recv 같은 저수준 심볼에 브레이크포인트를 걸고 메인스레드에서 멈추는지 본다. 멈추면 스택에 호출자가 그대로 나온다.
이 차이는 일반화된다 — CPU 프로파일러는 "바쁜" 문제를, 행 도구는 "기다리는" 문제를 잡는다. 둘 다 필요하다.
세마포어로 비동기 API를 동기처럼 감싸는 패턴은 왜 특히 위험한가?
세 가지 위험이 겹친다.
(1) 메인에서 부르면 즉시 행. semaphore.wait()는 메인스레드를 완전히 블록하므로 런루프가 멈춘다. 비동기 작업이 3초 걸리면 3초짜리 행이다.
(2) 교착 가능. 비동기 API의 완료 핸들러가 메인 큐로 디스패치되도록 되어 있으면 결정적으로 교착한다 — 메인이 세마포어를 기다리는데, 세마포어를 신호할 코드가 메인 큐에 들어가 실행 순서를 기다리기 때문이다. 서로를 영원히 기다린다.
(3) Swift Concurrency에서는 스레드 풀을 고갈시킬 수 있다. 협력적 스레드 풀은 스레드 수가 코어 수에 맞춰 제한되는데, Task 안에서 세마포어로 블록하면 그 스레드가 다른 태스크를 실행할 수 없게 된다. 여러 태스크가 동시에 그러면 풀 전체가 막혀 무관한 작업까지 멈춘다. Swift 팀이 "async 컨텍스트에서 블로킹 프리미티브를 쓰지 말라"고 하는 이유다.
대안은 명확하다 — 비동기를 동기로 바꾸지 말고 호출부를 비동기로 만든다. 콜백 기반 API는 withCheckedContinuation으로 async 함수로 감싸면 되고, 이 경우 스레드를 블록하지 않고 정지만 하므로 위 세 문제가 전부 사라진다.
레거시 코드에서 동기 네트워킹을 당장 비동기로 못 바꾸면 어떤 순서로 처리해야 하는가?
Apple이 제시하는 우선순위를 그대로 따르면 된다.
1순위: URLSession으로 비동기 전환. 근본 해결이고, 스레드 관리를 신경 쓸 필요가 없어진다. 가능하면 무조건 이쪽이다.
2순위: 보조 스레드에서 동기 실행. Apple이 명시적으로 인정하는 절충안이다 — 동기 네트워킹을 전제로 짜인 크고 이식성 있는 코드베이스처럼 비동기화가 지나치게 어려운 경우, 동기 코드를 그대로 두고 보조 스레드에서 돌려 워치독을 피한다. 코드 변경 범위가 작아 위험이 낮다.
다만 2순위에는 함정이 있다. 결과를 UI에 반영하려면 결국 메인으로 돌아와야 하는데, 이때 세마포어로 기다리면 원점이다. 콜백이나 async 경계를 만들어야 한다.
실무적 이행 순서를 붙이면 이렇다 — (a) 먼저 어디가 문제인지 측정해 우선순위를 정하고, (b) 사용자 영향이 큰 경로(앱 시작, 주요 화면 진입)부터 1순위 방식으로 고치고, (c) 나머지는 2순위로 임시 완화하고, (d) 새 코드가 같은 패턴을 다시 만들지 않도록 CI 검사와 API 차단을 건다.
(d)가 빠지면 고치는 속도보다 새로 생기는 속도가 빨라진다.
Q6. async/await로 바꾸면 행이 사라지는가?
아니다. Swift Concurrency는 데이터 경쟁을 막아주지 응답성을 보장하지 않는다. @MainActor 함수 안에서 await 없이 200 ms짜리 계산을 하면 그건 메인스레드를 200 ms 잡은 것이고, 런루프는 그동안 아무 일도 못 한다 — 이름이 async여도 마찬가지다. async 함수는 await 지점에서만 스레드를 놓아주고, 그 사이의 코드는 평범한 동기 실행이다. 행을 없애려면 무거운 작업을 메인 액터 밖으로 옮기는 명시적 조치(nonisolated 함수, Task.detached, 전용 액터)가 필요하다. 실수로 메인에 남기 쉬운 이유는 UI 타입이 대체로 @MainActor라 그 안의 헬퍼도 자동으로 메인이 되기 때문이다.
CS 원리
await가 없는 구간은 평범한 동기 실행이다. 이름이 async여도 그렇다.혼동의 뿌리는 "비동기"와 "동시"를 같은 말로 쓰는 것이다. P0 02장에서 다룬 구분이 여기서 결정적으로 중요해진다.
- 비동기(asynchronous) — 호출자가 결과를 기다리며 블록되지 않는다. 정지했다가 나중에 재개된다.
- 동시(concurrent) — 여러 작업이 겹쳐서 진행된다.
- 병렬(parallel) — 여러 작업이 물리적으로 동시에 실행된다.
async 함수는 비동기일 뿐이다. 그 함수 본문이 어느 실행 컨텍스트에서 도는지는 격리(isolation)가 결정하고, 격리가 메인 액터면 본문은 메인스레드에서 돈다.
정지(suspension)의 의미도 정확히 이해해야 한다. await는 "여기서 실행을 멈추고 스레드를 놓아준다"는 뜻이다. 정지 지점 사이의 코드는 중단 없이 쭉 실행된다. 그러므로 await가 없는 100줄짜리 계산은 async 함수 안에 있어도 통째로 한 덩어리다.
@MainActor
func loadReport() async { // async 이지만
let rows = parseHugeCSV(data) // ← await 없음. 메인스레드에서 통째로 실행된다.
render(rows) // 이 함수가 2초 걸리면 2초짜리 행이다.
}
여기에 Swift 특유의 함정이 하나 더 있다. 격리는 전염된다. @MainActor가 붙은 타입의 메서드는 전부 메인 액터로 격리되고, 그 안에서 호출하는 격리되지 않은 동기 함수도 메인스레드에서 실행된다. UI 타입은 대체로 @MainActor이므로, 뷰 모델에 계산 헬퍼를 추가하면 아무 표시 없이 메인스레드 작업이 된다.
iOS에서는
실무에서 이 함정이 나타나는 전형적 패턴 넷이다.
| 패턴 | 왜 메인에 남는가 | 해법 |
|---|---|---|
@MainActor 뷰모델의 private 헬퍼 | 타입 격리가 메서드에 전염 | nonisolated 선언 또는 별도 타입으로 분리 |
Task { } 를 @MainActor 컨텍스트에서 생성 | 비격리 Task는 둘러싼 액터 컨텍스트를 상속 | Task.detached 또는 내부에서 비격리 함수 호출 |
await 뒤 무거운 후처리 | 재개 지점이 원래 액터로 돌아옴 | 무거운 부분을 비격리 함수로 빼기 |
| 액터 메서드 안의 긴 동기 루프 | 정지 지점이 없어 통째 실행 | 작업 분할 + await Task.yield() |
두 번째가 특히 오해가 많다. Task { }는 새 스레드를 만들지 않는다. 비격리 Task는 생성된 곳의 액터 컨텍스트를 상속하므로, @MainActor 안에서 만든 Task { }의 본문은 메인 액터에서 실행된다. "Task로 감쌌으니 백그라운드로 갔겠지"는 틀렸다.
다만 이건 행을 완전히 막지는 못해도 완화는 한다. Task 본문은 별도의 작업 단위라 런루프가 그 사이에 다른 일을 처리할 기회가 생긴다. 그래도 본문 자체가 길면 그 구간은 그대로 블로킹이다.
실험 · 도구
어느 코드가 실제로 메인스레드에서 도는지는 직접 확인해야 한다.
import SwiftUI
@MainActor
final class ReportViewModel: ObservableObject {
@Published var rows: [Row] = []
// ── ❌ 함정 1: async 인데 await 이 없어 메인을 통째로 잡는다 ──
func loadBad(_ data: Data) async {
assert(Thread.isMainThread) // 참이다
let parsed = Self.parse(data) // 2초 걸리는 동기 파싱 → 2초 행
rows = parsed
}
// ── ❌ 함정 2: Task { } 는 둘러싼 액터를 상속한다 ──
func loadAlsoBad(_ data: Data) {
Task { // 새 스레드가 아니다
assert(Thread.isMainThread) // 여전히 참이다
let parsed = Self.parse(data)
rows = parsed
}
}
// ── ✅ 해법 A: 무거운 부분을 nonisolated 로 빼고 await 로 넘긴다 ──
func loadGood(_ data: Data) async {
let parsed = await Self.parseOffMain(data) // await 지점에서 메인을 놓아준다
rows = parsed // 재개 후 메인에서 UI 갱신
}
/// nonisolated + async → 메인 액터가 아닌 곳에서 실행된다
nonisolated static func parseOffMain(_ data: Data) async -> [Row] {
assert(!Thread.isMainThread) // 참이다
return parse(data)
}
nonisolated static func parse(_ data: Data) -> [Row] { /* 무거운 동기 작업 */ [] }
}
정지 지점이 없는 긴 루프를 다루는 방법도 알아둘 만하다.
// 메인 액터에서 반드시 돌아야 하는 긴 작업이라면 — 중간중간 양보한다
@MainActor
func rebuildLayout(items: [Item]) async {
for (i, item) in items.enumerated() {
apply(item)
if i % 50 == 0 {
// 런루프에 숨 쉴 틈을 준다. 총 시간은 늘지만 행은 사라진다.
await Task.yield()
}
}
}
검증 도구는 이렇다.
# (1) 런타임 확인 — 의심 지점에 dispatchPrecondition 을 건다
# dispatchPrecondition(condition: .notOnQueue(.main))
#
# (2) Instruments — Swift Concurrency 계측기
# 태스크가 어느 액터/실행자에서 도는지 시각화된다
#
# (3) Swift 6 strict concurrency 를 켠다
# 격리 전염이 컴파일 경고로 드러나는 경우가 많다
swift build -Xswiftc -strict-concurrency=complete
프로젝트 적용
Swift Concurrency 코드에서 행을 예방하는 규칙 넷.
- 계산 로직을 UI 타입 안에 두지 않는다. 파싱·정렬·필터링·변환은
@MainActor가 아닌 별도 타입(구조체나 전용 액터)에 두면 격리 전염이 애초에 일어나지 않는다. 이건 P0 17장의 관심사 분리가 성능 문제로 되돌아온 사례다. Task { }가 백그라운드를 뜻한다고 가정하지 않는다. 무거운 작업이라면 호출 대상이 비격리인지 명시적으로 확인한다.await뒤 코드가 어느 액터에서 재개되는지 의식한다. 메인 액터 함수에서는await후 다시 메인으로 돌아온다. 후처리가 무거우면 그것도 행이다.- 메인에서 불가피한 긴 작업은 분할하고 양보한다.
Task.yield()로 런루프에 틈을 준다.
그리고 측정으로 검증한다. "async로 바꿨으니 괜찮겠지"가 아니라 Q3의 도구로 busy 구간이 실제로 줄었는지 확인해야 한다. 리팩터링 전후 행률 비교가 가장 확실한 증거다.
"async를 붙이면 백그라운드에서 실행된다"가 가장 흔한 오해다. async는 정지 가능하다는 뜻일 뿐 실행 위치와 무관하다. "Task { }로 감싸면 별도 스레드"도 틀렸다 — 비격리 Task는 둘러싼 액터 컨텍스트를 상속한다. "await를 쓰면 그 줄에서 메인이 자유로워진다"는 맞지만, await가 없는 구간은 전혀 자유롭지 않다는 반쪽을 놓치기 쉽다. 또 "actor를 쓰면 메인스레드가 안전해진다"도 혼동이다 — actor는 데이터 경쟁을 막을 뿐 메인 액터에 올린 작업의 실행 시간을 줄여주지 않는다. 마지막으로 "Swift 6 strict concurrency를 켜면 행도 잡힌다"도 틀렸다 — 컴파일러는 데이터 경쟁을 검사하지 작업 시간은 모른다.
다시 창구 하나짜리 은행이다. 이번엔 새로운 업무 방식이 도입됐다.
예전에는 손님 서류를 확인하러 창고에 갈 때 창구를 비우고 다녀왔다. 그동안 아무도 일을 못 봤다.
새 방식(await)에서는 창고에 갈 때 "잠시 다른 분 먼저 받으세요" 하고 자리를 내준다. 창고에서 돌아오면 다시 순서를 받는다. 훨씬 낫다.
그런데 여기서 중요한 게 있다. 자리를 내주는 건 창고에 갈 때뿐이다. 창구에 앉아서 계산기를 두드리는 30분은? 그건 그냥 창구를 30분 잡고 있는 것이다. 새 방식을 쓴다고 달라지지 않는다.
더 헷갈리는 게 하나 있다. "이 업무는 다른 팀에 맡겼습니다"(Task { })라고 말했는데, 알고 보니 그 다른 팀도 같은 창구를 쓴다. 서류상으로만 다른 팀이지 실제로는 같은 자리에 앉는다. 그래서 여전히 줄이 밀린다.
진짜로 줄을 안 밀리게 하려면 "이 일은 뒷방에서 하세요"라고 명확히 지정해야 한다(nonisolated). 그리고 정 창구에서 해야 하는 긴 일이라면, 중간중간 "다음 분 먼저요" 하고 끊어주는 수밖에 없다(Task.yield()).
꼬리 질문
Task { }와 Task.detached { }는 격리 말고 또 무엇이 다른가?
세 가지가 더 다르다.
(1) 우선순위 상속. Task { }는 생성 지점의 태스크 우선순위를 물려받는다. Task.detached는 물려받지 않으므로 명시하지 않으면 기본 우선순위로 실행된다. UI에서 시작한 작업이 detached로 가면 우선순위가 떨어져 더 느려질 수 있다.
(2) 태스크 로컬 값 상속. @TaskLocal 값은 Task { }에는 전파되지만 Task.detached에는 전파되지 않는다. 18장 Q5에서 본 관측 맥락 전파가 detached 경계에서 끊긴다는 뜻이라, 로그와 이벤트에 맥락이 빠진다.
(3) 취소 전파. 둘 다 구조적 자식은 아니지만, 실무에서 Task { }는 대개 뷰나 객체 생명주기와 함께 관리되고 detached는 그런 연결이 더 약하다. detached 작업이 오래 살아남아 이미 사라진 화면의 상태를 갱신하려 하는 실수가 흔하다.
그래서 무거운 계산을 옮기는 목적이라면 detached보다 "비격리 async 함수를 await로 호출"하는 쪽이 대체로 낫다. 우선순위와 태스크 로컬이 유지되면서 실행 컨텍스트만 메인 밖으로 나가기 때문이다.
Task { }는 같은 부서에 일을 넘기는 것이라 급한 일이면 급하게, 담당자 정보도 같이 넘어간다. Task.detached는 완전히 다른 회사에 외주를 주는 것이다. 독립적이지만 "이거 급해요", "이건 김 과장 건이에요" 같은 정보가 안 따라간다.await Task.yield()로 양보하면 총 소요 시간은 어떻게 되는가?
총 시간은 늘어난다. 양보할 때마다 실행 컨텍스트를 저장하고 다른 작업에 기회를 준 뒤 다시 스케줄링되므로 오버헤드가 붙는다. 양보 횟수가 많을수록 이 비용이 커진다.
그런데 이건 의도한 트레이드오프다. 목표가 "전체를 빨리 끝내기"가 아니라 "진행 중에도 앱이 반응하게 하기"이기 때문이다. 사용자 입장에서는 3초 동안 완전히 얼어붙는 것보다 3.3초 걸리더라도 스크롤과 탭이 되는 편이 훨씬 낫다.
양보 주기 설정이 실무 판단이다. 너무 자주 양보하면 오버헤드가 커지고, 너무 드물면 그 사이 구간이 행이 된다. 양보 사이 구간이 프레임 예산(8~16 ms) 안에 들어오도록 잡는 것이 기준이다. 항목당 처리 시간을 측정해 몇 개마다 양보할지 역산한다.
더 나은 대안이 있는지도 먼저 확인해야 한다. 애초에 메인에서 안 해도 되는 일이면 비격리로 옮기는 것이 정답이고, 양보는 "메인에서 반드시 해야 하는데 길다"는 좁은 경우의 해법이다. UI 갱신처럼 메인이 강제되는 작업이 그 예다.
또 하나 — 양보는 취소 확인 지점으로도 유용하다. 루프 중간에 Task.checkCancellation()을 함께 두면 화면을 떠났을 때 즉시 중단할 수 있다.
UI 타입에서 계산 로직을 분리하는 것이 왜 성능과 설계 양쪽에 좋은가?
같은 원리가 두 방향으로 작용하기 때문이다.
설계 측면(P0 17장). 계산 로직이 UI 타입 안에 있으면 결합도가 높아진다 — 로직을 테스트하려면 뷰모델을 만들어야 하고, 뷰모델은 UI 프레임워크에 의존하며, 순수 함수였을 것이 상태에 얽힌다. 분리하면 입력과 출력만 있는 순수 함수가 되어 테스트가 쉬워지고 재사용이 가능해진다.
성능 측면. Swift에서 UI 타입은 대개 @MainActor이므로, 그 안의 로직은 기본적으로 메인스레드에서 실행된다. 분리해서 별도 타입으로 빼면 격리가 따라오지 않으므로 메인 밖에서 실행하는 것이 자연스러워진다. 즉 좋은 분리가 좋은 격리를 만든다.
이 일치가 우연이 아니다. 격리 모델은 "이 상태에 누가 접근할 수 있는가"를 다루고, 관심사 분리는 "이 코드가 무엇에 의존하는가"를 다룬다. 상태 의존이 적은 코드는 격리 요구도 적다.
실무 적용은 단순하다 — 뷰모델은 조율만 하고, 계산은 순수 타입에 위임한다. 뷰모델의 메서드가 "데이터를 받아 → 비격리 함수에 넘기고 → 결과로 상태를 갱신"하는 세 줄이 되면 이상적이다.
Q7. 프로덕션에서 행의 원인을 어떻게 좁히는가?
세 소스를 순서대로 쓴다. (1) Organizer 행률로 "어느 릴리스·기기·OS에서 나쁜가"를 좁히고, (2) Organizer 행 리포트로 "어느 함수가 전체 행 시간의 몇 %를 차지하는가"를 확인하고, (3) MXHangDiagnostic으로 자체 백엔드에 콜스택을 모아 breadcrumb·태그와 결합한다. 그 다음 재현 후 Instruments로 확정한다. Apple이 권하는 좁히기 절차는 명확하다 — 차원으로 필터링 → 문제가 처음 나타난 버전 찾기 → 그 사이 변경으로 범위 축소 → 집중 테스트로 재현. 재현이 안 되면 느린 기기와 나쁜 네트워크를 먼저 의심한다.
CS 원리
프로덕션 디버깅은 탐색 공간을 줄이는 문제다. 로컬 디버깅은 브레이크포인트로 상태를 직접 관찰할 수 있지만, 프로덕션에서는 관찰이 제한적이므로 가설을 세우고 데이터로 배제하는 방식이 된다.
효율적인 좁히기의 원칙은 "한 번에 가장 큰 부분집합을 배제하는 질문부터"다.
- 시간축 이분 — "어느 버전부터 나빠졌나"는 한 번의 질문으로 코드 변경 범위를 그 버전의 diff로 줄인다. 가장 강력한 질문이라 항상 먼저 한다.
- 차원축 분할 — 기기·OS·화면·네트워크로 쪼개 특정 조합에만 몰려 있는지 본다. 몰려 있으면 그 조합의 특성이 곧 단서다.
- 스택 기반 집계 — 행 리포트를 유사 백트레이스끼리 묶고 기여도 순으로 정렬하면, 어디를 고칠 때 가장 많이 회수되는지 나온다.
세 번째가 특히 중요한데, 행은 대개 파레토 분포를 따르기 때문이다. 수십 개의 행 지점 중 상위 두세 개가 전체 행 시간의 대부분을 차지하는 경우가 흔하다. 그래서 "행 종류의 개수"가 아니라 "기여 시간 비중"으로 우선순위를 매겨야 한다.
iOS에서는
Xcode Organizer의 행 리포트가 제공하는 정보가 이 작업에 최적화돼 있다.
- 리포트 목록 — 행을 유발한 함수 호출과, 그 함수가 해당 릴리스의 전체 행 시간에서 차지하는 비율. 기여도 내림차순 정렬이라 우선순위가 그대로 나온다.
- 리포트 상세 — 메인스레드 샘플 스택 + Inspector에 iOS 버전, 기기 모델, 수신 로그 수, 14일 추세, 총 행 시간.
- 주의할 구분 — iOS 버전·기기 모델·로그 수·14일 추세는 리포트에 대한 정보이고, 총 행 시간은 함수 호출에 대한 정보다. 둘을 섞어 읽으면 오해가 생긴다.
여기에 MXHangDiagnostic을 결합하면 Apple이 안 주는 것을 채울 수 있다.
import MetricKit
extension AppMetrics {
func handleHangs(_ payload: MXDiagnosticPayload) {
for hang in payload.hangDiagnostics ?? [] {
// OS가 캡처한 콜스택 — 우리가 메인스레드를 건드릴 필요가 없다 (Q3)
let stack = hang.callStackTree.jsonRepresentation()
let duration = hang.hangDuration
// 우리만 아는 맥락을 붙인다 — 이게 Organizer에는 없는 부분
upload(
event: "hang",
stack: stack,
fields: [
"duration_ms": Int(duration.converted(to: .milliseconds).value),
"screen": CurrentScreen.name, // 어느 화면이었나
"session_age_s": SessionClock.elapsed, // 세션 초반인가
"local_items": DataStore.approximateItemCount, // 데이터 규모 (Q4 꼬리질문)
"network": NetworkKind.current.rawValue,
]
)
}
}
}
이렇게 하면 Q4에서 언급한 90퍼센타일만 나쁜 원인을 직접 확인할 수 있다 — 행을 겪은 사용자의 local_items 분포가 전체보다 크게 높으면 데이터 규모 문제다.
실험 · 도구
재현 단계에서 쓰는 도구를 순서대로 정리하면 이렇다.
# 1) 느린 기기 확보 — 지원 목록 중 가장 느린 모델
# 같은 코드가 기기에 따라 행이 되기도 하고 안 되기도 한다 (Apple 문서 명시)
# 2) 나쁜 네트워크 시뮬레이션
# Xcode › Window › Devices and Simulators › (기기 선택) 에서
# Network Link Conditioner 프로파일 적용, 또는 기기의
# 설정 › 개발자 › Network Link Conditioner 에서 100% Loss / Very Bad Network
# 3) 온디바이스 행 감지 켜기
# 설정 › 개발자 에서 행 감지 관련 옵션을 켜면
# 실사용 중 행이 발생할 때 기기에서 바로 알림을 받는다
# 4) Instruments 로 확정
# Time Profiler 템플릿 + Hangs 계측기 추가
# Hangs 계측기의 임계를 250 ms 아래로 낮춘다
# → 행 구간이 표시되고, 그 구간의 메인스레드 스택을 그대로 볼 수 있다
# 5) 데이터 규모 재현
# 실사용자와 비슷한 규모의 로컬 데이터를 만들어 넣는다.
# 빈 계정으로 테스트하면 헤비 유저의 행은 절대 재현되지 않는다.
5번이 실무에서 자주 빠진다. 개발자 계정은 대개 데이터가 적어서, 목록 정렬이나 마이그레이션처럼 데이터 크기에 비례하는 행은 사무실에서 결코 재현되지 않는다. 시드 데이터 생성 스크립트를 만들어두는 것이 좋다.
프로젝트 적용
행 조사를 절차로 만들어두면 매번 처음부터 헤매지 않는다. 다음 체크리스트를 그대로 쓸 만하다.
- 회귀인가? — Organizer 행률을 버전축으로 본다. 특정 버전부터 나빠졌으면 그 버전의 변경 목록이 1순위 용의자다.
- 누구에게 몰려 있는가? — 기기 모델, OS 버전으로 필터. 중앙값 대비 90퍼센타일 격차 확인.
- 어디가 기여하는가? — 행 리포트를 기여도순으로 보고 상위 2~3개를 고른다. 개수가 아니라 비중으로 고른다.
- 어느 화면인가? —
MXHangDiagnostic+ 자체 맥락으로 화면·데이터 규모 분포 확인. - 재현 — 느린 기기 + 나쁜 네트워크 + 큰 데이터로 좁혀진 시나리오 실행.
- 확정 — Instruments Hangs + Time Profiler로 스택 확인.
- 수정 후 검증 — 다음 릴리스의 행률을 같은 기기·OS 필터로 비교.
마지막 단계를 빼먹는 경우가 많은데, 고쳤다고 생각한 것이 실제로 지표를 움직였는지 확인하지 않으면 다음 사이클에 같은 자리를 또 판다.
"행 리포트 목록의 항목 수가 문제 개수"라는 읽기는 틀렸다. 목록은 함수 호출별 기여도로 정렬돼 있고, 상위 몇 개가 대부분을 차지하는 것이 보통이다. "재현이 안 되면 사용자 기기 문제"라고 결론짓는 것도 위험하다 — 느린 기기·나쁜 네트워크·큰 데이터 세 조건 중 하나가 빠져서 재현이 안 되는 경우가 대부분이다. "행 리포트가 오니까 우리 앱의 모든 행을 보고 있다"도 틀렸다 — 백트레이스 캡처는 1초 이상일 때만이고, 사용자가 데이터 공유에 동의한 경우에만 수집된다. 또 "Organizer 지표는 실시간"이 아니다 — 집계와 전송에 지연이 있어 릴리스 직후 판단은 이르다.
식당에서 "요즘 음식이 늦게 나온다"는 불만이 들어왔다. 어디를 고쳐야 할까? 주방을 통째로 뜯어볼 수는 없으니 범위를 좁혀야 한다.
1단계 — 언제부터? 장부를 보니 지난달 15일부터 늦어졌다. 그날 뭐가 바뀌었는지만 보면 된다. 이 질문 하나로 후보가 확 줄어든다.
2단계 — 누구에게? 모든 손님이 늦나, 아니면 단체 손님만 늦나? 단체만 늦으면 문제는 "양이 많을 때"에 있다.
3단계 — 어느 단계에서? 기록을 보니 전체 지연 시간의 70%가 "재료 손질"에서 발생한다. 나머지 열 개 단계를 다 고쳐도 30%밖에 못 줄인다. 여기부터 고쳐야 한다.
4단계 — 재현. 그런데 낮에 실험해보면 멀쩡하다. 왜? 손님이 적어서다. 바쁜 시간에, 단체 손님으로, 재료가 떨어져가는 상태에서 해봐야 재현된다.
여기서 제일 흔한 실수가 "우리 가게에선 안 그러던데요"라고 하는 것이다. 조건이 다르니 당연하다. 손님 쪽 조건을 흉내내야 보인다.
꼬리 질문
행 리포트에 우리 코드가 안 보이고 시스템 프레임만 있으면 어떻게 해석하는가?
세 가지 가능성을 순서대로 확인한다.
(1) 심볼화 실패. 우리 코드가 주소로만 남아 인앱으로 분류되지 않았을 수 있다(21장). 스택에 0x… 형태가 많으면 이쪽이다. dSYM 업로드를 확인한다.
(2) 시스템 API 안에서의 대기. 우리 코드가 호출한 시스템 함수가 오래 걸리는 경우다. 스택 아래쪽으로 내려가면 첫 인앱 프레임이 있고, 그게 호출자다. 예를 들어 스택 상단이 파일 시스템 관련 시스템 프레임이고 아래에 우리 저장소 코드가 있으면, 원인은 "우리가 메인에서 파일 I/O를 했다"이다.
(3) 락 대기. 메인스레드가 다른 스레드가 쥔 락을 기다리는 경우다. 이때 메인 스택만 보면 pthread_mutex_lock 같은 프레임뿐이라 원인을 알 수 없다. 다른 스레드의 스택을 봐야 하며, 그 스레드가 무엇을 오래 하고 있었는지가 진짜 원인이다.
(3)은 특히 진단이 어렵다. 메인스레드가 "아무것도 안 하면서" 막혀 있으므로 CPU 프로파일러에도 안 잡히고 스택도 무의미해 보인다. 이럴 때는 전체 스레드 스택을 함께 보는 것이 유일한 방법이고, Instruments나 MXHangDiagnostic의 콜스택 트리가 여러 스레드를 포함하는지 확인해야 한다.
수정 후 행률이 안 줄었다면 무엇을 점검해야 하는가?
네 가지를 순서대로 점검한다.
(1) 채택률. 새 버전 사용자가 충분히 늘었는지 확인한다. 초기에는 표본이 적어 값이 흔들리고, Q4 꼬리질문에서 본 얼리 어답터 편향도 있다.
(2) 같은 조건에서 비교했는가. 전체 값이 아니라 기기 모델과 OS 버전을 고정한 부분집합끼리 비교해야 한다. 사용자 구성이 바뀌면 전체 값은 코드와 무관하게 움직인다.
(3) 고친 것이 상위 기여자였는가. 행 리포트 기여도가 5%인 항목을 고쳤다면 전체 행률은 거의 안 움직인다. 상위 항목을 고쳤는지 확인하고, 아니라면 우선순위를 다시 잡는다.
(4) 실제로 메인에서 빠졌는가. Q6에서 본 함정 때문에 "비동기로 바꿨다"고 생각한 코드가 여전히 메인 액터에서 돌고 있을 수 있다. dispatchPrecondition이나 Instruments의 Swift Concurrency 계측기로 실행 컨텍스트를 직접 확인한다.
그리고 새 행이 생겼을 가능성도 본다. 리팩터링으로 A를 고쳤는데 B가 새로 생기면 총합은 그대로다. 행 리포트 목록을 이전 릴리스와 항목별로 비교하면 이게 보인다.
Organizer 데이터가 사용자 동의 기반이라는 점이 통계에 어떤 영향을 주는가?
Apple의 행 리포트는 개발자와 데이터 공유에 동의한 사용자로부터 익명 수집된다. 그래서 두 가지 영향이 있다.
(1) 표본 편향 가능성. 동의한 사용자군이 전체와 다른 특성을 가질 수 있다. 예를 들어 기술에 익숙한 사용자가 동의율이 높다면 기기 분포가 최신 쪽으로 치우칠 수 있다. 다만 이 편향의 방향과 크기는 우리가 측정할 수 없다.
(2) 절대값보다 상대 비교. 표본이 전체를 대표한다고 보장할 수 없으므로 절대 수치를 목표로 삼기보다 같은 조건의 시계열 비교에 쓰는 것이 안전하다. Q4에서 "절대값 목표보다 회귀 감시로"라고 한 이유 중 하나다.
또한 수집 자체가 통계적 샘플링이라는 점도 있다. Apple 문서는 시스템이 실행 중인 앱의 행과 히치를 모니터링하고 통계적 표본에서 주기적으로 리포트를 수집한다고 명시한다. 모든 행이 리포트되는 것이 아니다.
실무적 대응은 MetricKit과의 교차 확인이다. MXHangDiagnostic은 우리 앱이 직접 받는 경로라 수집 조건이 다르고, 우리 백엔드로 모으면 표본 구성을 우리가 파악할 수 있다. 두 소스가 같은 방향으로 움직이면 신뢰도가 올라간다.