2026.02.10AI 개발 패턴
Next.jsClaude Code아키텍처 설계tmuxCLI 자동화WebSocket

OpenClaude 아키텍처 설계기: 웹에서 Claude Code CLI를 제어하는 방법

배경: 왜 웹에서 Claude Code를 돌리고 싶었나

Claude Code CLI는 강력한 AI 코딩 어시스턴트입니다. 터미널에서 직접 실행하면 파일을 읽고, 코드를 수정하고, 명령어를 실행하는 등 거의 모든 개발 작업을 자동화할 수 있습니다. 하지만 한 가지 불편함이 있습니다. 반드시 터미널 앞에 앉아 있어야 한다는 점입니다.

출퇴근 중 모바일에서 간단한 지시를 내리고 싶을 때, 외출 중에 진행 상황을 확인하고 싶을 때, CLI 전용이라는 제약이 발목을 잡습니다. OpenClaude는 이 문제를 해결하기 위해 시작된 프로젝트입니다. 웹 브라우저에서 Claude Code CLI를 원격으로 제어하고, 실시간으로 응답을 스트리밍하는 시스템을 만드는 것이 목표입니다.

이 글에서는 초기 설계(PRD)부터 실제 인프라 검증을 거쳐 최종 아키텍처를 확정하기까지의 과정을 공유합니다.

문제 정의: CLI를 웹으로 옮기려면 무엇이 필요한가

Claude Code CLI를 웹에서 제어하려면 크게 세 가지 문제를 풀어야 합니다.

  1. 프로세스 영속성: 브라우저를 닫거나 서버를 재시작해도 진행 중인 Claude 작업이 중단되면 안 됩니다.
  2. 실시간 스트리밍: Claude의 응답이 한꺼번에 오는 것이 아니라, 토큰 단위로 실시간 전달되어야 합니다.
  3. 대화 연속성: 여러 번의 프롬프트가 하나의 대화 맥락을 유지해야 합니다.

이 세 가지를 동시에 만족시키는 것이 생각보다 까다로웠습니다.

초기 설계와 그 한계

PRD 원안: 대화형 CLI를 tmux로 감싸기

처음 구상한 방식은 직관적이었습니다. tmux 세션 안에서 대화형 Claude CLI를 실행하고, tmux send-keys로 사용자 입력을 전달한 뒤, tmux capture-pane으로 출력을 캡처하는 것이었습니다.

bash
# 초기 구상 (실제로는 작동하지 않음)
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 플래그가 열쇠였습니다.

bash
# 첫 번째 프롬프트 실행
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 세션 안에서 실행하여 서버와 독립적으로 유지

웹 아키텍처 설계 다이어그램

실제 검증: 작동하는 것과 작동하지 않는 것

아키텍처를 확정하기 전에 모든 조합을 실제로 테스트했습니다. 이 검증 단계가 없었다면 프로덕션에서 치명적인 문제를 만났을 것입니다.

bash
# 작동하는 조합
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 단일 구조로 변경했습니다.

typescript
// 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)을 별도 모듈로 분리했지만, 실제로 이 둘은 밀접하게 결합되어 있어 하나의 모듈로 통합했습니다.

typescript
// 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}
}

데이터 흐름: 프롬프트 전송부터 스트리밍까지

전체 데이터 흐름을 단계별로 정리하면 다음과 같습니다.

code
[브라우저] → 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 세션을 다시 발견하고, 출력 파일을 이어서 감시할 수 있습니다.

출력 파일 저장 구조

각 프로젝트별로 실행 기록을 체계적으로 관리합니다.

code
~/.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 세션에 접근하는 것을 차단합니다.

typescript
// 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();
});

핵심 정리

설계 원칙 세 가지

  1. 검증 먼저, 설계 나중: CLI 옵션 조합을 실제로 테스트한 후에야 아키텍처를 확정했습니다. 문서만 보고 설계했다면 프로덕션에서 실패했을 것입니다.
  2. 기존 패턴 재사용: 새로운 기술 스택을 도입하는 대신, 이미 검증된 Next.js Custom Server 패턴을 그대로 활용했습니다.
  3. 단순함 추구: 2개의 서버를 1개로, 2개의 모듈을 1개로. 분리가 항상 좋은 것은 아닙니다.

기술적 발견

발견의미
--print + --resume 조합이 작동비대화형 모드에서도 대화 연속성 확보 가능
stdin은 파이프가 아닌 리다이렉트만 작동CLI 도구 연동 시 stdin 전달 방식에 주의 필요
--verbose 플래그 필수stream-json 출력에 필요한 숨겨진 의존성
tmux + 파일 기반 통신프로세스 영속성과 깨끗한 출력을 동시에 달성

아직 남은 과제

이 아키텍처는 아직 설계 단계입니다. 실제 구현에서는 동시에 여러 프로젝트 세션을 운영할 때의 리소스 관리, 긴 실행 작업의 타임아웃 처리, 출력 파일의 정리 정책 등을 추가로 고려해야 합니다. 다음 글에서는 이 설계를 바탕으로 실제 구현 과정을 다룰 예정입니다.