실전 품질: 테스트, 배포, 유지보수




시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 본편의 마지막입니다. 지금까지 만든 greet-cligitx남도 쓸 수 있는 도구 로 완성합니다. 테스트 작성·설정 파일·npm 배포·CI/CD·README까지 다룹니다.


이전 편 복습

4편에서 gitx를 완성하며 Type A Wrapper의 핵심 — child_process.spawn, shell injection 방어, 출력 캡처 vs 스트리밍, 대화형 프롬프트 — 을 모두 익혔습니다. 3편에서는 Type B 대표인 greet-cli를 완성했고요.

이제 두 도구가 “내 컴퓨터에서 동작”하는 상태에서 “누구든 npm install -g 한 줄로 쓸 수 있는” 상태로 끌어올립니다. 이 거리가 보기보다 꽤 멉니다.


들어가며

개인 프로젝트로 만든 CLI를 실제 배포하는 데는 몇 가지 허들이 있습니다.

  • 테스트 없이 배포하면 사용자가 첫 번째 버그 발견자가 됩니다 (가장 피하고 싶은 상황)
  • 하드코딩된 값들 (예: gitx의 보호 브랜치 목록)은 다른 사람의 환경과 맞지 않습니다
  • 수동 배포는 실수가 나옵니다 — 빌드 없이 publish, 버전 업데이트 누락 등
  • README가 허술하면 잠재 사용자가 10초 안에 떠납니다

이번 편에서는 이 모든 걸 다룹니다. 마지막 편답게 코드 분량은 적지만, 실무에서 반드시 필요한 작업들이 압축되어 있어요.


1. 테스트 전략 — 무엇을 테스트하고 무엇을 빼는가

CLI 도구의 테스트는 웹 앱과 달라야 합니다. 네트워크 호출·파일 시스템·외부 프로세스 때문에 전수 E2E 테스트를 만들면 느리고 불안정해요. 대신 이 원칙을 따릅니다.

대상테스트 방법
순수 함수 (인사 메시지 생성, 옵션 검증 등)단위 테스트 — 빠르고 안정적
외부 API 호출fetch 모킹으로 성공/실패 케이스
외부 프로세스 호출spawn 모킹으로 exit code와 stdio 분기
UI/출력 포맷팅최소한만 — 회귀 방지용 스냅샷 정도
실제 API·git 저장소별도 “통합 테스트”로 분리, CI에서만 조건부 실행

핵심은 “테스트 용이하게 코드를 구조화” 하는 것부터입니다. 지금까지의 코드는 리팩터가 조금 필요해요.

테스트 가능한 구조로 리팩터

greet-clihello 명령이 좋은 예입니다. 3편에서는 이렇게 생겼었죠.

// ❌ 테스트하기 어려움 — 로직과 IO가 섞여있음
.action((opts) => {
  const greetings = { en: n => `Hello, ${n}!`, ... };
  const fn = greetings[opts.lang];
  if (!fn) { console.error(...); process.exit(1); }
  console.log(fn(opts.name));
});

이걸 순수 함수로 분리합니다. src/lib/greeting.ts 를 새로 만들고:

export type Language = 'en' | 'ko' | 'ja';

export function isValidLanguage(lang: string): lang is Language {
  return ['en', 'ko', 'ja'].includes(lang);
}

export function buildGreeting(name: string, lang: Language): string {
  switch (lang) {
    case 'en': return `Hello, ${name}!`;
    case 'ko': return `안녕하세요, ${name}님!`;
    case 'ja': return `こんにちは、${name}さん!`;
  }
}

그리고 src/index.ts의 action은 이 함수를 호출하는 얇은 껍데기가 됩니다.

.action((opts: { name: string; lang: string }) => {
  if (!isValidLanguage(opts.lang)) {
    console.error(`Unsupported language: ${opts.lang}`);
    process.exit(1);
  }
  console.log(buildGreeting(opts.name, opts.lang));
});

이 구조의 이점은 명확합니다.

  • buildGreeting 은 입력 → 출력의 순수 함수 — 네트워크·파일·process도 안 건드림
  • 테스트할 때 console.logprocess.exit의 부작용 걱정 없음
  • 로직 변경 시 회귀를 빠르게 감지

Type A/B 모든 Wrapper에 적용되는 원칙: “순수 로직”과 “IO”를 분리하라. 이게 테스트 전략의 전부나 마찬가지입니다.


2. vitest로 단위 테스트 작성

테스트 도구로는 vitest를 쓰겠습니다. jest보다 빠르고, TypeScript/ESM을 네이티브로 지원해서 설정이 간단합니다.

npm install -D vitest

vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    globals: false,
  },
});

package.jsonscripts에 추가:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

순수 함수 테스트

src/__tests__/greeting.test.ts:

import { describe, it, expect } from 'vitest';
import { buildGreeting, isValidLanguage } from '../lib/greeting.js';

describe('buildGreeting', () => {
  it('영어 인사를 생성한다', () => {
    expect(buildGreeting('world', 'en')).toBe('Hello, world!');
  });

  it('한국어 인사를 생성한다', () => {
    expect(buildGreeting('철수', 'ko')).toBe('안녕하세요, 철수님!');
  });

  it('일본어 인사를 생성한다', () => {
    expect(buildGreeting('Sakura', 'ja')).toBe('こんにちは、Sakuraさん!');
  });
});

describe('isValidLanguage', () => {
  it('지원 언어는 true', () => {
    expect(isValidLanguage('en')).toBe(true);
    expect(isValidLanguage('ko')).toBe(true);
    expect(isValidLanguage('ja')).toBe(true);
  });

  it('미지원 언어는 false', () => {
    expect(isValidLanguage('fr')).toBe(false);
    expect(isValidLanguage('')).toBe(false);
  });
});

실행해보면:

$ npm test
 ✓ buildGreeting > 영어 인사를 생성한다
 ✓ buildGreeting > 한국어 인사를 생성한다
 ✓ buildGreeting > 일본어 인사를 생성한다
 ✓ isValidLanguage > 지원 언어는 true
 ✓ isValidLanguage > 미지원 언어는 false

 Test Files  1 passed (1)
      Tests  5 passed (5)

1초도 안 걸리는 이 테스트들이 회귀 감지의 최전선입니다.

fetch 모킹 — 외부 API 호출 테스트

weatherquote 같은 API 호출을 테스트하려면 fetch를 모킹해야 합니다. vitest의 vi.stubGlobal 을 쓰면 깔끔합니다.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchQuote } from '../commands/quote.js';  // API 호출 부분을 함수로 분리했다고 가정

describe('fetchQuote', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('성공 응답을 파싱한다', async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true,
      status: 200,
      json: async () => ({ content: '테스트', author: 'A', tags: [] }),
    } as unknown as Response);

    const result = await fetchQuote();
    expect(result.content).toBe('테스트');
    expect(fetch).toHaveBeenCalledWith('https://api.quotable.io/random');
  });

  it('카테고리 파라미터를 URL에 포함한다', async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true,
      status: 200,
      json: async () => ({ content: 'x', author: 'y' }),
    } as unknown as Response);

    await fetchQuote('technology');
    expect(fetch).toHaveBeenCalledWith(
      'https://api.quotable.io/random?tags=technology',
    );
  });

  it('비정상 HTTP 응답은 에러를 던진다', async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: false, status: 503,
    } as unknown as Response);
    await expect(fetchQuote()).rejects.toThrow('API returned 503');
  });

  it('네트워크 오류는 전파한다', async () => {
    vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
    await expect(fetchQuote()).rejects.toThrow('ECONNREFUSED');
  });
});

이 테스트들이 커버하는 것: 정상 응답, 쿼리 파라미터 조립, HTTP 에러, 네트워크 에러. 3편에서 다룬 “네트워크 에러 분류”가 제대로 동작하는지 검증합니다.

child_process 모킹 — 외부 명령 호출 테스트

gitxrunGit을 테스트하려면 spawn을 모킹해야 하는데, spawn은 EventEmitter를 반환하는 스트림 기반 API라 조금 트릭이 필요합니다.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'node:events';

vi.mock('node:child_process', () => ({
  spawn: vi.fn(),
}));

import { spawn } from 'node:child_process';
import { runGit } from '../git.js';

// 가짜 자식 프로세스 객체
class FakeChildProcess extends EventEmitter {
  stdout = new EventEmitter();
  stderr = new EventEmitter();
}

function createFakeSpawn(opts: { stdout?: string; stderr?: string; exitCode?: number }) {
  return () => {
    const fake = new FakeChildProcess();
    // 비동기로 이벤트 발생 (실제 spawn과 같은 타이밍)
    setImmediate(() => {
      if (opts.stdout) fake.stdout.emit('data', Buffer.from(opts.stdout));
      if (opts.stderr) fake.stderr.emit('data', Buffer.from(opts.stderr));
      fake.emit('close', opts.exitCode ?? 0);
    });
    return fake as unknown as ReturnType<typeof spawn>;
  };
}

describe('runGit', () => {
  beforeEach(() => vi.clearAllMocks());

  it('성공 시 stdout과 exitCode 0을 반환한다', async () => {
    vi.mocked(spawn).mockImplementation(
      createFakeSpawn({ stdout: 'a1b2c3d\n', exitCode: 0 }),
    );

    const result = await runGit(['rev-parse', '--short', 'HEAD']);
    expect(result.stdout).toBe('a1b2c3d\n');
    expect(result.exitCode).toBe(0);
    expect(spawn).toHaveBeenCalledWith(
      'git', ['rev-parse', '--short', 'HEAD'], { shell: false },
    );
  });

  it('실패 시 stderr와 exitCode를 그대로 전달한다', async () => {
    vi.mocked(spawn).mockImplementation(
      createFakeSpawn({ stderr: 'fatal: not a git repository\n', exitCode: 128 }),
    );

    const result = await runGit(['status']);
    expect(result.stderr).toContain('not a git repository');
    expect(result.exitCode).toBe(128);
  });
});

핵심은 setImmediate 입니다. 실제 spawn은 비동기로 이벤트를 발생시키므로, 즉시 이벤트를 발생시키면 리스너가 등록되기 전에 이벤트가 날아가서 테스트가 실패합니다.

이 패턴 하나를 알면 Type A Wrapper의 거의 모든 로직을 모킹으로 테스트할 수 있습니다.


3. 설정 파일 지원 — 하드코딩 제거

4편의 gitx cleanup에는 이런 코드가 있었습니다.

const protectedBranches = new Set(['main', 'master', 'develop', currentBranch]);

실무에서는 팀마다 기본 브랜치 이름이 다릅니다. trunk, production, release 같은 이름을 쓰는 곳도 있어요. 이걸 사용자별 설정 파일로 뺍니다.

cosmiconfig로 설정 읽기

npm install cosmiconfig

cosmiconfig는 설정 파일의 표준 위치와 포맷을 자동으로 탐색합니다. ~/.gitxrc, ~/.gitxrc.json, ~/.gitxrc.yaml, ~/.config/gitx/config.json 등등을 순차적으로 찾죠.

src/config.ts:

import { cosmiconfig } from 'cosmiconfig';

export interface GitxConfig {
  protectedBranches: string[];
}

const DEFAULTS: GitxConfig = {
  protectedBranches: ['main', 'master', 'develop'],
};

export async function loadConfig(): Promise<GitxConfig> {
  const explorer = cosmiconfig('gitx');
  const result = await explorer.search();
  if (!result || result.isEmpty) return DEFAULTS;

  return {
    protectedBranches: result.config.protectedBranches ?? DEFAULTS.protectedBranches,
  };
}

cleanup.ts 에서 사용:

import { loadConfig } from '../config.js';

export async function runCleanup(): Promise<void> {
  const config = await loadConfig();
  const current = await runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
  const currentBranch = current.stdout.trim();

  const protectedBranches = new Set([...config.protectedBranches, currentBranch]);
  // ... 이하 동일
}

이제 사용자는 홈 디렉토리에 .gitxrc.json 을 만들어서 커스터마이징할 수 있습니다.

{
  "protectedBranches": ["main", "trunk", "production", "staging"]
}

설정 병합 전략

위 코드의 미묘한 점: 사용자 설정이 있으면 기본값을 완전히 대체합니다(덮어쓰기). 병합(merge)이 아니에요.

이게 맞는 선택인지는 도구 성격에 따라 다릅니다. gitx 같은 경우엔 “보호 브랜치에서 develop을 빼고 싶다”는 사용자가 있을 수 있으니 덮어쓰기가 맞아요. 반면 다른 설정(예: 플러그인 목록)은 병합이 더 직관적일 수 있습니다.

어느 쪽이든 문서에 명확하게 적어야 사용자가 헷갈리지 않습니다.


4. npm 배포 준비

드디어 배포. 여러 단계가 있습니다.

package.json 정돈

{
  "name": "greet-cli",
  "version": "1.0.0",
  "description": "A simple CLI for greetings, weather, and quotes",
  "keywords": ["cli", "greet", "weather", "quote"],
  "homepage": "https://github.com/you/greet-cli",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/you/greet-cli.git"
  },
  "license": "MIT",
  "author": "Your Name <you@example.com>",
  "type": "module",
  "bin": { "greet": "dist/index.js" },
  "files": ["dist"],
  "engines": { "node": ">=18" },
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm test && npm run build",
    "test": "vitest run"
  }
}

주목할 항목들:

"files"npm publish 시 포함할 경로. dist만 포함하고 srcnode_modules는 빼는 게 핵심입니다. 이게 없으면 .gitignore/.npmignore 기반으로 동작하는데 실수 여지가 큽니다.

"engines": { "node": ">=18" } — 필수 Node 버전 명시. Node 17 이하 사용자가 npm install 하면 경고를 받습니다. 우리는 내장 fetch를 쓰니 18+ 필수.

"prepublishOnly"npm publish 실행 전 자동으로 돌아가는 스크립트. “테스트 통과 + 빌드 완료”를 보장하는 관문으로 씁니다. 이게 없으면 테스트 깨진 채로 배포하는 사고가 납니다.

.npmignore vs files 필드

배포에 포함할/제외할 파일은 두 가지 방법으로 제어합니다.

  • "files" 필드 (화이트리스트)
  • .npmignore 파일 (블랙리스트)

"files" 를 권장합니다. 실수로 민감 파일이 배포되는 걸 막아주거든요. 새 폴더를 추가해도 files에 넣지 않으면 자동으로 배포에서 제외됩니다.

배포 전 점검 — npm publish --dry-run

$ npm publish --dry-run
npm notice 📦  greet-cli@1.0.0
npm notice === Tarball Contents ===
npm notice 234B   dist/commands/quote.js
npm notice 856B   dist/commands/weather.js
npm notice 412B   dist/index.js
npm notice 645B   package.json
npm notice 1.8kB  README.md
npm notice === Tarball Details ===
npm notice name:          greet-cli
npm notice version:       1.0.0
npm notice package size:  1.7 kB

이 출력에서 예상한 파일만 있는지 확인합니다. src/.env 가 섞여 있으면 멈추고 files를 다시 봐야 해요.

실제 배포

# npm 계정 로그인 (처음 한 번만)
npm login

# 배포
npm publish

# scoped 패키지(@myorg/greet-cli)는 public 지정
npm publish --access=public

배포 성공하면 몇 초 안에 npm install -g greet-cli 가 전 세계에서 동작합니다.


5. 시맨틱 버저닝과 CHANGELOG

버전 번호는 MAJOR.MINOR.PATCH 를 따릅니다 (semver.org).

  • PATCH (1.0.0 → 1.0.1) — 버그 수정만
  • MINOR (1.0.0 → 1.1.0) — 하위 호환 기능 추가
  • MAJOR (1.0.0 → 2.0.0) — 하위 호환 깨지는 변경

CLI에서 “하위 호환 깨짐”의 의미는 명확합니다:

  • 기존 명령어/옵션 제거
  • 기본 동작 변경 (예: --push 가 기본이었는데 --no-push 가 기본이 됨)
  • 출력 포맷의 비호환 변경 (JSON 스키마 변경 등)

0.x 버전의 특별한 의미

1.0.0 이전은 “아직 공개 API가 안정화되지 않았음”의 신호입니다. 이 기간엔 MINOR 변경이 breaking change를 포함해도 관례적으로 용납됩니다.

도구가 어느 정도 익으면 1.0.0을 찍으세요. 그 순간부터는 breaking change를 조심스럽게 다뤄야 합니다. 이게 사용자에게 “이 도구가 안정됐다”는 신호예요.

CHANGELOG.md

# Changelog

## [1.1.0] - 2025-10-15
### Added
- `greet quote --category <tag>` 옵션 추가
- `--json` 출력 모드

### Changed
- 에러 메시지를 색상으로 구분

## [1.0.0] - 2025-10-01
### Added
- 초기 릴리스
- `greet hello`, `greet weather`, `greet quote` 명령어

형식은 Keep a Changelog 를 따르는 게 표준이에요. 버전 태그와 날짜, 그리고 Added/Changed/Deprecated/Removed/Fixed/Security 카테고리.


6. GitHub Actions로 자동 릴리스

수동 배포는 실수의 원천입니다. 태그를 푸시하면 자동으로 npm publish가 되도록 CI를 구성합니다.

.github/workflows/release.yml:

name: Release

on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write  # npm provenance 활성화용
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci
      - run: npm test
      - run: npm run build

      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

세팅 순서:

  1. npm에서 automation token 을 발급받음 (npm token create --type=automation)
  2. GitHub 저장소 Settings → Secrets → NPM_TOKEN 에 추가
  3. 로컬에서 npm version patch (또는 minor/major) 실행 → 버전 bump + 태그 생성
  4. git push --follow-tags 로 태그까지 푸시
  5. CI가 자동으로 테스트 → 빌드 → publish

npm provenance의 가치

npm publish --provenance“이 패키지가 어떤 CI 워크플로우에서 어떤 커밋으로부터 빌드됐는지” 를 npm 레지스트리에 서명으로 기록합니다. 오픈소스 공급망 공격 방어에 중요한 요소예요. 번거로움 없이 얻을 수 있는 신뢰 지표이니 켜는 걸 권장합니다.


7. README 베스트 프랙티스

README는 첫 10초에 사용자를 붙잡는 역할을 해야 합니다. 구조는 이렇게.

# greet-cli

[![npm version](https://img.shields.io/npm/v/greet-cli.svg)](https://www.npmjs.com/package/greet-cli)
[![CI](https://github.com/you/greet-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/you/greet-cli/actions)
[![License](https://img.shields.io/npm/l/greet-cli.svg)](./LICENSE)

> 인사·날씨·명언을 터미널에서 빠르게 조회하는 CLI

## Quick Start

\`\`\`bash
npm install -g greet-cli
greet hello --name 철수 --lang ko
greet weather Seoul
\`\`\`

![demo](./docs/demo.gif)

## Installation

...

## Usage

...

## Configuration

...

## Contributing

...

## License

MIT

핵심 원칙:

  • 한 문장 설명 을 인용 블록으로 위에
  • 뱃지 3개 (npm version, CI 상태, license) — 신뢰성의 시각적 신호
  • Quick Start 가 가장 위 — 3줄 안에 실행까지 닿기
  • 데모 GIF — 말 백 마디보다 효과적 (terminalizerasciinema로 녹화)

8. 다음 단계 — 여기서 더 나아가고 싶다면

본편은 여기서 끝이지만, 실전에서 마주칠 만한 확장 주제들을 간단히 짚습니다.

플러그인 시스템gitx 같은 도구에 사용자가 자기 명령을 추가하게 하려면? ~/.gitx/plugins/*.js 를 동적으로 import하는 구조가 일반적입니다. oclif는 이걸 기본 제공하고요.

TUI (Terminal UI)@inquirer/prompts 수준을 넘어서, 풀스크린 대시보드를 만들고 싶다면 Ink (React for CLI) 나 blessed 를 찾아보세요.

바이너리 배포 — Node.js 없이도 실행되게 하려면 pkg 나 Node.js 21+의 Single Executable Applications 를 써서 단일 바이너리를 만들 수 있습니다. Go의 장점을 TypeScript에서도 얻는 방법이에요.

자동 업데이트 — 사용자가 greet --version을 쳤을 때 “새 버전 있음” 알림을 보여주려면 update-notifier.

크로스플랫폼 이슈 — Windows 지원이 중요하다면 경로 구분자 (path.join), 줄바꿈 (os.EOL), 셸 차이 (cmd.exe vs powershell) 를 세심히 살펴야 합니다.


다음 편 예고 — 번외편

본편 5부작은 여기서 끝납니다. 이후 두 편의 번외편을 통해 언어 비교를 다룹니다.

번외 1 — 같은 CLI를 Python으로 다시 만들기

greet-cligitx를 Python의 click + rich + httpx 로 포팅합니다. TypeScript 코드와 줄 단위로 대조하며 “설계 원리는 언어 독립적” 을 실증합니다.

번외 2 — 같은 CLI를 Go로 다시 만들기

cobra + lipgloss + os/exec 로 포팅합니다. 주된 초점은 배포예요. 단일 바이너리의 장점, cross-compile, Homebrew tap, GitHub Releases 자동화를 TypeScript 대비로 짚습니다.


마치며

본편을 완주하신 걸 축하드립니다. 여기까지 오신 분은 이제 이런 질문에 답할 수 있습니다.

  • CLI Wrapper를 만들어야 할 때, Type A인지 Type B인지 바로 판단한다
  • TypeScript로 인자 파싱부터 배포까지의 전체 파이프라인을 구축할 수 있다
  • 외부 API/프로세스 호출 코드를 테스트 가능한 구조로 작성할 수 있다
  • npm 배포와 CI/CD를 세팅할 수 있다
  • 언어가 달라져도 같은 설계 원리를 적용할 수 있다

이 시리즈의 본편 핵심을 한 문장으로 요약한다면 이겁니다.

CLI Wrapper의 본질은 “사용자 입력 → 무언가 실행 → 가공된 출력”이라는 단순한 파이프라인이다. 언어·라이브러리·배포 방식이 달라져도 이 본질은 변하지 않는다.

그래서 이 시리즈에서 배운 것들은 앞으로 여러분이 마주칠 모든 CLI 도구 — 만드는 것이든 읽는 것이든 — 에 적용됩니다.

시리즈를 따라와주셔서 감사합니다. 질문이나 개선 제안은 각 편의 댓글이나 GitHub 저장소 이슈로 주시면 됩니다. 번외편에서 다른 언어로 또 만나요. 👋




댓글 남기기