CodeNomad on desktop and mobile
AI 코딩 에이전트는 방대한 양의 기록(history)을 생성합니다. 단일 세션은 며칠 동안 지속될 수 있으며, 수백 개의 도구를 호출하고, 전체 파일을 읽고, 메가바이트 단위의 테스트 출력을 덤프하며, 자체적인 기록을 가진 서브에이전트를 생성할 수 있습니다. 여기에 동시에 열어둔 수십 개의 프로젝트를 곱하면, 에이전트 앞에 놓인 클라이언트 자체가 병목 지점(bottleneck)이 됩니다.
저희는 OpenCode의 오픈 소스 데스크톱 및 모바일 클라이언트인 CodeNomad를 구축하는 과정에서 이 벽에 부딪혔습니다. 저희 사용자는 여러 세션과 프로젝트를 동시에 실행 상태로 유지하고, 에이전트가 장시간 동안 자율적으로 작업하도록 하는 사람들입니다. 다음은 측정했던 수치와 함께 그들이 인터페이스를 반응형으로 유지할 수 있었던 방법들입니다.
간단히 요약하자면: 보이지 않는 것은 절대 로드하지 않고, 모든 렌더링을 경계화하며(bound), 데이터가 존재하는 곳에서 쿼리하고, 백그라운드 작업이 포그라운드를 고갈시키지 않도록 합니다.
1. 데이터를 다운로드하지 않고 기록 검색하기
대화를 검색하는 순진한 방법은 대화 전체를 클라이언트에 로드하여 스캔하는 것입니다. 긴 에이전트 세션의 경우, 단지 특정 단어를 찾기 위해서 모든 도구 출력을 메모리로 끌어와야 합니다.
대신, 검색은 기록이 이미 존재하는 곳에서 실행됩니다. CodeNomad는 OpenCode 서비스 내부에서 실행되는 작은 플러그인을 제공하며, 저장된 기록에 대해 인증된 RPC(Remote Procedure Call)를 노출합니다. 이 플러그인은 읽기 전용 SQLite 연결을 데몬 자체 스토리지에 바인딩하고 경계가 설정된 페이지 단위로 스캔합니다:
- 한 페이지는 최대 1,024개의 행을 검사하며, 32개의 일치하는 메시지에서 중단됩니다.
- 스캔은 행 사이사이에 이벤트 루프(event loop)에 양보하고, 대략 40ms의 작업 후 페이지를 종료합니다.
- 각 메시지의 JSON은 한 번에 하나씩 구현화되며(materialized), 최대 16 MiB로 제한되고, 크기가 큰 콘텐츠는 조용히 버려지는 대신 건너뛴 것으로 보고됩니다.
의도적으로 전체 텍스트 인덱스(full-text index)를 사용하지 않으며, 트리거(trigger)나 트랜스크립트의 두 번째 사본도 없습니다. UI는 제한된 결과 페이지(최대 63개 항목) 하나만 유지하고, 다음 페이지가 필요할 때 요청합니다. 전체 작업 공간, 모든 워크트리(worktree)의 모든 세션을 검색할 때, 트랜스크립트를 렌더러로 페이징하지 않습니다.
Workspace-wide history search in CodeNomad
2. 메시지 본문과 접촉하지 않는 타임라인
긴 세션에는 탐색 기능이 필요합니다. 스크러빙(scrub)하고 건너뛸 수 있는 마커(marker) 레일 같은 것이죠. 로드된 메시지에서 이를 그리면 목적을 상실하게 됩니다.
동일한 플러그인이 아웃라인(outline)을 제공합니다. 이는 구조적 메타데이터(ID, 시퀀스 번호, 파트 유형, 기술 파트 수, 아이콘용 첫 번째 도구 이름)만을 포함하며, 페이지당 최대 16,384개의 항목에 발췌문이나 본문은 없습니다. SQL이 이 메타데이터를 직접 처리하므로, 레일을 그리기 위해 대용량 콘텐츠가 JavaScript로 들어올 필요가 없습니다.
새로고침은 증분적(incremental)입니다. 행들은 512개 행 헤더의 체크포인트(checkpoint)로 그룹화되며, 클라이언트는 이미 가지고 있는 체크포인트의 다이제스트(digest)를 전송합니다. 변경된 범위만 재계산되므로, 큰 세션을 다시 여는 것은 재검색하는 대신 유효성 검사(revalidates)를 수행합니다.
마커로 이동하면 대상 주변의 제한된 창(bounded window)이 로드됩니다. 최대 200개의 메시지 중, 대상 이전 80개와 이후 120개를 포함하며, 읽기 위치가 경계를 넘을 때 유지되도록 이웃 창들이 16으로 오버랩(overlapping) 됩니다. 이전 트랜스크립트는 목적지가 준비될 때까지 화면에 남아 있습니다.
3. 모든 렌더링을 제한하기 (Bound every render)
에이전트는 저장하기에는 좋지만 한 번에 모두 렌더링하기에는 최악인 텍스트를 생성합니다. 구문 강조(syntax highlighting)가 적용된 1MB의 쉘 로그는 모든 UI 스레드를 멈추게 할 것입니다.
따라서 모든 블록은 인플레이스("show more") 기능과 함께 제한된 접두사(bounded prefix)를 렌더링하며, 전체 텍스트는 복사 및 검색을 위해 스토어에 보관됩니다:
| Block | Initial bound | Why |
|---|
| Prose Markdown | 50,000 characters | Chromium에서 파싱 및 레이아웃하는 데 약 35 ms 소요 |
몇 가지 규칙 덕분에 이러한 경계는 실제로는 눈에 보이지 않습니다. 창은 가까운 줄 바꿈 지점에서 끝나기 때문에 코드 라인과 터미널 이스케이프 시퀀스는 온전하게 유지됩니다. 막대한 나머지 데이터를 표시하는 것은 약 250,000 문자 간격으로 이루어지므로, 단일 렌더링이 4분의 1 메가바이트 이상 점프하지 않습니다. 100,000 문자를 초과하는 Markdown은 구문 하이라이팅을 건너뜁니다. 또한 답장이 스트리밍되는 동안 대형 창은 최대 200ms마다 하이라이팅 없이 재렌더링된 다음, 답장 완료 시점에 다시 한번 하이라이팅되어 렌더링됩니다.
4. 아무도 보지 않을 작업을 중단하기
프로파일링 결과, 비용의 놀라운 부분이 그 결과가 버려지는 작업에서 발생한다는 것을 보여주었습니다:
- 덮어쓰인(Superseded) Markdown. 스트리밍 업데이트가 발생할 때마다 중간 버전별로 파싱이 트리거되었습니다. 파싱 전에 덮어쓰인 호출을 폐기하는 것은 33회 파싱에서 1회로 급격히 줄였습니다.
- 변경되지 않은 프로젝션(Unchanged projections). 타임라인은 하위 버킷 중 하나라도 변경될 때마다 전체 프로젝션을 재구축했습니다. 아웃라인 스냅샷, 마커 식별자 및 레이블이 변경되지 않았을 때 이를 재사용하는 것은 10,000개 항목의 피처에서 64개의 전체 프로젝션에서 0으로 줄였습니다.
- 이벤트 재생(Event replay). 재연결 후, 대기열에 쌓인 이벤트는 이벤트당 전체 페이지를 한 번씩 리하이드레이션(re-hydrated)했습니다. 재생을 협력적(cooperative)으로 만들고 경계가 지정된 청크(bounded chunk)마다 한 번만 발행하도록 변경하여 가장 긴 작업 시간을 3,195 ms에서 62 ms로, 그리고 재생 완료 시간을 3,237 ms에서 184 ms로 줄였습니다.
5. DOM을 건드리지 않고 검색 결과 하이라이트하기
일치하는 부분을 하이라이트하려면 트랜스크립트의 DOM을 다시 작성하여 모든 일치 항목을 <mark> 태그로 감싸야 했습니다. 비용 외에도 사용자의 텍스트 선택 영역을 파괴했습니다.
우리는 CSS Custom Highlight API로 전환했습니다. 이제 일치하는 항목들은 브라우저에 등록되는 Range 객체가 되며, 문서를 변경하지 않고도 하이라이팅을 칠 수 있습니다. 혼합된 스크립트의 경우, 동기 작업(synchronous work)은 16.45 ms에서 4.05 ms로 (중앙값, Chromium 기준) 감소했으며, 검색 DOM 변경(search DOM mutations)은 3,708회에서 0회가 되었고, 이제 선택된 내용이 유지됩니다. 이것이 모든 면에서 완벽한 개선은 아닙니다. 한 Electron 측정에서는 중앙값이 개선되었음에도 불구하고 95번째 백분위수 페인트 지연 시간(paint latency)이 오히려 악화되었는데, 이는 여러 수치를 살펴봐야 한다는 좋은 상기시켜주는 사례입니다.
6. 시작 시점: 읽고 있던 대화가 우선순위를 가집니다
느린 Windows 환경에서 실행했을 때, 선택된 대화 내용은 12.5초 후에 나타났습니다. 수집된 데이터를 분석한 결과 두 가지 원인이 눈에 띄었습니다:
- 스크립트 읽기 작업이 세 번 재시작되었습니다. 반응형 효과(reactive effect) 내부의 준비 상태 확인(readiness check)이 다른 워크스페이스가 복원될 때마다 이 효과를 다시 실행하여, 진행 중인 읽기 작업을 취소하고 다른 트래픽 뒤로 재큐잉했습니다. 이 확인 과정을 반응형 범위 밖에서 처리함으로써 문제가 해결되었습니다.
- 보조 읽기 작업들이 서버에 대한 브라우저의 몇 안 되는 연결을 점유하고 있었습니다. 플러그인 제어, 업데이트 확인 및 타임라인 개요가 진행 중인 동안 스크립트가 연결을 기다리느라 거의 2초를 소비했습니다. 이제 이들은 제한된 배경 읽기 예산(bounded background-read budget)을 공유하므로, 전경 읽기 작업은 항상 통과됩니다.
동일한 조사 과정에서 가상화된 행들이 마운트될 때마다 권한 영수증을 다시 가져오는 현상을 발견했습니다. 한 세션에서 4분 동안 약 700개의 요청이 발생했으며, 같은 메시지가 최대 12번까지 나타났습니다. 각 행의 완료된 스냅샷을 저장하고 진행 중인 읽기 작업을 공유하는 작은 캐시를 도입하여 이러한 과부하를 제거했습니다.
7. 배경 작업은 모든 것을 고갈시킬 수 있습니다
마지막 예시를 하나 더 들자면, 저희도 놀랐던 사례입니다. Git worktree가 많은 사용자들이 OpenCode 서비스가 느린 것처럼 보이는 복구 경고(recovery warnings)를 보았습니다. 하지만 실제로는 그렇지 않았습니다. 네이티브 읽기 작업은 약 5ms 만에 응답했습니다. 자체 소유권 확인(ownership checks) 과정에서 저희는 모든 후보 디렉터리마다 git rev-parse --git-common-dir 프로세스를 생성했는데, Windows 환경에서는 각 프로세스 생성이 50~800ms의 비용이 들었습니다. 314개의 worktree를 30초마다 재확인하면서 프로세스 큐(process queue)는 절대 비워지지 않았습니다.
Git의 관리 파일(.git, commondir, HEAD)을 직접 읽어 공통 디렉터리를 해결하는 방식은 일반적인 경우에는 프로세스 생성을 제거했지만, 특이한 레이아웃의 경우 여전히 Git 자체에 의존해야 했습니다.
핵심 시사점 (Takeaways)
- 쿼리를 데이터 쪽으로 푸시하라. 스토리지 옆에 위치한 플러그인이 클라이언트로 히스토리(history)를 전송하는 것보다 낫습니다.
- 기본적으로 모든 것을 바운드(bound)하고, 이 바운드를 자르기(truncation)가 아닌 공개(reveal)로 만드세요.
- 구식이 된 작업은 파싱(parsing) 전에, 나중에가 아니라 일찍 폐기하세요.
- **백그라운드 작업을 예산 책정(budget background work)**하여 사용자가 보고 있는 것을 절대 차단하지 못하게 하세요.
- 실제 기기에서 측정하세요. 저희의 최악의 문제는 특정 Windows 설치본에서만 나타났을 뿐, 깨끗한 테스트 환경(clean fixture)에서는 결코 그렇지 않았습니다.
CodeNomad는 MIT 라이선스를 따르며 Windows, macOS, Linux 및 휴대폰에서 OpenCode V2와 함께 작동합니다. 만약 한 번에 많은 에이전트 세션을 실행한다면, 여전히 느리게 느껴지는 부분이 어디인지 알려주시면 감사하겠습니다: github.com/NeuralNomadsAI/CodeNomad.
공개 사항: 저는 CodeNomad 유지 관리자 중 한 명입니다.