Claude Code 공용 라이브러리 만들기 — Node.js + Python 래퍼 개발과 통합 검증
배경: 프로젝트마다 반복되는 CLI 호출 코드
Claude Code CLI를 여러 프로젝트에서 사용하다 보면, 거의 동일한 코드가 반복됩니다. child_process.spawn으로 프로세스를 실행하고, stdout을 수집하고, JSON을 파싱하고, 타임아웃을 걸고, rate limit을 감지하는 로직이 프로젝트마다 미묘하게 다른 형태로 존재하게 됩니다.
블로그 자동 생성 시스템, 노트 편집기, 주식 분석 등 여러 프로젝트가 Claude Code CLI를 사용하는 상황에서, 이 중복은 점점 부담이 됩니다. CLI 업데이트로 출력 형식이 바뀌면 각 프로젝트를 개별 수정해야 하고, 에러 처리 방식도 제각각이 됩니다.
이 문제를 해결하기 위해 claude-lib라는 공용 라이브러리를 만들었습니다. Node.js 코어 라이브러리와 Python 래퍼로 구성됩니다.
설계 결정: HTTP 게이트웨이 vs 로컬 라이브러리
처음에는 HTTP 게이트웨이 서비스를 구상했습니다. 중앙 서버가 Claude Code CLI 세션을 관리하고, 각 프로젝트는 HTTP 요청으로 호출하는 방식입니다.
초기 구상 (HTTP 게이트웨이)
[Blog] ──HTTP──▶ [Claude Gateway :5000] ──spawn──▶ [Claude CLI] [Note] ──HTTP──▶ [Claude Gateway :5000] [Stock] ──HTTP──▶ [Claude Gateway :5000]
하지만 검토해보니 단점이 더 많았습니다.
- 같은 서버 내 통신인데 HTTP 레이어를 추가하는 것은 불필요한 오버헤드
- 게이트웨이 장애 시 모든 프로젝트가 영향을 받는 단일 장애점
- 관리할 PM2 프로세스가 하나 더 늘어남
- 대형 프롬프트 전달 시 HTTP 바디 크기 제한 문제
결론적으로, 로컬 라이브러리 방식을 선택했습니다.
최종 구조 (로컬 라이브러리)
[Blog] ──import──▶ [claude-lib] ──spawn──▶ [Claude CLI] [Note] ──import──▶ [claude-lib] [Stock] ──import──▶ [claude-lib-python] ──subprocess──▶ [Claude CLI]
라이브러리 구조: 6개 모듈로 분리
claude-lib의 모듈 구성은 다음과 같습니다.
claude-lib/src/ ├── index.ts # 진입점 (re-export) ├── types.ts # 타입 정의 ├── executor.ts # CLI 실행 엔진 (핵심) ├── session.ts # 세션 관리 ├── stream-parser.ts # 스트림 파싱 ├── logger.ts # 로깅 및 통계 └── config.ts # 설정 관리
외부 의존성은 제로입니다. TypeScript와 @types/node만 devDependencies로 사용하고, 런타임 의존성이 없습니다.
핵심 구현: 3가지 실행 모드
1. 일괄 응답 모드
가장 기본적인 모드입니다. CLI를 실행하고, stdout 전체를 수집한 뒤 JSON으로 파싱합니다.
typescript import { executeOneShot } from 'claude-lib';
const result = await executeOneShot({ prompt: '1+1은?', caller: 'blog', }); console.log(result.result); // "2"
내부적으로는 spawn으로 프로세스를 실행하고, --output-format json 플래그를 전달합니다.
2. 스트리밍 모드
긴 응답을 점진적으로 받아야 할 때 사용합니다. AsyncIterable을 반환합니다.
typescript const stream = await executeOneShot({ prompt: '긴 설명을 해줘', stream: true, caller: 'blog', });
for await (const event of stream) { if (event.type === 'text') { process.stdout.write(event.content); } }
내부적으로 readline 모듈을 사용해 stdout을 줄 단위로 읽고, 이벤트 큐 기반으로 비동기 이터레이터를 구현합니다.
3. 세션 모드
멀티턴 대화가 필요할 때 사용합니다. 첫 요청에서 sessionId를 추출하고, 이후 요청에 --resume 플래그를 전달합니다.
typescript import { createSession } from 'claude-lib';
const session = await createSession({ prompt: '너의 이름을 테스트봇이라고 기억해줘.', caller: 'blog', });
const reply = await session.send({ prompt: '방금 기억한 이름이 뭐야?', }); console.log(reply.result); // "테스트봇"
await session.end();
스트림 파서: CLI 출력을 표준 이벤트로
Claude Code CLI의 stream-json 출력 형식은 줄 단위 JSON입니다. 하지만 이벤트 타입이 다양하므로 이를 5가지 표준 이벤트로 매핑합니다.
typescript // CLI 출력 타입 → 라이브러리 이벤트 타입 // 'assistant' → 'text' (전체 텍스트) // 'content_block_delta' → 'text' (스트리밍 델타) // 'tool_use' → 'tool_use' // 'tool_result' → 'tool_result' // 'result' → 'done' (세션ID 포함) // 'error' → 'error'
특히 assistant 타입은 message.content 배열에서 type: 'text'인 블록만 추출해서 합칩니다. content_block_delta는 delta.type === 'text_delta'인 경우만 처리합니다.
typescript case 'content_block_delta': if (data.delta && typeof data.delta === 'object') { const delta = data.delta as Record<string, unknown>; if (delta.type === 'text_delta' && typeof delta.text === 'string') { return { type: 'text', content: delta.text }; } } return null;
Python 래퍼: 동일 인터페이스, 동일 설정
Python 래퍼의 핵심 설계 원칙은 Node.js 버전과 완벽한 호환성입니다.
공유하는 것들
| 항목 | 경로/형식 |
|---|---|
| 설정 파일 | ~/.claude-lib/config.json |
| 로그 디렉토리 | ~/.claude-lib/logs/ |
| 로그 포맷 | camelCase JSON Lines |
| CLI 인자 구성 | 동일한 플래그 순서 |
| 이벤트 타입 | 동일한 매핑 로직 |
| 에러 분류 | timeout, rate_limit, cli_error, process_error |
Python에서의 사용법은 거의 동일합니다.
python from claude_lib import execute_one_shot, create_session
일괄 응답
result = execute_one_shot( prompt='1+1은?', caller='stock' ) print(result['result']) # "2"
스트리밍 (async)
import asyncio
async def main(): stream = execute_one_shot( prompt='설명해줘', stream=True, caller='stock' ) async for event in stream: if event['type'] == 'text': print(event['content'], end='')
asyncio.run(main())
Node.js와 Python의 구현 차이
| 기능 | Node.js | Python |
|---|---|---|
| 프로세스 실행 | child_process.spawn | subprocess.run (batch) / asyncio.create_subprocess_exec (stream) |
| 스트림 읽기 | readline + 이벤트 큐 | asyncio.subprocess + readline() |
| 일괄 응답 | async (Promise) | 동기 (subprocess.run) |
| 타임아웃 | setTimeout + SIGTERM/SIGKILL | time.monotonic() 데드라인 체크 |
| 설정 키 | camelCase | snake_case (로드 시 자동 변환) |
가장 큰 차이는 일괄 응답 모드입니다. Node.js는 async 함수지만, Python은 동기 함수로 구현했습니다. subprocess.run이 블로킹 호출이므로 async로 감쌀 이유가 없었습니다.
팀 에이전트 병렬 개발
Node.js 코어와 Python 래퍼는 Claude Code의 팀 에이전트 기능으로 병렬 개발했습니다.
[Team Lead] ── 설계 문서 작성, 태스크 분배 ├── [Worker 1] Node.js 프로젝트 초기화 + 핵심 모듈 개발 ├── [Worker 2] Python 래퍼 프로젝트 개발 └── [Worker 3] 통합 검증 및 검수
Worker 1이 Node.js 코어를 만드는 동안 Worker 2가 Python 래퍼를 개발하고, 두 Worker 작업이 끝난 후 Worker 3이 통합 검증을 수행하는 구조입니다.
통합 검증에서 발견한 스트리밍 파싱 버그
통합 검증 단계에서 중요한 버그를 발견했습니다. Node.js 스트리밍 테스트에서 stream-json 출력에 --verbose 플래그가 누락되면 이벤트가 제대로 파싱되지 않는 문제였습니다.
Claude Code CLI는 --output-format stream-json 사용 시 반드시 --verbose 플래그를 함께 전달해야 상세 이벤트(content_block_delta, tool_use 등)를 출력합니다. 이것 없이는 최종 result 이벤트만 나옵니다.
typescript // executor.ts - stream-json 시 --verbose 필수 const args: string[] = [ '-p', options.prompt, '--output-format', options.stream ? 'stream-json' : 'json', ];
if (options.stream) { args.push('--verbose'); }
개별 프로젝트에서 CLI를 직접 호출할 때는 시행착오로 이 사실을 알고 있었지만, 라이브러리 코드에는 초기에 누락되었습니다. 통합 테스트에서 스트리밍 응답이 text 이벤트 없이 done 이벤트만 반환되는 것을 확인하고, 원인을 추적하여 --verbose 플래그를 추가했습니다.
이 버그가 개별 프로젝트 레벨이 아닌 공용 라이브러리에서 수정되었기 때문에, 향후 모든 프로젝트가 동일한 수정 혜택을 받게 됩니다.
에러 처리 전략: 5가지 유형 분류
claude-lib는 모든 에러를 ClaudeError 클래스로 래핑하고, errorType으로 원인을 구분합니다.
typescript type ClaudeErrorType = | 'timeout' // 타임아웃 초과 → SIGTERM → 3초 대기 → SIGKILL | 'rate_limit' // stderr에서 패턴 매칭 (429, overloaded 등) | 'cli_error' // exit code !== 0 | 'process_error' // spawn 자체 실패 | 'parse_error'; // JSON 파싱 실패
특히 프로세스 종료는 2단계입니다. 먼저 SIGTERM을 보내고, 3초 후에도 살아있으면 SIGKILL을 보냅니다.
typescript function killProcess(proc): Promise<void> { return new Promise((resolve) => { proc.kill('SIGTERM'); const killTimer = setTimeout(() => { if (proc.exitCode === null && !proc.killed) { proc.kill('SIGKILL'); } resolve(); }, 3000); proc.on('exit', () => { clearTimeout(killTimer); resolve(); }); }); }
로깅: Node.js와 Python이 같은 로그 파일 사용
두 라이브러리 모두 ~/.claude-lib/logs/ 디렉토리에 일별 JSON Lines 파일로 로그를 기록합니다.
{"timestamp":"2026-02-09T15:30:00+09:00","caller":"blog","type":"one-shot","prompt":"블로그 포스트를 작성해...","stream":false,"status":"success","durationMs":12345}
Python에서 기록한 로그도 camelCase 키를 사용합니다. 이렇게 하면 getUsage() 통계 조회 시 Node.js든 Python이든 구분 없이 통합 집계가 가능합니다.
핵심 정리
-
HTTP 게이트웨이보다 로컬 라이브러리가 나은 경우가 있습니다. 같은 서버 내 프로젝트 간 공유라면, 네트워크 오버헤드 없는 라이브러리 import가 더 단순합니다.
-
Node.js와 Python 래퍼는 설정/로그를 공유하도록 설계하세요. 동일한
~/.claude-lib/config.json, 동일한 로그 포맷을 사용하면 언어가 달라도 통합 관리가 가능합니다. -
통합 검증은 반드시 수행하세요. 개별 모듈이 정상 동작해도 통합 시점에 드러나는 버그가 있습니다. 스트리밍 파싱에서
--verbose플래그 누락은 단위 테스트로는 잡기 어려운 종류의 버그입니다. -
CLI 래퍼 라이브러리의 핵심은 에러 처리입니다. 타임아웃, rate limit, 비정상 종료 등 다양한 실패 시나리오를 분류하고 적절히 처리하는 것이 라이브러리의 가치입니다.
-
팀 에이전트 병렬 개발은 언어가 다른 모듈 개발에 효과적입니다. Node.js 코어와 Python 래퍼를 동시에 작업하고, 마지막에 통합 검증 에이전트가 전체를 점검하는 워크플로우가 잘 동작했습니다.