어느 날 Enter가 느려졌다

터미널에서 Enter를 눌렀는데 다음 프롬프트가 뜰 때까지 300ms 넘게 비는 느낌이 왔다. 큰 레포일 땐 그러려니 했는데, 작은 디렉토리에서도 똑같이 굼떴다. “디렉토리 크기와 무관하게 느리다” — 이 사실이 첫 번째 단서였다.

이 글은 그 지연의 정체를 찾아가서, 얕은 수정으로 안 됐고, 결국 프롬프트 테마를 갈아엎고 Starship으로 넘어온 여정의 기록이다.


첫 시도: status를 빠르게 한다

처음엔 단순하게 접근했다. “Enter 뒤 지연 = git status가 느림"이라고 가정하고, 속도를 높이는 옵션들부터 모두 켰다.

# ~/.zshrc
DISABLE_UNTRACKED_FILES_DIRTY="true"
# git 전역
git config --global core.fsmonitor true
git config --global core.untrackedCache true
git config --global feature.manyFiles true

fsmonitor는 파일 시스템 이벤트를 상주 데몬이 추적해 git status의 내부 스캔 비용을 사실상 0에 가깝게 만든다. 실제로 git status --porcelain 자체의 시간은 극적으로 짧아졌다.

레포.git 크기fsmonitor 적용 후 status
small-repo작음즉시
mid-repo-A30 MB0.025 s
mid-repo-B51 MB0.041 s
big-repo246 MB0.042 s

그런데 Enter 지연은 거의 그대로였다. git status가 10배 빨라졌는데도 체감은 안 바뀐다는 건, 내가 엉뚱한 걸 최적화했다는 뜻이었다.


측정을 다시 한다 — 세 가지 함정

문제를 다시 풀어헤치려면 먼저 제대로 측정해야 했다. 여기서 쉽게 빠지는 함정이 세 개 있었다.

함정 1. zsh -i -c exit는 per-Enter 지연이 아니다

흔히 쓰는 “쉘 벤치마크"인 time zsh -i -c exit는 전혀 다른 걸 잰다. 쉘이 시작할 때 한 번만 드는 비용이다. compinit, plugin 로딩 같은 일회성 작업. 매번 Enter를 칠 때 드는 비용과는 다른 층이라, 이 값을 아무리 줄여도 프롬프트 재평가 비용은 그대로다.

함정 2. precmd를 직접 호출해도 안 된다

일반적인 zsh 테마는 precmd_functions에 훅을 걸어 매 프롬프트 직전에 준비 작업을 한다. 그러나 agnoster는 다르다.

PROMPT='%{%f%b%k%}$(build_prompt) '

PROMPT 변수 안에 명령 치환 $(build_prompt)를 박아놓는 구조라, 프롬프트 문자열이 expansion될 때 비로소 git 호출이 일어난다. 그래서 precmd를 수동으로 실행해 재면 거의 0ms가 찍혀버린다. 실제 Enter 경로와 다른 데서 측정하는 셈이다.

함정 3. 반복마다 새 zsh를 띄우면 측정이 오염된다

# 나쁜 예
for i in {1..10}; do
  time zsh -ic 'print -P "$PROMPT" >/dev/null'
done

이러면 매 반복이 zsh 시작 비용(~200ms)에 묻힌다. 같은 zsh 프로세스 안에서 루프를 돌려야 순수 프롬프트 렌더 비용만 남는다.

올바른 one-liner

결국 이렇게 측정해야 했다 (큰 레포에서 cd 한 상태로):

zsh -ic '
  print -P "$PROMPT" >/dev/null        # 웜업
  { time (for i in {1..10}; do print -P "$PROMPT" >/dev/null; done) } 2>&1
'

print -P "$PROMPT"는 PROMPT 문자열을 강제로 expand시키기 때문에 내부의 $(build_prompt)가 실제로 실행되고, 진짜 비용이 드러난다.


진짜 범인: agnoster의 7× git spawn

측정을 제대로 돌리자 범인이 또렷이 보였다. ~/.oh-my-zsh/themes/agnoster.zsh-themeprompt_git()은 매 프롬프트 렌더마다 git 서브프로세스를 일곱 번 띄운다.

1. git config --get oh-my-zsh.hide-status
2. git rev-parse --is-inside-work-tree
3. git rev-parse --git-dir
4. git status --porcelain        (parse_git_dirty)
5. git symbolic-ref HEAD
6. git log --oneline @{upstream}..   (ahead)
7. git log --oneline ..@{upstream}   (behind)

그리고 macOS의 기본 git 바이너리는 Apple wrapper다.

/Library/Developer/CommandLineTools/usr/libexec/git-core/git

호출 한 번당 fork → exec → wrapper → core 체인에 고정적으로 40~50ms가 든다. 이 오버헤드는 git이 아무리 빨라도 줄지 않는다.

산수가 맞아떨어진다.

7 × 50ms ≈ 350ms

그래서 .git이 수십 MB든 수백 MB든 바닥값은 비슷하게 나온다. 이게 Enter 지연의 정체였다.

측정 결과 (10회 평균)

상태big-repo (246MB .git)small-repo
agnoster 원본 (prompt_git 포함)~427 ms~335 ms
prompt_git를 빈 함수로 대체~14.7 ms~13.6 ms

프롬프트 지연의 95% 이상이 prompt_git의 spawn 오버헤드였다. git status 자체는 fsmonitor 덕분에 이미 충분히 빨랐다. 내가 status 내부를 최적화하느라 헛돈 건 줄이기가 가능한 구간에서 줄이려 했기 때문이다. spawn 횟수는 그대로 7이었으니 체감이 안 바뀐 게 당연했다.


세 가지 선택지

구조적 한계가 확인되자 길이 명확해졌다.

A. agnoster의 git 세그먼트를 끈다

git config --global oh-my-zsh.hide-status 1

혹은 zshrc 마지막에:

prompt_git() { : }

프롬프트 렌더 비용이 15ms로 떨어진다. 대신 브랜치/dirty 표시가 사라진다. 나는 브랜치를 프롬프트에서 보는 편이 좋아서 이 선택지는 제외.

B. powerlevel10k로 간다

gitstatusd라는 상주 데몬이 변경을 감시하며 결과를 파이프로 돌려준다. git 바이너리를 띄우지 않으므로 spawn 0. instant prompt로 초기 띄우기도 빠르다.

괜찮은 옵션이지만, 설정을 직접 다듬기엔 .p10k.zsh 포맷이 다소 난잡해 보였다.

C. Starship으로 간다

Rust로 짠 단일 바이너리. zsh 프롬프트 훅에서 starship 바이너리를 한 번만 fork하고, 그 안에서 libgit2를 라이브러리로 링크해 호출한다. 외부 git 프로세스는 띄우지 않는다. 즉 spawn 1회로 끝.

  • 설정은 ~/.config/starship.toml 한 파일 (TOML)
  • git diff로 리뷰/롤백이 쉬움
  • 프리셋 갤러리가 존재해 시작점 제공

풍부한 정보 + 속도 + 설정 가독성. Starship으로 결정.


Starship 전환 작업

설치

brew install starship
# 1.25.0

~/.zshrc 두 군데 수정

agnoster를 끄고:

- ZSH_THEME="agnoster"
+ # ZSH_THEME="agnoster"   # 비활성, Starship 사용
+ ZSH_THEME=""

파일 끝에 초기화 추가:

# Starship prompt. agnoster 대신. 없으면 graceful skip.
if command -v starship >/dev/null 2>&1; then
  eval "$(starship init zsh)"
fi

바이너리가 없으면 조용히 건너뛰도록 command -v 체크를 넣었다. 새 머신 셋업할 때 zshrc를 그대로 복사해도 안전하다.

프리셋 적용

starship preset pastel-powerline -o ~/.config/starship.toml

pastel-powerline이 가장 정보 밀도가 괜찮아서 선택. 이대로 써도 되지만 두 가지가 걸렸다.

  1. 한 줄 프롬프트라 긴 디렉토리/브랜치명이 나오면 명령 입력 공간이 좁아진다
  2. 다크 터미널에서 일부 세그먼트의 글자가 묻힌다

튜닝 1: 두 줄 프롬프트

format 문자열 끝(마지막 """ 직전)에 줄바꿈과 character 모듈을 추가:

$line_break\
$character

파일 끝에 [character] 섹션:

[character]
success_symbol = "[❯](bold #FCA17D)"
error_symbol = "[❯](bold red)"
vimcmd_symbol = "[❮](bold green)"

이제 첫 줄은 정보 표시(유저/경로/git/언어/시간), 두 번째 줄은 하나만. 명령 입력이 세그먼트에 영향 받지 않는다.


튜닝 2: 다크 터미널 가독성

기본 pastel-powerline은 각 세그먼트 배경색만 지정하고 글자색은 터미널 기본을 쓴다. 어두운 터미널(내 환경은 거의 검정 배경)에서 짙은 배경 세그먼트 안의 글자가 잘 안 보였다.

각 세그먼트에 명시적 글자색과 bold를 박아넣었다.

세그먼트배경글자색
username#9A348E (보라)#ffffff bold
directory#DA627D (분홍)#1a1a2e bold
git_branch / git_status#FCA17D (피치)#1a1a2e bold
언어 버전 (golang 등)#86BBD8 (연파랑)#1a1a2e bold
docker_context#06969A (청록)#ffffff bold
time#33658A#4A86C5#ffffff bold

time 세그먼트의 원래 배경 #33658A는 너무 어두워서 거의 터미널 배경과 합쳐져 보였다. 더 밝은 파랑 #4A86C5로 바꾸고, format 문자열 안의 배경/전경 치환자 두 군데도 같이 교체했다.

bg:#33658A)  →  bg:#4A86C5)
fg:#33658A)  →  fg:#4A86C5)

Nerd Font 글리프가 많은 파일이라 직접 편집하면 글자가 깨지기 쉽다. 수정은 Python 스크립트로 바이트 안전하게 적용했다.


결과

속도 (20회 평균, 같은 zsh 내 print -P "$PROMPT")

상태big-repo (246MB .git)small-repo
agnoster 원본427 ms335 ms
Starship (pastel-powerline)~70 ms~60 ms

약 6배. 인간 지각 한계(~100ms) 안쪽이라 체감상 즉시. Enter가 걸리던 감각이 완전히 사라졌다.

시각

실제 화면 (일부 모자이크):

Starship 프롬프트 실제 렌더링

표시 요소:

  • 사용자명 (흰 bold, 보라 배경)
  • 디렉토리 (3계층 축약)
  • git 브랜치 + 상태 (stashed $ / untracked ? / modified ! / renamed / ahead·behind 등)
  • 감지된 언어 버전 (위 예시엔 Go v7.3.2, Java v25.0.1)
  • 시간 (♥ HH:MM)
  • 두 번째 줄: 입력 커서

이 과정에서 배운 것

  1. 느린 걸 고치려면 먼저 제대로 재야 한다. 내가 잰 zsh -i -c exit는 Enter 지연이 아니라 쉘 시작 시간이었다. 엉뚱한 걸 최적화하느라 며칠 돌아갔다.

  2. spawn 횟수는 내부 로직 속도와 독립이다. git이 1ms에 끝나도 7번 띄우면 350ms가 든다. 개선 가능한 구간이 보일 때, 그게 병목인지부터 확인하자.

  3. 도구의 구조를 모르면 설정도 못 고친다. agnoster가 PROMPT 변수 안에 명령 치환을 넣는 구조라는 걸 몰랐으면 precmd 벤치마크만 반복하며 “왜 안 느리지?“라 했을 거다.

  4. 근본 전환이 결국 싸게 먹힐 때가 있다. 보조 옵션을 쌓아도 못 넘는 한계가 있을 때는 테마 자체를 갈아치우는 편이 빠르다. agnoster → Starship은 설정 파일 두 개 수정 + brew install 한 번이었다.


참고 자료