
솔직히 말해봅시다. 새벽 3시에 PagerDuty 알람을 받고 깼을 때, 당신의 뇌 가동률은 몇 퍼센트나 될까요? 장담컨대 30% 미만입니다. 비몽사몽 한 상태로 터미널을 켜고 긴급 핫픽스를 배포하려는데, 탭(Tab)을 눌렀더니 수백 개의 브랜치 목록이 쏟아진다면?
그건 정보가 아닙니다. 그냥 노이즈죠.
대부분의 CLI 도구는 멍청합니다. --repo 옵션에 무엇을 넣었든 상관없이, --branch 옵션은 세상의 모든 브랜치를 보여줍니다. 결국 엔지니어는 화면에 뜬 수많은 텍스트 뭉치 속에서 실수할 확률과 싸워야 합니다.
오늘은 이 '인적 오류(Human Error)'의 가능성을 시스템 레벨에서 제거하는 방법, 구체적으로 Optique 0.10.0을 활용한 상황 인식형(Context-Aware) CLI 구축법을 이야기하려 합니다.
CLI가 '눈치'를 챙겨야 하는 이유
기존의 CLI 파서들은 각 옵션을 독립적인 섬처럼 취급합니다. --repo 값이 /backend이든 /frontend이든, --branch 자동 완성은 그저 알고 있는 모든 브랜치를 나열할 뿐입니다.
이게 왜 위험하냐고요?
긴급 상황에서는 시야가 좁아집니다(Tunnel Vision). 엉뚱한 저장소의 develop 브랜치를 프로덕션에 배포하는 사고는 보통 이런 사소한 UI의 결핍에서 시작됩니다.
사용자가 입력한 앞선 옵션의 값(Context)을 이해하고, 그에 맞는 다음 선택지(Dependent Option)를 제시해야 합니다.
해결책: 런타임 의존성 (Runtime Dependency)
Optique 0.10.0은 이 문제를 해결하기 위해 의존성 시스템을 도입했습니다. 핵심은 간단합니다. 옵션 간의 위계질서를 만드는 겁니다.

1. 정적 의존성 (Static) 컴파일 타임에 관계가 명확할 때 씁니다. 예를 들어 --json 플래그가 켜지면 --pretty 옵션이 활성화되는 식입니다. or() 조합자를 사용하면 타입스크립트 레벨에서 타입 안전성까지 보장됩니다. 하지만 이건 뻔한 기능이죠.
2. 동적 의존성 (Dynamic) 우리가 진짜 필요한 건 이겁니다. 사용자가 입력하는 값에 따라 유효한 선택지가 달라지는 경우입니다.
- --environment가 prod면? -> 프로덕션용 서비스만 보여줌.
- --repo가 payment-service면? -> 해당 리포지토리의 브랜치만 보여줌.
Action Item: '눈치 있는' CLI 만들기
자, 이제 코드를 봅시다. dependency()와 derive()가 핵심입니다.
Step 1: 의존성 소스 정의 먼저 기준이 되는 옵션을 의존성 소스로 마킹합니다. 여기서는 Git 리포지토리 경로가 되겠네요.
import { dependency, string } from "@optique/core";
// repoParser를 의존성 소스로 지정
const repoParser = dependency(string());Step 2: 파생 파서(Derived Parser) 생성 이제 repoParser의 값에 따라 동작이 바뀌는 branchParser를 만듭니다. factory 함수가 마법이 일어나는 곳입니다.
import { gitBranch } from "@optique/git";
const branchParser = repoParser.deriveAsync({
metavar: "BRANCH",
// 사용자가 입력한 repoPath가 실시간으로 주입됩니다.
factory: (repoPath) => gitBranch({ dir: repoPath }),
defaultValue: () => ".",
});이 factory 함수는 사용자가 --repo에 /path/to/project를 입력하는 순간 실행됩니다. 그리고 정확히 그 경로에 있는 브랜치 목록만 가져와서 자동 완성 목록에 띄웁니다.
Step 3: 커맨드 조립 이제 두 파서를 하나로 묶어주면 끝입니다.
const checkout = command(
"checkout",
object({
repo: option("--repo", repoParser, {
description: message`Path to the repository`,
}),
branch: option("--branch", branchParser, {
description: message`Branch to checkout`,
}),
}),
);결과가 어떨까요? my-cli checkout --repo /netflix-core --branch <TAB>을 누르면, 더 이상 쓸모없는 ui-component 브랜치는 보이지 않습니다. 오직 netflix-core의 브랜치만 보입니다. 실수를 하고 싶어도 할 수 없는 구조가 되는 겁니다.
심화: 복합 의존성 (Multiple Dependencies)
현실 세계는 더 복잡합니다. 배포 대상을 고르려면 '환경(Env)'과 '리전(Region)'을 모두 알아야 할 때가 있죠. 이럴 땐 deriveFrom()을 씁니다.
const envParser = dependency(choice(["dev", "prod"]));
const regionParser = dependency(choice(["us-east", "ap-northeast"]));
const serviceParser = deriveFrom({
dependencies: [envParser, regionParser], // 두 가지 조건을 모두 봅니다.
metavar: "SERVICE",
factory: (env, region) => {
// env와 region 조합에 맞는 서비스 목록만 리턴
return choice(getAvailableServices(env, region));
},
// ...
});이제 운영팀 막내가 들어와도 prod 환경의 ap-northeast 리전에, 엉뚱하게 us-east 전용 서비스를 배포할 일은 없습니다. 시스템이 애초에 그 선택지를 보여주지 않으니까요.
생존을 위한 제언
이런 기능이 "편의성"을 위한 거라고 생각한다면 오산입니다. 이건 안전장치(Safety Guardrail)입니다.
저는 팀원들에게 항상 말합니다. "여러분의 기억력을 믿지 말고, 스크립트를 믿으세요." 새벽 3시, 카페인과 수면 부족에 절어 있는 당신의 뇌는 믿을 게 못 됩니다.
Optique가 내부적으로 수행하는 '3단계 파싱(1차 파싱 -> 값 수집 -> 팩토리 호출 -> 2차 파싱)' 과정은 복잡해 보일 수 있습니다. 하지만 그 복잡함을 개발자가 떠안음으로써, 운영 단계에서의 리스크는 획기적으로 줄어듭니다.
화려한 대시보드보다 중요한 건, 매일 쓰는 CLI 도구가 내 손가락을 잡아주는 투박한 친절함입니다. 지금 당장 여러분 팀의 배포 스크립트를 열어보세요. 탭을 눌렀을 때 모든 것이 다 튀어나오고 있진 않습니까? 그렇다면 그건 시한폭탄입니다.


