하나의 제어 평면(Control Plane)에서 5개의 Claude Code CLI를 실행하는 방법: 그 구현 과정
요약
여러 개의 Claude Code CLI를 효율적으로 관리하기 위해 Flask 기반의 제어 평면(Control Plane)을 구축한 사례를 소개합니다. API 대신 CLI 서브프로세스를 감독하는 방식을 채택하여 비용을 절감하고 기존 에이전트 기능을 그대로 활용합니다.
핵심 포인트
- API 호출 대신 CLI 프로세스를 직접 감독하여 비용과 신뢰성 확보
- Flask와 SSE를 활용해 각 에이전트의 상태를 실시간 대시보드로 시각화
- 브라우저의 SSE 연결 제한(6개) 문제를 해결하기 위한 스트림 관리 전략
- 사용자가 메시지 버스가 되는 병목 현상을 해결하기 위한 제어 구조 설계
Claude Code는 하나의 저장소(Repo)에서 하나의 터미널을 사용하는 하나의 에이전트(Agent)로서는 매우 훌륭합니다. 문제는 에이전트가 세 번째가 될 때부터 시작됩니다. 저는 다섯 개의 프로젝트를 진행 중이었고, 다섯 개의 터미널 창을 띄워 놓았습니다. 매일 아침 자리에 앉으면 어떤 에이전트가 작업 중간에 있는지, 어떤 에이전트가 저를 기다리며 차단(Blocked)되어 있는지, 그리고 어떤 에이전트가 한 시간 전에 조용히 작업을 마쳤는지 파악하는 데에만 10분을 허비하곤 했습니다.
여러 에이전트를 실행할 때 아무도 말해주지 않는 사실은 당신이 메시지 버스(Message Bus)가 된다는 점입니다. 모든 에이전트가 당신의 주의력(Attention)을 통해 라우팅됩니다. 이를 다섯 개로 확장하면 병목 현상(Bottleneck)은 에이전트가 아니라 바로 당신이 됩니다. 그래서 저는 저 대신 메시지 버스 역할을 할 작은 제어 평면(Control Plane)을 구축했습니다. 저항이 있었던 부분들을 포함하여, 이것이 어떻게 연결되었는지 소개합니다.
모든 것을 결정지은 제약 조건: API가 아닌 CLI를 구동할 것
가장 뻔한 방법은 Anthropic의 API를 직접 호출하여 자신만의 에이전트 루프(Agent Loop)를 구축하는 것입니다. 저는 의도적으로 그렇게 하지 않았습니다. 두 가지 이유가 있습니다.
첫째, 비용과 신뢰성입니다. claude CLI를 구동한다는 것은 작업이 사용량 기반의 API 키가 아니라 기존의 Claude 구독을 통해 실행됨을 의미합니다. 비용은 현재 Claude Code를 사용하는 것과 정확히 일치하며, 제가 운영하는 서버를 통해 아무것도 라우팅되지 않습니다. 사람들이 자신의 기기에 직접 설치하는 도구의 경우, "당신의 구독, 당신의 키, 당신의 데이터"는 단순한 기능이 아니라 핵심적인 가치입니다.
둘째, CLI 자체가 이미 에이전트입니다. CLI에는 도구 사용 루프(Tool-use loop), 권한 모델(Permission model), 파일 편집, MCP 지원 기능이 이미 포함되어 있습니다. 이를 로우(Raw) API를 대상으로 다시 구현하는 것은 가장 좋은 부분을 버리는 것과 같습니다. 그래서 설계 방향을 뒤집었습니다. 에이전트가 되는 대신, 제어 평면은 claude CLI 프로세스를 생성하고 감독(Supervise)하며 그 외의 일에는 관여하지 않도록 했습니다.
API를 호출하는 대신 서브프로세스(Subprocess)를 감독하기로 한 이 한 가지 결정이 나머지 과정을 흥미롭게 만들었습니다.
구조: Flask 제어 평면, 프로젝트당 하나의 서브프로세스, 라이브 피드를 위한 SSE
핵심은 Flask 앱입니다. 각 프로젝트는 하나의 claude 프로세스에 매핑됩니다. 작업을 할당하면 서버는 해당 프로젝트의 프로세스를 생성(또는 재사용)하고, 표준 출력(stdout)을 스트리밍하며, Server-Sent Events (SSE)를 통해 브라우저로 토큰을 푸시합니다. 대시보드는 이러한 라이브 피드 N개를 그리드 형태로 타일링한 것에 불과하며, 각 타일은 **작업 중(working), 사용자 대기 중(waiting on you), 또는 유휴 상태(idle)**의 상태를 가집니다.
단순해 보입니다. 하지만 여기서부터 복잡해지기 시작했습니다 —
장애물 1: 브라우저는 오리진(origin)당 6개의 SSE 연결만 허용함
대시보드는 프로젝트당 라이브 스트림을 원합니다. 프로젝트 6개를 열면 일곱 번째 타일은 그냥... 멈춰버립니다. 이것은 코드의 버그가 아니라 브라우저의 문제입니다. Chromium은 단일 오리진에 대한 동시 연결을 6개로 제한하며, 수명이 긴 SSE 스트림은 전체 시간 동안 연결 하나를 점유합니다. 5개의 스트리밍 에이전트에 길을 잃은 재연결(reconnect) 하나만 더해져도 한계치에 도달하게 됩니다.
해결책은 스트림을 희소한 자원으로 취급하는 것입니다. 활발하게 running 중인 프로젝트에 대해서만 SSE 연결을 열고, "혹시 모르니까"라는 생각으로 연결을 유지하는 대신 턴(turn)이 완료되는 즉시 연결을 닫습니다. 유휴 상태인 프로젝트는 스트림을 전혀 받지 않으며, 대신 저렴한 비용의 폴링(polling) 방식을 사용합니다. 스트림이 타일이 아닌 활동(activity)에 종속되게 되면, 연결 제한은 더 이상 문제가 되지 않습니다.
장애물 2: Windows는 8,191자 제한의 명령줄(command line)을 가짐
에이전트에게 컨텍스트(context)를 제공하려면 CLI 인자(argument)로 전달하는 것이 자연스러운 본능입니다. 하지만 Windows의 cmd.exe는 전체 명령줄을 8,191자에서 조용히 잘라버립니다. 괜찮은 수준의 시스템 프롬프트(system prompt)에 프로젝트의 메모리까지 더해지면 즉시 이 제한을 넘어서게 되며, 오류 메시지도 없이 손상된 호출이 발생합니다. 즉, 에이전트가 잘못된 상태로 시작하게 됩니다.
해결책: 컨텍스트를 인라인(inline)으로 전달하지 마세요. 이를 파일로 작성한 뒤 CLI에 --append-system-prompt-file을 전달하십시오. 이 트릭은 두 번째 문제, 즉 큰 프롬프트에서 셸(shell)이 따옴표와 줄바꿈을 망가뜨리는 문제도 해결해 줍니다. 파일을 쓰고, 경로를 인자로 넘기면 끝입니다. 지나고 보니 당연한 일이지만, 이 문제로 저녁 시간을 통째로 날렸습니다.
지속 프로세스(persistent-process) 결정 (그리고 그 대가)
매 턴(turn)마다 새로운 claude를 생성(spawn)할 수도 있고, 여러 턴에 걸쳐 하나의 프로세스를 계속 유지할 수도 있습니다. 매 턴 새로 생성하는 방식은 깔끔하지만 느리고 망각이 빠릅니다. 지속 프로세스(persistent process)는 빠르고 작업 상태(working state)를 유지하므로 기본값으로 사용됩니다. 여기서 발생하는 문제는, 매 턴 실행될 것이라고 가정했던 세션 종료(session-teardown) 로직이 이제 프로세스가 실제로 종료될 때만 실행된다는 점입니다. 메모리 기록, 스트림 해제, 상태 업데이트 등 "턴 종료(end of turn)" 시점에 연결해 두었던 모든 작업은 실제 생명주기(lifecycle) 이벤트에 맞춰 다시 고정해야 하며, 그렇지 않으면 조용히 실행이 중단됩니다. 지속 프로세스는 그만한 가치가 있지만, "완료(done)\
저장(Store) — 두 개의 영역, 서로 다른 소유자. 프로젝트별 MEMORY.md는 의도적으로 분리되어 있습니다. 상단에는 사람이 직접 큐레이션한 작은 인덱스가 위치하며, 이는 바이트 단위로 보존되어 시스템(machinery)이 절대 다시 쓰지 않습니다. 그 아래에는 쓰기 파이프라인(write pipeline)만이 접근할 수 있는, 센티널(sentinel)로 구분된 관리형(managed) 영역이 있습니다. 신뢰할 수 있는 수기 노트를 자동 캡처된 변동 사항(churn)과 물리적으로 분리해 두는 것이, 파일을 계속 감시하는 대신 실제로 파일에 의존할 수 있게 만드는 핵심입니다. 쓰기 작업은 프로젝트별로 잠금(lock)이 걸리고 원자적(atomic)으로 수행되므로, 다섯 개의 에이전트가 동시에 체크포인트(checkpointing)를 생성하더라도 파일이 손상되지 않습니다.
읽기(Read) — 모델이 선택하는 것이 아닌, 결정론적(deterministic) 방식. 다음 디스패치(dispatch) 시, 에이전트가 무엇인가를 수행하기 전에 해당 메모리의 고정된 슬라이스가 읽기 하한선(read-floor)으로 주입됩니다. 에이전트는 정보를 찾아볼지 말지를 결정할 권한이 없습니다. 따라서 에이전트는 정보를 탐색(spelunking)하는 대신, 프로젝트의 상태를 이미 알고 있는 상태에서 작업을 시작합니다.
다듬기(Trim) — 적은 것이 실제로 더 많다. 이는 직관에 반하는 부분입니다. 모델이 주입된 컨텍스트를 효과적으로 흡수하는 데에는 엄격한 바이트 예산(byte budget)이 존재합니다. 이 예산을 초과하면 회상(recall) 성능이 좋아지는 것이 아니라 오히려 악화됩니다. 따라서 다듬기 작업은 무자비하며 라인 키(line-keyed) 방식으로 이루어집니다. 즉, 손실 없는 기계적 하한선(mechanical floor)을 두고 그 위에서 모델 주도형 압축(model-driven condense)을 수행합니다. 그리고 축출된 모든 내용은 절대 삭제되지 않는 영구적이고 검색 가능한 아카이브로 이동합니다. 이는 쓰레기통이 아니라 콜드 스토리지(Cold storage)입니다. 작업 메모리(working memory)는 작고 날카롭게 유지되며, 실제로 손실되는 데이터는 아무것도 없습니다.
처음에 제가 범했던 두 가지 실수는 근본 원인이 같습니다. 첫째, 에이전트가 자신의 메모리를 자유롭게 다시 쓰도록 허용한 것입니다. 이 경우 메모리는 표류하고 비대해지며, 일주일만 지나도 단 한 줄도 믿을 수 없게 됩니다. 둘째, 메모리가 많을수록 좋다고 가정한 것입니다(다듬기 부분을 참조하십시오). 두 가지 실수 모두 메모리를 *제어 평면(control plane)*이 에이전트를 대신해 관리하는 관리형 저장소(managed store)로 취급하는 대신, 에이전트가 제어하는 연습장(scratchpad)으로 취급했기 때문에 발생했습니다. 소유권을 뒤집고 나니 전체 시스템이 신뢰할 수 있게 되었습니다.
상주 에이전트(Standing agents): 채팅 창보다 오래 지속되는 작업
제어 평면(Control Plane)이 프로세스를 감독하고 상태(State)를 기억하게 되면, 새로운 것이 가능해집니다. 바로 사용자가 탭을 닫은 후에도 계속해서 작업을 수행하는 에이전트(Agents)입니다. 스케줄러(Scheduler)는 크론(cron)에 따라 작업을 실행하고, "차터(charter)" 에이전트는 지속적인 목표를 부여받아 되돌릴 수 있는 단계(reversible steps)를 거치며 자율적으로 작업하며, 되돌릴 수 없는 작업을 수행하기 전에는 반드시 질문을 던집니다. 이것이 더 이상 메시지 버스(Message bus) 역할에 머물지 않음으로써 얻는 보상입니다. 사용자가 키보드에서 떨어져 있는 동안에도 작업은 계속 진행되며, 사용자는 휴대폰으로 확인하여 실제로 사용자의 개입이 필요한 단 하나의 사항에만 응답하면 됩니다.
더 어려운 문제: 서로의 사각지대가 되는 에이전트들
사람들이 다음에 던지는 질문은 "두 명의 에이전트가 동시에 동일한 프로젝트에서 작업할 수 있는가?"입니다. 표면적인 질문은 파일 충돌(File conflicts)에 관한 것이며, 이에 대한 답은 지루할 정도로 간단합니다. 각 에이전트에게 고유한 git 워크트리(worktree)와 브랜치(branch)를 부여하고 마지막에 병합(Merge)하면 됩니다.
하지만 흥미로운 버전은 충돌이 아니라 **조율(Coordination)**입니다. 동일한 파일이 아니라, 서로 상호 의존적인(interdependent) 기능을 작업하는 두 에이전트의 경우입니다. 에이전트 B가 에이전트 A가 방금 마친 작업을 다시 빌드합니다. 에이전트 B가 선택한 접근 방식이 A가 방금 반영한 변경 사항으로 인해 쓸모없게 되어버립니다. 그들은 같은 줄을 건드리지 않습니다. 그저 서로를 인지하지 못할 뿐입니다.
그리고 여기에 함정이 있습니다. 워크트리 격리(Worktree isolation)가 이 문제를 더 악화시킨다는 점입니다. 격리는 각 에이전트를 자신만의 체크아웃(Checkout) 상태로 두어 편집 안전성(Edit-safety)을 확보하지만, 이로 인해 형제 에이전트가 반영한 커밋(Commit)은 더 잘 보이는 것이 아니라 오히려 덜 보이게 됩니다. 안전성과 인지 능력(Awareness)은 서로 반대 방향으로 작용하며, 이는 하나를 설계할 때 다른 하나를 고려하지 않고는 설계할 수 없음을 의미합니다.
나는 이를 제대로 설계하기 위해 자리에 앉았고, 유용한 놀라움은 빠진 조각이 인프라(infrastructure)가 아니라 바로 _전달(delivery)_이었다는 점입니다. 기질(substrate)은 이미 존재하고 있었습니다. 내구성이 있는 추가 전용(append-only) 메시지 버스, 공유 지식 저장소, SSE 팬아웃(fan-out), 리컨실러 데몬(reconciler daemon) 패턴, 그리고 메모리 시스템이 사용하는 것과 동일한 읽기 플로어 주입(read-floor injection) 방식이 그것입니다. 존재하지 않았던 것은 그 위의 의미론(semantics)이었습니다. 즉, 에이전트가 의도(intentions) ("SSE 슬롯 매니저를 수정할 예정입니다")와 완료(completions) ("수정 완료, sha abc123, 해당 경로들")를 게시하고, — 가장 어려운 부분인데 — 아무도 폴링(poll)하지 않는 버스에 가만히 앉아 있는 대신, 작업 중인 형제(sibling)의 컨텍스트로 해당 이벤트들이 _푸시(pushed)_되는 방식이었습니다.
그래서 나는 이를 구축했습니다. 두 가지 요소가 필요하며, 이들은 반드시 함께 존재해야 합니다.
인지 능력(Awareness). 에이전트들은 프로젝트별 버스에 의도와 완료를 게시하며, 서버는 가능한 곳에서 에이전트를 대신하여(on their behalf) 게시를 수행합니다. 즉, 에이전트가 프로토콜을 기억할 것이라고 믿는 대신, 커밋(commit)에서 수정된 경로로부터 완료 사항을 도출해냅니다. 형제 에이전트들은 이를 읽기 플로어(read-floor)의 SIBLING ACTIVITY 블록으로 전달받으며, 이는 대상 중첩도(target-overlap), 즉 당신이 수정하는 것과 상대방이 수정하는 것이 얼마나 교차하는지에 따라 순위가 매겨집니다. 메커니즘보다 중요한 것은 게이트(gate)입니다. 필터링되지 않은 정보의 홍수(firehose)는 너무 수다스러운 알림처럼 에이전트들이 해당 섹션을 무시하도록 만들 뿐입니다. 상위 3개, 중첩도 순위 기준, 활성 상태인 에이전트만 포함합니다.
격리(Isolation). 각 동시 실행 에이전트는 자신만의 git 워크트리(worktree)와 브랜치(branch)를 가지며, 완료 시 머지백(merge-back)이 이루어집니다. 내가 가장 좋아하는 스코핑(scoping) 세부 사항은 두 번째 이상의 동시 에이전트만 격리된다는 점입니다. 에이전트가 하나인 프로젝트 — 압도적으로 흔한 경우 — 는 아무런 비용도 지불하지 않습니다. 실제로 필요할 때만 구체화되는 복잡성입니다.
결국 두 가지 규칙이 핵심적인 역할을 하게 되었는데, 둘 다 지루한 방식을 통해 배운 것들입니다. 충돌은 에스컬레이션(escalate)되며 절대 자동 해결되지 않는다, 그리고 자동 정리(automatic cleanup)는 절대로 에이전트의 결과물을 파괴해서는 안 된다는 것입니다. 자신이 조정(reconcile)할 수 없는 작업을 조용히 삼켜버리는 오케스트레이터(orchestrator)는 오케스트레이터가 없는 것보다 더 나쁩니다. 왜냐하면 그 어떤 것도 신뢰할 수 없게 되기 때문입니다.
제가 처음에 실수했던 부분은 조정 상태 파일(coordination state file)에서의 레이스(race)였습니다. 두 개의 스레드 — 인지 데몬(awareness daemon)과 디스패치(dispatch) — 가 모두 동일한 JSON에 대해 읽기-수정-쓰기(read-modify-write)를 수행했고, 중복 제거(dedup) 레코드들이 소리 없이 사라졌습니다. 전형적인 사례이며, 이를 명시적으로 언급할 가치가 있는데, 그 이유는 "에이전트 간의 조정(agents coordinating)"이라는 말이 마치 AI 문제처럼 들리지만, 실제 버그는 잠금(lock) 누락과 비원자적 쓰기(non-atomic write)였기 때문입니다. 이 작업의 대부분은 새로운 모자를 쓰고 있는 평범한 시스템 엔지니어링(systems engineering)입니다.
여전히 맞지 않는 부분: 세 번째 전달 단계(delivery rung)입니다. 당신이 수정하겠다고 의도를 선언한 경로에 형제(sibling) 프로세스가 변경 사항을 반영하면 당신의 차례를 방해할 수 있습니다. 메커니즘은 구축되어 있지만, 더 조용한 단계들이 어떻게 작동하는지 지켜보기 전까지는 기본적으로 꺼짐(default-off) 상태로 배포됩니다. 작동 중인 에이전트를 방해하는 것은 나중에 후회하기 매우 쉬운 종류의 기능이며, 저는 이를 가정하기보다 증거를 통해 입증하고 싶습니다.
여전히 해결되지 않은 과제: 실제로 작업을 완료하는 에이전트
여기에 제가 아직 해결하지 못한 부분이 있으며, 이것이 가장 중요할 수도 있습니다. 현재 에이전트는 너무 성급하게 당신에게 차례를 넘겨줍니다. 첫 번째 분기점(fork)이나 확신이 없는 첫 번째 상황에 부딪히면 결정을 상위로 넘겨버립니다. 다섯 개의 에이전트를 감독하고 있을 때, 이것은 메시지 버스(message-bus) 문제가 옆문으로 다시 스며드는 것과 같습니다. 즉, 당신은 더 이상 작업을 라우팅(routing)하는 것이 아니라, 모든 미완성된 차례에 대한 완료 확인자(completion-checker) 역할을 수행하게 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기