설계와 프로젝트 세팅: greet-cli 시작하기




시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 설계와 프로젝트 세팅 편입니다. 요구사항을 정의하고, TypeScript CLI 프로젝트 구조를 잡고, commander로 첫 명령어를 실행시키는 것까지 다룹니다. 1편에서 다룬 개념(Type A/B, 언어 지형도)을 전제로 진행합니다.


이전 편 복습

지난 1편에서 우리는 CLI Wrapper가 “프로세스·stdio·exit code·argv 위에 새로운 인터페이스 층을 쌓는 독립 실행 프로그램” 이라는 정의에 도달했습니다. 그리고 두 가지 유형 — 기존 CLI를 감싸는 Type A, 임의 동작을 CLI로 노출하는 Type B — 이 있음을 확인했고, 이 시리즈는 TypeScript로 두 유형의 대표 프로젝트를 각각 만들기로 했습니다.

이번 편에서는 Type B 대표인 greet-cli 의 골격을 잡습니다. 2편이 끝나면 터미널에서 greet hello --name 철수 --lang ko 를 쳐서 안녕하세요, 철수님! 이 출력되는 상태까지 갑니다.


들어가며

많은 CLI 프로젝트가 설계 없이 코딩부터 시작해서 중간에 방향이 꼬입니다. 명령어 이름을 일관성 없이 짓거나, 옵션 체계가 산만해지거나, 나중에 기능을 추가할 때 기존 명령어와 충돌하는 식이죠.

이런 사태를 막는 방법은 간단합니다. 구현 전에 “이 CLI가 사용자에게 어떻게 보일지”를 먼저 정하는 것이에요. 이번 편의 전반부는 이 “설계”에, 후반부는 TypeScript로 그 설계를 실행 가능한 뼈대로 만드는 작업에 할애합니다.


1. greet-cli 요구사항 정의

기능을 나열하기 전에 “이 도구가 누구를 위해, 무엇을 하는가” 부터 한 문장으로 정리합니다.

greet-cli — 터미널에서 간단한 인사, 날씨, 명언을 조회할 수 있는 도구. 외부 공개 API를 호출하는 Type B Wrapper의 학습 예제로서, 인자 파싱·비동기 처리·출력 포맷의 전형을 모두 담는다.

이 한 문장에서 목적(학습 예제), 대상 동작(인사·날씨·명언 조회), 유형(Type B) 이 명확해집니다. 이후 모든 설계 판단은 이 한 문장을 기준으로 내립니다.

구현할 기능 목록

명령어동작외부 의존성
greet hello인사 메시지 출력 (다국어)없음
greet weather <city>도시의 날씨 조회wttr.in API
greet quote랜덤 명언 조회Quotable API

기능이 3개뿐이지만, 이 조합에서 Type B Wrapper의 핵심 문제들이 모두 나타납니다.

  • hello → 인자 파싱·다국어 처리의 기본
  • weather → 외부 API 호출·비동기 처리·네트워크 에러
  • quote → 옵션 파싱·JSON 출력 모드·카테고리 필터

2편에서는 hello까지, 3편에서 weatherquote를 완성합니다.


2. 명령 체계 설계 — “동사-명사” 규칙

설계에서 가장 먼저 정해야 할 건 명령어를 어떤 문법으로 구성할지입니다. 좋은 CLI들은 대부분 일관된 패턴을 따릅니다.

# Git: 동사-명사 혼합형
git status     # 동사
git remote     # 명사

# gh: 명사-동사형 (리소스 중심)
gh pr create
gh repo clone

# docker: 명사-동사형
docker container run
docker image ls

greet-cli는 규모가 작고 “명사”보다는 “동작” 중심이기 때문에 동사형 서브커맨드로 가겠습니다.

greet <verb> [args] [options]

greet hello
greet weather Seoul
greet quote

이 규칙을 문서화해두면 나중에 기능을 추가할 때 헷갈리지 않습니다. 예를 들어 “좋아하는 명언을 저장하는 기능”이 필요해지면 greet save-quote 가 아니라 greet bookmark 같은 동사형으로 짓자는 판단이 자동으로 내려져요.

옵션 설계 원칙

옵션에도 일관성을 둡니다. 이 시리즈에서는 다음 원칙을 따릅니다.

  • 모든 옵션은 긴 이름(--name)짧은 이름(-n) 을 함께 제공
  • 불리언 플래그는 --verbose, --quiet, --json 처럼 부정형 없이 긍정형으로 (--no-color 같은 건 예외)
  • 값이 있는 옵션은 --name <value> 형태
  • 자주 쓰는 조합은 나중에 환경변수로도 받을 수 있게 설계 (4편에서 다룸)

3. TypeScript CLI 프로젝트 구조

이제 실제 파일들을 만들어봅니다. 먼저 프로젝트 디렉토리를 만들고 초기화합니다.

mkdir greet-cli && cd greet-cli
npm init -y

package.json이 생겼을 겁니다. 여기에 CLI 고유의 설정 세 가지를 추가하게 되는데, 하나씩 짚어봅시다.

의존성 설치

# 런타임 의존성 — 인자 파싱
npm install commander

# 개발 의존성 — TypeScript 컴파일러와 실행기
npm install -D typescript @types/node tsx

각 패키지의 역할:

  • commander — 인자 파싱 라이브러리. Node.js에서 가장 보편적
  • typescript / @types/node — TypeScript 컴파일러와 Node 타입 정의
  • tsx — TypeScript 파일을 컴파일 없이 바로 실행하는 도구. 개발 루프가 빨라집니다

tsconfig.json

프로젝트 루트에 tsconfig.json을 생성합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": false,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

중요한 몇 가지만 짚자면:

  • module: "NodeNext" — Node.js의 ESM 지원을 제대로 쓰기 위한 설정. import 문에서 .js 확장자를 써야 하는 것에 주의 (컴파일 후 기준)
  • strict: true — 중급 이상 개발자 기준이니 당연히 활성화
  • outDir: "./dist" — 컴파일 결과물이 들어갈 곳. 이후 npm publish 할 때 이 디렉토리가 배포됩니다
  • rootDir: "./src" — 소스 파일의 루트

package.json에 CLI 설정 추가

가장 중요한 부분입니다. 세 가지를 수정·추가합니다.

{
  "name": "greet-cli",
  "version": "0.1.0",
  "type": "module",
  "bin": {
    "greet": "dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  },
  "files": ["dist"],
  "dependencies": { "commander": "^12.0.0" },
  "devDependencies": { "typescript": "^5.0.0", "@types/node": "^22.0.0", "tsx": "^4.0.0" }
}

포인트 셋:

"type": "module" — 프로젝트 전체를 ESM으로 선언. 이게 있어야 import 문법을 그대로 쓸 수 있습니다.

"bin": { "greet": "dist/index.js" } — 이게 핵심입니다. 이 선언이 있어야 npm install -gnpm link 했을 때 터미널에서 greet 명령어가 동작합니다. key(greet)가 실제 터미널에서 치는 명령어 이름이고, value가 실행할 파일입니다.

"files": ["dist"]npm publish 시 포함할 파일들. 소스 코드(src)는 배포 안 하고 컴파일된 결과물만 배포합니다.


4. 첫 명령어 greet hello 구현

드디어 실제 코드입니다. src/index.ts 파일을 만들고 다음 내용을 넣습니다.

#!/usr/bin/env node
import { Command } from 'commander';

const program = new Command();

program
  .name('greet')
  .description('인사와 공개 API 정보를 제공하는 CLI')
  .version('0.1.0');

program
  .command('hello')
  .description('인사 메시지를 출력합니다')
  .option('-n, --name <name>', '인사할 대상 이름', 'world')
  .option('-l, --lang <lang>', '언어 (en, ko, ja)', 'en')
  .action((opts: { name: string; lang: string }) => {
    const greetings: Record<string, (n: string) => string> = {
      en: (n) => `Hello, ${n}!`,
      ko: (n) => `안녕하세요, ${n}님!`,
      ja: (n) => `こんにちは、${n}さん!`,
    };
    const fn = greetings[opts.lang];
    if (!fn) {
      console.error(`Unsupported language: ${opts.lang}`);
      process.exit(1);
    }
    console.log(fn(opts.name));
  });

program.parseAsync(process.argv);

코드 몇 부분은 명시적으로 설명할 가치가 있습니다.

셰뱅(shebang) — #!/usr/bin/env node

파일의 첫 줄에 있는 #!/usr/bin/env node가 셰뱅입니다. 이게 있어야 컴파일된 dist/index.js 파일이 ./dist/index.js 처럼 직접 실행 가능한 파일로 동작합니다. Unix 계열 OS에서 파일 실행 시 이 줄을 보고 “아, Node.js로 실행하라는 거구나” 하고 인식합니다.

주의할 점은 이게 TypeScript 소스의 첫 줄에 있어야 한다는 것. 컴파일러는 셰뱅을 그대로 유지합니다.

commander의 핵심 API

  • .command('hello') — 서브커맨드 정의. 사용자가 greet hello를 치면 이 블록이 실행됩니다
  • .option('-n, --name <name>', 설명, 기본값) — 옵션 정의. <name>은 값 있는 옵션, [name]은 선택적, 없으면 불리언 플래그
  • .action(콜백) — 명령이 실제로 하는 일. opts 객체는 commander가 파싱해서 넘겨줍니다
  • .parseAsync(process.argv) — 실제 인자 파싱 트리거. parse 대신 parseAsync를 쓰는 건 3편에서 비동기 명령(API 호출)을 추가할 것이기 때문입니다

왜 commander인가 — yargs, oclif와의 비교

commander 말고도 선택지는 있습니다. 각각의 성격을 짧게:

  • yargs — 더 많은 기능, 더 복잡한 API. 기능이 많아질수록 보일러플레이트가 늘어남
  • oclif (Salesforce) — 큰 규모 CLI 프레임워크. 플러그인·자동 완성·업데이트 내장. 작은 프로젝트엔 과함
  • commander — 가장 보편적, 가장 간결. 대부분의 CLI에 충분

우리 시리즈처럼 학습 목적이라면 commander가 최선입니다. 코드가 짧고 API가 예측 가능해서 “인자 파싱 라이브러리가 하는 일” 자체가 눈에 들어옵니다.


5. 개발 루프 세팅과 실행

코드를 다 썼으니 실행해봅시다. 두 가지 방법이 있습니다.

방법 1: tsx로 바로 실행 (개발 중)

npx tsx src/index.ts hello
# → Hello, world!

npx tsx src/index.ts hello --name 철수 --lang ko
# → 안녕하세요, 철수님!

tsx는 TypeScript를 컴파일 없이 즉석에서 실행해줍니다. 소스 수정 → 바로 확인 → 수정의 루프가 빨라서 개발 중에 씁니다. 위에서 scripts"dev": "tsx src/index.ts"를 넣어뒀으니 npm run dev -- hello --name 철수 처럼도 쓸 수 있어요.

방법 2: 빌드 후 npm link로 글로벌 설치 (실제 사용감 확인)

배포된 상태에 가깝게 써보려면 이 방법을 씁니다.

# 1) TypeScript 빌드
npm run build
# → dist/index.js 생성

# 2) 로컬 패키지를 글로벌로 심볼릭 링크
npm link

# 3) 이제 어디서든 greet 명령어 사용 가능
greet hello --name Claude --lang en
# → Hello, Claude!

npm link현재 프로젝트를 글로벌 패키지로 등록해서 터미널 어디서든 greet 명령을 칠 수 있게 만듭니다. 나중에 제거하려면 npm unlink -g greet-cli.

이 상태에서 에러 케이스도 확인해봅시다.

greet hello --lang fr
# → Unsupported language: fr
# 종료 코드 확인
echo $?
# → 1

1편에서 강조했듯, 에러는 stderr로 내보내고 종료 코드는 0이 아닌 값으로 끝내야 합니다. console.error는 stderr로, process.exit(1)은 종료 코드 1을 설정합니다. 이 둘을 제대로 쓰는 것만으로도 CLI는 다른 도구와 파이프라인으로 조합 가능한 “시민”이 됩니다.

greet hello --lang fr && echo "성공"
# → Unsupported language: fr
# (성공 메시지는 출력되지 않음 — exit code가 0이 아니므로)

6. 여기까지의 프로젝트 구조

지금까지 만든 파일들을 정리하면:

greet-cli/
├── src/
│   └── index.ts          ← 진입점 (hello 명령어 구현)
├── dist/                 ← 빌드 산출물 (gitignore 대상)
│   └── index.js
├── node_modules/
├── package.json          ← bin 설정, type: module
├── tsconfig.json         ← TS 컴파일 설정
└── .gitignore            ← dist, node_modules 제외

.gitignore는 별도로 만드셔야 합니다. 기본 내용은 이 정도면 충분합니다.

node_modules/
dist/
*.log
.env

다음 편 예고

3편 — greet-cli 완성: 공개 API 호출하기 (Type B)

다음 편에서는 greet-cli에 외부 API 호출 명령어 두 개를 추가합니다.

  • greet weather <city> — wttr.in API로 날씨 조회
  • greet quote — Quotable API로 랜덤 명언 조회

이 과정에서 Type B Wrapper의 핵심 주제들을 다룹니다.

  • fetch API로 외부 호출하기 — Node.js 18+ 네이티브 fetch의 주의점
  • 비동기 명령의 종료 코드 관리 — promise rejection과 exit code
  • 네트워크 에러 처리 — 타임아웃·DNS 실패·HTTP 에러 코드 구분
  • ora로 로딩 스피너 — 느린 API 호출에 UX 입히기
  • chalk로 색상 출력 — 정보·경고·에러의 시각적 구분
  • --json 출력 모드 — 다른 도구와 파이프라인 조합을 위한 기계 가독 출력

3편이 끝나면 greet-cli는 완성됩니다. 그 다음은 gitx 차례입니다.


마치며

이번 편의 핵심을 세 문장으로 요약하면 이렇습니다.

  • 구현 전에 “한 문장 정의”와 “명령 체계 규칙”을 먼저 정하면 이후 판단이 일관된다. greet-cli는 “공개 API를 호출하는 Type B 학습 예제”이며, “동사형 서브커맨드” 규칙을 따른다.
  • TypeScript CLI의 세 가지 필수 설정은 "type": "module", "bin" 필드, 셰뱅(shebang)이다. 이 셋이 맞춰져야 npm link로 터미널 명령어가 동작한다.
  • 개발 루프는 tsx로, 실제 사용감은 npm link로 확인한다. 둘을 왔다갔다 하며 개발하면 빠른 피드백과 현실적인 검증을 동시에 얻을 수 있다.

여기까지 왔으면 여러분의 터미널에서 greet hello --name 본인이름 --lang ko 를 쳤을 때 인사 메시지가 뜨고 있을 겁니다. 축하드려요 — CLI Wrapper의 첫 명령어를 만드신 겁니다.

다음 편에서는 여기에 실제 API 호출을 얹어서 진짜 쓸모 있는 도구로 키워봅니다. 👋




댓글 남기기