본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 27. 12:48

acp-components: AI 에이전트 워크벤치를 탈부착 가능한 레고처럼 만들기

요약

acp-components는 Agent Client Protocol(ACP)을 기반으로 한 프론트엔드 컴포넌트 라이브러리입니다. 데이터 레이어와 UI 레이어를 엄격히 분리하여 웹, 데스크톱, IDE 플러그인 등 다양한 환경에서 멀티 에이전트 워크벤테를 일관되게 구현할 수 있도록 지원합니다.

핵심 포인트

  • 데이터 레이어(core)와 UI 레이어(react)의 완전한 분리 설계
  • React 의존성 없는 core 패키지로 다양한 프레임워크 지원 가능
  • 멀티 에이전트 환경에서의 세션 및 상태 관리 문제 해결
  • 순수 함수 기반의 액션 설계를 통한 높은 테스트 가능성 확보

Agent Client Protocol (ACP)을 기반으로 구축된 프론트엔드 컴포넌트 라이브러리입니다. "데이터 레이어 / UI 레이어 분리 + 직교적 플랫폼 추상화 (orthogonal Platform abstraction)" 설계를 통해, 단 한 세트의 컴포넌트만으로 웹(Web), 데스크톱, IDE 플러그인 전반에서 멀티 에이전트 워크벤치를 실행할 수 있습니다.

왜 존재하는가?

AI 에이전트 프론트엔드를 구축해 본 적이 있다면, 다음과 같은 문제에 부딪혔을 가능성이 높습니다:

  • 호스트마다 UI를 재구현해야 함: 브라우저용 버전, Electron/Tauri용 버전, 그리고 VS Code 플러그인용 버전이 각각 필요합니다. 채팅, 도구 호출 (tool calls), 권한 대화 상자, 파일 트리 등을 매번 새로 작성해야 합니다.
  • 멀티 에이전트의 혼란: OpenCode, Codex, Claude를 동시에 연결하고 싶을 때, 누가 연결을 관리할까요? 누가 디렉토리(워크스페이스)별로 세션을 정리할까요? 워크스페이스를 전환할 때 파일 트리는 어떻게 될까요?
  • 프로토콜 통신과 상태의 뒤엉킴: NDJSON 스트림, 세션 업데이트, 권한 요청이 모두 React 컴포넌트 내부에 결합되어 있어 테스트와 유지보수가 어렵습니다.
  • 파일 I/O 및 기타 고위험 기능의 잘못된 위치: 프론트엔드에 파일 시스템 호출을 하드코딩하거나, 권한 결정이 에이전트 통신 레이어로 유출되어 보안 경계가 모호해집니다.

acp-components는 정확히 이 네 가지 문제를 해결하는 것을 목표로 합니다. 이 라이브러리는 에이전트 런타임 (Agent runtime)을 구현하거나 호스트를 대신하여 시스템 권한을 가져오지 않습니다. 대신, 이러한 경계를 깔끔하게 구분해 주는 임베드 가능한 프론트엔드 프로토콜, 상태 및 UI 프리미티브 (primitives) 세트를 제공합니다.

핵심 설계: 모든 것을 관통하는 세 가지 원칙

원칙 1: 프레임워크에 구애받지 않는 코어를 통한 깔끔한 데이터/UI 분리

이 프로젝트는 두 개의 패키지로 제공됩니다:

@acp-components/react  (UI 레이어: 18개 컴포넌트 디렉토리, 13개의 세밀한 훅 (hooks), Contexts, 테마, i18n)
       ↓ 의존함
@acp-components/core   (데이터 레이어: createAcpProvider + AcpClient + Transport + vanilla Zustand 스토어 + Actions)
...

가장 중요한 규칙: core는 React 의존성이 전혀 없습니다. 단순히 말로만 하는 것이 아니라, 코드 수준에서 강제됩니다.

  • core의 package.json에는 react@types/react가 포함되어 있지 않습니다.

이것이 무엇을 의미할까요? core는 React 없이도 사용할 수 있습니다. README에서도 이를 명시적으로 권장합니다. Vue, Svelte 또는 Solid를 원하시나요? 통신(comms), 상태(state), 액션(Actions)을 위해 core를 있는 그대로 가져다 쓰고, UI 프레임워크는 원하는 것을 선택하면 됩니다. React 레이어는 단지 "공식 기본 구현 (official default implementation)"일 뿐입니다.

이러한 분리 방식의 추가적인 이점은 다음과 같습니다:

  • 액션(Actions)은 순수 함수 (pure functions)입니다: 각 액션은 첫 번째 인자로 AcpClient를 명시적으로 받습니다. 즉, 상태가 없고 (stateless) 단위 테스트 (unit-testable)가 가능합니다. agentId → client 매핑은 프로바이더 클로저 (provider closure) 내에 존재하며 전역으로 유출되지 않습니다.
  • 컴포넌트(Components)는 테스트 가능합니다: UI 컴포넌트가 window.promptlocalStorage를 직접 건드리지 않으므로, jsdom 환경에서 실행될 수 있습니다.

원칙 2: 멀티 에이전트 × 멀티 워크스페이스를 위한 3계층 상태 모델

실제 시나리오에서는 단 하나의 에이전트만 실행하지 않으며, 단 하나의 디렉토리에서만 작업하지도 않습니다. core는 깔끔한 **3계층 추상화 (three-tier abstraction)**를 중심으로 상태를 구성합니다:

추상화 (Abstraction)의미 (Meaning)위치 (Home)
Agent하나의 독립적인 ACP 연결 (전송 + 상태 + 기능)acpStore.agents: Map<agentId, AgentConnection>
...

영리한 부분은 다음과 같습니다: 활성 워크스페이스 (active workspace)는 전역 activeSessionId로부터 SessionMeta.cwd를 조회하여 도출됩니다. 즉, "현재 워크스페이스"를 별도로 유지 관리할 필요가 없습니다. 세션을 전환하면 워크스페이스도 함께 전환됩니다.

createAcpProviderPromise.allSettled를 통해 모든 에이전트를 병렬로 연결하며, 이는 논블로킹 (non-blocking) 방식입니다. 클로저 내의 scopedClientRegistryagentId별로 클라이언트를 격리하므로, 한 에이전트의 연결 실패가 나머지 에이전트들에게 영향을 주지 않습니다. 워크스페이스 전환 시, sessionCapabilities.list 기능을 선언한 에이전트에 대해서만 세션 목록을 가져오며, 이는 페이지네이션 커서 (pagination cursors)를 사용하는 멱등적 (idempotent) 작업입니다.

이 모델은 다음 문제를 직접적으로 해결합니다: OpenCode로 코드를 작성하고, Codex로 리뷰하며, Claude에게 질문하는 것 — 하나의 프레임 안에서 세 개의 세션을 디렉토리별로 아카이브하며, 서로 간섭 없이(no flavor bleed) 사용할 수 있습니다.

원칙 3: Platform과 AcpContext는 직교(orthogonal)하며, 고위험 기능은 호스트 측에 유지됩니다

이는 가장 쉽게 간과되기 쉽지만 가장 중요한 설계입니다. 이 프로젝트는 호스트 네이티브(host-native) 기능을 Platform 인터페이스로 추상화합니다:

interface Platform {
  platform: 'desktop' | 'web';
  // 네이티브 상호작용: 링크 열기, 디렉토리 선택 창, 시스템 알림
...

핵심 키워드는 **직교(orthogonal)**입니다. Platform / usePlatform()은 네이티브 기능을 소유하고, AcpContext / useAcpContext()는 에이전트 연결 및 세션 상태를 소유합니다. 이 둘은 인터페이스나 조립(assembly) 수준에서 서로를 절대 참조하지 않습니다. 에이전트 전송(Agent transport) 설정은 AcpProvider에 부착된 AgentConfig.transport에 존재하며, Platform의 일부가 아닙니다.

분리하는 세 가지 이유는 다음과 같습니다:

  1. 하나의 컴포넌트 세트, 다양한 호스트: 웹 데모, Tauri 데스크톱, Electron, IDE 플러그인이 하나의 컴포넌트 트리(component tree)를 공유합니다. 오직 Platform 구현체만 변경됩니다.
  2. 호스트에 의해 제어되는 고위험 기능: 파일 읽기/쓰기, 디렉토리 대화 상자, 업데이트 프로그램과 같이 보안에 민감한 작업은 호스트 측에 유지됩니다. 코어(core)는 절대 호스트를 대신하여 보안 결정을 내리지 않습니다. CLAUDE.md에 명시되어 있듯이, 코어는 ACP의 readTextFile / writeTextFile 역방향 콜백(reverse callbacks)을 구현하지 않습니다. 파일 접근은 usePlatform()을 통해 소비되는 UI 측 기능이며, 보안 결정권을 다시 호스트로 넘깁니다.
  3. 테스트 격리(Test isolation): 컴포넌트가 @tauri-apps/plugin-* 또는 브라우저 API에 직접 의존하지 않으므로, jsdom 환경에서 테스트가 가능합니다.

즉시 사용 가능한 두 가지 Platform 구현체는 이 추상화가 구축 가능하다는 것을 증명합니다:

  • createWebPlatform() (순수 브라우저): readDirectory는 브릿지 서버로 프록시되는 fetch('/api/readdir')를 통해 실행됩니다. 파일 트리 감시(file-tree watching)는 SSE 스트림을 구독하는 EventSource를 사용하며, 영속성(persistence)은 네임스페이스 접두사가 붙은 localStorage를 사용합니다. 브라우저에는 네이티브 디렉토리 피커(directory picker)가 없으므로 window.prompt로 대체(degrade)됩니다.
  • createTauriPlatform() (데스크톱): readDirectory@tauri-apps/plugin-fsreadDir을 사용합니다. 디렉토리 피커는 plugin-dialog의 네이티브 다이얼로그를 사용하며, 워크스페이스 목록은 Rust가 invoke('load_workspaces')를 통해 app_data_dir/workspaces.json에 읽고 씁니다.

호스트는 최상단에 <PlatformProvider platform={...}>를 감싸기만 하면 됩니다. 이는 기본적으로 <PlatformFileTreeAuto>를 자동 마운트하며, platform.readDirectory / watchFileTreefileTreeStore에 설정 없이(zero-config) 연결합니다. 활성 워크스페이스에 대해서만 루트 트리를 프리로딩(preloading)하고 활성 워크스페이스에 대해서만 감시 이벤트(watch events)를 구독함으로써, 전환 시 이전 트리의 확장 상태(expanded state)를 유지합니다.

전송(Transport): 네 가지 옵션, 플러그인 방식

ACP 프로토콜의 전송 캐리어(transport carrier)는 NDJSON (한 줄당 하나의 JSON 메시지)입니다. 코어(core)는 전송을 최소한의 AcpTransport 인터페이스로 추상화합니다:

interface AcpTransport {
  connect(): Promise<Stream>;   // { readable, writable }을 반환
  disconnect(): void;
...

세 가지 내장 구현체와 하나의 커스텀 진입점이 있습니다:

TransportScenarioImplementation
StdioTransport데스크톱 기본 (Desktop primary)자식 프로세스를 spawn하고, stdin/stdout을 제어하며, ndJsonStream으로 분할합니다; disconnect 호출 시 kill()을 실행합니다
...

커스텀 전송 (Custom transport)은 빈 약속이 아닙니다 — 이 리포지토리는 프로덕션급 Tauri IPC 구현체TauriIpcTransport (147줄)를 포함하고 있습니다: connect() 시 먼저 invoke('start_agent')를 호출하여 Rust가 자식 프로세스를 spawn하게 한 뒤, listen('agent-output'/'agent-closed'/'agent-error')를 통해 이벤트를 등록하고, 페이로드(payload)를 JSON.parse하여 ReadableStream에 푸시합니다; WritableStream.write는 메시지를 JSON.stringify + '\n'로 인코딩하여 invoke('write_to_agent')를 통해 stdin으로 다시 씁니다.

Agent stdout ──> Rust (line by line) ──> Tauri event ──> ReadableStream
Agent stdin  <── Rust (write) <── Tauri command <── WritableStream

이 패턴을 따르면, Electron IPC (ipcRenderer.invoke / on) 및 iframe postMessage도 동일한 개념을 아주 쉽게 적용할 수 있습니다.

스트리밍 UX: 16ms 배치 처리 + 가상 스크롤링 (Virtual Scrolling)

채팅이 쾌적하게 느껴지는지는 80%가 스트리밍 렌더링(streaming rendering)에 달려 있습니다. 여기 몇 가지 구체적인 엔지니어링 세부 사항이 있습니다:

  • 세션별 텍스트 청크 배치 (Per-session text-chunk batching): 에이전트의 텍스트 청크는 매우 높은 빈도로 들어옵니다. 모든 청크마다 스토어(store)를 작성하면 React 리렌더링(re-renders)이 과도하게 발생할 수 있습니다. core는 동일한 세션에서 발생하는 고빈도 텍스트 청크를 BATCH_WINDOW_MS = 16(약 1프레임) 창 내에서 하나의 스토어 쓰기로 배치 처리합니다. 핵심은 세션별 버퍼(buffer) + 플러시 타이머(flush timer)입니다. 그렇지 않으면 한 세션의 빈번한 tool_call이 다른 세션에 쌓이고 있는 텍스트를 조기에 플러시(flush)해 버릴 수 있습니다. 텍스트가 아닌 블록(예: tool_use)이 먼저 플러시된 후 작성되어 메시지 순서를 보존합니다.
  • O(1) 텍스트 블록 병합 패스트 패스 (O(1) text-block merge fast path): appendContent는 마지막 메시지가 messageId와 일치하는지 확인합니다. 일치할 경우 제자리에서 병합(merge in place)하며, 주석(annotations)이 없는 인접한 텍스트 블록들은 lastBlock.text + block.text로 직접 연결하여 파트 파편화(part fragmentation)를 줄입니다.
  • react-virtuoso 롱 리스트 가상화 (react-virtuoso long-list virtualization): ChatView는 "사용자 턴(user turn) → 에이전트 턴(agent turn)" 그룹 단위로 가상화하여, 수천 개의 메시지에서도 부드러운 상태를 유지합니다.

즉시 사용 가능한 18개의 컴포넌트 디렉토리

단순한 셸(shell)이 아닌, 완전한 워크벤치(workbench)입니다. packages/react/src/components에는 약 24개의 명명된 컴포넌트가 있습니다 (디렉토리 수 기준 18개):

  • Skeleton (스켈레톤): Workbench 3단 리사이즈 가능 레이아웃 + AcpProvider + 접근 가능한 ResizeHandle;
  • Sessions (세션): SessionList (워크스페이스 → 에이전트 → 세션의 3단계 그룹화, 포크/삭제/더 보기), Sidebar (세션 목록/파일 트리 토글);
  • Chat family (채팅 제품군): ChatView + MessageBubble + ChatComposer (/ 입력 시 커맨드 팔레트(command palette) 트리거, 첨부 파일 업로드) + ToolCallCard + StreamingIndicator + ThoughtView + PlanView + UserMessage;
  • Files (파일): FileTree (디렉토리가 상단에 정렬됨), FileViewer (Monaco 에디터 지연 로딩(lazy-loaded), 테마 인식);
  • Dialogs (다이얼로그): PermissionDialog (한 번 허용/항상 허용/거부), LoginDialog (환경 변수(EnvVar) 인증, 300초 타임아웃);
  • Misc (기타): DiffView, CommandPalette, SessionConfigPanel, Select (포털 렌더링(portal-rendered)), Dropdown 제품군 (4가지 배치), SettingsMenu, ConnectionStatus + UsageBar (SVG 링 토큰 진행률).

모든 컴포넌트는 동일한 디자인 계약 (design contract)을 따릅니다: 상태 (state)는 코어의 vanilla stores와 세밀한 훅 (fine-grained hooks)에 의해 제어되며, 네이티브 기능은 usePlatform()을 통해 주입되고, 컴포넌트 자체는 렌더링과 상호작용만을 담당합니다.

세 가지 에이전트, 두 가지 예시, 하나의 브릿지

  • examples/demo: Vite + React 19 기반의 순수 브라우저 앱으로, 로컬 브릿지 서버와 WebSocket으로 연결됩니다. vite.config.ts는 소스 코드로 직접 별칭 (alias)을 지정하여 개발 중 빌드 단계가 필요 없습니다.
  • examples/server: Node 브릿지 서비스입니다. 하나의 HTTP 서버가 세 가지 요소를 마운트합니다 — WebSocket↔stdio NDJSON 브릿지 (브라우저가 로컬 에이전트의 자식 프로세스를 제어할 수 있게 함), HTTP 파일 시스템 API (/api/readdir, /api/readfile), 그리고 SSE 파일 감시 스트림 (/api/watch)입니다. 루트 package.json에는 **OpenCode / Codex (@zed-industries/codex-acp) / Claude (@agentclientprotocol/claude-agent-acp)**를 위한 원클릭 실행 스크립트가 포함되어 있습니다.
  • examples/tauri: 전체 Rust 백엔드(348줄, 5개의 tauri commands)를 갖춘 Tauri 2 데스크톱 템플릿입니다. start_agent는 Windows .cmd 완료를 처리하고, 콘솔을 숨기기 위해 CREATE_NO_WINDOW를 사용하며, 4개의 스레드가 stdin/stdout/stderr/exit를 브릿징합니다. 프로덕션 빌드에서는 에이전트 바이너리를 bundle.resources에 포함할 수도 있습니다.

스크린샷

하나의 컴포넌트 세트, 두 가지 호스트 형태 — 왼쪽에는 브라우저의 Web 워크벤치, 오른쪽에는 Tauri로 패키징된 데스크톱 앱이 있습니다. 두 형태 모두 @acp-components/react의 모든 컴포넌트와 @acp-components/core의 상태 계층을 공유합니다. 오직 Platform 구현체 (createWebPlatformcreateTauriPlatform)와 전송 방식 (WebSocket ↔ Tauri IPC)만 다릅니다.

Web 데모 (브라우저) — WebSocket을 통해 로컬 브릿지 서버에 연결되며, 파일 트리는 SSE에 의해 구동됩니다:

ACP Web Demo

Tauri Desktop — Tauri IPC를 통해 Rust에서 생성된 에이전트 자식 프로세스(child process)에 직접 연결하며, 네이티브 대화 상자(native dialog)를 통해 디렉토리를 선택합니다:

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0