oh-my-zsh의 속도를 견디지 못하고 zinit으로 바꿨습니다

2025년 1월 12일 일요일 / , , ,
简体中文한국어
요약 ·
oh-my-zsh를 몇 년째 써 왔지만 터미널이 점점 느리게 열려서 zinit으로 바꿨습니다. Turbo 모드로 플러그인을 지연 로드하고, 설정을 몇 개의 모듈로 나눴으며, oh-my-zsh에서 자주 쓰던 것들은 그대로 옮겨 왔습니다.

oh-my-zsh는 제가 개발을 배우기 시작한 첫날부터 거의 줄곧 써 왔고, 벌써 몇 년이 되었습니다. 하지만 몇 년 사이 설정이 점점 늘어나 플러그인, 자동 완성, 각종 도구의 초기화가 모두 하나의 .zshrc 에 쌓였고, 그 결과 터미널을 열 때마다 잠시 기다려야 입력을 시작할 수 있었습니다. 새 탭을 열거나 편집기에서 터미널을 열거나 화면을 분할할 때도 매번 기다려야 했고, 때로는 입력이 약간 따라오지 않는 느낌도 있었습니다.

처음에는 설정을 좀 줄여 플러그인 몇 개를 없애 볼까도 생각했지만, 줄여도 줄여도 효과가 뚜렷하지 않았고 실제로 쓰고 있는 플러그인도 많았습니다. 그래서 결국 플러그인 관리자를 아예 바꾸기로 했습니다.

어디가 느린가

바꾸기 전에 먼저 어디가 느린지 확인해 봤습니다. zsh에는 zprof 모듈이 기본으로 있어 시작 과정에서 각 함수가 얼마나 시간을 썼는지 집계할 수 있습니다. .zshrc 첫 줄에 zmodload zsh/zprof를 추가하고 마지막 줄에 zprof를 추가한 뒤 새 터미널을 열면 시간 소요 표가 출력됩니다.

Shell
# ~/.zshrc 第一行
zmodload zsh/zprof

# ... 原来的配置 ...

# ~/.zshrc 最后一行
zprof

그리고 time 으로 전체 시작 시간을 대략 측정해 봤습니다.

Shell
for i in {1..10}; do time zsh -i -c exit; done

테스트 결과에 따르면 시간은 주로 다음에 쓰였습니다:

  • oh-my-zsh 자체가 전체 lib를 불러온 다음 플러그인을 하나씩 source하고, 마지막으로 compinit 를 실행해 자동 완성을 초기화합니다. 이 작업은 모두 동기적으로 이루어져 전부 끝나야 프롬프트가 표시됩니다.
  • 각종 도구의 초기화입니다. 예를 들어 starship init, zoxide init, fzf --zsh, mise activate, gh copilot alias 같은 것들이 있는데, 각각은 eval "$(xxx)" 형식이라 시작할 때마다 먼저 하위 프로세스를 실행하고 그 출력을 실행해야 합니다.
  • 자잘한 PATH, 함수 정의, 별칭 같은 것들은 하나하나는 느리지 않지만 합치면 적지 않습니다.

oh-my-zsh의 문제는 이런 것들을 뒤로 미룰 수 없다는 데 있습니다. 플러그인이 당장 필요한지와 상관없이 프롬프트가 나오기 전에 모두 로드해야 합니다.

zinit

zinit은 zsh 플러그인 관리자로, 처음에는 zdharma가 개발했습니다. 2021년 원작자가 저장소를 삭제했고, 지금은 커뮤니티가 zdharma-continuum/zinit 에서 계속 유지보수하고 있습니다.

가장 매력적인 부분은 Turbo 모드입니다. 간단히 말해 프롬프트를 먼저 표시해 바로 입력을 시작할 수 있게 한 다음, 플러그인을 백그라운드에서 하나씩 불러오는 방식입니다. 사람이 프롬프트를 보고 첫 명령을 입력할 때까지는 항상 약간의 시간이 걸리는데, 그 시간이면 대부분의 플러그인을 불러오기에 충분합니다. 그래서 체감상 터미널을 열자마자 바로 쓸 수 있습니다.

또한 zinit은 oh-my-zsh의 개별 파일도 바로 불러올 수 있는데, 이런 사용법을 snippet이라고 부릅니다. 그래서 oh-my-zsh 프레임워크 전체가 필요 없고 자주 쓰던 lib와 플러그인 몇 개만 가져오면 되며, 익숙했던 축약어와 동작도 그대로 남아 있습니다.

설치

zinit 공식 설치 스크립트는 .zshrc 맨 위에 코드 한 단락을 추가합니다. zinit이 없으면 자동으로 클론한 뒤 불러옵니다.

Shell
### Added by Zinit's installer
if [[ ! -f $HOME/.local/share/zinit/zinit.git/zinit.zsh ]]; then
    print -P "%F{33} %F{220}Installing %F{33}ZDHARMA-CONTINUUM%F{220} Initiative Plugin Manager (%F{33}zdharma-continuum/zinit%F{220})…%f"
    command mkdir -p "$HOME/.local/share/zinit" && command chmod g-rwX "$HOME/.local/share/zinit"
    command git clone https://github.com/zdharma-continuum/zinit "$HOME/.local/share/zinit/zinit.git" && \
        print -P "%F{33} %F{34}Installation successful.%f%b" || \
        print -P "%F{160} The clone has failed.%f%b"
fi

source "$HOME/.local/share/zinit/zinit.git/zinit.zsh"
autoload -Uz _zinit
(( ${+_comps} )) && _comps[zinit]=_zinit

이렇게 해 두면 새 컴퓨터로 옮겨 갈 때 .zshrc 만 옮겨 두면 첫 터미널을 열 때 zinit이 알아서 설치되고, 플러그인도 처음 로드될 때 자동으로 다운로드됩니다.

아래 설정에서는 모두 zi를 사용했습니다. zinit에 내장된 축약어로, zinit 과 같은 명령입니다.

몇 가지 핵심 ice

zinit 설정은 조금 이상해 보입니다. ice라는 수식어를 많이 사용하기 때문입니다. ice는 zi ice 뒤에 쓰거나 for 문법 앞에 바로 쓰며, 바로 뒤따르는 한 번의 로드에만 적용됩니다. 마치 얼음처럼 한 번 쓰면 녹아 버리는데, 이름도 아마 여기서 나온 것 같습니다.

제가 사용한 것은 주로 다음과 같습니다.

ice 역할
wait Turbo 모드를 켜고 프롬프트가 나온 뒤 로드
lucid 백그라운드 로드가 완료되어도 안내 메시지를 출력하지 않음
light-mode 플러그인이 무엇을 하는지 추적하지 않아 로드가 더 빠르지만, 대신 zinit unload 로 언로드할 수 없음
blockf 플러그인이 스스로 fpath를 바꾸지 않게 하고 자동 완성은 zinit이 통합 관리
depth"1" 플러그인을 클론할 때 최신 커밋 하나만 가져옴
as"completion" 이 파일을 자동 완성 파일로 처리
atinit / atclone / atpull 로드 전, 클론 후, 업데이트 후에 명령 실행

oh-my-zsh 가져오기

oh-my-zsh의 lib와 플러그인은 OMZL:: 와 OMZP:: 두 접두사로 바로 불러올 수 있습니다. 원래 쓰던 것들을 모두 나열했고, 전부 지연 로드합니다.

Shell
zi wait lucid light-mode for \
    OMZL::theme-and-appearance.zsh \
    OMZL::key-bindings.zsh \
    OMZL::clipboard.zsh \
    OMZL::history.zsh \
    OMZL::git.zsh \
    OMZP::git \
    OMZP::history \
    OMZP::eza \
    OMZP::extract \
    OMZP::git-commit \
    OMZP::rust \
    OMZP::copyfile

OMZL 은 oh-my-zsh의 lib입니다. 예를 들어 키 바인딩, 히스토리 설정, git 관련 보조 함수 등이 있습니다. OMZP 는 플러그인입니다. 그러면 OMZP::git 은 원래 git 축약어를 잔뜩 제공하던 플러그인이고, gst, gco 같은 것들도 평소처럼 계속 쓸 수 있습니다.

다만 한 가지 주의할 점이 있습니다. oh-my-zsh는 원래 몇 가지 zsh 옵션을 대신 설정해 주었지만, 이제 프레임워크가 없으므로 직접 작성해야 합니다.

Shell
setopt promptsubst
setopt interactive_comments
setopt auto_cd
setopt auto_pushd
setopt pushd_ignore_dups
setopt pushdminus

프롬프트

제가 쓰는 프롬프트는 starship입니다. starship도 지연 로드하면, 로드가 끝나기 전까지 터미널에는 zsh 기본의 밋밋한 프롬프트가 먼저 표시되었다가 starship 모양으로 번쩍 바뀌어서 보기 불편합니다.

그래서 먼저 .zshrc 에 oh-my-zsh의 robbyrussell 테마와 똑같이 생긴 프롬프트를 직접 작성했습니다. 명령이 성공하면 초록색 화살표, 실패하면 빨간색 화살표가 나오고 뒤에 현재 디렉터리 이름이 붙습니다. 이 프롬프트는 어떤 플러그인에도 의존하지 않아 터미널을 열자마자 바로 표시됩니다.

Shell
PROMPT="%(?:%{$fg_bold[green]%}%1{➜%} :%{$fg_bold[red]%}%1{➜%} ) %{$fg[cyan]%}%c%{$reset_color%} "

그다음 starship을 백그라운드에서 로드하고, 로드가 끝나면 프롬프트를 넘겨받습니다. 둘이 비슷하게 생겨서 전환되는 순간은 거의 알아챌 수 없습니다.

starship 자체도 zinit으로 설치하도록 바꿨습니다. from"gh-r" 은 GitHub Release에서 컴파일된 바이너리를 바로 다운로드하고, atclone 은 다운로드 후 starship init zsh를 한 번 실행해 출력을 init.zsh로 저장합니다. 이후에는 시작할 때마다 이 파일을 바로 source하면 되고, starship init 은 더 이상 매번 실행할 필요가 없습니다. atpull"%atclone" 은 업데이트 후 다시 생성하라는 뜻입니다.

Shell
if $IS_VSCODE; then
    # vscode has _precmd and _preexec hooks conflict with starship if we use async
    zi ice lucid as"command" from"gh-r" \
        atclone"./starship init zsh > init.zsh; ./starship completions zsh > _starship" \
        atpull"%atclone" src"init.zsh"
else
    zi ice wait lucid as"command" from"gh-r" \
        atclone"./starship init zsh > init.zsh; ./starship completions zsh > _starship" \
        atpull"%atclone" src"init.zsh"
fi
zi light starship/starship

여기서는 VS Code를 따로 판별했습니다. VS Code의 터미널은 자체 shell integration이 있어 precmd 와 preexec 두 hook을 거는데, starship이 비동기로 로드되면 이것과 충돌합니다. 그래서 VS Code에서는 starship을 동기로 로드하게 했습니다. 판별 방법은 TERM_PROGRAM 변수를 보는 것입니다.

Shell
IS_VSCODE=false
if [[ "$TERM_PROGRAM" == "vscode" ]]; then
    IS_VSCODE=true
fi

다른 플러그인

GitHub에 있는 플러그인도 마찬가지로 저장소 이름만 적으면 됩니다.

Shell
zi wait lucid light-mode depth"1" blockf for \
    Aloxaf/fzf-tab \
    orbstack/orbstack \
    ajeetdsouza/zoxide

그중 fzf-tab은 zsh 기본 자동 완성 메뉴를 fzf로 바꿔 주는데, Tab을 누르면 퍼지 검색을 할 수 있고 미리보기도 됩니다. 저는 cd 의 자동 완성에 미리보기를 추가해, eza로 디렉터리 내용을 나열하도록 했습니다.

Shell
zstyle ':completion:*' menu no
zstyle ':fzf-tab:complete:cd:*' fzf-preview 'eza -1 --color=always $realpath'
zstyle ':fzf-tab:*' switch-group '<' '>'

자동 완성

자동 완성은 조금 신경 쓸 부분이 있습니다. zsh의 자동 완성은 먼저 compinit 를 실행해 초기화해야 하고, 자체가 빠르지 않기 때문에 한 번만 실행하는 것이 좋습니다. 하지만 플러그인은 백그라운드에서 차례로 로드되고 각 플러그인이 자기 자동 완성을 함께 가질 수 있으므로, compinit 은 모든 자동 완성이 준비된 뒤에 실행해야 합니다.

zinit의 방식은 플러그인을 로드할 때 각 플러그인에서 호출하는 compdef 를 먼저 기록해 두고, 마지막에 zicompinit 으로 자동 완성을 초기화한 다음 zicdreplay 로 앞서 기록한 compdef 를 다시 재생하는 것입니다. 그래서 이 단계를 마지막에 로드되는 zsh-syntax-highlighting에 붙여, atinit 으로 그것이 로드되기 전에 실행하게 했습니다.

Shell
zi wait lucid light-mode depth"1" for \
    atinit"ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay" \
        zsh-users/zsh-syntax-highlighting \
    blockf \
        zsh-users/zsh-completions

ZINIT[COMPINIT_OPTS]=-C 는 compinit 이 검사를 건너뛰고 이미 생성된 자동 완성 캐시를 바로 사용하게 해, 조금 더 빨라집니다.

docker, fd, ripgrep 같은 일부 개별 자동 완성 파일도 주소로 바로 불러올 수 있습니다. as"completion" 을 붙이면 자동 완성 파일로 간주되어 알맞은 위치에 배치됩니다.

Shell
zi wait lucid light-mode as"completion" for \
    OMZP::docker/completions/_docker \
    OMZP::docker-compose/_docker-compose \
    https://raw.githubusercontent.com/sharkdp/fd/master/contrib/completion/_fd \
    id-as"_rg" \
        https://raw.githubusercontent.com/BurntSushi/ripgrep/master/crates/core/flags/complete/rg.zsh

ripgrep의 자동 완성 파일 이름은 rg.zsh인데, zsh는 자동 완성 파일 이름이 밑줄로 시작해야 하므로 id-as"_rg" 로 이름을 바꿉니다.

몇 개의 모듈로 나누기

예전에는 모든 것이 하나의 .zshrc 안에 있어 200줄이 넘는 내용을 훑어보기 힘들었습니다. 이번에는 제가 직접 작성한 부분을 분리해 ~/.config/zinit/ 안의 네 파일에 넣었습니다.

TEXT
~/.config/zinit/
├── env.zsh      # 环境变量和 PATH
├── func.zsh     # 自己写的函数
├── active.zsh   # 需要 eval 激活的工具,比如 fzf 和 mise
└── alias.zsh    # 别名

그다음 zinit으로 이 파일들을 한 번에 불러오게 했고, 마찬가지로 지연 로드합니다.

Shell
zi ice wait'0' lucid light-mode blockf multisrc'~/.config/zinit/{env,func,active,alias}.zsh'
zi light z-shell/null

multisrc 는 여러 파일을 한 번에 source할 수 있지만, ice는 항상 한 번의 로드에 붙어야 하므로 여기서는 빈 플러그인 z-shell/null을 빌려 썼습니다. 이 플러그인은 아무것도 하지 않고 이 파일들을 싣는 매개체 역할만 합니다.

active.zsh 에는 기존 eval 초기화들이 들어 있는데, 이제 이것들도 모두 프롬프트가 나온 뒤로 옮겨졌습니다.

Shell
# Activate some plugins and completions

source <(fzf --zsh)
eval "$(mise activate zsh)"

func.zsh 에는 제가 평소 모아 둔 작은 함수들이 들어 있습니다. 예를 들어 마지막 커밋 시간을 수정하는 git-amend-date, yazi로 디렉터리를 둘러본 뒤 그 경로로 cd 해 주는 yy, 그리고 cargo 를 잘못 사용했는지 확인하는 check_cargo.

Shell
# git last commit date edit
function git-amend-date() {
  local date=$1
  GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git commit --amend --no-edit --date="$date" --no-verify
}

# check if using correct cargo
function check_cargo() {
  cargo_path=$(which cargo 2>/dev/null)
  if [[ -z "$cargo_path" ]]; then
    echo "Cargo is not installed or not in PATH."
    return 1
  fi
  if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
    echo "$cargo_path cargo path error"
  fi
}

이렇게 하면 .zshrc 에는 zinit의 로드 순서와 몇 가지 zsh 옵션만 남아, 이제 전체가 100줄도 되지 않습니다. 무엇을 바꾸고 싶어도 해당 파일을 금방 찾을 수 있습니다.

전체 로드 순서

마지막으로 정리해 보면, 터미널을 열 때 대략 이런 과정을 거칩니다.

Mermaid diagram

실선 부분은 동기적이며, zinit을 로드하고 몇 가지 옵션을 설정하는 것뿐이라 매우 빠릅니다. 점선 부분은 모두 백그라운드에서 프롬프트가 나온 뒤 차례로 완료됩니다.

써 보니

바꾸고 나니 터미널이 거의 즉시 열리고, 프롬프트가 나오자마자 입력할 수 있으며, 분할 화면이나 새 탭도 더 이상 기다릴 필요가 없습니다. oh-my-zsh에서 익숙하게 쓰던 git 축약어, 키 바인딩, 히스토리 동작은 모두 그대로 남아 있어 새로 적응할 부분이 거의 없습니다.

평소 플러그인 업데이트도 간단합니다. zi update --parallel 을 실행하면 모든 플러그인이 병렬로 업데이트되고, zi self-update 는 zinit 자체를 업데이트합니다.

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...