Claude Code CLI2026년 4월 30일7분 읽기

LLM을 필수 의존성에서 빼는 설계 — Claude Code CLI subprocess + baseline fallback

사이드 프로젝트에서 LLM API 비용을 피하면서도 LLM 품질을 활용하고 싶었습니다. Claude Code CLI를 subprocess로 띄우고, 항상 동작하는 baseline 템플릿을 fallback으로 깔아둔 설계 이야기입니다.

#Claude Code CLI#subprocess#Fallback 설계#LLM#Cost

LLM을 필수 의존성에서 빼는 설계 — Claude Code CLI subprocess + baseline fallback


1. 출발점 — LLM은 쓰고 싶은데 비용은 피하고 싶다

GitHub PR을 모아 Notion에 온보딩용 내러티브로 발행하는 사이드 프로젝트를 만들고 있었습니다. 핵심 가치는 명확했습니다. "신규 개발자가 모듈별 변경 히스토리를 사람이 쓴 글처럼 자연스럽게 읽을 수 있어야 한다."

이걸 결정론적 템플릿만으로 만들 수 있을까요? 가능은 합니다. PR 제목, 작성자, 본문 일부를 잘 짜낸 Markdown으로 묶으면 정보 전달은 됩니다. 그런데 읽히는 글은 안 나옵니다. 30개 PR이 나열된 문서는 결국 변경로그(changelog)지 내러티브가 아닙니다.

LLM이 답인 건 분명한데, 문제는 비용입니다.

  • Anthropic API: 회사 Claude Pro/Team 구독과 별개 과금 — 사이드 프로젝트에서 떠안기 부담
  • OpenAI API: 마찬가지
  • 로컬 모델: 품질 차이가 너무 크고, 인프라 부담은 또 별개

그러던 중 한 가지가 눈에 들어왔습니다. 노트북에 깔려있는 Claude Code CLI. 이미 구독 안에서 동작합니다. 추가 과금이 없습니다. 그렇다면 이걸 그대로 subprocess로 띄워서 쓰면 어떨까요?


2. 두 가지 우려

이 아이디어를 쓰려면 두 가지가 해결돼야 했습니다.

1. CLI는 깨질 수 있다. 사용자가 로그아웃 상태일 수도 있고, 인증이 만료됐을 수도 있고, 타임아웃이 날 수도 있고, 프로세스가 짧게 죽을 수도 있습니다. CLI 호출이 실패하면 시스템 전체가 멈추면 안 됩니다.

2. 환경 이전이 가능해야 한다. 지금은 노트북에서 돌리지만, 나중에 컨테이너나 CI로 옮길 가능성이 있습니다. 그때 CLI 의존성이 필수면 환경 옮길 때마다 코드를 다시 짜야 합니다.

두 우려 다 한 가지 원칙으로 답할 수 있었습니다. CLI는 절대 필수 의존성이 아니다. baseline 템플릿이 항상 fallback이다.


3. 설계 — baseline이 1급 시민, CLI는 업그레이드

구조는 이렇게 잡았습니다.

text
PR 데이터
   │
   ▼
NarrativeService.generate()
   │
   ├─▶ baseline 템플릿 렌더링 (항상 실행, 결정론적)
   │       │
   │       ▼
   │    Markdown baseline
   │       │
   ├─▶ LLM_PROVIDER === "claude-cli"?
   │       │
   │       ├─ No  ─▶ baseline 그대로 반환
   │       │
   │       └─ Yes ─▶ ClaudeCliService.upgrade(baseline)
   │                       │
   │                       ├─ 성공 ─▶ 업그레이드된 Markdown
   │                       │
   │                       └─ 실패 ─▶ baseline 반환 (조용히 fallback)
   │
   ▼
Notion 발행

핵심 포인트:

  • baseline은 항상 만들어집니다. CLI가 있든 없든, 잘 돌든 망가지든 상관없이 결정론적 Markdown이 먼저 만들어집니다.
  • CLI는 baseline을 받아서 더 좋게 만드는 역할만 합니다. "처음부터 만들어줘"가 아니라 "이걸 자연스럽게 다듬어줘"입니다. 입력에 이미 모든 정보가 들어있으니 환각 위험도 줄어듭니다.
  • CLI가 실패해도 baseline이 그대로 나갑니다. Notion 발행이 멈추지 않습니다.

4. baseline 템플릿 — 결정론적 핵심

baseline은 단순합니다. 모듈별로 PR 리스트를 시간 역순으로 묶고, 월 단위 섹션을 만듭니다.

ts
// narrative.service.ts (요약)
buildBaseline(moduleTag: string, prs: PullRequest[]): string {
  const byMonth = groupBy(prs, pr => format(pr.mergedAt, "yyyy-MM"))
  let md = `# ${moduleTag} 변경 히스토리\n\n`
  for (const [month, items] of Object.entries(byMonth)) {
    md += `## ${month}\n\n`
    for (const pr of items) {
      md += `### ${pr.title}\n`
      md += `- 작성자: ${pr.author}\n`
      md += `- 머지: ${format(pr.mergedAt, "yyyy-MM-dd")}\n`
      md += `- 링크: ${pr.htmlUrl}\n`
      if (pr.body) md += `\n${pr.body.slice(0, 500)}\n`
      md += `\n`
    }
  }
  return md
}

이게 "정보 전달은 충분한" 최저선입니다. 내러티브가 안 되고 단순 변경로그지만, 시스템이 멈추지 않는다는 게 더 중요합니다.


5. CLI subprocess — spawn('claude', ['-p'])

CLI 호출은 child_process.spawn 한 줄입니다.

ts
// llm-cli.service.ts
import { spawn } from "node:child_process"
 
@Injectable()
export class LlmCliService {
  async upgrade(baseline: string): Promise<string | null> {
    const cliPath = this.config.claudeCliPath ?? "claude"
    const timeoutMs = this.config.claudeCliTimeoutMs ?? 60_000
 
    return new Promise((resolve) => {
      const proc = spawn(cliPath, ["-p"], {
        stdio: ["pipe", "pipe", "pipe"],
      })
 
      let stdout = ""
      let stderr = ""
      const timer = setTimeout(() => {
        proc.kill("SIGKILL")
        this.logger.warn(`CLI timeout after ${timeoutMs}ms`)
        resolve(null)
      }, timeoutMs)
 
      proc.stdout.on("data", chunk => { stdout += chunk })
      proc.stderr.on("data", chunk => { stderr += chunk })
 
      proc.on("error", err => {
        clearTimeout(timer)
        this.logger.warn(`CLI spawn 실패: ${err.message}`)
        resolve(null)
      })
 
      proc.on("exit", (code) => {
        clearTimeout(timer)
        if (code !== 0) {
          this.logger.warn(`CLI exit code=${code} stderr=${stderr.slice(0, 200)}`)
          resolve(null)
          return
        }
        const trimmed = stdout.trim()
        if (trimmed.length < baseline.length / 4) {
          this.logger.warn(`CLI 출력이 너무 짧음 (${trimmed.length}자), fallback`)
          resolve(null)
          return
        }
        resolve(trimmed)
      })
 
      const prompt = this.buildPrompt(baseline)
      proc.stdin.write(prompt)
      proc.stdin.end()
    })
  }
}

여기에 fallback이 작동하는 지점이 다섯 군데 있습니다.

실패 시나리오감지 방법결과
CLI 바이너리 없음proc.on("error") ENOENTnull 반환 → baseline
인증 만료exit code !== 0null 반환 → baseline
타임아웃setTimeout + SIGKILLnull 반환 → baseline
출력이 비정상적으로 짧음길이 검사 (< baseline/4)null 반환 → baseline
stderr에 에러exit code 검사로 자연 포함null 반환 → baseline

호출자(NarrativeService)는 이 모든 실패를 한 줄로 처리합니다.

ts
const upgraded = await this.llmCli.upgrade(baseline)
return upgraded ?? baseline  // ← fallback이 1급 시민

6. 환경변수로 토글

CLI를 켜고 끄는 건 환경변수 하나입니다.

bash
# .env
LLM_PROVIDER=claude-cli           # 또는 'none'
CLAUDE_CLI_PATH=/usr/local/bin/claude
CLAUDE_CLI_TIMEOUT_MS=60000
ts
// narrative.service.ts
async generate(moduleTag: string, prs: PullRequest[]) {
  const baseline = this.buildBaseline(moduleTag, prs)
  if (this.config.llmProvider !== "claude-cli") return baseline
  const upgraded = await this.llmCli.upgrade(baseline)
  return upgraded ?? baseline
}

이 한 줄 덕분에:

  • 로컬에선 LLM_PROVIDER=claude-cli — 자연스러운 내러티브로 발행
  • CI/컨테이너에선 LLM_PROVIDER=none — baseline만으로도 동작
  • 실험 중엔 LLM_PROVIDER=claude-cli + 타임아웃 짧게 — 빨리 fallback 경로 검증

코드 변경 없이 환경 따라 동작이 바뀝니다. 컨테이너로 이사 갈 때 코드를 다시 짤 필요가 없습니다.


7. "fallback이 1급 시민이어야 한다"

이번 설계에서 가장 강하게 느낀 게 이겁니다. 외부 의존성을 다룰 때 흔한 패턴은 이런 거죠.

ts
// 흔한 패턴 — 실패하면 에러 throw
const result = await externalApi.call()  // 실패 시 throw
return processResult(result)

이러면 외부가 깨질 때마다 호출자도 같이 깨집니다. retry·circuit breaker로 어느 정도는 막을 수 있지만, 본질적으론 외부가 살아있어야 내가 동작한다는 결합입니다.

대신 fallback이 1급 시민인 패턴은 이렇습니다.

ts
// fallback 우선 패턴 — 실패해도 의미 있는 결과
const baseline = computeBaseline()           // 항상 동작
const upgraded = await tryUpgrade(baseline)  // best-effort
return upgraded ?? baseline                  // 실패 = 다운그레이드

이 두 줄 사이에 큰 철학적 차이가 있습니다. "외부가 깨지면 나도 깨진다"가 아니라 "외부가 깨지면 나는 다운그레이드된 모드로 계속 동작한다." 시스템 전체의 가용성이 외부 의존성 가용성의 하한이 아니라 상한이 되도록 설계합니다.

특히 LLM처럼 본질적으로 비결정론적이고 가끔 망가지는 의존성에는 이 패턴이 거의 필수에 가깝습니다.


8. 비용·품질 트레이드오프

이렇게 짜면 품질이 들쑥날쑥하지 않냐고 물을 수 있습니다. 같은 PR 데이터로 어떤 날은 baseline, 어떤 날은 LLM 업그레이드 — 일관성이 깨지지 않냐고요.

이번 시스템에선 받아들이는 트레이드오프였습니다.

우선순위결정
1. 시스템이 멈추지 않을 것baseline 항상 발행
2. 비용이 0에 가까울 것API 결제 없음
3. 평소엔 자연스러운 내러티브CLI 업그레이드
4. 일관된 품질(포기)

읽는 사람 입장에선 baseline도 "정보가 누락되거나 잘못되진 않은" 글입니다. LLM 업그레이드가 더 잘 읽힐 뿐입니다. 4번을 포기한 대신 1·2번을 챙겼습니다. 이 트레이드오프는 시스템 성격에 따라 바뀌어야 하는데, 사이드 프로젝트의 온보딩 도구라는 맥락에선 합리적이었습니다.


9. 마치며

이번 설계에서 얻은 룰을 정리합니다.

  1. 외부 의존성은 깨질 수 있다는 가정으로 시작하라. 깨질 일 없는 의존성은 거의 없고, 깨졌을 때 고민하는 비용은 미리 fallback을 만드는 비용보다 훨씬 큽니다.
  2. fallback은 1급 시민이어야 한다. "할 수 없을 때 어떻게 할 것인가"의 답이 시스템 핵심 경로 안에 있어야 합니다. 별도 모듈에 분리되거나 try/catch 안에 숨어 있으면 안 됩니다.
  3. 외부에 "처음부터 만들어줘" 대신 "이걸 다듬어줘"를 시켜라. 입력에 이미 모든 정보가 있으면 결과의 정확성이 보장되고, 실패해도 입력으로 폴백할 수 있습니다.
  4. subprocess는 가벼운 인터페이스다. API 라이브러리 의존, 인증 토큰 관리, SDK 버전 호환성 — 다 안 챙겨도 됩니다. CLI 하나만 있으면 끝입니다. 가용성이 떨어지는 단점은 fallback으로 메우면 됩니다.
  5. 환경변수 하나로 끄고 켤 수 있게 만들어라. 코드 변경 없이 환경 따라 동작이 바뀌면 이사 비용이 0이 됩니다.

비용을 안 들이고 LLM 품질의 80%를 받았습니다. 그리고 LLM이 사라져도 시스템은 멈추지 않습니다. 이런 종류의 의존성 관리가 사이드 프로젝트에서 가장 재미있는 설계 영역인 것 같습니다.

#Claude Code CLI#subprocess#Fallback 설계#LLM#Cost

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js