목록으로 돌아가기

블로그

프로토콜 내부: Mac에서 iPhone으로의 실시간 터미널 스트리밍

WebSocket 기반 프로토콜을 구축하여 tmux 터미널 출력을 차분 업데이트, ANSI 렌더링 및 엄격한 지연 목표와 함께 실시간으로 iPhone에 스트리밍하는 방법.

게시일 태그: engineering / protocol / performance / terminal
블로그

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

카테고리 보기

터미널 스트리밍은 Tactic Remote의 핵심입니다. 모든 기능 — 승인, 알림, 세션 모니터링 — 은 iPhone이 Mac 터미널에서 일어나는 일을 정확하고 반응성 있게 표시하는 데 의존합니다. 이를 올바르게 구현하려면 프로토콜 수준에서 몇 가지 비자명한 문제를 해결해야 했습니다.

이 글에서는 tmux 캡처부터 iPhone 렌더링까지 스트리밍 프로토콜의 동작 방식과 그 과정에서 내린 엔지니어링 트레이드오프를 설명합니다.

파이프라인

터미널 데이터는 다섯 단계를 거칩니다:

  1. tmux 캡처 — Mac companion 앱이 tmux 패인에서 터미널 내용을 읽습니다.
  2. 차분 계산 — 현재 캡처를 마지막으로 전송한 상태와 비교합니다.
  3. 직렬화 — 변경된 영역을 컴팩트한 바이너리 프레임으로 패킹합니다.
  4. WebSocket 전송 — 영구 WebSocket 연결을 통해 프레임을 iPhone으로 전송합니다.
  5. ANSI 렌더링 — iPhone 클라이언트가 이스케이프 시퀀스를 해석하고 스타일이 적용된 텍스트를 렌더링합니다.

각 단계에는 고유한 지연 예산이 있습니다. 목표는 로컬 네트워크에서 종단 간 전달을 100ms 이내로 유지하여 각 단계에 충분한 여유를 남기는 것입니다.

tmux 패인 캡처

Tactic Remote는 tmux를 통해 Claude Code 세션을 관리합니다. 이는 스트리밍에 큰 이점을 가져다주는 초기 아키텍처 결정이었습니다. tmux는 실제 PTY 스트림을 가로채지 않고 프로그래밍 방식으로 읽을 수 있는 가상 터미널 버퍼를 유지합니다.

-p 및 -e 플래그와 함께 tmux capture-pane을 사용하여 ANSI 이스케이프 시퀀스가 보존된 패인 내용을 추출합니다. 활성 스트리밍 중에는 50ms 폴링 간격으로 캡처를 실행합니다 — 실시간으로 느껴질 만큼 빠르면서도 의미 있는 CPU 오버헤드를 피할 만큼 낮은 빈도입니다.

한 가지 미묘한 점: capture-pane은 보이는 패인 내용과 스크롤백을 함께 반환합니다. 기본적으로 보이는 뷰포트만 캡처하고, 사용자가 iPhone에서 위로 스크롤할 때 요청에 따라 스크롤백을 스트리밍합니다. 이렇게 하면 일반적인 경우 프레임당 페이로드를 작게 유지할 수 있습니다.

캡처 프로세스는 최신 Mac에서 프레임당 약 3-8ms를 추가합니다(M1부터 M4 하드웨어에서 측정).

차분 업데이트

매 프레임마다 전체 터미널 버퍼를 전송하는 것은 낭비입니다. 일반적인 80x24 터미널 패인에는 약 1,920자가 포함됩니다. ANSI 시퀀스를 포함하면 전체 프레임은 8-12 KB에 달할 수 있습니다. 초당 20 프레임이면 160-240 KB/s의 대역폭이 필요합니다 — 로컬 Wi-Fi에서는 허용 가능하지만 Cloudflare Tunnel을 통한 셀룰러 연결에서는 문제가 됩니다.

대신 연속 프레임 간의 행 수준 차분을 계산합니다. 대부분의 터미널 업데이트는 몇 줄에만 영향을 미칩니다 — 새로운 로그 줄이 나타나거나, 프로그레스 바가 진행되거나, 커서가 이동합니다. 차분 알고리즘은 해시 비교를 통해 변경된 줄을 식별하고 수정된 내용만 전송합니다.

실제로 차분 업데이트는 평균 프레임 크기를 85-92% 줄입니다. Claude Code가 활발히 출력하는 동안 일반적인 차분 프레임은 400-800 바이트입니다. 유휴 기간(사용자 입력이나 API 응답을 기다리는 동안)에는 프레임이 전혀 전송되지 않습니다 — 내용이 변경될 때만 전송합니다.

지표전체 프레임차분
평균 프레임 크기8-12 KB400-800 바이트
20 fps 시 대역폭160-240 KB/s8-16 KB/s
유휴 시 대역폭160-240 KB/s0 KB/s

와이어 포맷

차분 프레임은 JSON이 아닌 컴팩트한 바이너리 포맷으로 직렬화됩니다. 각 프레임에는 다음이 포함됩니다:

  • 4바이트 헤더: 프로토콜 버전(1바이트), 프레임 타입(1바이트), 페이로드 길이(2바이트).
  • 순서 지정 및 손실 감지를 위한 시퀀스 번호.
  • 행 업데이트 레코드 배열. 각 레코드에는 행 인덱스, 새 내용(ANSI 시퀀스가 포함된 UTF-8), 무결성 검증을 위한 CRC32 체크섬이 포함됩니다.

Protocol Buffers나 MessagePack 대신 커스텀 바이너리 포맷을 선택했습니다. 벤치마킹 결과 작고 빈번한 메시지의 직렬화 오버헤드가 라이브러리 초기화 비용에 의해 좌우된다는 것을 확인했기 때문입니다. 직접 작성한 직렬화기는 프레임당 1ms 미만을 추가합니다.

WebSocket 전송

스트리밍 연결은 단일 영구 WebSocket을 사용합니다. Server-Sent Events (SSE)와 원시 TCP를 평가했지만, WebSocket이 양방향 통신(스크롤 요청과 흐름 제어에 필요), 광범위한 네트워크 호환성, 내장 프레이밍의 최적 조합을 제공했습니다.

연결 관리는 세 가지 시나리오를 처리합니다:

로컬 네트워크 — iPhone이 Mac companion의 로컬 서버에 직접 연결합니다. 왕복 시간은 일반적으로 2-8ms입니다. 전체 스트리밍 지연(캡처에서 렌더링까지)은 평균 40-60ms입니다.

Cloudflare Tunnel — 트래픽이 Cloudflare의 엣지 네트워크를 경유합니다. 왕복 시간은 지리적 위치에 따라 30-80ms로 증가합니다. 전체 스트리밍 지연은 평균 120-200ms입니다. 모니터링에는 충분히 반응적이지만, 빠르게 스크롤되는 출력에서는 사용자가 차이를 느낍니다.

네트워크 전환 — iPhone이 Wi-Fi와 셀룰러 사이(또는 네트워크 간)를 전환하면 WebSocket 연결이 끊어집니다. 재연결 로직은 1-2초 이내에 연결을 재설정하고 상태를 복원하기 위해 전체 프레임 재동기화를 요청합니다. 재연결 중에는 iPhone의 시각적 표시기가 연결 상태를 보여주어 사용자가 출력이 일시 중지된 이유를 알 수 있게 합니다.

ANSI 이스케이프 시퀀스 처리

Claude Code의 터미널 출력에는 풍부한 ANSI 이스케이프 시퀀스가 포함됩니다 — 구문 강조를 위한 색상, 강조를 위한 볼드, 프로그레스 표시기를 위한 커서 위치 지정. 이러한 시퀀스를 iPhone에서 정확하게 보존하는 것은 가독성에 필수적입니다.

iOS 렌더러는 가장 일반적인 SGR(Select Graphic Rendition) 시퀀스를 처리합니다: 16 표준 색상, 256색 팔레트, 24비트 트루 컬러, 볼드, 이탤릭, 밑줄, 반전. 렌더링 뷰에서는 커서 이동 시퀀스(CSI H, CSI A/B/C/D)를 의도적으로 지원하지 않습니다 — 이들은 tmux 캡처 시 소비되어 원시 이스케이프 코드가 아닌 행 위치 지정에 반영됩니다.

모바일에 특화된 과제 하나: 다크 모드. 어두운 배경용으로 설계된 터미널 색 구성표는 iOS가 라이트 모드일 때 잘 렌더링되지 않습니다(그 반대도 마찬가지). iPhone의 현재 외관 모드를 감지하고 대비율을 유지하는 색상 매핑을 적용합니다. 이것은 테마가 아니라, 원래의 색상 의도를 유지하면서 가독성을 보장하는 휘도 조정입니다.

성능 예산

세 가지 백분위수에서 스트리밍 성능을 추적합니다:

지표P50P95P99
캡처 지연4ms7ms12ms
차분 계산1ms3ms5ms
직렬화<1ms1ms2ms
네트워크 (로컬)3ms8ms15ms
네트워크 (터널)45ms90ms150ms
iPhone 렌더링6ms12ms18ms
합계 (로컬)15ms31ms52ms
합계 (터널)57ms113ms187ms

이 수치는 베타 사용자 기반의 프로덕션 텔레메트리에서 가져온 것입니다. P50 로컬 지연이 15ms라는 것은 대부분의 상호작용에서 iPhone 디스플레이가 인간의 눈으로 지연을 감지할 수 없을 만큼 빠르게 업데이트된다는 의미입니다.

배운 점

터미널 출력을 위한 실시간 스트리밍 프로토콜을 구축하면서 몇 가지 교훈을 얻었습니다:

이 유스 케이스에서는 폴링이 이벤트 기반보다 낫다. 처음에는 tmux의 내부 업데이트 이벤트에 훅하려 했으나, 50ms 폴링이 더 단순하고 안정적이며 성능 비용도 무시할 수 있는 수준이었습니다.

행 수준 차분이면 충분하다. 문자 수준 차분을 프로토타입했으나 추가적인 복잡성 대비 대역폭 추가 절감은 5-8%에 불과하여 유지 보수 부담을 감수할 가치가 없었습니다.

고빈도에서는 바이너리 프레이밍이 중요하다. 초당 20회 전송되는 메시지에서 직렬화 포맷의 모든 바이트 오버헤드는 증폭됩니다. JSON의 장황함은 측정 가능한 비용이었습니다.

재연결 UX는 연결 성능만큼 중요하다. 사용자는 무슨 일이 일어나고 있는지 이해하면 2초의 재연결을 용인합니다. 하지만 시각적 피드백이 없으면 500ms의 재연결도 고장으로 인식합니다.

구현 세부 사항은 아키텍처 문서를 참조하세요. 비슷한 것을 구축하고 계시다면 지원 페이지를 통해 연락해 주세요.

Tactic Remote 사용해 보기

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

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