OpenClaude 아키텍처 설계기: 웹에서 Claude Code CLI를 제어하는 방법
배경: 왜 웹에서 Claude Code를 돌리고 싶었나
Claude Code CLI는 강력한 AI 코딩 어시스턴트입니다. 터미널에서 직접 실행하면 파일을 읽고, 코드를 수정하고, 명령어를 실행하는 등 거의 모든 개발 작업을 자동화할 수 있습니다. 하지만 한 가지 불편함이 있습니다. 반드시 터미널 앞에 앉아 있어야 한다는 점입니다.
출퇴근 중 모바일에서 간단한 지시를 내리고 싶을 때, 외출 중에 진행 상황을 확인하고 싶을 때, CLI 전용이라는 제약이 발목을 잡습니다. OpenClaude는 이 문제를 해결하기 위해 시작된 프로젝트입니다. 웹 브라우저에서 Claude Code CLI를 원격으로 제어하고, 실시간으로 응답을 스트리밍하는 시스템을 만드는 것이 목표입니다.
이 글에서는 초기 설계(PRD)부터 실제 인프라 검증을 거쳐 최종 아키텍처를 확정하기까지의 과정을 공유합니다.
문제 정의: CLI를 웹으로 옮기려면 무엇이 필요한가
Claude Code CLI를 웹에서 제어하려면 크게 세 가지 문제를 풀어야 합니다.
- 프로세스 영속성: 브라우저를 닫거나 서버를 재시작해도 진행 중인 Claude 작업이 중단되면 안 됩니다.
- 실시간 스트리밍: Claude의 응답이 한꺼번에 오는 것이 아니라, 토큰 단위로 실시간 전달되어야 합니다.
- 대화 연속성: 여러 번의 프롬프트가 하나의 대화 맥락을 유지해야 합니다.
이 세 가지를 동시에 만족시키는 것이 생각보다 까다로웠습니다.
초기 설계와 그 한계
PRD 원안: 대화형 CLI를 tmux로 감싸기
처음 구상한 방식은 직관적이었습니다. tmux 세션 안에서 대화형 Claude CLI를 실행하고, tmux send-keys로 사용자 입력을 전달한 뒤, tmux capture-pane으로 출력을 캡처하는 것이었습니다.
# 초기 구상 (실제로는 작동하지 않음)
tmux new-session -d -s openclaude-myproject
tmux send-keys -t openclaude-myproject 'claude' Enter
tmux send-keys -t openclaude-myproject '이 코드를 리팩토링해줘' Enter
tmux capture-pane -t openclaude-myproject -p # 출력 캡처
깔끔해 보이지만, 실제로 검증해보니 심각한 문제들이 드러났습니다.
검증에서 발견된 세 가지 벽
첫 번째 벽: JSON 스트리밍은 비대화형 모드 전용
Claude CLI의 --output-format stream-json 옵션은 실시간 스트리밍에 필수적입니다. 그런데 이 옵션은 --print(비대화형) 모드에서만 작동합니다. 대화형 모드에서는 사용할 수 없었습니다.
두 번째 벽: capture-pane의 오염된 출력
tmux capture-pane으로 가져온 텍스트에는 터미널 이스케이프 코드(색상, 커서 이동 등)가 포함됩니다. 이를 파싱해서 의미 있는 데이터로 변환하는 것은 매우 불안정한 접근입니다.
세 번째 벽: 비대화형 모드의 1회성 문제
--print 모드는 한 번 응답하면 프로세스가 종료됩니다. 대화를 이어갈 수 없다면 매번 새로운 맥락에서 시작해야 하는데, 이는 Claude의 강점인 맥락 유지를 포기하는 것과 같습니다.
해결: 하이브리드 아키텍처의 탄생
핵심 아이디어: --print + --resume + 파일 리다이렉트
세 가지 벽을 동시에 넘는 방법을 찾았습니다. Claude CLI의 --resume 플래그가 열쇠였습니다.
# 첫 번째 프롬프트 실행
claude -p --verbose --output-format stream-json \
--resume "session-abc123" \
< prompt1.txt > output1.jsonl 2>&1
# 두 번째 프롬프트 (같은 세션 ID로 이어가기)
claude -p --verbose --output-format stream-json \
--resume "session-abc123" \
< prompt2.txt > output2.jsonl 2>&1
이 조합이 해결하는 것들은 다음과 같습니다.
| 문제 | 해결 방법 |
|---|---|
| JSON 스트리밍 | --print + --output-format stream-json으로 깨끗한 JSON 스트림 |
| 대화 연속성 | --resume {sessionId}로 이전 대화 컨텍스트 유지 |
| 출력 캡처 | 파일 리다이렉트(>)로 터미널 이스케이프 코드 없는 순수 출력 |
| 프로세스 영속성 | tmux 세션 안에서 실행하여 서버와 독립적으로 유지 |
실제 검증: 작동하는 것과 작동하지 않는 것
아키텍처를 확정하기 전에 모든 조합을 실제로 테스트했습니다. 이 검증 단계가 없었다면 프로덕션에서 치명적인 문제를 만났을 것입니다.
# 작동하는 조합
claude -p --verbose --output-format stream-json --resume $SESSION < prompt.txt
# 작동하지 않는 조합 (stdin pipe)
cat prompt.txt | claude -p --verbose --output-format stream-json
주목할 점은 stdin은 파이프(|)가 아닌 리다이렉트(<)로만 작동한다는 것입니다. 이 차이는 미묘하지만 중요합니다. 파이프는 별도의 서브프로세스에서 데이터를 전달하는 반면, 리다이렉트는 셸이 직접 파일 디스크립터를 연결합니다. Claude CLI 내부에서 stdin의 연결 방식을 구분하는 것으로 보입니다.
또한 --verbose 플래그가 반드시 필요하다는 점도 발견했습니다. --print와 --output-format stream-json을 함께 쓸 때 --verbose가 없으면 에러가 발생합니다.
최종 아키텍처: 단일 서버 + tmux + 파일 기반 통신
서버 구조: Next.js Custom Server 단일화
초기 PRD에서는 Express 백엔드(포트 4100)와 Next.js 프론트엔드(포트 4101)를 분리하는 구조였습니다. 하지만 기존 프로젝트들의 검증된 패턴을 따라 Next.js Custom Server 단일 구조로 변경했습니다.
// server.js - 단일 서버에서 Next.js + Socket.io 동시 마운트
import { createServer } from 'http';
import { Server as SocketServer } from 'socket.io';
import next from 'next';
const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => handle(req, res));
// Socket.io를 같은 HTTP 서버에 마운트
const io = new SocketServer(server, {
path: '/socket.io',
cors: { origin: process.env.NEXT_PUBLIC_APP_URL }
});
// 채팅 핸들러 등록
io.on('connection', (socket) => {
// session:connect, message:send, stream:* 이벤트 처리
});
server.listen(4100);
});
이 구조의 장점은 명확합니다.
- Nginx 설정 단순화: 모든 요청을
localhost:4100하나로 프록시 - 프로세스 관리 일원화: PM2에서 하나의 프로세스만 관리
- CORS 문제 원천 차단: 프론트엔드와 백엔드가 같은 Origin
tmux-claude 모듈: 핵심 통신 레이어
PRD에서는 tmux 관리(tmux-manager.ts)와 Claude 통신(claude-bridge.ts)을 별도 모듈로 분리했지만, 실제로 이 둘은 밀접하게 결합되어 있어 하나의 모듈로 통합했습니다.
// src/lib/tmux-claude.ts - 핵심 모듈의 인터페이스
/** tmux 세션을 생성하고 Claude CLI 실행 준비 */
async function createSession(project: string): Promise<SessionInfo> {
// tmux new-session -d -s openclaude-{project}
// ~/.openclaude/outputs/{project}/ 디렉토리 생성
}
/** 프롬프트를 파일에 저장하고 tmux 세션에서 Claude CLI 실행 */
async function sendPrompt(project: string, prompt: string): Promise<string> {
// 1. 프롬프트를 임시 파일에 저장
// 2. tmux send-keys로 claude -p --resume ... < prompt.txt > output.jsonl 실행
// 3. output 파일 경로 반환
}
/** 출력 파일을 감시하여 새로운 JSON 라인을 스트리밍 */
async function* watchOutput(outputPath: string): AsyncGenerator<StreamEvent> {
// fs.watch + readline으로 JSONL 파일 실시간 감시
// 각 라인을 파싱하여 text, tool_use, tool_result, done 이벤트로 변환
}
/** 활성 tmux 세션 목록 조회 */
async function listSessions(): Promise<SessionInfo[]> {
// tmux list-sessions 파싱
}
/** tmux 세션 종료 */
async function killSession(project: string): Promise<void> {
// tmux kill-session -t openclaude-{project}
}
데이터 흐름: 프롬프트 전송부터 스트리밍까지
전체 데이터 흐름을 단계별로 정리하면 다음과 같습니다.
[브라우저] → Socket.io(message:send) → [서버]
↓
[서버] → 프롬프트를 파일에 저장 (prompt-{timestamp}.txt)
↓
[서버] → tmux send-keys로 Claude CLI 실행
│ claude -p --verbose --output-format stream-json
│ --resume {sessionId} < prompt.txt > output.jsonl
↓
[tmux 세션] → Claude CLI 실행 → output.jsonl에 스트리밍 기록
↓
[서버] → fs.watch로 output.jsonl 감시
↓
[서버] → 새 라인 감지 → JSON 파싱 → Socket.io(stream:text) 전송
↓
[브라우저] → 실시간 텍스트 렌더링
이 흐름에서 중요한 점은 서버가 중간에 재시작되어도 tmux 세션은 살아있다는 것입니다. 서버가 복구되면 기존 tmux 세션을 다시 발견하고, 출력 파일을 이어서 감시할 수 있습니다.
출력 파일 저장 구조
각 프로젝트별로 실행 기록을 체계적으로 관리합니다.
~/.openclaude/outputs/
├── projectA/
│ ├── run-1707500000.jsonl # 완료된 실행
│ ├── run-1707500100.jsonl # 현재 실행 중
│ └── session.json # 세션 메타정보 (sessionId, 상태)
├── projectB/
│ ├── run-1707499000.jsonl
│ └── session.json
session.json에는 --resume에 필요한 세션 ID와 현재 상태(idle, running, error)를 저장합니다. 서버가 재시작되어도 이 파일을 읽어 세션을 복구할 수 있습니다.
보안 설계: 무엇을 허용하고 무엇을 막을 것인가
웹에서 CLI를 원격 제어한다는 것은 보안에 매우 민감한 영역입니다. 두 가지 핵심 결정을 내렸습니다.
프로젝트 생성 기능 제외
PRD에는 웹에서 새 프로젝트를 생성하는 기능이 있었지만, v1에서 과감히 제외했습니다. 웹 인터페이스에서 서버의 임의 디렉토리에 폴더를 생성할 수 있다면, 경로 조작(Path Traversal) 공격의 위험이 존재합니다. 대신 ~/myProjects/ 하위의 기존 프로젝트만 조회하고 선택할 수 있도록 제한했습니다.
인증 체계
기존에 운영 중인 auth-service(포트 3010)의 JWT 인증을 그대로 활용합니다. Socket.io 연결 시에도 JWT 토큰을 검증하여, 인증되지 않은 사용자가 Claude 세션에 접근하는 것을 차단합니다.
// Socket.io 미들웨어에서 JWT 검증
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
const user = await verifyToken(token); // auth-service 호출
if (!user) return next(new Error('Unauthorized'));
socket.data.user = user;
next();
});
핵심 정리
설계 원칙 세 가지
- 검증 먼저, 설계 나중: CLI 옵션 조합을 실제로 테스트한 후에야 아키텍처를 확정했습니다. 문서만 보고 설계했다면 프로덕션에서 실패했을 것입니다.
- 기존 패턴 재사용: 새로운 기술 스택을 도입하는 대신, 이미 검증된 Next.js Custom Server 패턴을 그대로 활용했습니다.
- 단순함 추구: 2개의 서버를 1개로, 2개의 모듈을 1개로. 분리가 항상 좋은 것은 아닙니다.
기술적 발견
| 발견 | 의미 |
|---|---|
--print + --resume 조합이 작동 | 비대화형 모드에서도 대화 연속성 확보 가능 |
| stdin은 파이프가 아닌 리다이렉트만 작동 | CLI 도구 연동 시 stdin 전달 방식에 주의 필요 |
--verbose 플래그 필수 | stream-json 출력에 필요한 숨겨진 의존성 |
| tmux + 파일 기반 통신 | 프로세스 영속성과 깨끗한 출력을 동시에 달성 |
아직 남은 과제
이 아키텍처는 아직 설계 단계입니다. 실제 구현에서는 동시에 여러 프로젝트 세션을 운영할 때의 리소스 관리, 긴 실행 작업의 타임아웃 처리, 출력 파일의 정리 정책 등을 추가로 고려해야 합니다. 다음 글에서는 이 설계를 바탕으로 실제 구현 과정을 다룰 예정입니다.