시리즈 소개 이 시리즈는 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편에서 weather와 quote를 완성합니다.
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 -g 나 npm 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 호출을 얹어서 진짜 쓸모 있는 도구로 키워봅니다. 👋