AI 코딩 에이전트를 위한 데스크톱 클라이언트 구축하기
요약
xAI의 오픈 소스 Rust 코딩 에이전트인 grok-build를 위한 데스크톱 클라이언트 구축 사례를 다룹니다. Electron 대신 Tauri를 선택한 이유와 ACP(Agent Client Protocol)를 활용하여 에이전트 런타임을 재구현하지 않고 GUI를 구축하는 아키텍처를 설명합니다.
핵심 포인트
- Tauri 2를 사용하여 약 8MB의 가벼운 데스크톱 클라이언트 구축
- ACP(Agent Client Protocol)를 통한 stdio 기반 JSON-RPC 2.0 통신 활용
- 에이전트 루프를 재구현하지 않고 기존 CLI를 래핑하는 효율적 접근법
- 도구 호출 및 스트리밍 이벤트를 지원하는 반응성 있는 UX 구현
grok-build를 래핑하며 얻은 교훈 — 아키텍처, 함정, 그리고 왜 Electron 대신 Tauri를 선택했는가.
요약 (TL;DR)
grok-build는 xAI의 오픈 소스 Rust 코딩 에이전트입니다. 이 프로젝트는 TUI (Text User Interface) 형태로 제공됩니다. 우리는 이를 위해 네이티브 데스크톱 클라이언트를 작성했습니다 — Tauri 2 (~8 MB 바이너리), React 프론트엔드, 그리고 CLI를 자식 프로세스로 실행하여 ACP/JSON-RPC 2.0을 통해 통신하는 Rust 런타임으로 구성됩니다. 이 포스트는 아키텍처에 대한 심층 분석입니다: 각 구성 요소가 어떻게 결합되는지, 무엇이 우리를 놀라게 했는지, 그리고 다음에 다시 만든다면 다르게 구축할 부분들에 대해 다룹니다.
전체 소스 코드는 github.com/timexingxin/grok-gui에서 확인할 수 있습니다. MIT 라이선스입니다. README에 데모 GIF가 있습니다.
문제 상황
grok-build는 코드 작업에 있어 진정으로 뛰어납니다 — 제 워크플로우 기준으로 Claude Code와 비교할 만한 수준입니다. 하지만 Rust TUI 형태로 제공됩니다. 터미널과 브라우저 탭 사이를 6개월 동안 cmd+tab으로 전환하며 사용한 끝에, 저는 CLI의 장점을 잃지 않으면서도 진정한 데스크톱 UX를 원하게 되었습니다.
단순한 선택지들은 모두 문제가 있었습니다:
- Webview 내에서 tmux 세션으로 래핑하기. 도움이 되지 않습니다 — 여전히 스크롤 백(scrollback)을 읽어야 하기 때문입니다.
- 커뮤니티에서 만든 웹 래퍼(web wrapper) 사용하기. 이들은 모두 OpenAI Chat Completions API를 직접 래핑합니다. 실제 에이전트 런타임과 통신하지 않기 때문에, 도구 호출 (tool calls), 계획 업데이트, 권한 요청, 그리고 코딩 에이전트를 반응성 있게 느끼게 만드는 스트리밍 이벤트 인터페이스를 놓치게 됩니다.
- 데스크톱 GUI를 처음부터 작성하기. 이는 에이전트 루프, 모델 통합, 도구 호출을 다시 구현해야 함을 의미합니다. 6개월의 작업이 필요하며, 결과물인 클라이언트는 항상 업스트림(upstream)보다 뒤처지게 될 것입니다.
정답은 이미 제 앞에 있었습니다: grok-build는 이미 Agent Client Protocol (ACP)이라고 불리는 stdio 기반의 JSON-RPC 2.0 인터페이스를 가지고 있습니다. 제가 클라이언트가 되어야 할 대상은 바로 그 프로토콜입니다. 제 역할은 단지 클라이언트를 작성하는 것뿐입니다.
ACP란 무엇인가?
ACP는 코딩 에이전트 CLI(Command Line Interface)가 stdin/stdout(표준 입력/표준 출력)을 통해 노출하는 JSON-RPC 2.0 프로토콜입니다. 에이전트는 알림(text deltas, 도구 호출 (tool calls), 계획 업데이트 (plan updates), 권한 요청 (permission requests), 세션 생명주기 (session lifecycle))을 방출하며, 클라이언트는 요청(사용자 프롬프트 (user prompts), 권한 응답 (permission responses), 모델 전환 (model switches), 세션 로드 (session loads))을 보냅니다.
만약 당신의 에이전트가 ACP를 지원한다면, 에이전트 루프 (agent loop)를 다시 구현할 필요 없이 클라이언트를 작성할 수 있습니다. 그저 stdio에 연결하여 JSON 프레임 (JSON frames)을 파싱하고 렌더링하기만 하면 됩니다.
┌──────────────────────────────────────────┐
│ Desktop Shell (Tauri 2, ~8 MB) │ React + Vite + Tailwind
└──────────────┬───────────────────────────┘
...
Rust 브릿지 (bridge)가 흥미로운 부분입니다. 이는 생명주기 (lifecycle)를 관리합니다: 자식 프로세스를 생성(spawn)하고, 초기화 핸드셰이크 (initialize handshake, 에이전트가 자신의 버전과 지원 기능을 알려주는 단계)를 수행하며, session/new 또는 session/load를 실행하고, 모든 이벤트를 프론트엔드로 스트리밍합니다. 프론트엔드는 단순히 타입이 지정된 이벤트를 수신하여 렌더링합니다.
왜 Electron이 아닌 Tauri인가
두 가지를 모두 고려했습니다. 결정 매트릭스 (decision matrix)는 다음과 같습니다:
| Tauri 2 | Electron | |
|---|---|---|
| Binary size | ~8 MB | ~150 MB |
| ... |
Tauri가 세 가지 이유로 승리했습니다:
-
백엔드가 이미 Rust로 작성될 예정이었습니다.
grok-build는 Rust이며, ACP 클라이언트가 이를 적절히 실행하고 통신하기 위해서는 Rust여야 했습니다. 또한 Rust는 장시간 실행되는 프로세스 풀 (process-pool) 패턴에 적합합니다. Electron을 사용했다면 동일한 프로젝트 내에 Rust와 Node.js를 함께 사용해야 했을 것입니다. -
배포 시 8 MB 대 150 MB의 차이는 중요합니다. 코딩 도구의 설치 파일 크기가 디스크에 있는 Electron 런타임 자체보다 커서는 안 됩니다. 시스템의 나머지 부분이 작은 네이티브 바이너리(native binaries)로 구성된 MacBook에서 150 MB 크기의 Electron 앱은 무겁게 느껴집니다.
-
OS 네이티브 웹뷰 (webview)가 적절한 느낌을 줍니다. macOS의 WebKit은 Safari와 동일하게 렌더링되므로, React 앱이 Windows-95 웹페이지가 아닌 Mac 앱처럼 보입니다.
title-bar-style="hiddenInset"및trafficLightPosition설정은 눈에 보이는 타이틀 바 없이도 네이티브 창 컨트롤을 제공합니다.
트레이드오프(Tradeoff): Tauri는 Windows에서 MSVC 빌드 도구가 필요합니다. 하지만 windows-latest GitHub Actions 러너(runners)에는 이 도구들이 이미 설치되어 있으므로, CI 비용은 동일합니다.
멀티 세션 병렬 처리 (Multi-session parallel)
저에게 가장 강력한 기능은 여러 대화를 동시에 진행할 수 있다는 점입니다. Grok에게 다른 작업을 요청하며 기다리는 동안 파일 하나를 리팩터링하도록 요청하고 있다면, 대화를 전환할 때 첫 번째 턴의 스트림 (stream)을 놓치고 싶지 않습니다.
단순한 구현 방식: 앱 시작 시 N개의 CLI 프로세스를 실행합니다. 세션을 하나만 사용하는 사용자에게는 낭비입니다.
제가 구축한 방식: 유휴 상태 시 LRU(Least Recently Used) 방식으로 제거되는 **라이브 런타임 풀 (pool of live runtimes)**입니다.
// 의사 코드 (pseudocode)
struct RuntimePool {
runtimes: HashMap<SessionId, GrokRuntime>,
...
사용자가 새 세션을 시작하면, 풀(pool)에서 해당 세션 ID에 대한 기존 라이브 런타임을 조회합니다. 찾을 수 있다면 이를 재사용합니다. 즉, 실행(spawn) 비용과 핸드셰이크 (handshake) 재초기화가 발생하지 않습니다. 만약 풀이 용량에 도달했다면, 가장 최근에 사용되지 않은 유휴 런타임을 종료합니다 (에이전트가 정리될 때까지 최대 5초를 기다리는 우아한 종료(graceful kill) 방식). 그렇지 않으면 새로운 CLI를 실행합니다.
LRU (Least Recently Used) 제거(eviction)는 매우 중요합니다. grok agent stdio가 메모리에 수백 MB의 컨텍스트 (context)를 유지할 수 있기 때문입니다. 제거 기능이 없다면, 몇 개의 세션만 열어도 사용 가능한 RAM을 모두 소모했을 것입니다. 제거 기능 덕분에 유휴 (idle) 세션은 정리되며 사용자의 워킹 셋 (working set)은 제한된 범위를 유지합니다.
제가 가장 자랑스럽게 생각하는 부분인 "백그라운드에서도 턴 (turn) 스트리밍이 계속되는" 동작은 이러한 설계 덕분에 자연스럽게 따라옵니다. 사용자가 현재 보고 있지 않은 세션이라도 풀 (pool) 안에 GrokRuntime이 살아있으므로, 이는 자식 프로세스 (child process)가 여전히 실행 중임을 의미하며, 곧 에이전트가 여전히 이벤트를 스트리밍하고 있음을 의미합니다. 사용자가 다시 돌아왔을 때, 사용자는 뒤늦게 따라잡아야 하는 빈 패널이 아니라 최신 상태를 즉시 보게 됩니다.
멀티 프로바이더 추상화 (Multi-provider abstraction)
grok-build는 기본적으로 xAI를 사용합니다. 우리는 단일 벤더 (single-vendor) 클라이언트가 되고 싶지 않았습니다. 그것은 전략적인 막다른 길이기 때문입니다.
프로토콜이 이를 쉽게 만들어 줍니다. 에이전트의 initialize 핸드셰이크 (handshake)는 availableModels 목록을 반환하며, session/set_model 요청을 통해 모델 간 전환이 이루어집니다. CLI가 프로바이더별 세부 구현 (API 키, 요청 형태, 스트리밍 형식 등)을 처리하며, 우리는 단지 어떤 모델을 사용할지만 알려주면 됩니다.
프론트엔드 측면: useActiveModel() Zustand 셀렉터 (selector)가 핸드셰이크의 availableModels를 읽어옵니다. 모델 전환은 단 한 번의 IPC 호출로 이루어집니다.
// 스토어 레이어 (store layer)
const resp = await tauri.invoke("start_session", {
workspacePath: workspace,
...
// 러스트 브릿지 (rust bridge) — 모델이 CLI로 그대로 전달됨
let mut cmd = Command::new(&grok_bin);
cmd.env("XAI_API_KEY", api_key);
...
결과적으로: 동일한 UI가 xAI, OpenAI, Anthropic, Google, DeepSeek, OpenRouter, Ollama 및 모든 OpenAI 호환 엔드포인트 (endpoint)에서 작동합니다. 에이전트 런타임 (runtime)이 모델을 지원하는 한, 선택기 (picker)에 해당 모델이 표시됩니다.
LANG/LC_ALL 버그 (그리고 이것이 생각보다 중요한 이유)
제가 배포했던 가장 창피한 버그는 이것이었습니다: UI 언어 선택기 (picker)는 사용자의 선택("English" / "简体中文")을 저장했지만, 제가 이를 생성된 grok agent stdio 프로세스까지 전달(thread)하지 않았습니다. 프로세스는 시스템 로케일 (locale) — 제 Mac에서는 zh_CN — 을 상속받았고, UI에서 무엇을 선택하든 중국어로 응답했습니다.
해결 방법은 한 줄짜리 코드이지만, 그 원리는 더 큽니다:
if let Some(lang_value) = locale_env_value(options.locale.as_deref()) {
cmd.env("LANG", &lang_value);
cmd.env("LC_ALL", &lang_value);
...
LANG은 사실상의 표준 로케일 환경 변수 (env var)입니다. LC_ALL은 오버라이드 (override)용입니다 (일부 CLI는 LANG보다 이를 먼저 확인합니다). 둘 다 설정하는 것은 이중 안전장치 (belt-and-suspenders)를 마련하는 것입니다. 매핑은 두 가지 선택기 값을 명시적으로 처리합니다:
fn locale_env_value(locale: Option<&str>) -> Option<String> {
let tag = locale?;
let posix = match tag {
...
일반적인 '-' -> '_' 폴스루 (fallthrough) 덕분에, 향후 fr-FR 선택기가 추가되더라도 코드 변경 없이 바로 작동합니다.
원칙: 만약 생성된 서브프로세스 (subprocess)에 언어와 관련된 사용자 대상 출력 (user-facing output)이 있다면, 시스템 로케일만으로는 충분하지 않습니다. 사용자가 실제로 제어할 수 있는 유일한 것은 UI에서의 선택뿐입니다. 이를 명시적으로 전달하세요.
권한 모드: Ask / Plan / Build
grok-build에는 세 가지 샌드박스 (sandbox) 모드 — 읽기 전용 (read-only), 읽기 전용 + 셸 사용 불가 (no shell), 전체 액세스 (full access) — 가 있습니다. 저는 이를 UI에서 Codex 스타일의 Ask / Plan / Build 선택기로 매핑했습니다:
| 선택기 (Picker) | 샌드박스 (Sandbox) | 셸 (Shell) | 파일 쓰기 (File writes) | 네트워크 (Network) |
|---|---|---|---|---|
| Ask | strict | ❌ | ❌ | ✅ |
| ... |
직관적이지 않은 부분은, 사용자가 모드를 전환할 때 에이전트 (agent)를 재시작해야 한다는 점입니다. 샌드박스는 프로세스 생성 시점 (process-creation time)에 설정되기 때문에 (argv 플래그 --sandbox 사용), 실행 중인 런타임 (runtime)의 권한을 낮출 수는 없습니다.
// UI에서 모드 변경 시:
fn switch_mode(session_id: SessionId, new_mode: String) {
let old_rt = pool.runtimes.remove(&session_id);
...
실제 사용 시에는 전환할 때 2~3초 정도 멈춤 현상이 발생하여 번거롭지만, 대안은 UI가 무엇이 안전한지에 대해 거짓 정보를 제공하는 것뿐입니다. 우리는 정직한 버전을 선택했습니다.
서명(Signing) 및 배포: 문서에 없는 부분들
제품을 출시하기 전까지는 아무도 말해주지 않는 것들입니다:
macOS의 ad-hoc 서명은 "진정한" 서명이 아닙니다. -로 서명된 .app 번들은 Gatekeeper 관점에서 서명되지 않은 상태입니다. 따라서 처음 설치할 때 "확인되지 않은 개발자" 프롬프트가 나타납니다. 해결 방법은 마우스 오른쪽 버튼 클릭 → 열기(Open)를 선택하거나, com.apple.quarantine 확장 속성(xattr)을 제거하는 우리의 First-Run-Open-Me.command 스크립트를 사용하는 것입니다. 이 문제를 완전히 해결하려면 실제 Apple Developer ID(연간 $99)와 xcrun notarytool을 통한 공증(notarized)된 빌드가 필요합니다. 저희는 둘 다 가지고 있지 않습니다.
Electron의 플레이스홀더(placeholder) 서명은 제대로 작동하지 않습니다. 기본 설정 상태에서 electron-builder는 특정 방식으로 Authenticode 서명이 깨진 Windows .exe 파일을 생성합니다. 링커(linker)가 바이너리를 Identifier=Electron으로 서명하면, electron-builder가 실행 파일 이름을 제품명으로 변경하는데, 이때 CodeDirectory는 "sealed resources 없음"이라고 표시하지만 번들에는 리소스가 포함되어 있게 됩니다. Windows 코드 서명 도구들은 이를 거부할 것입니다. macOS Sequoia의 Gatekeeper 또한 "손상되어 열 수 없습니다"라는 메시지와 함께 이를 거부합니다.
해결책: electron-builder 작업이 완료된 후, .app 번들을 ad-hoc ID로 다시 서명하는 post-build codesign --force --deep --sign - 훅(hook)을 사용하는 것입니다. 저희는 이를 electron-builder.yml의 afterSign 훅을 통해 연결했으며, 이 훅은 scripts/afterSign.js를 호출합니다(darwin이 아닌 플랫폼에서는 건너뛰도록 process.platform 체크를 포함합니다).
Windows CI에서의 코드 서명은 5분짜리 작업입니다. Tauri Windows는 WiX 3.14의 light.exe를 사용하는데, 이는 github-actions의 windows-latest 환경에서 "failed to run ...\WixTools14\light.exe"라는 오류와 함께 충돌합니다(알려진 .NET Framework 의존성 문제입니다). 저희는 tauri build에 --bundles nsis를 전달하여 이를 우회합니다. 이렇게 하면 MSI 타겟을 건너뛰고 NSIS 설치 프로그램만 빌드하게 되는데, NSIS는 Tauri CLI에 포함된 makensis를 사용하므로 의존성 지옥(dependency hell)이 발생하지 않습니다.
중첩된 packages/core/ 중복은 유지보수 위험 요소입니다.
Tauri와 Electron 런타임(runtime)은 동일한 레포지토리(repo) 내의 별개 프로젝트입니다. Electron 버전은 독립적으로 빌드할 수 있도록 packages/core/src/stores/의 복사본을 electron-version/packages/core/src/로 벤더링(vendor)합니다. 스토어(store)가 변경될 때마다(예: 새로운 locale 파라미터 추가), 이 중첩된 복사본을 동기화해야 합니다. 아직 이를 위한 프리 커밋 훅(pre-commit hook)은 구현되지 않았습니다.
우리가 다르게 구축할 부분
-
에이전트의 계획/권한 요청을 별도의 알림 인터페이스로 스트리밍합니다. 현재는 대화창 내의 인라인 도구 호출(tool-call) 카드 형태로 나타납니다. 사용자가 다른 탭을 보고 있는 동안에도 조치를 취할 수 있도록 푸시 알림(push notifications) 형태여야 합니다.
-
grok agent stdio의 초기화 과정을 캐싱합니다. 세션이 생성될 때마다 JSON-RPC 초기화 핸드셰이크(handshake)를 다시 수행하며, 이 과정에서 약 300ms가 소요됩니다. 워크스페이스 상태 지문(workspace-state fingerprinting)을 충분히 활용한다면, "방금 대화했던 것과 동일한 워크스페이스"인 경우 핸드셰이크를 건너뛸 수 있습니다. -
멀티 런타임 풀(multi-runtime pool)을 다른 백엔드(backend)로 확장합니다. 현재 풀(pool)은
grok agent stdio로 하드코딩되어 있습니다. 다른 ACP 서버들(OpenCode, Claude Code, Continue)도 동일한 풀 인프라를 공유할 수 있습니다. -
AGENTS.md를 인식하도록 만듭니다. 현재는 워크스페이스 수준의 규칙이 UI 언어 선택기보다 우선순위를 갖습니다(이는 올바른 동작입니다. 규칙이 더 구체적이기 때문입니다). 하지만 사용자가 이 사실을 직접 알아내야 합니다.AGENTS.md가 적용 중일 때 채팅 헤더에 작은 배지를 표시하면 도움이 될 것입니다.
사용해 보기
brew install --cask timexingxin/grok-gui/grok-gui-lite
또는 릴리스 페이지에서 macOS DMG 또는 Windows .exe 파일을 받으세요.
소스 코드는 github.com/timexingxin/grok-gui에서 확인할 수 있습니다.
MIT 라이선스입니다. 래핑된 grok-build 런타임은 xai-org/grok-build의 Apache-2.0 라이선스를 따릅니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기