목록으로 돌아가기

블로그

SwiftUI 화면 멈춤 추적: 문제가 비즈니스 코드 밖에 있을 때

화면은 계속 렌더링되는데 터치에 전혀 반응하지 않다가 WebSocket이 타임아웃으로 끊겼습니다. 원인은 비즈니스 코드가 아니라 SwiftUI에서 안정적으로 재현되는 레이아웃 피드백 루프였습니다. 전체 추적 과정을 기록합니다.

게시일 태그: engineering / swiftui / debugging / ios / performance
블로그

이 카테고리의 제품 및 엔지니어링 업데이트를 확인하세요.

카테고리 보기

이 글은 디버깅 회고이지 홍보 글이 아닙니다. 당시 판단이 틀렸다면 그대로 씁니다. 이번 추적 과정에서 우리는 한 번이 아니라 여러 번 틀렸습니다.

현상

한 사용자가 iPad에서 채팅 모드로 agent에게 긴 답변을 작성해 달라고 요청했습니다. 답변이 한 글자씩 스트리밍되는 도중에 앱 전체가 멈췄습니다. 화면 렌더링은 계속됐지만 스크롤도 탭도 되지 않았고, 어떤 버튼도 반응하지 않았습니다. 잠시 후 기기와 서버 사이의 WebSocket 연결이 "요청 타임아웃"(-1001) 오류와 함께 끊겼습니다.

사용자 입장에서는 네트워크 장애처럼 보였습니다. 그런데 우리에게는 중요한 방증 자료가 있었습니다. 당시 읽기 전용 프로토콜 옵저버가 두 번째 클라이언트로서 같은 서버 데이터 스트림을 수신하고 있었는데, 그 기록에 따르면 해당 시간 동안 서버는 완전히 정상이었고 스트리밍 이벤트도 빠짐없이 전송됐으며 해당 답변도 정상적으로 완료됐습니다.

멈춘 것은 클라이언트 기기였고, 그 타이밍은 해당 답변이 거의 끝나 마지막 렌더링을 수행하는 순간이었습니다. WebSocket 단절은 원인이 아니라 결과였습니다. 패킷 수신 루프도 메인 스레드에서 실행되므로, 메인 스레드가 멈추면 수신도 멈추고, 서버가 보내는 데이터가 TCP 버퍼에 쌓이다가 수십 초 후 시스템이 해당 연결을 타임아웃으로 판정한 것입니다.

세 가지 잘못된 방향

메인 스레드가 멈춘 순간에 샘플링을 수행했습니다. 수천 프레임의 콜 스택이 모두 SwiftUI 레이아웃 업데이트에서 멈춰 있었고, 실행 중인 비즈니스 코드는 없었으며, 쓰여지는 상태도 없었습니다. _printChanges는 처음부터 끝까지 조용했습니다.

이것 자체가 중요한 단서였습니다. 데이터 주도 재계산 루프였다면 _printChanges가 끊임없이 출력을 쏟아냈을 것입니다. 조용하다는 것은 루프가 데이터 레이어가 아닌 레이아웃 레이어에서 발생하고 있다는 뜻입니다.

그렇다면 레이아웃 레이어의 어디에서? 우리는 세 곳을 차례로 의심했고, 각각에 대해 통제된 시뮬레이터 재현 실험을 진행했습니다. 동일한 긴 답변, 동일한 조작, 매번 하나의 변수만 바꿔가며 검증했습니다.

첫 번째 용의자는 UITextView의 자체 측정이었습니다. 메시지 버블은 선택 가능한 서식 있는 텍스트를 위해 UITextView를 사용하는데, 그것의 intrinsicContentSize는 매번 TextKit 전체 레이아웃을 트리거합니다. 범인처럼 들렸습니다. 그래서 순수 SwiftUI의 Text로 교체했는데, 여전히 멈췄습니다. 배제.

두 번째 용의자는 TextKit 2였습니다. iOS 16 이후 UITextView는 기본적으로 TextKit 2를 사용하는데, 스크롤 없는 긴 텍스트에 대한 증분 레이아웃은 동일한 (텍스트, 너비) 조합에서 측정값이 항상 안정적이지 않습니다. TextKit 1로 강제 전환했는데, 여전히 멈췄습니다. 배제.

세 번째 용의자는 렌더링 자체의 오버헤드였습니다. 긴 버블의 Markdown 파싱과 서식 있는 텍스트 재구성은 실제로 비용이 적지 않습니다. 캐시를 추가하자 피크가 낮아졌지만 프리즈는 여전했습니다. 증폭기일 뿐, 근본 원인이 아니었습니다.

세 방향 모두 막다른 골목이었습니다. 두 차례의 독립적인 정적 분석에서 "수상해 보이는" 증폭기들을 찾아낼 수 있었지만, 답변이 끝난 후에도 사라지지 않는 100% CPU 점유를 설명할 수 있는 것은 하나도 없었습니다.

이진 분할로 위치 찾기

정적 분석으로 원인을 찾을 수 없었기에 가장 단순하지만 가장 신뢰할 수 있는 방법으로 전환했습니다. 관련 가능성이 있는 UI 요소를 하나씩 꺼가며 어느 것이 CPU 점유를 0으로 떨어뜨리는지 확인하는 것입니다.

먼저 자동 스크롤, 즉 새 메시지가 도착하면 목록 맨 아래로 스크롤하는 proxy.scrollTo 호출을 비활성화했습니다. 프리즈가 즉시 사라지고 CPU가 0으로 돌아왔습니다.

다음으로 변수 하나만 바꿔서, 메시지 목록을 담고 있는 LazyVStack을 한 번에 레이아웃하는 VStack으로 교체하되 자동 스크롤을 포함한 나머지는 모두 유지했습니다. 프리즈가 마찬가지로 사라졌습니다.

두 실험이 같은 결론을 가리켰습니다.

채팅 내용이 한 화면을 초과하는 경우, ScrollView와 LazyVStack을 함께 사용할 때 어떤 scrollTo 호출이든—애니메이션 유무나 특정 메시지 또는 플레이스홀더 뷰로의 앵커 여부와 관계없이—스크롤 뷰를 순수 레이아웃 피드백 루프에 빠뜨립니다.

발생 원리는 다음과 같습니다. LazyVStack은 화면에 보이는 영역만 정밀하게 레이아웃하고, 화면 밖 콘텐츠의 크기는 추정값으로 처리합니다. scrollTo로 특정 위치로 이동하면, 스크롤 오프셋 변경이 화면 밖 콘텐츠의 지연 로드를 트리거하고, 로드 시 콘텐츠 전체 높이가 다시 추정되며, 높이가 변하면 앵커의 목표 위치도 이동하므로 다시 스크롤해야 합니다. 이 "재앵커링 ⇄ 크기 재추정" 루프는 어떤 데이터 변경으로도 구동되지 않으며 순수하게 레이아웃 레이어에서 공회전하기 때문에 스스로 멈추지 않습니다. 이것이 바로 메인 스레드가 100%를 차지하면서도 _printChanges는 조용한 이유입니다.

수정

방향이 명확해지자 수정은 빠르게 수렴됐습니다.

  • 메시지 목록 컨테이너를 LazyVStack에서 한 번에 레이아웃하는 VStack으로 교체했습니다. 채팅 내용은 많아봤자 수십 개의 버블이므로 한 번에 레이아웃하는 오버헤드는 무시할 수 있습니다. 그리고 전체 높이가 확정적이므로 위 루프가 공회전할 여지가 없습니다.
  • 자동 스크롤을 애니메이션 없이 변경하고, 목록 맨 끝의 1픽셀 높이 플레이스홀더 뷰를 대상으로 anchor: nil("보이기만 하면 된다"는 의미)을 사용하도록 했습니다. 화면보다 높을 수 있는 메시지에 앵커 스크롤을 하지 않습니다. 앵커 포지셔닝 메커니즘이 바로 이전 샘플링에서 발견된 "영구 기관"이었습니다.
  • 이번에 발견한 두 가지 실질적 최적화도 함께 반영했습니다. Markdown 파싱과 렌더링을 메모리 부족 시 시스템이 전체를 한꺼번에 비워버릴 수 있는 NSCache 대신 예측 가능한 LRU 캐시로 교체하여, 매번 재계산을 테이블 조회로 대체했습니다.

같은 2500자 Markdown 긴 답변으로 검증했습니다. 수정 전에는 해당 답변이 끝난 후 메인 스레드가 영구적으로 100%에 고정되어 인터페이스를 전혀 조작할 수 없었습니다. 수정 후에는 답변이 끝나는 즉시 CPU가 0%로 떨어지고, 스크롤, 히스토리 탐색, 코드 블록 렌더링 모두 정상이었습니다.

SwiftUI 개발자를 위한 네 가지 교훈

  1. "단일 콘텐츠가 화면보다 높을 수 있는" 시나리오에서 ScrollView + LazyVStack + scrollTo는 본질적으로 불안정한 조합입니다. 채팅, 로그, 긴 텍스트 스트리밍 렌더링이 모두 이 범주에 해당합니다.
  2. "멈추지 않는 레이아웃 루프"를 추적할 때 _printChanges의 침묵은 핵심 신호입니다. 루프가 데이터 레이어가 아닌 레이아웃 레이어에 있다는 뜻이므로, @State와 onChange를 들여다봐도 찾을 수 없습니다. 뷰 트리에 대한 이진 분할 추적만이 답입니다.
  3. 자동 스크롤은 크기가 불확실한 실제 콘텐츠를 앵커하는 대신, 크기가 아주 작은 끝부분 플레이스홀더 뷰에 anchor: nil로 스크롤하는 방식을 우선적으로 사용하세요.
  4. 한 번에 레이아웃할 수 있다면 편의를 위해 Lazy를 사용하지 마세요. Lazy가 절약하는 것은 화면 밖 레이아웃 오버헤드이지만, 도입하는 크기 추정이 바로 이 피드백 루프의 연료입니다. 목록 자체가 짧을 때는 이 거래가 손해입니다.

Tactic Remote를 직접 사용해 보고 싶으신가요? iOS 앱은 App Store에서 무료로 다운로드할 수 있으며, 서버 앱과 CLI도 무료입니다. 이번 수정의 배경이 되는 더 완전한 아키텍처 이야기는 대화 기록은 서버에 맡기세요에서 확인하실 수 있습니다.

Tactic Remote 사용해 보기

휴대전화로 코딩 에이전트를 원격 제어하세요

Mac, Windows, Linux 컴퓨터의 Claude Code, Codex 등에 연결하세요. iPhone이나 iPad에서 진행 상황을 확인하고 다음 요청을 보낼 수 있습니다.