ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • shellcheck로 bash 스크립트 정리하기 — 그리고 Hook으로 재발 방지까지
    IT 2026. 4. 28. 22:00
    shellcheck로 bash 스크립트 정리하기 — 그리고 Hook으로 재발 방지까지

    ruff 바로 뒤이어서 — 같은 날 shellcheck까지

    오전에 Python 프로젝트 10개에 ruff를 넣으면서 한 줄을 이렇게 남겨뒀다. "shellcheck는 다음 주기에 다루기로 했다 — 한 번에 한 도구씩 확실히 안착시키자." 그렇게 ruff 쪽 정리가 끝나고 나니, 그 "다음 주기"를 굳이 다른 날로 미룰 이유가 없어졌다. 손이 뜨거워진 김에 같은 날 그대로 shellcheck로 넘어갔다.

    대상은 개인 프로젝트 4곳에 흩어져 있는 bash 스크립트 17개. cron용 엔트리포인트, GPU 자원 관리 도구, 백그라운드 서비스 재시작 스크립트 — 전부 손으로 짠 글루 코드다. Python 쪽은 테스트·ruff로 촘촘히 덮었지만, bash는 "잘 돌아가길래 그냥 둔" 상태였다. 그 "잘 돌아간다"가 얼마나 믿을 만한지 한 번 찔러보기로 했다.

    설치 — apt 없이 aarch64에 바이너리로

    홈 서버가 aarch64(ARM64)라 흔한 설치 가이드가 안 먹는다. apt install shellcheck은 sudo가 필요한데, 이번 세션에선 비밀번호 입력 단계로 넘어가고 싶지 않았다. 다행히 shellcheck는 단일 정적 바이너리로 배포된다:

    cd /tmp
    curl -sL "https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.aarch64.tar.xz" -o shellcheck.tar.xz
    tar -xJf shellcheck.tar.xz
    cp shellcheck-v0.10.0/shellcheck ~/bin/shellcheck
    chmod +x ~/bin/shellcheck
    

    ~/bin은 PATH 앞쪽에 있으니 이걸로 끝. shellcheck --version으로 0.10.0 확인. ruff가 Rust 단일 바이너리였던 것처럼 shellcheck는 Haskell로 쓰였는데, 사용자 입장에선 "의존성 없는 한 개 파일"이라는 점에서 똑같이 편하다. 시스템 패키지 관리자를 건드릴 필요가 없다.

    베이스라인 — 17개 중 얼마나 문제가 있었나

    전수 스캔부터. shellcheck --severity=warning으로 style·info 레벨은 빼고 warning 이상만 잡았다. 결과:

    프로젝트 스크립트 수 경고 있는 파일
    프로젝트 A 1 1
    프로젝트 B 11 3
    프로젝트 C 2 2
    프로젝트 D 4 2
    합계 18 (tests 포함) 8

    총 17개 스크립트 + 1개 테스트 러너 중 8개에서 경고. 절반이 안 되지만, 0건은 결코 아니었다. 예상보다 많았다는 게 솔직한 느낌. 경고 종류를 정리해 봤더니 6가지로 수렴했다.

    1. SC1017 — 파일 전체가 CRLF (error 레벨)

    가장 놀란 건 프로젝트 A의 cron 엔트리포인트 하나. shellcheck가 줄마다 "Literal carriage return"을 찍었다:

    In album_cron.sh line 1:
    #!/bin/bash
               ^-- SC1017 (error): Literal carriage return.
    

    file 명령으로 확인하니 "CRLF line terminators". Windows에서 한 번 열었다 저장했거나, Obsidian·VSCode에서 어쩌다 설정이 바뀌었거나. 로컬에선 bash가 어쨌든 실행해주지만, CRLF가 섞인 스크립트는 쉘 변수 값에 숨은 \r이 들어가 비교가 조용히 깨진다. 메커니즘은 이렇다 — 쉘은 \n 하나만 줄 구분자로 인식하기 때문에, mode=start 줄의 실제 바이트가 mode=start\r\n이면 \n까지만 잘라내고 그 앞의 \r변수 값의 일부로 남는다. 그래서 mode"start"가 아니라 "start\r"가 된다.

    이 상태에서 if [ "$mode" = "start" ]; then ...을 만나면, 좌변은 start\r, 우변은 start — 당연히 다르다. if 블록이 통째로 건너뛰어진다. 더 끔찍한 건 진단이 안 된다는 점이다. echo "mode=[$mode]"로 찍어보면 터미널에선 \r이 커서를 줄 맨 앞으로 되돌려서 뒤에 찍힌 글자가 앞글자를 덮는다. "분명히 mode=[start]로 나오는데 왜 비교가 실패하지?" — 실은 mode=[start까지 찍고 \r로 돌아간 뒤 ]m을 덮어쓴 결과다. 파일을 bash -x로 돌리거나 cat -A로 raw 바이트를 찍기 전까지는 원인을 못 찾는다. cron에서 "어제까지 돌던 게 오늘 갑자기 안 먹네"의 1순위 용의자.

    tr -d '\r' < album_cron.sh > /tmp/clean.sh && mv /tmp/clean.sh album_cron.sh
    

    한 방에 정리. file로 다시 찍어보니 "ASCII text executable" — 깔끔.

    2. SC2034 — 안 쓰는 루프 변수 / 안 쓰는 변수

    가장 흔했다. 3군데에서 나왔다.

    for i in $(seq 1 20); do
        if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
            exit 0
        fi
        sleep 0.5
    done
    

    여기서 i는 그냥 "20번 반복"이라는 뜻일 뿐 실제로 쓰이지 않는다. 관용적으로 _로 바꾸면 shellcheck가 "의도적으로 버린다"는 신호로 받아들여 경고를 안 낸다:

    for _ in $(seq 1 20); do
    

    Python의 for _ in range(20)와 같은 컨벤션. bash에서도 동일하게 통한다.

    비슷한 케이스로 LOG="$PIPELINE/logs/daily_check.log"라고 선언해놓고 정작 script 본문에선 사용하지 않는 변수도 있었다. 예전에 tee -a "$LOG" 식으로 쓰다가, crontab에서 >> 리다이렉트로 로그를 처리하게 바꾸면서 변수만 남은 경우였다. 삭제가 맞는 수정.

    3. SC2002 — Useless cat

    고전 안티패턴:

    echo "선점된 작업: $(cat "$PREEMPTED_FILE" | tr '\n' ', ')"
    

    cat file | cmd는 cmd가 파일을 직접 읽을 수 있으면 cat 한 프로세스가 통째로 쓸모없다는 경고. 수정은 shell 리다이렉션으로:

    echo "선점된 작업: $(tr '\n' ', ' < "$PREEMPTED_FILE")"
    

    기능은 같지만 fork 하나가 줄어든다. 초당 수천 번 호출되는 핫패스가 아니니 성능 차이는 무의미하지만, "cat을 무조건 끼우는 습관"을 지적하는 건 코드 리뷰로도 충분히 가치가 있다.

    4. SC2088 — 따옴표 안의 ~는 확장되지 않는다

    이건 정말 버그였다. 프로젝트 C의 두 스크립트에 같은 패턴:

    STAMP="~/projects/knowledge-vault-rag/qdrant_data/.last_access"
    ...
    if [ ! -f "$STAMP" ]; then
        docker stop qdrant
        exit 0
    fi
    

    쉘에서 ~따옴표 바깥에 있을 때만 $HOME으로 확장된다. 따옴표 안에 넣는 순간 그냥 문자 ~다. 즉 STAMP의 실제 값은 ~/projects/...라는 리터럴 문자열이고, [ ! -f "$STAMP" ]은 항상 true가 된다(그런 파일이 있을 리 없으니). 결과적으로 이 idle-stop 크론은 마지막 접근 시각과 무관하게 매번 컨테이너를 중지하고 있었을 가능성이 크다.

    수정은 $HOME으로:

    STAMP="$HOME/projects/knowledge-vault-rag/qdrant_data/.last_access"
    

    "돌아는 간다"가 "의도대로 돈다"는 뜻이 아님을 다시 확인한 순간. 린트가 없었으면 영원히 모르고 지나갔을 버그.

    5. SC2086 — 쿼팅 누락

    if [ $EXIT_CODE -eq 0 ]; then
    

    이 줄에서 $EXIT_CODE는 바로 위에서 ${PIPESTATUS[0]}로 받은 값이라 "무조건 숫자 하나"여야 정상이다. 하지만 shellcheck는 "그 가정이 깨지는 순간"을 본다. 가장 흔한 경로는 변수가 빈 문자열일 때다 — 이전 줄 수정 중 실수로 대입이 빠지거나, PIPESTATUS 참조가 잘못돼서.

    쿼팅이 없을 때 쉘은 변수 전개 이후 값을 공백으로 단어 분리한다. 그래서 $EXIT_CODE가 빈 문자열이면 [ $EXIT_CODE -eq 0 ][ -eq 0 ]으로 바뀌어 아예 토큰 수가 달라진다. test는 "unary operator expected"로 죽고 if 조건은 거짓으로 떨어져 else 브랜치만 실행된다 — 재인덱싱이 멀쩡히 끝났는데도 "실패" 로그가 찍히는 은근한 버그다. 반대로 [ "$EXIT_CODE" -eq 0 ]으로 쿼팅하면 빈 문자열도 하나의 인자로 보존돼 [ "" -eq 0 ]이 되고, 에러 메시지는 "integer expression expected: """ — 뭐가 비었는지가 에러에서 바로 드러난다. 사고가 안 나는 게 아니라, 사고가 났을 때 원인이 로그에 남는다.

    비슷하게, 값이 "0 0"처럼 공백을 포함한 이상한 상태가 되면 쿼팅이 없는 쪽은 [ 0 0 -eq 0 ]이 되어 "too many arguments"로 죽고, 쿼팅이 있는 쪽은 [ "0 0" -eq 0 ]으로 하나의 인자가 유지된다. 어느 쪽도 비교가 정상 동작하진 않지만, 쿼팅은 디버그가 가능한 실패를 만든다. 그래서 shellcheck는 "현실적으로 공백이 들어올 일 없으니 괜찮다"는 주장을 받아들이지 않고 어쨌든 쿼팅을 요구한다.

    if [ "$EXIT_CODE" -eq 0 ]; then
    

    6. SC2188 — 명령어 없는 리다이렉션

    > "$KILLED_FILE"  # 초기화
    

    "파일 비우기"를 의도한 한 줄. 실제로 동작은 한다. 쉘이 이 줄을 파싱할 때 리다이렉션은 명령 실행과 별개의 단계로 처리되기 때문이다 — 설령 실행할 명령이 없어도 쉘은 먼저 > 리다이렉트를 처리하려고 $KILLED_FILEO_WRONLY | O_CREAT | O_TRUNC로 연다. 바로 이 O_TRUNC 플래그가 "열면서 길이 0으로 잘라라"라는 뜻이다. 그 뒤 실행할 명령이 없으니 아무 데이터도 쓰지 않고 파일을 닫는다. 결과적으로 파일은 존재하되 내용이 비워진 상태가 된다.

    동작하긴 해도 뜻이 명시적이지 않다. shellcheck는 "명령을 적어라, 최소한 true라도"라고 권한다. 가장 정직한 수정은 :(no-op 내장 명령):

    : > "$KILLED_FILE"  # 초기화
    

    :는 "아무 것도 하지 않고 성공 반환"이라는 POSIX 내장 명령이다. 읽기에 한 글자 늘어났을 뿐이지만 의도는 100% 명확해진다.

    그 외 — SC2317 (unreachable)는 의도된 false positive

    테스트 러너에서 shellcheck가 assert_eq, write_conf 같은 함수 정의를 "도달 불가 코드"라고 찍었다. 테스트 메인에서 "$SCHED" acquire ... 식으로 외부 프로세스로 실행되는 구조라 정적 분석이 호출 관계를 추적하지 못한 것. 이런 건 수정이 아니라 무시가 정답이다. info 레벨이므로 --severity=warning 필터에 걸리지도 않는다. 린트는 "무조건 0건"이 목표가 아니라 "의도된 0건"이 목표다.

    사후 검증 — 테스트는 여전히 그린

    수정 후 프로젝트 B의 bash 테스트 러너를 돌렸다:

    bash tests/run_tests.sh
    ═══ GPU 스케줄러 시스템 테스트 ═══
    
      TC: 동시 acquire (메모리 예산 내)     → PASS
      TC: 예산 초과 시 선점                   → PASS
      TC: release 후 background 자동 시작   → PASS
      TC: freeze/unfreeze                      → PASS
      TC: register + memory_budget            → PASS
    
    ═══ 결과: 5/5 PASS, 0 FAIL ═══
    

    5/5 그대로. useless cat 삭제가 stdout 포맷을 건드리지 않았는지, local ok 제거가 테스트 로직에 영향을 주지 않았는지 — 이런 것들을 수작업으로 하나씩 따지는 대신 테스트가 확인한다. 이게 없었으면 린트 수정이 더 조심스러워질 수밖에 없다.

    핵심 결정 — Hook에 넣어 재발 방지하기

    여기까지가 1회성 정리. 진짜 문제는 "6개월 후 같은 상태로 돌아가 있을까"다. Python은 이미 ruff가 hook에 걸려 있어서, 코드를 수정하고 린트를 돌리지 않으면 git commit이 막히고 세션을 끝내려 할 때 Claude Code가 "경고가 남아있다"며 멈춘다. 같은 메커니즘을 shellcheck로도 만들기로 했다.

    이미 같은 구조의 ruff 파이프라인이 있어서 복제하는 수준이었다. ~/scripts/hooks/pipelines/shellcheck_lint.py 한 파일에 네 개 함수:

    • track_edit(file_path, data)~/projects/<name>/ 아래 .sh 편집이 감지되면 프로젝트명을 "dirty 세트"에 추가
    • track_bash(cmd, data)shellcheck 명령이 exit 0으로 끝나면 해당 프로젝트를 dirty에서 제거 (사용자가 직접 검증했다는 신호)
    • pre_commit(cmd, data)git commit 명령을 보면 dirty 프로젝트가 커맨드 문자열에 포함되는지 확인, 포함되면 차단
    • check_completion(state) — 세션이 끝날 때 dirty 프로젝트에 대해 실제로 shellcheck --severity=warning을 다시 돌려서, 아직 경고가 남아있으면 "남은 항목"으로 보고

    상태는 /tmp/.shellcheck_lint_state JSON 한 파일. 스키마는 단순하다:

    {
      "pipeline": "shellcheck_lint",
      "dirty": ["프로젝트-B", "프로젝트-D"],
      "claude_pid": 2052433
    }
    

    Claude Code는 네 개의 hook 시점을 제공한다 — PreToolUse, PostToolUse, Stop이 주로 쓰인다. 각 시점에 dispatcher 스크립트가 하나씩 돌며, 디스패처가 여러 pipeline 모듈을 호출해주는 구조. 여기에 shellcheck_lint를 wiring하는 건 네 줄 import + 네 줄 호출 추가:

    # dispatcher_post_edit.py — .sh 편집 감지
    shellcheck_lint.track_edit(file_path, data)
    
    # dispatcher_post_bash.py — shellcheck 실행 감지
    shellcheck_lint.track_bash(cmd, data)
    
    # dispatcher_pre_bash.py — git commit 차단
    for validator in [..., shellcheck_lint.pre_commit]:
        ...
    
    # dispatcher_stop.py — 세션 종료 검증
    CHECKERS = {..., "shellcheck_lint": shellcheck_lint.check_completion}
    

    동작 확인 — 의도적으로 넣은 나쁜 코드

    Wiring 후 정말 막히는지 확인해봐야 한다. 일부러 경고가 있는 _lint_test_bad.sh를 프로젝트 B에 던져 넣고 세션 종료를 시도했다:

    # 나쁜 스크립트 (SC2088 + SC2034)
    #!/bin/bash
    LOG="~/app.log"
    for i in $(seq 1 5); do echo hi; done
    

    Stop hook 결과:

    ⛔ 파이프라인이 아직 완료되지 않았습니다.
    남은 항목:
      - 프로젝트-B: shellcheck 경고 2건 — 'shellcheck --severity=warning ~/projects/프로젝트-B/**/*.sh' 로 확인 후 수정
    
    모든 항목을 완료한 후 종료하세요.
    exit=2
    

    git commit 시도도 같은 파이프라인에서 차단:

    ⛔ 프로젝트-B: shellcheck 미통과 — git commit 차단
      - shellcheck 경고 2건
      - shellcheck --severity=warning ~/projects/프로젝트-B/**/*.sh 로 확인 후 다시 커밋
    

    둘 다 의도대로 작동. 의도적으로 넣은 파일을 지우고 상태를 초기화하니 다시 통과. 에이전트가 경고를 남겨두고 조용히 끝내는 길을 막았다.

    왜 수동 검증으로는 부족한가

    "그냥 커밋 전에 한 번씩 shellcheck *.sh 돌리면 되는 거 아닌가?" 맞다. 그게 디시플린으로 될 수 있다면. 그런데 개인 프로젝트에서 한 달 전의 나와 이번 달의 나는 다른 사람이다. 기억은 휘발되고, 자정 넘어 급한 수정을 넣을 때 린트는 가장 먼저 잘리는 절차다.

    훅에 넣으면 기본값이 반대가 된다. 돌리는 게 기본, 건너뛰려면 의식적으로 우회해야 한다. 그게 의식적으로 느껴지는 순간 "아, 오늘은 그냥 넘어가면 안 되겠네"라고 되돌아올 확률이 올라간다. 디시플린을 개인의 의지에 맡기지 않고, 도구의 기본 동작에 옮겨놓는 것 — 이것이 ruff hook에서 얻은 교훈이고, shellcheck에도 그대로 적용했다.

    정리

    • bash 스크립트 17개 전수 린트 → 8개 파일에서 경고
    • 6종 경고: CRLF(1), 미사용 변수(3), useless cat(1), tilde 확장(2), 쿼팅(1), 빈 리다이렉트(1)
    • 그중 SC2088(tilde)은 실제 동작 버그. 린트 없었으면 못 잡았다.
    • 수정 후 테스트 5/5 여전히 그린
    • Hook 파이프라인에 추가해 재발 방지 — .sh 편집 시 dirty 등록, Stop/git commit 시점에 자동 재검증
    • ruff 패턴을 그대로 복제. 새 도구를 동일한 구조 안에 끼워 넣는 건 하루짜리 작업

    다음 차례는? YAML이 남았다(yamllint), 그리고 shell 스크립트 포매터 shfmt. 한 번에 한 도구씩, 확실히. 이미 만든 dispatcher 패턴이 있으니 다음부턴 더 빠를 거다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.