본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 25. 23:12

Claude Code를 zsh 함수로 감싸기: 내가 거의 실수할 뻔했던 모든 결정들

요약

Claude Code를 효율적으로 사용하기 위해 zsh 함수로 래퍼(wrapper)를 만드는 과정에서의 기술적 결정 사항을 다룹니다. alias나 쉘 스크립트 대신 함수를 선택한 이유와 명령어 명명 시 발생할 수 있는 시스템 컴파일러와의 충돌 문제를 분석합니다.

핵심 포인트

  • 서브커맨드 지원과 TTY 상속을 위해 쉘 함수 방식 채택
  • alias의 한계와 쉘 스크립트의 서브쉘 생성 문제 해결
  • 시스템 명령어(cc)와의 이름 충돌 및 섀도잉(shadowing) 주의
  • zsh 함수를 통한 탭 완성(tab-completion) 구현 가능성

Claude Code의 --help 명령어를 보면 50개 이상의 플래그(flags)가 나열되어 있습니다. 2주 동안 매일 사용해 본 결과, 저는 제가 실제로 원하는 플래그들을 미리 포함시킨 cco라는 zsh 래퍼(wrapper)를 만들었습니다. 래퍼 자체는 60줄에 불과합니다. 흥미로운 점은 그 60줄 뒤에 숨겨진 결정들입니다. 그중 대부분은 적어도 한 번은 되돌아가야 했던 결정들이었습니다.

이것은 결정 로그(decision log)입니다. 만약 여러분이 Claude Code를 진지하게 사용하고 있다면, 이 중 일부가 여러분의 시행착오를 줄여줄 것입니다.

결정 1: 쉘 스크립트나 alias가 아닌 함수(Function)

가장 단순한 본능은 alias cc="claude --permission-mode acceptEdits --append-system-prompt ..."를 만드는 것입니다. 하지만 서브커맨드(subcommands)를 사용하고 싶어지는 순간 이 방식은 한계에 부딪힙니다. cco plan, cco safe, cco review와 같이 인자(arguments)에 따라 분기하는 작업은 alias로는 불가능합니다.

다음 생각은 ~/.local/bin/cc에 독립적인 쉘 스크립트(shell script)를 만드는 것이었습니다. 대부분의 경우 잘 작동하지만, 서브쉘(subshell)을 생성한다는 단점이 있습니다. 상태가 없는(stateless) 명령어를 사용할 때는 괜찮습니다. 하지만 명령어가 tmux 부착(attachment)이나 프롬프트 렌더링(prompt rendering)을 위해 부모 터미널의 tty를 필요로 하는 대화형 프로세스(interactive process)를 감싸는 경우에는 문제가 됩니다. 테스트할 때는 잘 작동했지만, 엣지 케이스(edge cases)에서 이상하게 동작했습니다.

zsh 함수(function)는 현재 쉘(shell)에서 실행됩니다. tty를 깔끔하게 상속받으며, 서브커맨드에 따라 명령을 분기할 수 있습니다. 또한 compdef를 통해 탭 완성(tab-completion)도 가능합니다. 이것이 제가 선택한 방식입니다.

비용: .zshrc(또는 소싱된 모듈 파일)에 저장됩니다. 다시 작성하지 않는 한 bash 사용자에게는 이식성(portable)이 없습니다. 저는 상관없습니다. 이것을 다른 사람들에게 배포할 계획은 아니니까요.

결정 2: cc vs cco

처음에는 cc를 선택했습니다. 두 글자이고, "Claude Code"를 연상시키기 때문입니다. 거의 확정할 뻔했습니다.

그러다 제 alias 파일을 확인했습니다. cl은 이미 cargo clippy --all-targets가 사용 중이었습니다. 괜찮습니다, 어차피 cl은 사용하지 않았으니까요. 하지만 그 덕분에 cc를 더 주의 깊게 살펴보게 되었습니다.

macOS에서 cc/usr/bin/cc에 있는 C 컴파일러(C compiler)로 연결되는 심볼릭 링크(symlink)입니다. 제 $PATH에는 /opt/homebrew/opt/llvm/bin이 앞에 설정되어 있어, which cc를 실행하면 시스템 clang으로 해결됩니다. zsh 함수는 이를 가릴(shadow) 것입니다. 대화형 쉘(interactive shells)에서는 함수가 $PATH 조회보다 우선순위를 갖기 때문입니다.

그럼에도 불구하고 가릴(shadowing) 수밖에 없는 이유: 저는 컴파일러를 호출하기 위해 직접 cc를 입력하는 일이 전혀 없습니다. Cargo, CMake, Make — 이들은 모두 프로그래밍 방식으로 이를 호출합니다.

반론: 프로그래밍 방식의 호출은 쉘 함수를 인식하지 못하는 execvp를 통해 발생합니다. 하지만 — Rust의 cc crate (openssl-sys, ring, zstd-sys 및 수천 개의 다른 의존성에서 사용됨)는 빌드 스크립트 내에서 쉘 래퍼(shell wrappers)를 통해 cc를 호출하는 경우가 가끔 있습니다. 이 상황에 맞닥뜨릴 확률은 낮습니다. 하지만 일단 발생했을 때의 디버깅 비용 — 도무지 이해할 수 없는 ring 빌드 실패를 뚫어지게 쳐다보는 비용 — 은 매우 높습니다.

cco로 이름을 변경했습니다. 두 번의 키 입력 대신 세 번을 입력하게 되었지만, 그만한 가치가 있습니다.

교훈: 짧은 명령어를 선점하기 전에

트레이드오프 (trade-off): 파일 의존성이 하나 더 늘어납니다. 만약 파일이 없다면, 함수는 에러와 함께 종료됩니다. 수용 가능한 수준입니다.

결정 4: 플래그 (flags) 대신 서브커맨드 (Subcommands)

함수는 첫 번째 인자에 따라 명령을 분기합니다:

case "$sub" in
  plan)   ...  # 읽기 전용 분석 (read-only analysis)
  safe)   ...  # dontAsk + 엄격한 화이트리스트 (tight whitelist)
...

cco --plan, cco --safe 등을 고려했습니다. 반대하는 두 가지 이유는 다음과 같습니다:

  1. 플래그 파싱 (Flag parsing)이 Claude의 플래그와 충돌합니다. cco --plan은 "래퍼의 plan 모드"를 의미할 수도 있고, "--plan을 Claude에게 전달"하는 것을 의미할 수도 있습니다 (현재 Claude에는 해당 플래그가 없지만, 파싱 로직이 빠르게 모호해집니다).
  2. 서브커맨드가 탭 완성 (tab completion)과 더 잘 결합됩니다. cco <Tab>을 누르면 메뉴가 나타납니다. 반면 cco --<Tab>을 누르면 모든 Claude 플래그가 쏟아져 나올 것입니다.

기본 케이스(default case)는 run|*입니다. 즉, 그냥 cco만 입력하거나 cco "some prompt"라고 입력해도 둘 다 작동합니다. run 키워드는 주로 탭 완성이 기본값에 대해 메뉴에 보여줄 무언가가 있도록 하기 위해 존재합니다.

한 가지 남겨둔 예외 케이스가 있습니다: cco "plan my vacation"은 첫 단어가 plan이기 때문에 plan) 분기에 매칭됩니다. 만약 누군가 이 상황을 겪게 된다면 — cco run "plan my vacation"이 해결책입니다. 저는 이 충돌이 무시해도 될 정도로 드물다고 판단했습니다.

결정 5: 기본 모드에서의 --tmux

이 부분에 대해서는 솔직해지고 싶습니다. Claude를 호출할 때마다 또 다른 tmux 세션이 생기는 것을 아무도 원하지 않을 것이라고 가정했기에, tmux를 제외할 뻔했습니다.

저는 스스로에게 단도직입적으로 물었습니다: 당신은 tmux 환경에서 생활합니까? 네. 그래서 기본값은 tmux-on 상태를 유지합니다.

만약 tmux 환경에서 생활하지 않는다면, 이 기능의 가치는 무너집니다. --tmux는 오직 다음과 같은 경우에만 의미가 있습니다:

  • 세션을 분리(detach)한 후 나중에 다른 셸에서 다시 연결(reattach)하고 싶을 때.
  • 하나의 터미널에서 전환하며 여러 Claude 작업을 병렬로 실행하고 싶을 때.
  • 가끔 개발 머신에 SSH로 접속할 때.

이 중 어느 것도 해당하지 않는다면, --tmux는 그저 tmux 세션을 유출(leak)할 뿐입니다. 일주일 정도 작업하고 나면 tmux ls에 40개의 좀비 세션이 쌓여 있을 것입니다. 그런 경우라면 건너뛰십시오.

만약을 대비해 정리용 별칭 (cleanup alias)을 추가했습니다:

alias cco-cleanup='tmux ls 2>/dev/null | grep "^cco-" | cut -d: -f1 | xargs -I{} tmux kill-session -t {}'

결정 6: 기본값은 Worktree로, "here" 모드는 탈출구로

--worktree는 호출마다 별도의 git worktree를 생성합니다. Claude는 병렬 디렉토리 내의 병렬 브랜치에서 작업하며, 사용자의 메인 체크아웃(checkout) 상태는 전혀 건드리지 않습니다.

이 방식의 장점은 확실합니다. 특히 새로운 직장에서 수습 기간 중일 때 유용합니다. Claude가 공격적으로 리팩터링(refactor)을 수행하더라도, 상황이 잘못되면 그냥 git worktree remove를 실행하면 그만이며 아무런 영향도 미치지 않습니다. git stash를 할 필요도, "내가 어떤 상태였지?"라며 당황할 필요도, 패닉에 빠질 필요도 없습니다.

단점은 때때로 격리(isolation)를 원하지 않을 때가 있다는 점입니다. 작업 중간에 VSCode에 파일들이 열려 있고, 머릿속에 작업 모델(mental model)이 로드되어 있는 상태라면 말이죠. 이때는 Claude가 평행 세계가 아닌, 바로 "여기(here)"에서 버그 하나를 수정해주길 원하게 됩니다.

그래서 here 서브커맨드를 추가했습니다:

here)
  shift
  local branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')
...

시스템 프롬프트(system prompt), acceptEdits, 로깅(logging) 방식은 모두 동일합니다. 하지만 worktree도, tmux도 사용하지 않습니다. 그냥 바로 투입되어 작업을 수행하고, 작업을 마치면 빠져나옵니다.

"auth-store 아키텍처를 변경해줘"라고 할 때는 cco를 사용하세요. "42번 라인의 null 체크를 수정해줘"라고 할 때는 cco here를 사용하면 됩니다.

결정 7: 기본 모드에 caffeinate -is 래핑하기

macOS의 클램쉘(clamshell) 절전 모드는 오래 걸리는 에이전트(agent) 작업들을 망쳐놓습니다. 노트북 덮개를 닫고 차를 한 잔 가져왔다가 돌아오면, 자리를 비운 순간부터 작업이 일시 중단되어 있습니다.

caffeinate -is는 래핑된 프로세스가 실행되는 동안 시스템을 깨어 있는 상태(-s)로 유지하고 유휴 상태 절전(-i)을 방지합니다. Claude가 종료되면 caffeinate는 해당 어서션(assertion)을 해제합니다. 상태 누수(leaked state)가 발생하지 않습니다.

caffeinate -is claude --worktree "$wt_name" --tmux ... "$@"

솔직한 한계점: caffeinate -s는 AC 전원이 연결되어 있을 때만 작동합니다. Apple의 SMC는 사용자 영역(userland)의 명령과 상관없이 배터리 사용 시 클램쉘 절전 모드를 강제합니다. 제가 절대 설치하지 않을 제3자 커널 확장 프로그램(kexts) 없이는 이를 우회할 방법이 없습니다.

따라서: 덮개 닫음 + AC 전원 + (외부 디스플레이 또는 키보드) → 표준 클램쉘 모드로 작동합니다. 덮개 닫음 + 배터리 사용 → 무엇을 하든 절전 모드로 들어갑니다. 저는 이 점을 미리 사람들에게 알립니다. 그렇지 않으면 사람들이 이 래퍼(wrapper)가 고장 났다고 생각할 수 있기 때문입니다.

저는 caffeinate를 기본 모드(default mode)에만 추가했을 뿐, plan, here, safe, 또는 review 모드에는 추가하지 않았습니다. 그 이유는 다른 모드들은 실행 시간이 짧기 때문입니다. 기본 모드(worktree + 긴 리팩토링 작업)야말로 caffeinate가 제 역할을 다할 수 있는 영역입니다.

결정 8: 대화형 선택기(interactive pickers)를 제외한 모든 것을 tee로 처리하기

각 호출은 tee를 통해 ~/claude-logs/<timestamp>_<projectname>.log에 로그를 남깁니다:

claude ... "$@" 2>&1 | tee "$log_file"

이를 통해 Claude의 내부 세션 저장소(internal session storage)에 의존하지 않고도 검색 가능한 히스토리를 가질 수 있습니다. 3일 뒤에 무언가 잘못되었을 때 — "잠깐, 화요일에 인증(auth) 리팩토링에 대해 Claude가 뭐라고 했었지?" — 저는 로그에서 rg(ripgrep)로 검색합니다.

예외: cco resume은 Claude의 대화형 세션 선택기(interactive session picker)를 사용합니다. 이를 tee로 파이프라이닝하면 선택기의 TUI 렌더링이 깨집니다. 따라서 resume에 대한 로그는 남기지 않습니다. script(1)를 사용하여 이를 해결하는 방안도 고려했지만, 이는 거의 사용하지 않을 기능을 위해 불필요한 수고(yak shave)를 들이는 일이었습니다.

내가 다르게 했을 점

만약 처음부터 다시 시작한다면:

  1. 열망이 아닌, 소거법을 통해 이름을 먼저 정하겠습니다. 저는 cc, cl, cco 사이에서 갈팡질팡하며 15분을 허비했습니다. 처음부터 네 가지 후보에 대해 type <candidate>를 실행해 보았다면 즉시 결정되었을 것입니다.
  2. 래퍼(wrapper)를 만들기 전에 시스템 프롬프트(system prompt)를 작성하겠습니다. 래퍼는 배관(plumbing) 작업일 뿐입니다. 시스템 프롬프트가 실제적인 영향력(leverage)이며, Claude에게 어떻게 생각해야 하는지 알려주는 핵심입니다. 저는 재미있는 부분이었기에 래퍼를 먼저 만들었습니다. 순서가 틀렸습니다.
  3. 추측에 기반하여 기능을 추가하지 않겠습니다. --add-dir을 위해 공유 타입(shared types)과 노트 디렉토리를 가져오는 --wide 플래그를 추가할 뻔했습니다. 코드를 작성하기 전에 이를 잘라냈습니다. 6개월이 지난 지금도 여전히 그 기능은 필요하지 않습니다. 잘 잘라낸 결정이었습니다.

래퍼

전체 코드: gist.github.com/IgorKramar/9b4c698909047934ee8e5dd775e94ebc

만약 여러분이 이와 유사한 것을 만든다면, 여러분은 다른 결정을 내리게 될 것입니다. 저의 결정 중 일부는 상황 특화적(new job에서의 수습 기간 → worktree 격리가 더 중요함)이었고, 일부는 도구 특화적(tmux 사용자 → --tmux 기본값 설정)이었습니다. 핵심은 코드를 그대로 복사하는 것이 아닙니다. 핵심은 이것입니다: 여러분의 래퍼(wrapper)가 60줄에 달할 때, 모든 줄은 누군가의 튜토리얼이 제공한 기본값이 아니라 의도적인 선택이어야 한다는 것입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0