Fish Shell - first contribution

Published:

입사하기 전에 오픈소스에 뭐라도 contribution 하고 싶었다. 뭔가 거창한 걸 찾기보단 평소에 매일 쓰던 aws-cli 레포의 이슈 트래커를 뒤졌고, 거기서 #9670 이 눈에 들어왔다. fish shell 관련 이슈였다.

fish shell 이 뭔지도 정확히 몰랐다. 그래서 shell 이란 건 뭐고, 종류는 뭐가 있고, aws-cli 는 어떤 shell 들을 지원하고, fish 는 왜 쓰는 건지 – 하나씩 짚어보면서 PR 을 준비했다. 아래가 그 정리.

Shell 이 뭐고, 종류는?

Shell 은 OS 커널과 사용자를 중간에서 이어주는 인터프리터. 터미널에 입력한 커맨드를 해석해서 프로세스를 띄워주는 놈이다.

대표적인 것들:

  • sh (Bourne shell) – 1977. 모든 POSIX shell 의 조상.
  • bash (Bourne Again SHell) – 1989. sh 호환 + 편의 기능. 리눅스 기본값.
  • zsh – bash 호환 + 더 많은 옵션. macOS 기본값 (Catalina 이후).
  • ksh (Korn shell) – AT&T, POSIX 표준화에 영향.
  • tcsh – C 문법 기반 csh 확장.
  • fish (Friendly Interactive SHell) – 2005. POSIX 호환을 의도적으로 포기.
  • PowerShell – Windows/.NET 오브젝트 기반 shell.

크게 나누면 POSIX 계열(sh, bash, zsh, ksh) 과 비-POSIX 계열(fish, PowerShell, tcsh) 로 갈린다. fish 가 이 글의 주인공.

Fish Shell 은 왜 쓰나

한 줄 요약: 기본 설정만으로 편하게 쓰기 좋다. bash/zsh 가 “플러그인 생태계 + 커스터마이징” 으로 완성되는 쉘이라면, fish 는 “박스를 열자마자 현대적인 UX” 가 목표다.

  • Autosuggestion – 히스토리·파일시스템·man page 기반으로 타이핑 중에 회색으로 뒷부분을 미리 보여준다. Right arrow / Ctrl+F 로 수락, Alt+→ 로 단어 단위 수락.
  • Syntax highlighting – 실행 전부터 색으로 알려준다. 없는 커맨드는 빨간색, 존재하는 경로는 밑줄, 인용부 불일치는 즉시 표시. 오타를 실행 전에 잡는다.
  • 웹 기반 설정fish_config 한 줄 치면 브라우저에서 테마·함수·단축키·abbreviation 을 GUI 로 설정할 수 있다. 바꾼 즉시 설정 파일에 기록된다.
  • Abbreviations (abbr) – alias 보다 한 단계 위의 기능. 스페이스를 치는 순간 축약어가 원래 커맨드로 확장되면서 히스토리에도 확장된 버전이 남는다 (뒤에 자세히 다룬다).
  • 기본값이 친화적 – bash/zsh 은 플러그인 몇 개 깔아야 얻는 UX 가 기본으로 들어있다. 설정 파일이 사실상 비어있어도 충분히 쓸만하다.
  • 깔끔한 man pagehelp 커맨드로 shell 내장 HTML 문서를 브라우저로 띄울 수 있다.

Fish Shell Deep Dive

좀 더 들어가보자.

철학과 역사

2005 년 Axel Liljencrantz 가 만들었다. 이름의 약자부터가 “Friendly Interactive SHell”. bash 처럼 sh 호환을 유지하며 기능을 얹는 길을 포기하고, 처음부터 다시 설계한 shell 이다. POSIX 호환을 버리는 대신 일관성·사용성을 택한 트레이드오프다.

설계 원칙을 공식 문서에서는 다음과 같이 정리한다:

  • Configurability is the root of all evil – 설정 옵션을 줄이고 “기본값이 옳도록” 설계.
  • The law of user focus – 일관된 인터페이스가 유연성보다 우선.
  • Orthogonality – 하나의 기능은 한 가지 방식으로만 제공.
  • Discoverability – 새 사용자가 검색 없이 기능을 찾을 수 있어야 함.

구현 측면에서도 변화가 있었다. 초기엔 C++ 로 쓰였으나 2023 년부터 Rust 로 점진적 포팅이 시작됐고 (fish 4.0 부터), 현재는 상당 부분 Rust 로 교체됐다. 의존성은 거의 없다 – libcurl, ncurses, pcre2 정도.

버전 이정표:

  • 2.0 (2013) – autosuggestion 도입.
  • 3.0 (2019) – 문법 일부 정리 (&&/|| 허용 추가 등 점진적 완화).
  • 3.1& 백그라운드 문법 표준화.
  • 4.0 (2025) – Rust 포팅 완료.

변수와 스코프

bash 는 변수 다룰 때 목적마다 다른 커맨드를 쓴다:

VAR=val            # 현재 shell 에만
export VAR=val     # 환경변수 (자식 프로세스에 상속)
local VAR=val      # 함수 안 로컬

fish 는 set 하나로 통일하고 스코프를 플래그로 지정한다:

set -l VAR val     # local
set -g VAR val     # global
set -x VAR val     # exported (환경변수)
set -U VAR val     # universal (모든 세션 영구 공유)
set -gx PATH $PATH /usr/local/bin

특이점은 universal variable (set -U). ~/.config/fish/fish_variables 파일에 영구 저장되고 그 시스템의 모든 fish 세션에서 공유된다. bash/zsh 에는 없는 개념. 편하긴 한데 디버깅하다 보면 “이거 어디서 설정된 거지?” 하는 순간이 온다.

또 하나 특이한 건 **리스트가 first-class ** 이라는 점이다. 변수는 기본적으로 배열이고, 단일 값도 길이 1 짜리 리스트처럼 다룬다.

set fruits apple banana cherry
echo $fruits[1]          # apple  (1-indexed)
echo $fruits[-1]         # cherry
echo $fruits[2..3]       # banana cherry
echo (count $fruits)     # 3

# PATH 도 그냥 리스트
echo $PATH               # 공백으로 쪼개져서 출력
set -a PATH /opt/bin     # append
set -p PATH /opt/bin     # prepend

bash 의 $PATH 는 콜론 구분자가 들어간 문자열이라 IFS=: 로 쪼개거나 ${PATH//:/ } 치환이 필요한데, fish 에선 그냥 리스트를 iterate 하면 된다.

문법 차이 (vs bash)

POSIX 비호환의 구체적인 모습:

# 명령 치환: $(...) 가 아니라 ()
set files (ls)

# if/for/while 끝맺음이 fi/done 이 아니라 end
if test -f foo.txt
    echo exists
end

for i in 1 2 3
    echo $i
end

# && / || 대신 and / or
mkdir build; and cd build

# 함수 정의
function greet
    echo "hi $argv[1]"
end

bash 스크립트를 fish 에 그대로 붙여넣으면 대부분 깨진다. 이게 fish 의 정체성인 동시에 한계다.

몇 가지 더 걸리는 포인트:

# 산술연산: $((...)) 가 아니라 math
set result (math "2 + 3 * 4")

# 문자열 다루기: ${var%.*} 같은 건 없음. string 커맨드로 통일
string replace -r '\.txt$' '.md' file.txt
string split , "a,b,c"          # a\nb\nc
string length "hello"           # 5
string upper hello              # HELLO

# 테스트: [[ ]] 없음. test 만 씀
if test "$name" = "alice"; and test -f foo.txt
    echo match
end

# 파이프: stderr 리다이렉션이 2>&1 가 아니라 &|
command 2>&1 | grep error      # fish 에선
command &| grep error          # 이렇게도 가능

# heredoc 없음 – echo 와 string 으로 흉내내거나 외부 파일 사용

string 서브커맨드가 특히 인상적이다. bash/sed/awk 의 문자열 처리가 한 커맨드로 통합돼 있어 sed s/.../.../ 보다 읽기 쉽다.

Autosuggestion & Syntax Highlighting

설치만으로 둘 다 기본 동작한다. 비교하자면 zsh 에서 같은 UX 를 얻으려면 zsh-autosuggestions, zsh-syntax-highlighting 같은 플러그인을 수동으로 깔고 ~/.zshrc 에 source 해야 한다. bash 는 기본 제공이 거의 없다.

Tab completion

fish 는 /usr/share/man 의 man page 를 파싱해서 subcommand·flag 를 자동으로 completion 후보로 쓴다. 예를 들어 aws --<TAB> 치면 flag 이름 옆에 man page 설명이 같이 뜬다. 그래서 외부 도구가 fish 전용 completer 를 안 짜도 어느 정도는 굴러간다.

fish_update_completions 한 번 돌리면 시스템에 설치된 모든 man page 를 파싱해 ~/.local/share/fish/generated_completions/ 에 completion 파일을 생성한다. 직접 completion 을 짜려면 complete 커맨드로:

complete -c mytool -s h -l help -d 'Show help'
complete -c mytool -n '__fish_seen_subcommand_from deploy' \
         -l env -xa 'dev staging prod'

-n 으로 조건 completion, -xa 로 argument 후보 제시 – bash 의 compgen/_init_completion 조합보다 선언적이다.

Abbreviations (abbr) — alias 의 진화형

fish 쓰는 사람들 보면서 “아, 이거 좋다” 싶었던 기능이다. bash 의 alias 는 커맨드를 치면 그냥 치환돼서 히스토리엔 축약어가 남는다. fish 의 abbr 은 스페이스/엔터를 치는 순간 원래 커맨드로 확장되면서 커서에 그대로 보인다:

abbr -a gco 'git checkout'
abbr -a gcm 'git commit -m'
abbr -a k  'kubectl'

# 쳐보면:
# gco<SPACE>  →  git checkout<SPACE>    (눈으로 확인 가능)
# 히스토리에도 'git checkout main' 으로 남음

나중에 히스토리를 뒤질 때 축약어 대신 실제 커맨드가 남아 있어서 기억도 쉽고 공유도 쉽다. alias 는 따로 있긴 하다 (alias, 내부적으로 함수 생성) — 함수 래핑이 필요한 경우에만 쓰고, 단순 단축은 abbr 쪽이 권장된다.

Function

fish 의 함수는 파일 하나당 함수 하나 가 관례다 (~/.config/fish/functions/<name>.fish). 첫 호출 시 lazy load 돼서 shell 시작 속도가 빨라진다.

인터랙티브하게 편집·저장하는 전용 커맨드도 있다:

funced myfunc       # $EDITOR 로 myfunc 함수 편집
funcsave myfunc     # 방금 정의한 함수를 파일로 영구 저장
functions myfunc    # 정의 보기
functions -e myfunc # 삭제

함수 정의 안에서 플래그를 파싱하는 것도 argparse 내장으로 깔끔하다:

function deploy
    argparse 'e/env=' 'd/dry-run' -- $argv
    or return

    set -q _flag_env; or set _flag_env dev
    echo "deploying to $_flag_env (dry=$_flag_dry_run)"
end

Event handlers — 훅 시스템

함수에 --on-event, --on-variable, --on-signal 등을 붙이면 이벤트 리스너처럼 동작한다. bash 의 trap 보다 표현력이 풍부하다.

function _on_cd --on-variable PWD
    ls
end

function _on_exit --on-event fish_exit
    echo "bye"
end

function _on_sigwinch --on-signal WINCH
    echo "window resized"
end

Plugin 생태계

zsh 가 oh-my-zsh / zinit 라면 fish 는 주로 fisher 를 쓴다. ~/.config/fish/ 아래에 플러그인 파일을 단순 복사하는 구조라 디버깅이 쉽다.

fisher install jorgebucaran/fisher
fisher install IlanCosman/tide      # 프롬프트 테마 (스타쉽 대안)
fisher install jethrokuan/z         # autojump 류
fisher install PatrickF1/fzf.fish   # fzf 통합

tide 는 fish 진영의 starship 대응. starship 자체도 fish 에서 잘 돈다.

프롬프트 커스터마이징

bash 처럼 $PS1 환경변수로 조립하지 않고, fish_prompt 함수를 오버라이드 하는 방식이다:

function fish_prompt
    set -l last_status $status
    set_color cyan
    echo -n (prompt_pwd)
    set_color normal
    echo -n ' ❯ '
end

함수니까 조건부 로직, git 브랜치 표시, 비동기 prompt 까지 자유롭게 짤 수 있다. 디버깅도 functions fish_prompt 로 정의를 바로 볼 수 있다.

설정 파일 로드 순서

/etc/fish/config.fish              # 시스템 전역
~/.config/fish/config.fish         # 사용자
~/.config/fish/conf.d/*.fish       # 드롭인 (알파벳순)
~/.config/fish/functions/*.fish    # 함수 (lazy-load)
~/.config/fish/completions/*.fish  # completion

conf.d/ 가 있어서 플러그인이 단일 rc 파일을 오염시키지 않고 자기 파일을 따로 끼워넣을 수 있다. systemd 의 *.d 패턴과 비슷.

히스토리와 인터랙티브 검색

히스토리는 기본적으로 타이핑 중인 prefix 기반으로 검색된다. cd 를 치고 ↑ 를 누르면 cd 로 시작한 과거 커맨드만 순회한다. bash 의 Ctrl+R 역방향 검색 같은 거 없이도 흐름이 자연스럽다.

Ctrl+R 을 누르면 interactive fuzzy search UI 가 뜬다 (3.1+). fzf.fish 플러그인을 깔면 fzf 기반으로 더 강력해진다.

# 히스토리 직접 조회
history                     # 전체
history search --prefix gco # prefix 매칭
history search --contains aws
history delete --exact "rm -rf /"

히스토리는 ~/.local/share/fish/fish_history 에 YAML 유사 포맷으로 저장된다. bash 의 라인 단위 포맷보다 구조적이라 스크립트로 파싱하기 쉽다.

키 바인딩과 vi 모드

fish 는 기본 emacs 키바인딩 이지만 fish_vi_key_bindings 한 줄로 vi 모드로 바꿀 수 있다.

# config.fish 에
fish_vi_key_bindings

vi 모드에선 prompt 에 모드 표시기(INSERT/NORMAL/VISUAL)가 자동으로 뜬다. 이건 fish 내장 기능 — bash-vi 모드에선 PS1 을 직접 짜 넣어야 한다.

커스텀 키 바인딩:

# Ctrl+L 에 clear + pwd 표시
bind \cl 'clear; pwd; commandline -f repaint'

# Alt+S 에 sudo prefix 토글
bind \es 'fish_commandline_prepend sudo '

fish_key_reader 를 실행하면 키가 어떤 이스케이프 시퀀스로 들어오는지 바로 보여줘서 매핑을 짜기 편하다.

디렉토리 스택과 autojump 류

기본 내장:

pushd /tmp; pushd ~; dirs       # 디렉토리 스택
cd -                            # 이전 디렉토리
prevd / nextd                   # 방문한 디렉토리 앞뒤로

autojump 류는 zoxidez.fish 를 깔면 된다. z aws 처럼 빈도 기반 이동.

색과 테마

모든 색 설정이 universal variable 로 들어간다 ($fish_color_*). fish_config GUI 에서 바꿔도 되고 CLI 로도:

set -U fish_color_command blue
set -U fish_color_error red --bold
set -U fish_color_autosuggestion 555

set_color 커맨드로 런타임에도 쉽게 색을 넣을 수 있어 프롬프트 함수에서 바로 쓸 수 있다.

한눈에 비교 (bash 항목 → fish 항목)

주제bashfish
변수 선언VAR=val, exportset, set -x
지역 변수local VARset -l VAR
명령 치환$(cmd)(cmd)
조건문 종결fi, done, esacend
논리 연결&&, \|\|and, or (&&/\|\| 도 3.0+ 지원)
산술$((1+2))math 1+2
문자열 조작${var%.*}, sedstring 서브커맨드
함수 정의func() { ... }function func ... end
배열 인덱스0-based1-based
alias 류aliasabbr (+ alias)
영구 변수없음 (rc 파일 수정)set -U
프롬프트$PS1fish_prompt 함수
스타트업 파일.bashrc, .bash_profileconfig.fish, conf.d/, functions/
스크립트 호환POSIX비-POSIX

단점 — 그리고 내가 bash 쓰는 이유

좋은 점만 있는 건 아니다. 오히려 실무 관점에선 단점이 더 뚜렷하다.

  • 스크립트 이식성 없음 – 서버·CI·Docker 이미지의 기본 shell 은 거의 항상 sh 또는 bash. fish 로 스크립트를 짜면 그대로는 아무 데서도 안 돈다.
  • bash 스크립트 source 불가nvm, rbenv, asdf 같이 쉘을 건드리는 도구들이 깔아주는 init 스크립트가 fish 에서 안 먹힌다. bass 같은 래퍼가 있긴 하지만 번거롭다.
  • 튜토리얼·StackOverflow 답변의 99% 가 bash 기준 – 문법을 매번 머릿속에서 번역해야 한다.
  • Universal variable 의 함정 – 편리한데 어디서 설정됐는지 추적이 어렵다. 초기화하려면 파일을 직접 수정.

그래서 나는 계속 bash 를 쓴다. 인터랙티브 쉘로는 fish 가 매력적이지만, 스크립트·서버 환경이 bash 에 맞춰져 있고 학습 자료도 bash 기준이라 일관성 측면에서 bash 가 이긴다.

AWS CLI 가 지원하는 shell

aws-cli 를 뜯어보면 금방 확인 가능하다. 크게 두 축이 있다.

1) 탭 자동완성 (command completion)

  • bin/aws_bash_completer – bash 용. complete -C aws_completer aws
  • bin/aws_zsh_completer.sh – zsh 용. bash compatibility 헬퍼(bashcompinit) 경유.
  • bin/aws_completer – 범용 파이썬 엔트리. COMP_LINE, COMP_POINT 환경변수를 읽어 완성 후보를 출력.
  • tcsh 는 README 에 complete aws 'p/*/aws_completer/' 한 줄로 안내.

fish 는 탭 완성 쪽엔 아직 공식 지원이 없다.

2) aws configure export-credentials --format 의 shell formatter

이건 자동완성과는 별개. aws configure export-credentials 가 현재 자격증명을 환경변수 export 구문으로 찍어주는 기능인데, 어떤 shell 문법으로 찍을지를 --format 이 결정한다. 원래 bash / PowerShell 같은 몇 개만 있었고 fish 는 빠져있었다. 관련 코드:

  • awscli/customizations/configure/exportcreds.pyBasePerLineFormatter 서브클래스들.

이슈 #9670 과 PR #9996

#9670 의 요청은 간단했다: fish shell 용 format 을 추가해 달라. 왜냐면 fish 는 export VAR=val 가 아니라 set -gx VAR "val" 형식이라서 기존 bash format 을 그대로 복붙하면 fish 에선 파싱이 깨진다.

핵심 추가분 – 기존 BasePerLineFormatter 를 하나 상속해서 한 줄 format 만 바꿔주면 된다:

class FishShellFormatter(BasePerLineFormatter):
    FORMAT = 'fish'
    DOCUMENTATION = (
        'Display credentials as Fish shell environment variables: '
        '``set -gx AWS_ACCESS_KEY_ID "EXAMPLE"``'
    )
    _VAR_FORMAT = 'set -gx {var_name} "{var_value}"'

이후 PR 리뷰에서 “특수문자 들어간 값이면 quoting 이 깨지지 않나?” 지적이 나왔고, follow-up 커밋(1c5457795 “Quote fish shell values for special characters”) 으로 보강됐다. 리뷰 코멘트 덕에 놓친 edge case 를 하나 배웠다.

사용 예:

aws configure export-credentials --format fish
# set -gx AWS_ACCESS_KEY_ID "AKIA..."
# set -gx AWS_SECRET_ACCESS_KEY "..."
# set -gx AWS_SESSION_TOKEN "..."

첫 contribution 완료

PR #9996 이 머지된 순간은 생각보다 싱거웠다. 이메일 하나 오고 끝. 근데 뭔가 – 매일 쓰던 도구에 내 커밋이 박혀있다는 게 이상하게 재미있었다. pip install awscli 하면 내가 쓴 클래스가 설치된다는 것도.

코드 양은 작았지만 준비하면서 얻은 건 훨씬 많았다. 내가 안 쓰는 shell 이라도 사용자들이 어떤 워크플로우로 쓰는지, 왜 그 포맷이 필요한지 맥락을 읽는 연습이 됐다. contribution 이란 게 거창한 기능 추가만 있는 게 아니라, 이렇게 누군가가 불편했던 한 줄의 gap 을 채우는 일이기도 하구나.

다음엔 좀 더 본질적인 이슈를 건드려보고 싶어졌다.