2026년 8월 29일 · 5분 읽기
렉을 세 갈래로 갈랐다: hitch, hang, 그리고 없는 프레임
사람이 실기를 만지는 동안만 관측해 구간별 커밋 단계 p95를 냈다. 프레임 예산 안에 있는 구간은 하나였고, '스크롤은 괜찮다'는 앞선 결론은 정정됐다.
"렉이 있다"는 신고는 수치가 아니다. 무엇이 늦었는지, 어디서 늦었는지가 없으면 고칠 자리도 없다. 그래서 실기를 USB로 붙여 두고 사람이 화면을 만지는 동안 기록만 했다. 나온 값은 세 종류였고 세 종류가 서로 다른 구간을 가리켰다.
사람이 만지는 것만 관측했다
앞선 판은 자동화로 조작을 만들었다. 그러면 측정값에 자동화의 왕복 지연이 섞이고, 사람이 실제로 느끼는 조작 순서와 다르다. 이번에는 에이전트가 앱을 띄우지도, 종료하지도, 붙지도 않았다.
증거는 프로세스 식별자다. 세 판 모두 녹화 전후로 앱의 pid가 7675로 같았다. 계측이 앱을 재시작시키지 않았다는 뜻이다. 자동화 부하도 사라졌다 — 이전 판에서 창을 함께 쓰던 자동화 프로세스 두 개가 이번에는 0이다.
그 전 판은 아무 수치도 얻지 못했다
방법을 바꾼 것은 취향 때문이 아니다. 같은 기기에서 앞 판은 원하는 표가 전부 비어 있었다.
// .artifacts/perf/measurements-v3.md:118-125
- `hitches` 0행, `potential-hangs` 0행, `hang-risks` 0행. **끊김이 없다는 뜻이 아니다** —
같은 트레이스 `hitches-updates`의 프레임 커밋이 `AutomationModeUI`·`SpringBoard`뿐이고
JustSend가 없다.
- 원인: `xcrun xctrace record --attach 'JustSend'` → `Cannot find process matching name: JustSend`.
Xcode Cloud 빌드 190에 `get-task-allow`가 없다.프레임 커밋의 주인이 자동화 프로세스와 시스템 셸이었고 앱은 목록에 없었다. 그 판의 0행은 "끊김이 없다"가 아니라 "앱을 보지 못했다"였다. 그래서 이번에는 전 프로세스 녹화로 바꾸고 귀속을 pid로 이었다.
hitch와 hang은 다른 자리에서 난다
hitch는 화면에 늦게 닿은 프레임이고, hang은 메인 스레드가 아예 멈춘 구간이다. 두 지표의 순위가 서로 달랐다.
| 구간 | hitch 비율 | hang |
|---|---|---|
| 탭 전환 반복 | 39.74 ms/s (최장 83.36 ms) | 0건 |
| 설정 들어가 스크롤 | 13.06 ms/s | 3건 |
| 긴 기록 열어 본문 스크롤 | 12.23 ms/s | 4건 |
| 보관함 빠르게 스크롤 | 5.00 ms/s | 0건 |
탭 전환은 hitch가 가장 나쁜데 hang이 없다. 본문 스크롤은 hitch가 낮은데 hang이 4건이다. 전자는 프레임을 늦게 냈고, 후자는 메인 스레드가 멈췄다. 사용자가 "가장 렉이 느껴진다"고 지목한 조작 30초에서는 hang이 9건, 합 1327.1 ms, 최장 247.7 ms였다.
예산을 넘긴 구간과 넘기지 않은 구간
프레임 하나는 앱의 커밋, 렌더 서버, GPU를 지난다. hitch가 난 프레임마다 어느 쪽이 가장 길었는지 세면 답이 한쪽으로 몰린다.
수치는 한 파일에서 그대로 읽을 수 있다.
$ grep -nE "설정 들어가 스크롤 \| 13|탭 전환 반복 \| 21" .artifacts/perf/session/findings.md
188:| 설정 들어가 스크롤 | 13 | 커밋 9 / 렌더 4 | **138.32 ms** | 8.81 | 7.37 | 8 (4배 초과 4) |
189:| 탭 전환 반복 | 21 | **커밋 19** / 렌더 2 | **88.92 ms** | 6.12 | 1.03 | 15 (4배 초과 2) |
렌더 p95는 전 구간에서 8.81 ms 이하, GPU p95는 7.37 ms 이하다. 60헤르츠 예산이 16.67 ms이므로 그림을 그리는 쪽은 여유가 있다. 예산을 넘기는 것은 커밋이다. 설정 진입은 138.32 ms로 예산의 8.3배, 탭 전환은 88.92 ms로 5.3배다.
커밋은 앱의 메인 스레드가 이번 프레임의 레이어 트리를 확정하는 단계다. 늦은 이유는 그림의 복잡도가 아니라 한 프레임에 다시 계산하는 뷰의 양이다.
타이핑은 늦은 프레임이 아니라 없는 프레임이다
검색창에 글자를 넣는 구간은 hitch가 1건뿐이다. 좋은 값처럼 보이지만 그 창의 프레임은 10초에 139개다. 다른 구간은 800~1200개다. hitch는 난 프레임의 지연을 재므로, 프레임을 아예 내지 않는 구간은 이 지표에 걸리지 않는다.
같은 사실이 터치→화면 지연에서도 보인다. 그 구간의 중위는 92.24 ms인데 표본이 13프레임이다. 글자마다 화면이 갱신되지 않았다는 뜻이다.
스크롤을 하나로 묶으면 틀린다
첫 완료 노트에 "스크롤은 손댈 게 없다"고 적었다. 그 문장은 틀렸다. 정확히는 보관함 스크롤에만 해당한다.
| 구간 | 커밋 p95 | hitch | hang |
|---|---|---|---|
| 설정 스크롤 | 138.32 ms | 13 | 3건 |
| 긴 본문 스크롤 | 59.69 ms | 11 | 4건 |
| 보관함 빠르게 | 15.42 ms | 9 | 0건 |
| 보관함 천천히 | 0.54 ms | 9 | 0건 |
낱말이 같아도 수치는 두 편으로 갈린다. 예산 안에 있는 것은 보관함 둘뿐이고, 설정과 긴 본문은 전환 쪽에 붙는다. "스크롤 성능"이라는 한 낱말로 묶어 보고하면 고칠 자리를 잃는다.
시간은 우리 함수가 아니라 그래프 갱신에서 갔다
가장 렉이 느껴지는 조작을 30초 프로파일한 뒤 leaf 프레임을 세면 상위 넷 중 셋이 뷰 그래프 갱신이다.
493.0ms AG::Graph::UpdateStack::update() [AttributeGraph]
330.0ms AG::Graph::propagate_dirty(...) [AttributeGraph]
231.0ms objc_msgSend [libobjcMsgSend.dylib]
189.0ms AG::Subgraph::update(unsigned int) [AttributeGraph]우리 번들에서 이름이 잡힌 자리는 그 위에 얹힌 것들이다. hang 구간만 잘라 세면 본문 크기 측정이 49 ms로 나온다(app/JustSend/Sources/Memory/Stream/SelectableBodyText.swift:336). 데이터베이스 조회(app/JustSend/Sources/Memory/Stream/SelectableBodyText.swift:437)와 권한 조회도 같은 구간에서 잡혔다. 프레임 상한 해제 키는 설정 파일 한 자리에 있다(app/project.yml:136-142).
여기서 오독하기 쉬운 값이 하나 있다. 우리 번들 프레임 상위에 $main()이 5512 ms로 뜨는데, 그것은 병목이 아니라 귀속 규칙의 결과다. 규칙이 "leaf에서 가장 가까운 우리 번들 프레임"이므로, 스택에 우리 프레임이 $main() 하나뿐인 표본이 그 값을 만든다. 그 시간은 우리 코드가 아니라 프레임워크 안에서 돌았다.
인용하면 안 되는 값
기기에 깔린 것은 디버그 빌드다. 최적화가 꺼져 있으므로 절대값은 릴리스에서 줄어든다. 그래서 이 관측에서 쓰는 것은 구간 사이의 상대 비교뿐이다.
주사율도 오독하기 쉽다. 패널은 120헤르츠이고 상한 해제 키도 켜져 있지만, 스크롤 구간의 프레임 간격은 60헤르츠가 120헤르츠의 3배였다. 그 키의 정의는 이렇다.
A Boolean value that allows your app to access frame rates higher than the system's default.
요청할 수 있게 해 주는 것이고, 실제 주파수는 여전히 시스템이 정한다. 켜져 있다는 사실만으로 120헤르츠로 돈다고 말할 수 없다.
한 가지 더. 같은 트리의 전수 실행에는 알려진 실패가 이미 둘 있다.
$ grep -m2 "AccountRepointStressTests" .artifacts/perf/full-suite-2.log
Test Case '-[JustSendTests.AccountRepointStressTests testWriteThroughAHeldWriterNeitherLandsNorLiesAboutTheReason]' failed (0.132 seconds).
Test Case '-[JustSendTests.AccountRepointStressTests testWriterHeldAcrossARepointFailsWithAnAccountError]' failed (0.280 seconds).
$ grep BASELINE_NOTE scripts/test/suite.sh
BASELINE_NOTE='stress: AccountRepointStressTests 2건(SQLite error 21) — 2026-08-26 확인'
그래서 성능 작업의 통과 조건은 "실패 0건"이 아니라 "기준선을 벗어난 새 실패 0건"이다. 기준선을 스크립트가 이름으로 들고 있지 않으면, 같은 두 건을 매번 다시 조사하거나 조용히 무시하게 된다.
발열도 판정하지 않았다. 관측 창이 40~90초이고 그 안에서 온도 상태 전이가 0회였다. 사용자가 발열을 느끼는 시간 규모를 재지 않았으므로, 이 관측은 발열에 대해 아무 말도 하지 않는다.