CLI Wrapper, 왜 그리고 언제 만드는가




시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 본격적인 구현에 들어가기 전, “Wrapper가 무엇이고, 어떤 언어로 만들며, 왜 이 시리즈는 TypeScript로 가는가” 를 정리하는 편입니다. 중급 이상 개발자를 대상으로 합니다. 각 편은 독립적으로 읽을 수 있지만, 순서대로 따라가면 실제 동작하는 CLI 도구 2개를 완성하게 됩니다.


들어가며

터미널에서 매일 같은 git 명령어 조합을 반복하다 “이걸 한 방에 처리하는 명령이 있었으면…” 하고 생각한 적, 아마 있으실 겁니다. 혹은 사내 API를 호출하려고 매번 curl에 긴 헤더와 JSON을 조립하다가 “이거 좀 정리하고 싶다”고 느꼈을 수도 있고요.

그럴 때 흔히 도달하는 결론이 CLI Wrapper를 만드는 것입니다. 그런데 막상 “Wrapper가 뭐냐”고 물으면 정의가 생각보다 모호합니다. alias도 Wrapper인가? shell function은? 그냥 Node.js 스크립트는? 그리고 curl 없이 API를 호출하는 도구를 만들면 그것도 Wrapper일까?

이번 편에서는 이런 모호함을 걷어내고 다음 세 가지를 정리합니다.

  1. CLI Wrapper란 무엇이고, 그 본질이 무엇인지
  2. 어떤 언어로 만드는 게 좋은지 — 그리고 왜 이 시리즈는 TypeScript를 선택했는지
  3. 우리가 앞으로 무엇을 만들 것인지

이 편을 다 읽고 나면, 2편부터 시작되는 실습을 따라가기 전에 “내가 왜 이걸 하는지, 내 상황에 이 선택이 맞는지”를 스스로 판단할 수 있게 됩니다.


1. CLI가 돌아가는 원리 — 아주 빠르게 복습

Wrapper를 이해하려면 CLI가 뭘 하는 존재인지부터 정리해야 합니다. 터미널에서 git status를 쳤을 때 내부에서 벌어지는 일은 대략 이렇습니다.

핵심은 네 가지입니다.

프로세스 — CLI 도구는 OS가 실행하는 독립된 프로세스입니다. git, node, docker 전부 마찬가지예요. 셸은 fork()로 자식 프로세스를 만들고 exec()로 그 자식을 원하는 프로그램으로 교체합니다.

stdio 3종 — 입력(stdin), 출력(stdout), 에러(stderr). 이 세 스트림이 CLI의 인터페이스입니다. 파이프(|), 리다이렉트(>, <)가 전부 이 스트림을 조작하는 문법이고요.

종료 코드 — 프로세스가 끝날 때 남기는 숫자. 0이면 성공, 그 외는 실패의 종류를 의미합니다. 이 값으로 셸이 성공 여부를 판단하고 &&, || 같은 연산자가 동작합니다.

인자argv로 전달되는 문자열 배열. git status -s 를 실행하면 argv = ["git", "status", "-s"] 가 됩니다.

이 네 가지(프로세스, stdin, stdout/stderr, exit code, argv)만으로 모든 CLI 도구는 세상과 소통합니다. 이게 Wrapper 설계의 출발점입니다. Wrapper를 만든다는 건 결국 이 인터페이스 위에 새로운 층을 쌓는 일이니까요.


2. Wrapper란 무엇인가 — alias, function과의 차이

사람들이 “Wrapper”라고 부르는 것들을 늘어놓고 보면 꽤 다양합니다.

방식예시특징한계
aliasalias gs='git status'단순 문자열 치환로직 없음, 인자 조작 불가
shell functionfunction gp() { git add . && git commit -m "$1"; }간단한 로직 가능셸 문법 한정, 복잡해지면 관리 어려움
shell script#!/bin/bash 파일재사용·배포 가능크로스 플랫폼 취약, 에러 처리 빈약
CLI Wrapper (프로그램)별도 언어로 작성된 독립 실행 파일구조적 설계, 배포 가능, 테스트 가능제작 비용 높음

alias와 shell function도 넓게 보면 “감싸는” 행위입니다. 하지만 우리가 이 시리즈에서 다루는 CLI Wrapper는 그보다 한 단계 위 — 즉 “별도의 프로그램으로 작성되어, 독립 실행 파일로 배포 가능하며, 구조적으로 설계된 CLI 도구” 를 가리킵니다.

엄밀한 정의를 내려보자면 이렇습니다.

CLI Wrapper: 사용자의 입력(인자·옵션·stdin)을 받아, 내부적으로 다른 프로그램을 실행하거나 특정 로직을 수행한 뒤, 그 결과를 가공해서 stdout/stderr/exit code로 출력하는 독립 실행 가능한 프로그램.

이 정의에서 중요한 건 “내부적으로 다른 프로그램을 실행하거나 특정 로직을 수행한다” 는 부분입니다. 여기서 Wrapper의 두 가지 유형이 갈립니다.


3. Wrapper의 두 가지 유형

Type A: 기존 CLI 래핑

이미 존재하는 CLI 도구를 내부에서 실행하여, 더 나은 인터페이스를 제공하는 유형입니다.

대표 예시: gh (GitHub CLI)

gh는 내부적으로 GitHub API를 호출하지만, gh pr create 같은 명령은 결국 git 정보를 읽기 위해 git 명령을 실행합니다. 사용자는 gh pr create --fill 한 줄로 끝내지만, 내부에서는 현재 브랜치 확인, 원격 저장소 파악, 커밋 메시지 수집 등이 일어나고 있죠.

대표 예시: gitflow

git flow feature start my-feature 는 내부적으로 git checkout develop && git checkout -b feature/my-feature 같은 여러 git 명령을 묶어 실행하는 전형적인 Type A Wrapper입니다.

이 유형의 핵심 과제는 “어떻게 원본 CLI를 안전하고 효율적으로 호출하고, 그 출력을 가공할 것인가” 입니다.

Type B: 임의 동작의 CLI화

기존 CLI를 감싸는 게 아니라, 어떤 동작이든 CLI 인터페이스로 제공하는 유형입니다. API 호출, 파일 처리, 계산, 심지어 "Hello, world!" 출력 같은 것도 전부 여기 해당합니다.

대표 예시: stripe CLI

Stripe CLI는 감쌀 원본 CLI가 없습니다. Stripe API를 직접 호출하는 독립 도구입니다. stripe customers list 같은 명령은 내부적으로 HTTPS 요청을 날리고 그 응답을 예쁘게 출력하죠.

대표 예시: vercel, netlify CLI

배포 서비스의 CLI들도 마찬가지입니다. 각자의 API를 호출하고, 인증을 관리하고, 결과를 보여주는 전형적인 Type B 도구들입니다.

이 유형의 핵심 과제는 “임의의 로직을 어떻게 CLI 관례(인자, stdout, exit code)에 맞게 잘 노출할 것인가” 입니다.

왜 이 구분이 중요한가

두 유형은 구현 시 마주치는 문제가 다릅니다.

  • Type A는 child_process로 외부 프로세스를 띄우고, stdio 스트리밍을 관리하고, shell injection을 방어해야 합니다.
  • Type B는 HTTP 요청, 파일 I/O, 인증, 비동기 처리, JSON 직렬화 같은 “애플리케이션 개발”의 문제를 풉니다.

하지만 사용자 인터페이스 측면(인자 파싱, 색상 출력, 에러 처리, 배포)은 거의 동일합니다. 이 공통 부분이 우리가 이 시리즈에서 배울 핵심이고, 두 유형의 차이점은 각각의 프로젝트(gitxgreet-cli)에서 구체적으로 다룹니다.


4. 어떤 언어로 만들까? — 언어 지형도

CLI Wrapper는 이론적으로 어떤 언어로도 만들 수 있습니다. 앞서 살펴본 설계의 본질 — 프로세스 실행, 인자 파싱, stdio 처리 — 은 언어에 독립적이니까요.

하지만 실전에서는 언어 선택이 다음 세 가지를 좌우합니다.

  • 생태계 — 인자 파싱·색상·프롬프트 라이브러리의 성숙도
  • 배포 방식 — 사용자가 어떻게 설치하고 실행하는가
  • 기동 속도 — 자주 호출되는 도구일수록 중요

주요 선택지를 비교해봅시다.

언어대표 라이브러리배포 방식기동 속도추천 상황
TypeScript (Node.js)commander, chalk, oranpm, npx보통 (~100ms)웹 개발자, 빠른 프로토타이핑, 사내 API 도구
Pythonclick, typer, richpip, pipx보통 (~150ms)데이터/ML, 스크립팅, 사내 도구
Gocobra, bubbletea단일 바이너리매우 빠름 (~10ms)배포 편의성 최우선, DevOps 도구
Rustclap, ratatui단일 바이너리매우 빠름 (~5ms)고성능, 장기 유지보수, 시스템 도구

각 언어는 저마다의 “성격”이 있습니다. 간단히 정리하면 이렇습니다.

TypeScript/Node.js — 가장 보편적인 선택. npm 생태계가 크고, 웹 개발자에게 친숙하며, 비동기 HTTP 호출이 자연스럽습니다. 단점은 Node.js 런타임이 있어야 실행되고, 기동 시간이 다소 느리다는 점입니다.

Python — 사내 도구나 ML 쪽에서 지배적. clickrich의 조합은 업계 표준에 가깝습니다. 단점은 TypeScript와 비슷하게 런타임 의존성이 있다는 것이고, 특히 사용자에게 Python 버전 문제를 떠넘기기 쉽습니다 (pipx가 이를 많이 해결해주긴 합니다).

Go — 배포 경험이 압도적으로 좋습니다. go build로 만든 단일 바이너리를 그냥 복사해서 실행하면 끝. Docker, kubectl, gh 같은 굵직한 CLI들이 Go로 작성된 이유입니다. 단점은 언어 자체의 학습 곡선과, 동적 언어만큼 빠르게 프로토타이핑하기는 어렵다는 점.

Rust — Go의 장점(단일 바이너리, 빠른 기동) + 더 높은 성능 + 더 엄격한 안정성. 대신 학습 곡선이 가파르고, 초기 개발 속도가 느립니다. ripgrep, fd, bat처럼 “오래 쓰일, 많이 호출될” 도구에 잘 맞습니다.

이 시리즈는 왜 TypeScript로 가는가

세 가지 이유로 TypeScript를 선택했습니다.

1. 생태계가 실용적이고 접근성이 높음 commander, chalk, ora, @inquirer/prompts 등 검증된 라이브러리가 풍부하고, 각각 문서와 예제가 잘 되어 있습니다. 중급 개발자가 처음 CLI를 만들 때 막힘이 적습니다.

2. 많은 개발자에게 친숙한 언어 프론트엔드/백엔드 개발자 대부분이 이미 JS/TS에 익숙합니다. 언어 문법을 배우느라 Wrapper 설계에 쏟을 에너지를 빼앗기지 않습니다. 이 시리즈의 초점은 “TS 문법”이 아니라 “CLI Wrapper를 어떻게 설계하고 구현하는가” 이기 때문에, 언어는 가능한 투명해야 합니다.

3. 웹 개발 자산과 연결 실무에서 CLI Wrapper를 만드는 흔한 이유 중 하나가 “사내 API를 CLI로 노출하기” 입니다. 이때 기존 TypeScript 타입 정의와 HTTP 클라이언트를 그대로 재사용할 수 있는 건 큰 이점입니다.

Python이나 Go를 선호한다면

설계 원리는 언어 독립적이기 때문에, 이 시리즈에서 다루는 개념은 그대로 다른 언어로 이식 가능합니다. 본편 완성 후 발행될 번외편 2편에서 같은 앱을 Python과 Go로 다시 만드는 과정을 다룹니다.

지금 당장 다른 언어로 따라가고 싶다면 라이브러리 매핑만 알아두세요.

  • Python으로 따라간다면: commander → click, chalk → rich, ora → rich.progress, @inquirer/prompts → questionary 또는 rich.prompt
  • Go로 따라간다면: commander → cobra, chalk → lipgloss, ora → bubbletea의 spinner, @inquirer/prompts → huh 또는 promptui

각 라이브러리의 철학은 조금씩 다르지만, 본 시리즈에서 설명하는 설계 판단(어떤 명령 체계로 할지, 에러를 어떻게 전파할지, 어떤 출력 모드를 제공할지) 은 언어와 무관하게 그대로 적용됩니다.


5. 우리가 만들 것

이 시리즈에서는 두 가지 Wrapper를 각각 만듭니다. 두 유형(A, B) 각각을 대표하는 프로젝트를 하나씩 가져가면서, 각 유형 특유의 문제들을 집중적으로 다루기 위해서입니다.

프로젝트 1: greet-cli — Type B 대표

임의 동작을 CLI로 노출하는 예제입니다. 간단한 인사부터 공개 API 호출까지 점진적으로 확장합니다.

# 가장 단순한 동작
$ greet hello
Hello, world!

$ greet hello --name 철수 --lang ko
안녕하세요, 철수님!

# 공개 API 호출 (키 불필요)
$ greet weather Seoul
📍 Seoul: ☀️  +18°C, 습도 45%

$ greet quote
"The only way to do great work is to love what you do."
 — Steve Jobs

$ greet quote --category tech --json
{"content":"...","author":"...","tags":["tech"]}

사용하는 공개 API는 wttr.in(날씨)과 Quotable(명언)입니다. 둘 다 API 키가 필요 없어서 독자가 바로 따라 할 수 있습니다. 2편에서 프로젝트 골격을 잡고, 3편에서 완성합니다.

프로젝트 2: gitx — Type A 대표

기존 CLI(git)를 감싸는 예제입니다. 매일 쓰는 git 워크플로우를 단축합니다.

# add + commit + push를 한 번에
$ gitx save "fix: 로그인 버그 수정"
✔ 3개 파일 스테이지
✔ 커밋 완료 (a1b2c3d)
✔ origin/main에 푸시됨

# fetch + rebase + 충돌 안내
$ gitx sync
✔ origin에서 가져오는 중...
✔ main 위에 리베이스 완료 (3개 커밋)

# 머지된 로컬 브랜치 일괄 정리 (대화형)
$ gitx cleanup
다음 브랜치는 이미 머지되었습니다. 삭제할 것을 선택하세요:
  ◯ feature/login
  ◯ fix/typo
  ◉ hotfix/prod-crash

4편에서 이 프로젝트를 완성합니다. 여기서는 child_process로 git을 호출하고, 체이닝·에러 전파·대화형 프롬프트까지 다룹니다.

그리고 5편에서는

만든 두 도구를 남도 쓸 수 있게 만듭니다. 테스트 작성, npm 배포, GitHub Actions로 자동 릴리스까지. 5편을 마치면 실제로 npm install -g greet-cli 같은 명령으로 다른 사람이 설치할 수 있는 도구가 완성됩니다.


다음 편 예고

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

다음 편에서는 본격적으로 키보드를 두드립니다. TypeScript CLI 프로젝트를 처음부터 세팅하고, commander로 첫 명령어를 구현하고, npm link로 로컬에서 실행해봅니다.

구체적으로는 이런 주제들을 다룹니다.

  • greet-cli의 요구사항과 명령 체계 설계 — 기능을 나열하기 전에 “어떤 원칙으로 명령을 구성할지” 부터 정합니다
  • TypeScript CLI 프로젝트 구조 — bin 엔트리, 셰뱅 처리, tsconfig 세팅
  • 인자 파싱 라이브러리 선택 — commander vs yargs vs oclif 비교 후 왜 commander인지
  • 첫 명령어 greet hello 구현과 npm link로 로컬 설치
  • 개발 루프 세팅 — TypeScript 소스를 수정하면서 CLI를 바로 실행하는 워크플로우

2편이 끝나면 여러분의 터미널에서 직접 만든 greet 명령이 동작합니다.


마치며

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

  • CLI Wrapper는 “프로세스·stdio·exit code·argv”라는 CLI의 본질 위에 새로운 인터페이스 층을 쌓는 독립 실행 프로그램이다.
  • Wrapper에는 두 유형(Type A: 기존 CLI 래핑 / Type B: 임의 동작의 CLI화)이 있고, 둘은 구현 과제가 다르지만 사용자 인터페이스 설계는 공통이다.
  • 언어 선택은 생태계·배포·기동속도의 트레이드오프다. 이 시리즈는 진입 장벽이 낮고 실무 연결성이 좋은 TypeScript로 진행하되, 원리는 어떤 언어로도 이식 가능하다.

이 정도가 머릿속에 들어왔다면 2편에서 바로 실습에 돌입할 준비가 된 겁니다. 질문이나 피드백은 댓글로 주시면 다음 편에 반영하겠습니다.

다음 편에서 만나요. 👋




댓글 남기기