CLI 검증 코드 작성을 그만두세요. 처음부터 올바르게 파싱하세요.

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
저는 이런 나쁜 습관이 있습니다. 어떤 것이 충분히 짜증나게 하면, 결국 그것을 위한 라이브러리를 만들게 됩니다. 이번에는 CLI 검증 코드가 그 대상이었습니다.
저는 다른 사람들의 코드를 읽는 데 많은 시간을 보냅니다. 오픈 소스 프로젝트, 업무 관련 코드, 새벽 2시에 우연히 발견한 GitHub 저장소 등을 살펴보죠. 그러다 계속 이런 것을 발견했습니다: 모든 CLI 도구에는 어딘가에 똑같은 지저분한 검증 코드가 숨어 있습니다. 이런 종류의 코드 말이죠:
if (!opts.server && opts.port) {
throw new Error("--port requires --server flag");
}
if (opts.server && !opts.port) {
opts.port = 3000; // default port
}
// 잠깐, --port를 값 없이 전달하면 어떻게 될까요?
// 포트가 범위를 벗어나면 어떻게 될까요?
// 만약...
이런 코드를 작성하기 어렵다는 게 문제가 아닙니다. 문제는 이런 코드가 어디에나 있다는 겁니다. 모든 프로젝트, 모든 CLI 도구에서요. 같은 패턴, 약간 다른 형태로요. 다른 옵션에 의존하는 옵션들. 함께 사용할 수 없는 플래그들. 특정 모드에서만 의미가 있는 인수들.
그리고 제가 정말 놀란 점은 이것입니다: 우리는 다른 유형의 데이터에 대해서는 이 문제를 수년 전에 해결했습니다. 단지... CLI에 대해서는 아직 해결하지 못했죠.
검증의 문제점
제가 파싱에 대한 생각을 완전히 바꾸게 한 블로그 포스트가 있습니다. Alexis King의 검증하지 말고, 파싱하라(Parse, don't validate)라는 글입니다. 요점은? 데이터를 느슨한 타입으로 파싱한 다음 유효한지 확인하지 말고, 유효할 수밖에 없는 타입으로 직접 파싱하라는 것입니다.
생각해 보세요. API에서 JSON을 받을 때, 그냥 any
로 파싱한 다음 여러 if
문을 작성하지 않습니다. Zod와 같은 도구를 사용해 원하는 형태로 직접 파싱합니다. 유효하지 않은 데이터? 파서가 거부합니다. 끝.
하지만 CLI에서는? 인수를 속성들의 묶음으로 파싱한 다음, 그 묶음이 의미가 있는지 확인하는 데 다음 100줄을 소비합니다. 이건 거꾸로 된 방식입니다.
그래서 네, 저는 Optique를 만들었습니다. 세상이 절실히 또 다른 CLI 파서를 필요로 해서가 아니라(그렇지 않았습니다), 어디서나 같은 검증 코드를 보고—그리고 작성하는 것—에 지쳤기 때문입니다.
검증하는 데 지친 세 가지 패턴
종속적인 옵션들
이건 어디에나 있습니다. 다른 옵션이 활성화되었을 때만 의미가 있는 옵션이 있습니다.
기존 방식? 모든 것을 파싱한 다음 확인합니다:
const opts = parseArgs(process.argv);
if (!opts.server && opts.port) {
throw new Error("--port requires --server");
}
if (opts.server && !opts.port) {
opts.port = 3000;
}
// 더 많은 검증이 다른 곳에 숨어 있을 가능성이 높습니다...
Optique를 사용하면 원하는 것을 그냥 설명하면 됩니다:
const config = withDefault(
object({
server: flag("--server"),
port: option("--port", integer()),
workers: option("--workers", integer())
}),
{ server: false }
);
TypeScript가 config
의 타입을 다음과 같이 추론합니다:
type Config =
| { readonly server: false }
| { readonly server: true; readonly port: number; readonly workers: number }
이제 타입 시스템은 server
가 false일 때 port
가 문자 그대로 존재하지 않는다는 것을 이해합니다. undefined
도 아니고, null
도 아니며—그냥 없습니다. 접근하려고 하면 TypeScript가 경고합니다. 런타임 검증이 필요 없습니다.
상호 배타적인 옵션들
또 다른 클래식입니다. 하나의 출력 형식을 선택하세요: JSON, YAML 또는 XML. 하지만 절대 두 개를 동시에 선택하면 안 됩니다.
예전에는 이런 지저분한 코드를 작성했습니다:
if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) {
throw new Error('Choose only one output format');
}
(판단하지 마세요, 여러분도 비슷한 코드를 작성해 보셨을 겁니다.)
이제는?
const format = or(
map(option("--json"), () => "json" as const),
map(option("--yaml"), () => "yaml" as const),
map(option("--xml"), () => "xml" as const)
);
or()
조합기는 정확히 하나만 성공한다는 의미입니다. 결과는 단순히 "json" | "yaml" | "xml"
입니다. 세 개의 불리언을 다루는 것이 아니라 하나의 문자열입니다.
환경별 요구사항
프로덕션에는 인증이 필요합니다. 개발에는 디버그 플래그가 필요합니다. Docker는 로컬과 다른 옵션이 필요합니다. 여러분도 알다시피요.
검증 미로 대신, 각 환경을 그냥 설명하면 됩니다:
const envConfig = or(
object({
env: constant("prod"),
auth: option("--auth", string()), // 프로덕션에서 필수
ssl: option("--ssl"),
monitoring: option("--monitoring", url())
}),
object({
env: constant("dev"),
debug: optional(option("--debug")), // 개발 모드에서 선택 사항
verbose: option("--verbose")
})
);
프로덕션에서 인증이 없나요? 파서가 즉시 실패합니다. 개발 모드에서 --auth
에 접근하려고 하나요? TypeScript가 허용하지 않습니다—해당 필드는 그 타입에 존재하지 않습니다.
"하지만 파서 컴비네이터라니..."
알아요, 알아요. "파서 컴비네이터"는 이해하려면 컴퓨터 과학 학위가 필요한 것처럼 들립니다.
사실은 이렇습니다: 저는 컴퓨터 과학 학위가 없습니다. 사실, 저는 어떤 학위도 없습니다. 하지만 파서 컴비네이터를 수년간 사용해 왔습니다. 왜냐하면 실제로는... 그렇게 어렵지 않기 때문입니다. 단지 이름이 실제보다 훨씬 더 무섭게 들릴 뿐입니다.
저는 다른 것들—설정 파일 파싱, DSL, 기타 등등—에 파서 컴비네이터를 사용해 왔습니다. 하지만 Haskell의 optparse-applicative를 보기 전까지는 CLI 파싱에도 사용할 수 있다는 것이 와닿지 않았습니다. 그때 정말 "잠깐, 당연하잖아"라는 순간이 왔습니다. 왜 우리가 다른 방식으로 이걸 하고 있었을까요?
알고 보니 이건 어처구니없이 간단합니다. 파서는 그냥 함수입니다. 컴비네이터는 파서를 받아 새로운 파서를 반환하는 함수일 뿐입니다. 그게 전부입니다.
// 이것은 파서입니다
const port = option("--port", integer());
// 이것도 파서입니다 (더 작은 파서들로 만들어진)
const server = object({
port: port,
host: option("--host", string())
});
// 여전히 파서입니다 (계속해서 파서들로 구성됨)
const config = or(server, client);
모나드도 없고, 범주론도 없습니다. 그냥 함수들입니다. 지루하지만 아름다운 함수들이죠.
TypeScript가 무거운 작업을 처리합니다
여전히 속임수처럼 느껴지는 점이 있습니다: 저는 더 이상 CLI 설정에 대한 타입을 작성하지 않습니다. TypeScript가 그냥... 알아서 파악합니다.
const cli = or(
command("deploy", object({
action: constant("deploy"),
environment: argument(string()),
replicas: option("--replicas", integer())
})),
command("rollback", object({
action: constant("rollback"),
version: argument(string()),
force: option("--force")
}))
);
// TypeScript는 이 타입을 자동으로 추론합니다:
type Cli =
| {
readonly action: "deploy"
readonly environment: string
readonly replicas: number
}
| {
readonly action: "rollback"
readonly version: string
readonly force: boolean
}
TypeScript는 action
이 "deploy"
이면 environment
는 존재하지만 version
은 존재하지 않는다는 것을 알고 있습니다. replicas
가 number
라는 것도 알고 있고, force
가 boolean
이라는 것도 알고 있습니다. 제가 이런 것들을 알려주지 않았는데도 말이죠.
이건 단지 좋은 자동 완성에 관한 것이 아닙니다(물론 자동 완성도 훌륭합니다). 버그가 발생하기 전에 잡아내는 것에 관한 것입니다. 어딘가에서 새로운 옵션을 처리하는 것을 잊으셨나요? 코드가 컴파일되지 않을 것입니다.
내게 실제로 바뀐 것
저는 몇 주 동안 이것을 직접 사용해 보았습니다. 솔직한 이야기를 해보겠습니다:
이제 코드를 삭제합니다. 리팩토링이 아니라 삭제입니다. 예전에 CLI 코드의 30%를 차지했던 검증 로직? 사라졌습니다. 매번 이상한 느낌이 듭니다.
리팩토링이 무섭지 않습니다. 보통 저를 겁먹게 하는 것이 무엇인지 아시나요? CLI가 인수를 받는 방식을 변경하는 것입니다. 예를 들어 --input file.txt
에서 위치 인수로서의 file.txt
로 바꾸는 것 같은 경우요. 전통적인 파서를 사용하면 모든 곳에서 검증 로직을 찾아야 합니다. 이 방식에서는? 파서 정의를 변경하면 TypeScript가 즉시 깨지는 모든 곳을 보여주고, 그것들을 수정하면 끝입니다. 예전에는 "모든 것을 잡았나?"라는 생각으로 한 시간이 걸렸던 일이 이제는 "빨간 물결선을 수정하고 넘어가자"가 되었습니다.
CLI가 더 멋져졌습니다. 복잡한 옵션 관계를 추가하는 것이 복잡한 검증을 작성하는 것을 의미하지 않으면, 그냥... 추가합니다. 상호 배타적인 그룹? 물론이죠. 컨텍스트에 따라 달라지는 옵션? 왜 안 되겠어요. 파서가 처리합니다.
재사용성도 실제로 있습니다:
const networkOptions = object({
host: option("--host", string()),
port: option("--port", integer())
});
// 어디서나 재사용하고, 다르게 구성
const devServer = merge(networkOptions, debugOptions);
const prodServer = merge(networkOptions, authOptions);
const testServer = merge(networkOptions, mockOptions);
하지만 솔직히? 가장 큰 변화는 신뢰입니다. 컴파일되면 CLI 로직이 작동합니다. "아마도 작동할 것"이나 "누군가 이상한 인수를 전달하지 않는 한 작동한다"가 아닙니다. 그냥 작동합니다.
관심을 가져야 할까요?
하나의 인수만 받는 10줄짜리 스크립트를 작성하고 있다면, 이것이 필요하지 않습니다. process.argv[2]
를 사용하고 끝내세요.
하지만 다음과 같은 경험이 있다면:
- 검증 로직이 실제 옵션과 동기화되지 않은 경우
- 특정 옵션 조합이 프로덕션에서 폭발한다는 것을 발견한 경우
--json
과 함께 사용할 때--verbose
가 왜 깨지는지 추적하는 데 오후 시간을 보낸 경우- 다섯 번째로 "옵션 A는 옵션 B가 필요합니다" 검사를 작성한 경우
그렇다면 네, 아마도 여러분도 이런 것들에 지쳤을 것입니다.
공정한 경고: Optique는 아직 젊습니다. 저는 아직 여러 가지를 알아가는 중이고, API가 약간 변경될 수 있습니다. 하지만 핵심 아이디어—검증하지 말고, 파싱하라—는 확고합니다. 그리고 저는 몇 달 동안 검증 코드를 작성하지 않았습니다.
여전히 이상한 느낌입니다. 좋은 의미에서요.
시도하든 말든
이 내용이 공감된다면:
저는 Optique가 모든 CLI 문제의 해답이라고 말하는 것이 아닙니다. 단지 어디에서나 같은 검증 코드를 작성하는 것에 지쳐서, 그것을 불필요하게 만드는 무언가를 만들었다는 것뿐입니다.
받아들이든 말든 자유입니다. 하지만 지금 작성하려는 그 검증 코드? 아마도 필요하지 않을 겁니다.