시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 Type B Wrapper(임의 동작의 CLI화)의 핵심 주제들 — 외부 API 호출, 비동기 처리, 네트워크 에러, 로딩 UI, 색상 출력, JSON 모드 — 을 한 번에 관통합니다.
이전 편 복습
2편에서 greet-cli의 요구사항을 정의하고, TypeScript 프로젝트 구조를 잡고, 첫 명령어 greet hello를 동작시켰습니다. 핵심은 세 가지 설정("type": "module", "bin" 필드, 셰뱅)과 commander로 서브커맨드를 정의하는 패턴이었죠.
이번 편에서는 외부 API를 호출하는 두 명령어 weather와 quote 를 추가해서 greet-cli를 완성합니다. 이 과정에서 Type B Wrapper가 실전에서 마주치는 문제들을 모두 겪게 됩니다.
들어가며
CLI에서 API를 호출하는 건 겉보기엔 단순합니다. fetch() 한 줄이면 끝 아닌가 싶죠. 하지만 “이게 CLI 도구로 쓸 만한가” 를 만족시키려면 고려할 게 의외로 많습니다.
- 느린 API 호출 중에 사용자는 뭘 보는가? (로딩 인디케이터)
- 네트워크가 끊기면 어떻게 되는가? (타임아웃·에러 분류)
- 이 도구를 다른 스크립트에서 쓸 수 있는가? (JSON 모드·종료 코드)
- 성공과 실패를 한눈에 구분할 수 있는가? (색상·아이콘)
이번 편에서 이 모든 걸 다루고, 마지막에는 “남이 쓰기에도 괜찮은” 수준의 Type B Wrapper가 완성됩니다.
1. 사용할 공개 API 두 개
구현 들어가기 전에 어떤 API를 쓸 건지 정리합니다. 둘 다 API 키 불필요로, 독자가 바로 따라 할 수 있도록 선정했습니다.
wttr.in — 날씨
curl 'https://wttr.in/Seoul?format=j1'
format=j1 쿼리 파라미터를 주면 JSON으로 응답합니다. current_condition 배열에서 온도·습도·날씨 설명을 얻을 수 있어요.
api.quotable.io — 명언
curl 'https://api.quotable.io/random'
curl 'https://api.quotable.io/random?tags=wisdom'
랜덤 명언을 JSON으로 반환합니다. tags 쿼리로 카테고리 필터가 가능합니다.
💡 팁: 두 API 모두 장기적으로 다운되거나 응답 형식이 바뀔 수 있습니다. 그럴 때를 대비해 이 편에서는 API별 로직을 별도 파일로 분리합니다. 나중에 API만 갈아끼우면 되도록요.
2. 추가 의존성 설치
2편에서 이미 commander는 설치했습니다. 이번 편에서 두 개를 더 추가합니다.
npm install chalk ora
- chalk — 터미널 색상 출력.
chalk.red(...),chalk.bold(...)같은 체이닝 API - ora — 로딩 스피너. API 호출 중
⠋ Fetching...같은 회전 인디케이터를 보여줌
두 라이브러리 모두 ESM 전용("type": "module")입니다. 2편에서 프로젝트를 ESM으로 세팅해둔 게 여기서 의미가 있죠.
3. 프로젝트 구조 리팩터링
2편에서는 모든 걸 src/index.ts 한 파일에 넣었습니다. 명령어가 3개로 늘어나면 곧 감당이 안 됩니다. 명령어 단위로 파일을 분리합시다.
src/
├── index.ts ← 진입점 (명령 등록만)
└── commands/
├── weather.ts ← weather 로직
└── quote.ts ← quote 로직
이 구조는 실제 많은 CLI 도구들이 따르는 표준 패턴입니다. index.ts는 얇게, 각 명령어는 자기 파일에서 완결되도록.
4. weather 명령어 구현
먼저 src/commands/weather.ts 파일을 만듭니다.
import chalk from 'chalk';
import ora from 'ora';
interface WeatherOptions {
json?: boolean;
}
// wttr.in JSON 응답에서 우리가 쓸 필드만 타입으로
interface WttrResponse {
current_condition: Array<{
temp_C: string;
humidity: string;
weatherDesc: Array<{ value: string }>;
FeelsLikeC: string;
}>;
nearest_area: Array<{
areaName: Array<{ value: string }>;
country: Array<{ value: string }>;
}>;
}
export async function runWeather(
city: string,
opts: WeatherOptions,
): Promise<void> {
const url = `https://wttr.in/${encodeURIComponent(city)}?format=j1`;
// JSON 모드일 때는 스피너를 띄우지 않음 (다른 프로세스가 파이프로 받을 수 있으므로)
const spinner = opts.json ? null : ora(`Fetching weather for ${city}...`).start();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) {
throw new Error(`wttr.in returned ${res.status}`);
}
const data = (await res.json()) as WttrResponse;
const current = data.current_condition[0];
const area = data.nearest_area[0];
if (opts.json) {
console.log(
JSON.stringify(
{
city: area.areaName[0].value,
country: area.country[0].value,
temp_c: Number(current.temp_C),
feels_like_c: Number(current.FeelsLikeC),
humidity: Number(current.humidity),
description: current.weatherDesc[0].value,
},
null,
2,
),
);
return;
}
spinner!.succeed(`${area.areaName[0].value}, ${area.country[0].value}`);
console.log(
` ${chalk.bold(current.temp_C + '°C')} ` +
chalk.gray(`(체감 ${current.FeelsLikeC}°C)`),
);
console.log(` ${chalk.cyan(current.weatherDesc[0].value)}`);
console.log(` 습도 ${current.humidity}%`);
} catch (err) {
spinner?.fail('날씨 조회 실패');
if (err instanceof Error) {
if (err.name === 'AbortError') {
console.error(chalk.red('요청이 시간 초과되었습니다 (8초)'));
} else {
console.error(chalk.red(err.message));
}
}
process.exit(1);
}
}
코드가 좀 깁니다. 중요한 부분만 하나씩 짚어봅시다.
fetch는 Node.js에 내장돼 있다
Node.js 18+ 부터는 fetch, Request, Response, AbortController가 전부 글로벌로 내장되어 있습니다. node-fetch 같은 패키지를 설치할 필요가 없어요. 이 시리즈는 Node 22+를 가정합니다.
AbortController로 타임아웃
기본적으로 fetch는 타임아웃이 없습니다. 네트워크가 죽으면 무한히 기다리죠. CLI 도구에서 이건 치명적입니다. 사용자는 터미널 앞에서 “이게 된 건가 안 된 건가” 고민하게 되니까요.
AbortController로 해결합니다.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout); // 성공 시 타이머 정리 (메모리 누수 방지)
타임아웃이 발동하면 fetch가 AbortError를 throw합니다. 이걸 catch 블록에서 다른 에러와 구분해서 처리합니다.
JSON 모드에서 스피너를 띄우지 않는 이유
const spinner = opts.json ? null : ora(...).start();
매우 중요한 판단입니다. --json 모드는 사용자가 다른 스크립트와 파이프로 연결하려고 쓰는 경우가 대부분입니다.
greet weather Seoul --json | jq '.temp_c'
이때 스피너의 회전 애니메이션이 stdout에 섞이면 뒤의 jq 가 JSON 파싱에 실패합니다. 그래서 기계 가독 출력 모드에서는 장식을 일체 내보내지 않는다 — 이건 Unix CLI의 오랜 관례입니다.
ora의 스피너는 실제로는 stderr로 나가긴 하지만, 그래도 “조용한 모드가 필요할 땐 진짜로 조용해야 한다” 원칙을 지키는 게 좋습니다.
에러를 stderr로, 종료 코드 1로
console.error(chalk.red(err.message));
process.exit(1);
1편에서 강조한 원칙 그대로입니다. 사용자용 출력은 stdout, 에러는 stderr, 실패 시 exit code ≠ 0. 이 셋을 지키면 이 도구는 다른 도구와 조합 가능한 “시민”이 됩니다.
5. quote 명령어 구현
src/commands/quote.ts를 만듭니다. 구조는 weather.ts와 비슷하지만, 쿼리 파라미터 조립과 카테고리 필터가 추가됩니다.
import chalk from 'chalk';
import ora from 'ora';
interface QuoteOptions {
category?: string;
json?: boolean;
}
interface QuotableResponse {
content: string;
author: string;
tags: string[];
}
export async function runQuote(opts: QuoteOptions): Promise<void> {
const params = new URLSearchParams();
if (opts.category) params.set('tags', opts.category);
const url = `https://api.quotable.io/random${params.toString() ? '?' + params : ''}`;
const spinner = opts.json ? null : ora('Fetching quote...').start();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) throw new Error(`quotable returned ${res.status}`);
const data = (await res.json()) as QuotableResponse;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
spinner!.stop();
console.log(chalk.italic(`"${data.content}"`));
console.log(chalk.gray(` — ${data.author}`));
if (data.tags.length > 0) {
console.log(chalk.dim(` ${data.tags.map((t) => '#' + t).join(' ')}`));
}
} catch (err) {
spinner?.fail('명언 조회 실패');
if (err instanceof Error) {
if (err.name === 'AbortError') {
console.error(chalk.red('요청이 시간 초과되었습니다 (8초)'));
} else {
console.error(chalk.red(err.message));
}
}
process.exit(1);
}
}
새로운 포인트 하나 — URLSearchParams로 쿼리 조립.
const params = new URLSearchParams();
if (opts.category) params.set('tags', opts.category);
CLI에서 사용자 입력을 URL에 넣을 때 절대로 문자열 연결로 쓰지 마세요. `?tags=${opts.category}` 같은 코드는 카테고리에 &나 공백이 들어가면 깨집니다. URLSearchParams는 자동으로 인코딩해줍니다.
weather.ts에서 encodeURIComponent(city) 를 쓴 것도 같은 이유예요. 도시명이 “New York”일 때 공백이 %20으로 인코딩되지 않으면 요청이 실패합니다.
6. index.ts 업데이트 — 새 명령어 등록
이제 src/index.ts에 두 명령어를 연결합니다.
#!/usr/bin/env node
import { Command } from 'commander';
import { runWeather } from './commands/weather.js';
import { runQuote } from './commands/quote.js';
const program = new Command();
program
.name('greet')
.description('인사와 공개 API 정보를 제공하는 CLI')
.version('0.2.0');
program
.command('hello')
.description('인사 메시지를 출력합니다')
.option('-n, --name <n>', '인사할 대상 이름', 'world')
.option('-l, --lang <lang>', '언어 (en, ko, ja)', 'en')
.action((opts: { name: string; lang: string }) => {
// 2편과 동일
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
.command('weather <city>')
.description('도시의 현재 날씨를 조회합니다')
.option('--json', 'JSON 형식으로 출력')
.action((city: string, opts: { json?: boolean }) => runWeather(city, opts));
program
.command('quote')
.description('랜덤 명언을 가져옵니다')
.option('-c, --category <tag>', '카테고리 필터 (예: wisdom, technology)')
.option('--json', 'JSON 형식으로 출력')
.action((opts: { category?: string; json?: boolean }) => runQuote(opts));
program.parseAsync(process.argv);
import 경로의 .js 확장자
import { runWeather } from './commands/weather.js';
TypeScript 파일인데 import할 때는 .js로 적는 게 이상해 보이죠. 이건 ESM + NodeNext 조합의 규칙입니다. 컴파일 후 런타임에는 .js 파일이 되므로 그 기준으로 적는 게 맞아요.
처음엔 낯설지만 프로젝트 전체에 일관되게 쓰면 됩니다. 2편의 tsconfig.json에서 "module": "NodeNext"를 설정한 덕에 이렇게 쓸 수 있어요.
<city> vs [city]
.command('weather <city>') // 필수 인자 (꺾쇠)
.command('weather [city]') // 선택 인자 (대괄호)
weather는 도시 이름이 없으면 의미가 없으니 꺾쇠(필수)로 썼습니다. quote는 인자 없이도 동작하니 .command('quote') 만 쓰고요.
7. 실행해보기
먼저 타입 체크부터.
npx tsc --noEmit
# (아무 출력 없이 성공)
이제 실제 실행해봅시다.
# 인사 (2편과 동일하게 동작)
$ npx tsx src/index.ts hello --name 철수 --lang ko
안녕하세요, 철수님!
# 날씨 조회
$ npx tsx src/index.ts weather Seoul
✔ Seoul, South Korea
18°C (체감 17°C)
Partly cloudy
습도 53%
# JSON 모드
$ npx tsx src/index.ts weather Seoul --json
{
"city": "Seoul",
"country": "South Korea",
"temp_c": 18,
"feels_like_c": 17,
"humidity": 53,
"description": "Partly cloudy"
}
# 명언
$ npx tsx src/index.ts quote
"The only way to do great work is to love what you do."
— Steve Jobs
#famous-quotes #wisdom
# 카테고리 필터
$ npx tsx src/index.ts quote --category technology --json
{
"content": "...",
"author": "...",
"tags": ["technology"]
}
# 다른 도구와 파이프 연결
$ npx tsx src/index.ts weather Seoul --json | jq '.temp_c'
18
마지막 예시가 특히 의미 있습니다. “CLI 도구로서의 정당성” 을 보여주는 장면이에요. 출력이 기계가 읽을 수 있는 형식이면, 이 도구는 곧장 쉘 스크립트·다른 CLI·cron 잡에서 재사용 가능한 부품이 됩니다.
8. 이번 편에서 놓치기 쉬운 것들
1) API 타입을 “필요한 필드만” 정의하라
WttrResponse 타입에는 wttr.in이 실제로 주는 50개가 넘는 필드 중 우리가 쓰는 것만 적혀 있습니다. 이건 의도적인 선택입니다.
- 전체 스키마를 적으면 관리 부담이 큼
- 우리가 쓰지 않는 필드가 변경되어도 우리 코드는 영향 없음
- 실제로 쓰는 필드의 타입이 틀렸을 때만 TypeScript가 경고함
외부 API 응답을 쓸 때는 “내가 소비하는 필드만” 타입으로 적는 것이 실무 패턴입니다.
2) Response 바디를 두 번 읽으려 하지 마라
// ❌ 이건 에러
if (!res.ok) {
const errBody = await res.text();
throw new Error(errBody);
}
const data = await res.json();
// ⭕ 이게 정답
if (!res.ok) {
throw new Error(`API returned ${res.status}`);
}
const data = await res.json();
Fetch API의 Response 바디는 한 번만 읽을 수 있는 스트림입니다. 에러 본문을 정말 보고 싶다면 res.text() 하나만 호출하고 그 안에서 상황별로 처리해야 합니다.
3) 비동기 에러가 parseAsync에서 어떻게 처리되는가
commander의 .action 콜백이 Promise를 반환하면 parseAsync가 그걸 알아서 기다립니다. 하지만 안에서 throw한 에러는 자동으로 process.exit(1)이 되지 않습니다.
그래서 우리 코드는 명시적으로 process.exit(1)을 호출합니다. 다른 방법으로는 parseAsync().catch(err => { ... process.exit(1) }) 형태로 최상위에서 처리하는 것도 가능해요. 4편에서 gitx를 만들 때 이 패턴을 써봅니다.
다음 편 예고
4편 — gitx 만들기: 기존 CLI 래핑하기 (Type A)
다음 편에서는 완전히 다른 유형의 Wrapper — git 명령을 내부에서 실행하는 Type A Wrapper인 gitx를 만듭니다.
다룰 주제는 Type B와 전혀 다른 문제들입니다.
child_process제대로 쓰기 —spawnvsexecvsexecFile의 차이와 선택 기준- Shell injection 방어 — 사용자 입력을 절대 shell에 넘기지 않는 법
- stdio 스트리밍 — git의 실시간 출력을 그대로 보여주면서 파싱도 하는 기법
- 명령 체이닝과 원자성 —
add + commit + push가 중간에 실패했을 때의 처리 - 대화형 프롬프트 —
@inquirer/prompts로 머지된 브랜치를 선택해서 삭제하기
4편이 끝나면 gitx save "fix: 버그 수정" 같은 명령으로 여러분의 git 워크플로우가 단축됩니다.
마치며
이번 편의 핵심을 세 문장으로 요약하면 이렇습니다.
- 외부 API 호출은 “타임아웃·에러 분류·JSON 모드”가 없으면 CLI 도구로 미완성이다.
AbortController로 타임아웃을 걸고, 성공·네트워크 에러·HTTP 에러를 구분하고,--json모드에서는 장식을 일체 제거해야 다른 도구와 조합 가능하다. - 파일 분리는 명령어가 2~3개 넘어가는 순간 시작하라.
src/commands/*.ts패턴은 대부분의 CLI에 맞는 표준 구조이고, 각 명령어가 자기 파일에서 완결되면 테스트·리팩터링이 훨씬 쉬워진다. chalk와ora는 “장식”이 아니라 “UX 기본기”다. 느린 작업에 스피너를 붙이고, 성공·실패를 색으로 구분하는 것만으로 도구의 체감 품질이 크게 달라진다. 단, JSON 모드에서는 전부 꺼야 한다.
여기까지 왔으면 greet-cli는 완성입니다. 이 도구는 이제 여러분의 .zshrc에서 별칭을 걸어 실제로 쓸 수도 있고, 5편에서 npm에 배포해서 다른 사람과 공유할 수도 있는 수준에 도달했어요.
다음 편에서는 완전히 다른 세계 — child_process로 외부 프로세스를 다루는 Type A의 세계로 들어갑니다. 👋