시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 번외 2편(최종편)입니다. 같은
greet-cli와gitx를 Go로 만들면서, Go가 CLI 도구의 세계에서 갖는 결정적 차별점인 “배포” 에 집중합니다.
들어가며
1편에서 언어 지형도를 다룰 때 이런 표를 봤습니다.
| 언어 | 배포 방식 | 기동 속도 |
|---|---|---|
| TypeScript (Node.js) | npm, npx | 보통 (~100ms) |
| Python | pip, pipx | 보통 (~150ms) |
| Go | 단일 바이너리 | 매우 빠름 (~10ms) |
이 차이가 CLI 생태계에서 Go가 지배적인 이유입니다. Docker, kubectl, gh, ripgrep, fzf, hugo — 여러분이 매일 쓰는 굵직한 CLI 도구 대부분이 Go 아니면 Rust예요. 왜일까요?
답은 간단합니다. “런타임 없이 파일 하나만 복사하면 돌아간다” 는 속성이 CLI 배포에서 갖는 가치가 어마어마하기 때문이에요.
이번 편은 그 가치를 구체적으로 확인합니다. 번외 1편과 달리 이번엔 “포팅 디테일보다 배포 이야기” 에 더 많은 지면을 씁니다.
1. Go CLI 라이브러리 매핑
먼저 본편에서 쓴 도구들의 Go 대응을 정리합니다.
| 용도 | TypeScript | Python | Go |
|---|---|---|---|
| 인자 파싱 | commander | click | cobra |
| 색상 출력 | chalk | rich | lipgloss 또는 fatih/color |
| 로딩 스피너 | ora | rich.status | briandowns/spinner |
| HTTP | fetch (내장) | httpx | net/http (내장) |
| 대화형 프롬프트 | @inquirer/prompts | questionary | huh 또는 promptui |
| 외부 프로세스 | child_process.spawn | subprocess.run | os/exec (내장) |
| 테스트 | vitest | pytest | testing (내장) |
Go의 특징이 바로 드러납니다. fetch, os/exec, testing 같은 기본 도구가 표준 라이브러리에 내장되어 있어요. 외부 의존성이 훨씬 적습니다. 이게 번들 크기(바이너리 크기)와 빌드 속도 양쪽에 영향을 줍니다.
2. 프로젝트 구조
greet-go/
├── go.mod # package.json/pyproject.toml 대응
├── go.sum # 의존성 해시 (lockfile)
├── main.go # 메인 엔트리
├── cmd/
│ ├── hello.go
│ ├── weather.go
│ └── quote.go
└── internal/
└── greeting/
└── greeting.go
관례:
cmd/에 각 서브커맨드를 파일로internal/은 외부에서 import 불가능한 패키지 (같은 모듈 내부에서만 씀)main.go는 얇게, 서브커맨드 등록만
go.mod
module github.com/you/greet
go 1.22
require (
github.com/spf13/cobra v1.10.2
)
go.mod는 한눈에 보입니다. package.json이 점점 비대해지는 것과 대조적이에요.
3. hello 명령어 — cobra로
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var version = "0.1.0"
func main() {
root := &cobra.Command{
Use: "greet",
Short: "인사와 공개 API 정보를 제공하는 CLI",
Version: version,
}
root.AddCommand(newHelloCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func newHelloCmd() *cobra.Command {
var name, lang string
cmd := &cobra.Command{
Use: "hello",
Short: "인사 메시지를 출력합니다",
RunE: func(cmd *cobra.Command, args []string) error {
greetings := map[string]func(string) string{
"en": func(n string) string { return fmt.Sprintf("Hello, %s!", n) },
"ko": func(n string) string { return fmt.Sprintf("안녕하세요, %s님!", n) },
"ja": func(n string) string { return fmt.Sprintf("こんにちは、%sさん!", n) },
}
fn, ok := greetings[lang]
if !ok {
return fmt.Errorf("unsupported language: %s", lang)
}
fmt.Println(fn(name))
return nil
},
}
cmd.Flags().StringVarP(&name, "name", "n", "world", "인사할 대상 이름")
cmd.Flags().StringVarP(&lang, "lang", "l", "en", "언어 (en, ko, ja)")
return cmd
}
commander·click과의 차이
1. RunE 는 에러를 반환한다
RunE: func(cmd *cobra.Command, args []string) error {
...
return fmt.Errorf("unsupported language: %s", lang)
}
TypeScript/Python은 핸들러 안에서 process.exit(1) 이나 sys.exit(1) 을 호출했습니다. Go 스타일은 다릅니다. 에러를 값으로 반환하고 root.Execute() 가 최상위에서 그걸 stderr에 찍고 exit code 1로 종료합니다.
이건 Go의 전반적인 철학이에요. “에러는 값이다, 예외로 던지지 말고 흐름으로 다뤄라.” 이 방식이 익숙해지면 에러 경로가 코드에 명시적으로 나타나서 추적이 쉬워집니다.
2. 플래그 바인딩이 포인터 기반
cmd.Flags().StringVarP(&name, "name", "n", "world", "...")
변수의 주소를 넘깁니다. cobra가 파싱 후 그 변수에 값을 채워넣는 구조예요. TypeScript의 opts.name 같은 객체 접근이 아니라, 지역 변수로 값을 받는 방식입니다.
3. cobra의 자동 help
실행해보면:
$ ./greet hello --help
인사 메시지를 출력합니다
Usage:
greet hello [flags]
Flags:
-h, --help help for hello
-l, --lang string 언어 (en, ko, ja) (default "en")
-n, --name string 인사할 대상 이름 (default "world")
commander·click 수준의 자동 도움말이 나옵니다. 세 언어 모두 여기서는 동등해요.
4. gitx — os/exec
gitx의 Go 버전 runGit은 TypeScript보다 짧고, Python과 비슷한 길이입니다.
package main
import (
"os"
"os/exec"
)
type GitResult struct {
Stdout string
Stderr string
ExitCode int
}
// runGit은 git 명령을 실행하고 결과를 캡처한다.
// exec.Command는 shell을 거치지 않으므로 shell injection 불가능.
func runGit(args ...string) GitResult {
cmd := exec.Command("git", args...)
stdout, err := cmd.Output()
if exitErr, ok := err.(*exec.ExitError); ok {
return GitResult{
Stdout: string(stdout),
Stderr: string(exitErr.Stderr),
ExitCode: exitErr.ExitCode(),
}
}
if err != nil {
return GitResult{Stderr: err.Error(), ExitCode: 1}
}
return GitResult{Stdout: string(stdout), ExitCode: 0}
}
// runGitStreaming은 출력을 부모 프로세스로 그대로 스트리밍한다 (git push 등).
func runGitStreaming(args ...string) int {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
return 1
}
return 0
}
핵심 포인트
1. exec.Command 는 기본이 shell 없음
exec.Command("git", args...)
Python의 subprocess.run(shell=False) 와 마찬가지로, Go의 exec.Command는 기본적으로 shell을 거치지 않습니다. TypeScript의 spawn(..., { shell: false }) 같은 옵션 지정도 불필요해요. Go가 더 안전한 기본값을 택한 겁니다.
shell이 꼭 필요하면 exec.Command("sh", "-c", "...") 처럼 명시적으로 호출합니다. 명시적이어서 실수로 injection 길이 열리기 어려워요.
2. 에러에서 stderr·exit code를 뽑아내는 관용구
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := exitErr.Stderr
code := exitErr.ExitCode()
}
Go의 타입 단언(type assertion) 관용구입니다. “err이 *exec.ExitError 타입이면”을 검사하면서 동시에 그 타입으로 캐스팅하는 이디엄이에요. 처음엔 낯설지만 Go 코드에 편재합니다.
3. cmd.Stdout = os.Stdout 한 줄로 스트리밍
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
4편에서 TypeScript의 { stdio: 'inherit' } 에 해당합니다. 자식 프로세스의 출력을 부모와 같은 곳으로 직접 흘려보내는 것. 한 줄로 끝납니다.
5. 여기까지 포팅하고 보니
크게 보면 Python 포팅 때 말한 것의 반복입니다.
- 인자 파싱: 라이브러리 API가 다를 뿐 하는 일은 같음
- subprocess: 구조적으로 동등, 표기만 다름
- 에러 처리 패턴: 언어 철학(예외 vs 값)에 맞게 조정
- shell injection 방어: 세 언어 모두 동일한 원칙
Go에서도 본편의 설계 판단은 그대로 적용됩니다. 동사형 서브커맨드, --json 모드, stderr 에러 출력, 적절한 exit code — 이 모든 것이 언어와 무관하게 유지돼요.
그래서 이제부터는 Go가 진짜 빛나는 지점 — 배포 로 넘어갑니다.
6. 빌드와 단일 바이너리
go build 한 줄이면 끝입니다.
$ go build -o greet .
$ ls -lh greet
-rwxr-xr-x 1 you staff 3.0M Apr 18 09:43 greet
3MB짜리 독립 실행 파일 하나. 그게 전부예요.
이 파일에는 Go 런타임, 표준 라이브러리, 우리 코드, cobra 라이브러리가 전부 정적으로 링크되어 들어가 있습니다. 실행 머신에 Go가 설치되어 있을 필요도, node_modules 도, Python 환경도 필요 없어요.
Node.js/Python과의 비교
| 배포 방식 | 사용자가 필요한 것 | 크기 |
|---|---|---|
| npm (greet-cli) | Node.js 18+ 설치 + node_modules 다운로드 | Node.js ~50MB + deps 몇 MB |
| pipx (greet-cli) | Python 3.10+ 설치 + 가상환경 + 패키지 | Python ~40MB + deps 몇 MB |
| Go 바이너리 | 없음 (OS가 맞기만 하면) | 3MB, 파일 하나 |
사용자 입장에서의 차이가 극적입니다. Go 바이너리는 USB로 옮겨도 동작합니다. 에어갭 환경, 컨테이너 베이스 이미지, 원격 서버 — 어디든 그냥 복사하고 실행권한만 주면 끝이에요.
바이너리 크기 줄이기
3MB도 부담스러운 경우 몇 가지 옵션이 있습니다.
# 디버그 정보·심볼 테이블 제거 (보통 30~40% 감소)
go build -ldflags="-s -w" -o greet .
# UPX로 추가 압축 (런타임 해제 오버헤드 있음)
upx --best greet
-s -w 플래그는 거의 항상 쓸 만한 가치가 있습니다. 스택 트레이스 품질이 약간 떨어지지만 배포 사이즈가 크게 줄어요.
7. 크로스 컴파일 — Go의 진짜 마법
여기가 정말 인상적인 부분입니다. 여러분의 Mac에서 Windows용 바이너리를 빌드할 수 있습니다.
# Linux (amd64)
GOOS=linux GOARCH=amd64 go build -o greet-linux-amd64 .
# macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o greet-darwin-arm64 .
# macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o greet-darwin-amd64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o greet.exe .
빌드 속도도 빠릅니다. 제 환경에서 4개 플랫폼 전부 빌드해도 10초 안에 끝났어요.
이게 왜 대단한가
Node.js나 Python으로 같은 일을 하려면:
- Node.js:
pkg나nexe같은 도구로 런타임을 번들링 (40~80MB 바이너리) - Python:
PyInstaller나cx_Freeze로 인터프리터 번들링 (30~60MB, 플랫폼별 빌드 환경 필요)
그나마도 Mac에서 Windows용 바이너리를 만드는 건 상당히 까다로워요. 보통 각 OS에 VM이나 Docker로 빌드 환경을 띄워야 하죠.
Go는 이걸 기본 기능으로 제공합니다. 이것만으로도 CLI 개발자들에게 Go를 선택할 충분한 이유가 됩니다.
지원되는 플랫폼 조합 보기
$ go tool dist list
aix/ppc64
android/386
android/amd64
android/arm
android/arm64
...
js/wasm
linux/amd64
linux/arm64
...
windows/arm64
40개가 넘는 조합을 지원합니다. Raspberry Pi(linux/arm)도, Termux(android/arm64)도, 심지어 WebAssembly도 가능해요.
8. GitHub Releases + 자동 바이너리 배포
본편 5편에서 npm 배포를 GitHub Actions로 자동화했죠. Go는 태그를 푸시하면 모든 플랫폼의 바이너리를 자동 생성해 GitHub Releases에 올리는 패턴이 표준입니다. GoReleaser 라는 도구가 사실상 표준이에요.
.goreleaser.yaml
version: 2
before:
hooks:
- go mod tidy
builds:
- id: greet
main: ./
binary: greet
env:
- CGO_ENABLED=0
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
ldflags:
- -s -w -X main.version={{.Version}}
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
checksum:
name_template: 'checksums.txt'
brews:
- name: greet
repository:
owner: you
name: homebrew-tap
description: "A simple CLI for greetings, weather, and quotes"
license: "MIT"
.github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write # GitHub Releases 생성 권한
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 태그 히스토리 필요
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: goreleaser/goreleaser-action@v6
with:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
이것만 세팅해두면:
- 로컬에서
git tag v1.0.0 && git push --tags - GitHub Actions가 자동으로 6개 바이너리 (3 OS × 2 arch) 빌드
- GitHub Releases에 자동 업로드, 체크섬까지 생성
- Homebrew tap도 자동 업데이트
사용자는 다음 중 어느 방법으로든 설치 가능해집니다.
# Homebrew (macOS/Linux)
brew tap you/tap
brew install greet
# 직접 다운로드 (Linux)
curl -L https://github.com/you/greet/releases/download/v1.0.0/greet_linux_amd64.tar.gz | tar xz
sudo mv greet /usr/local/bin/
# Windows (PowerShell)
Invoke-WebRequest -Uri https://github.com/you/greet/releases/download/v1.0.0/greet_windows_amd64.zip -OutFile greet.zip
Expand-Archive greet.zip
3가지 OS의 사용자 모두가 npm 같은 런타임 설치 없이 바로 도구를 쓸 수 있죠.
9. 배포의 트레이드오프 — 공짜 점심은 없다
Go가 배포에서 완승처럼 보이지만, 대가도 있습니다. 솔직하게 정리합니다.
Go의 단점
1. 개발 속도와 프로토타이핑
TypeScript/Python으로는 한 시간 만에 쓸만한 CLI를 만들 수 있습니다. Go는 같은 걸 만드는 데 시간이 조금 더 들어요. 에러를 값으로 다루는 게 안전하지만 장황하고, 제네릭이 1.18에야 들어왔고, 동적 언어의 유연성이 없습니다.
2. API 호출 코드가 길다
Go로 같은 weather 명령을 만들면 JSON 파싱에 구조체 정의가 필요하고, 에러 체크가 곳곳에 들어갑니다. TypeScript의 res.json() as WttrResponse 한 줄이 Go에선 10줄이 될 수 있어요.
3. 업데이트 배포의 번거로움
npm은 npm update -g greet-cli 한 줄이면 됩니다. Homebrew는 brew upgrade. 하지만 직접 다운로드한 사용자는 수동으로 새 버전을 받아야 합니다. 자동 업데이트를 넣으려면 추가 코드가 필요해요 (Go에도 selfupdate 라이브러리들이 있긴 합니다).
4. 의존성 업데이트 배포
TS/Python에서는 보안 패치 나오면 사용자가 npm update / pipx upgrade 로 받습니다. Go는 라이브러리가 바이너리에 정적 링크되어 있어서, 새 바이너리를 릴리스해야 사용자에게 패치가 전달됩니다. 결국 여러분이 의존성 업데이트를 발견하고 새 릴리스를 만드는 책임을 져야 해요.
각 언어의 스윗스팟
이 시리즈의 세 언어 중 선택을 돕는 요약:
| 상황 | 추천 |
|---|---|
| 빠른 프로토타이핑·사내 도구 | TypeScript 또는 Python |
| API 호출이 주된 작업 | TypeScript (async-first) 또는 Python (httpx) |
| 웹 개발 자산 재사용 | TypeScript (타입 정의 공유) |
| 데이터/ML 생태계 연동 | Python |
| 배포 편의성이 최우선 | Go |
| 기동 속도가 중요 (자주 호출) | Go |
| 크로스플랫폼 배포 | Go |
| 시스템 레벨 성능 | Go 또는 Rust |
정답은 상황에 있습니다. 사내 API를 CLI로 노출하는 10명 쓰는 도구라면 TypeScript가 더 빨라요. 수천 명의 외부 사용자에게 배포하는 개발자 도구라면 Go가 더 맞습니다.
10. 본편과 이어지는 마지막 메시지
세 언어를 다 본 지금, 본편 1편의 주장을 다시 읽어봅시다.
CLI Wrapper의 본질은 “사용자 입력 → 무언가 실행 → 가공된 출력”이라는 단순한 파이프라인이다. 언어·라이브러리·배포 방식이 달라져도 이 본질은 변하지 않는다.
세 언어의 코드를 실제로 나란히 놓고 본 결론은:
- 인터페이스 설계 원칙(동사형 서브커맨드,
--json, stderr, exit code)은 세 언어 모두 동일 - 보안 원칙(shell injection 방어)도 동일 (Go는 기본값이 안전해서 덜 신경 써도 됨)
- 테스트 전략(순수 로직 분리, 외부 호출 모킹)도 동일
- 배포 세부사항은 언어마다 크게 다름 — 그리고 이게 선택의 핵심이 됨
본편에서 TypeScript로 배운 것들은 헛되지 않았습니다. 그대로 다른 언어에 가져가셔도 됩니다. 라이브러리 API를 바꾸는 데는 며칠이면 충분하지만, 설계 원리를 배우는 데는 몇 년이 걸립니다. 여러분은 이 시리즈로 후자의 상당 부분을 쌓으신 거예요.
마치며 — 시리즈 전체를 닫으며
본편 5부작 + 번외 2편, 총 7편의 여정이 여기서 끝납니다. 돌아보면 우리는 이런 여정을 걸었어요.
- 1편에서 CLI Wrapper의 본질과 언어 지형도를 정리하고
- 2편에서 TypeScript 프로젝트의 뼈대를 세웠으며
- 3편에서 Type B 대표
greet-cli로 API 호출의 전형을 익혔고 - 4편에서 Type A 대표
gitx로 기존 CLI 래핑의 패턴을 배웠으며 - 5편에서 테스트·설정 파일·npm 배포·CI까지 다뤄 실전 수준에 도달했습니다
- 번외 1편에서 같은 도구를 Python으로 포팅하며 원리의 언어 독립성을 확인했고
- 번외 2편(이번 편)에서 Go로 포팅하며 배포의 관점에서 선택지를 넓혔습니다
시리즈 전체를 관통하는 한 문장을 다시 적어봅니다.
CLI Wrapper는 사용자 입력을 받아 무언가를 실행하고 가공된 출력을 내놓는 파이프라인이다. 이 본질 위에, 언어마다 다른 관용구와 배포 방식이 얹힌다. 본질을 이해하면 어느 언어로든 같은 도구를 만들 수 있다.
이 시리즈가 여러분의 첫 CLI 도구를 만드는 계기가 되었으면 합니다. 혹은 기존에 만들던 도구의 설계를 다시 돌아볼 기회가 되었길 바라요. 개인적으로 CLI는 가장 공유하기 쉽고, 가장 오래 쓰이는 종류의 소프트웨어 중 하나라고 믿습니다. 잘 만든 CLI 하나가 팀의 생산성을 바꾸고, 오픈소스 하나가 커뮤니티를 바꾸기도 하니까요.
긴 여정 함께해주셔서 감사합니다. 여러분의 greet-cli와 gitx가 실제로 누군가의 터미널에서 돌아가는 날이 오기를 응원합니다. 👋