간결하고 안전한 Bash 스크립트 템플릿

roeniss·2021년 1월 18일
0

이 포스트는 https://betterdev.blog/minimal-safe-bash-script-template 를 허락받아 번역하였음.

대부분의 사람들이 언젠가는 하나씩 작성 하는 그것. 대부분의 사람들이 좋아하지 않는 그것. 그래서 대부분의 사람들이, 작성할 때 깊게 집중하지 않는 바로 그것 - 배쉬 스크립트*.

당신을 Bash 전문가로 키워낼 생각은 딱히 없다(나도 전문가는 아니다). 대신 간결하고 훨씬 안전한 템플릿 하나를 보여주겠다. 나에게 고마워 하지 않아도 된다. 미래의 당신이 지금의 당신에게 고마워 할테니까.

(*역주: 이하 Bash Script)

왜 Bash script를 작성하는가

Bash에서 scirpting을 작성한다는게 어떤 의미인지는 내 트위터에서 잘 설명하고 있다:

"Bash scripting은 '자전거 타기'의 대척점에 서 있다.
다시 말해, 얼마나 많이 해봤든간에, 매번 새로 배워야 한다." (트위터 원문)

하지만 Bash는 널리 사랑받는 Javascript*와 마찬가지로, 메인 언어가 되지 않기를 원하지만 그럼에도 불구하고 좀처럼 사라지지 않는다. 항상 지척에 존재한다.

(*역주 : JS 돌려까기)

Bash는 왕위를 물려받았고*, 도커를 포함한 대부분의 리눅스에서 사용된다. 그리고 이러한 환경이 대부분의 백엔드 서비스들이 돌아가는 환경이다. 결국 당신은 서버 애플리케이션을 구동하거나, CI/CD를 구성하거나, 통합 테스트를 수행할 때 Bash를 쓰게 된다.

몇몇 커맨드를 연결할 때, 아웃풋을 A에서 B로 전달할 때, 혹은 실행파일을 실행할 때 Bash는 가장 쉽고도 native한 솔루션이다. 파이썬, 루비, fish, 혹은 그 밖의 인터프리터 언어를 사용해서 더 크고 복잡한 스크립트를 작성하는 것도 합리적인 선택이지만, 모든 환경에서 잘 실행될 것이라 확신하긴 어려울 것이다. 다시 말해 고급 언어를 쓰면, 스크립트를 적용하기 전에 이 스크립트가 적용되는 prod 환경의 서버, docker 이미지, CI 환경에 대해서 한 번 더 생각해봐야 할 것이다.

Bash가 분명 퍼펙트한 솔루션이긴 하다. 하지만 문법(Syntax)은 사실상 악몽이다. 에러 핸들링은 어렵고 지뢰가 사방팔방에 있다. 당신과 나는 그 힘든 길을 걸어가야만 하고.

(*역주 : Bourne shell(sh)가 사실상 표준으로 자리잡게 됨에 따라, sh의 superset으로 사용되던 bash도 덩달아 가장 유명한 script 언어로 자리잡았다는 Quora 답변)

Bash script template

거두절미하고 일단 결과를 보시라.

#!/usr/bin/env bash

set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

usage() {
  cat <<EOF
사용법: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]

스크립트 설명은 이곳에.

옵션:

-h, --help      지금 보고 있는 help 메시지 출력
-v, --verbose   디버그 출력 추가
-f, --flag      (플래그 설명을 이곳에)
-p, --param     (파라미터 설명을 이곳에)

EOF
  exit
}

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # 스크립트 뒷정리(리소스 해제 등)는 여기에.
}

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

die() {
  local msg=$1
  local code=${2-1} # default exit status를 1로 설정
  msg "$msg"
  exit "$code"
}

parse_params() {
  # 파라미터로 변수의 기본값 설정
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # 플래그 샘플
    -p | --param) # named parameter 샘플
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # 필수 파라미터 & 아규먼트 체크
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

parse_params "$@"
setup_colors

# 스크립트 로직(실제 코드)은 이쪽에.

msg "${RED}Read parameters:${NOFORMAT}"
msg "- flag: ${flag}"
msg "- param: ${param}"
msg "- arguments: ${args[*]-}"

무엇보다 너무 길어지지 않도록 노력했다. 스크립트 로직을 위해서 500줄이나 작성하고 싶지 않았다. 동시에, 어느 스크립트에나 적용될 수 있는 강력한 기반 코드를 만들고 싶었다. 하지만 Bash는 디펜던시 관리 시스템이 심각하게 부족하므로 이 또한 쉽지 않았다.

한 가지 대안은, 보일러플레이트와 유틸리티 함수들을 별도의 script로 만들고 실제 스크립트를 만들 때 이를 불러오는 방식이다. 이 방식의 문제점은 그 별도의 script라는걸 항상 준비해야 된다는 건데 이러면 'simple bash script'라는 기초적인 목표가 무너진다. 그래서 가능한 한 짧게 만들어서, 복붙해서 바로 사용할 수 있는* 템플릿을 만드는 방식을 쓰는 결론에 도달했다.

(*역주 : 이 템플릿을 스크립트 파일에 넣고, 그 아랫줄부터 바로 본래 작성하려던 코드를 작성해나가면 되도록 구현했다는 말)

하나씩 자세히 살펴보자.

Bash 선택

#!/usr/bin/env bash

최고의 호환성을 위해,/bin/bash를 바로 사용하는 대신 /usr/bin/env를 사용한다. 링크 속 SO를 읽어보면 알겠지만, 이렇게 하는데도 실패하는 경우가 있긴 하다.

빠른 실패 처리

set -Eeuo pipefail

set 커맨드로 script 실행 옵션을 지정할 수 있다. 예를 들어, 일반적으로 Bash는 중간에 커맨드가 실패해도 신경쓰지 않고, 막판에 non-zero exit status code를 리턴하기만 한다. 실패하면 그냥 싱글벙글 다음 커맨드를 실행할 뿐이다. 아래 예제를 보라.

#!/usr/bin/env bash
cp important_file ./backups/
rm important_file

이 스크립트를 실행할 때 backups 디렉토리가 없으면 어떻게 될 것 같은가? 콘솔에 에러 메시지가 일단 발생하는데("복사 실패") 뭔가 해보기도 전에 두번째 라인이 실행되고 important_file은 삭제될 것이다.

set -Eeuo pipefail이 정확히 무슨 옵션인지 그리고 어떻게 당신을 보호하는지 알고 싶다면 이 포스트*를 읽어보라.

(*역주 : 위 포스트의 설명을 간단히 요약하면 다음과 같다.

  • -e : 커맨드 실패하면 그 즉시 종료. 이 설정을 우회하기 위해 명령어 뒤에 || true를 붙여 무조건 exit code 0을 만드는 기법도 있음. 조건문의 condition으로 사용된 부분에선 실패해도 'immediate exit' 하지 않음.
  • -o pipefail : 파이프라인이 사용된 커맨드가 있을 때, -e 만 사용하면 가장 우측의 커맨드 결과(exit code)로만 중지/진행 여부를 결정함. 이 옵션을 쓰면 파이프를 거치는 모든 커맨드의 성공 여부를 확인하고, 하나라도 실패하면 그 라인이 끝날 때 (즉 가장 우측의 커맨드가 실행된 후) non-zero exit code를 리턴함.
  • -u : 지정되지 않은 variable을 사용하면 에러로 판단. 그리고 ${a:-b} 형태의 variable assignment 기법을 쓸 때는 a 값이 없어도 에러를 내지 않음("smart enough"). 참고로, 이 옵션 때문에 아래와 같은 폼을 사용해야 할 것임.
    if [ -z "${MY_VAR:-}" ]; then # ${MY_VAR-}와 동일함
      echo "MY_VAR was not set"
    fi
  • -x : 각각의 커맨드를 실행하기 전에 출력한다.
  • -E : ERR라는 trap이 기본적으로 적용 안되는 부분에서도 적용되도록 세팅한다. trap은 이벤트 리스너같은건데, ERR는 대략 다음과 같이 작동한다.
    #!/bin/bash
    trap 'catch' ERR # (3) triggered
    catch() {
      echo "에러 발견!" # (4)
    }
    echo "헬로" # (1)
    badcommand # (2) -> "command not found" (ERR)
    echo "배쉬"

)

하지만 이러한 옵션을 사용하는 것에 대한 논쟁*이 존재한다는 것도 알고 있으면 좋다.

(*역주 : 여러 얘기가 있는데, "파이프라인 좌측에서 non-zero exit code가 발생하는 것은 그렇게 이상한 일이 아니므로 set -o pipefail을 쓰면 안된다"는 부분이 기억에 남는다)

위치 찾기

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

이 라인은 스크립트 파일의 위치를 가장 정확하게 찾는다. ~~응~ cd ㅅㄱ~~

보통 우리의 스크립트는 상대 경로를 기반으로 작동한다. 파일 복사라던가 명령어 실행이라던가... 마치 script directory가 working directory인 것처럼 말이다. 우리가 스크립트가 있는 디렉토리에서 스크립트를 실행한다면 틀린 말은 아니다.

하지만 만약 CI 설정이

/opt/ci/project/script.sh

라면, CI tool의 workdir에서 스크립트가 실행된다. 스크립트가 포함된 project directory가 아니라. 이를 방지하기 위해 코드를 수정하면:

cd /opt/ci/project && ./script.sh

하지만 이런 방식보다 script 쪽에서 해결하는게 훨씬 나이스할 것이다. 만약 스크립트의 폴더를 기준으로 상대경로를 써야 한다면 앞으론 이렇게 하자:

cat "$script_dir/my_file"
cp "$script_dir/my_file1" "$script_dir/../my_file1"

이렇게 하면 현재 workdir이 바뀌지 않는다는 장점도 있다. 유저가 특정 파일에 대한 상대 경로를 가지고 있을 때 스크립트가 다른 디렉토리에서 실행되게 두고, 가지고 있는 상대 경로는 그대로 사용할 수 있는 것이다.

뒷정리

trap cleanup SIGINT SIGTERM ERR EXIT

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # script cleanup here
}

trap은 스크립트 전용 finally 블록이라고 생각하면 된다. 위 구문으로 인해, 정상 종료나 에러에 의한 종료나 외부 시그널에 의한 종료가 발생할 때 cleanup() 함수가 실행될 것이다. 스크립트에 의해 생성된 임시 파일 같은 것을 여기서 삭제하면 된다.

꼭 스크립트가 마지막 줄까지 실행되지 않아도 cleanup()이 실행될 수 있다는 것을 기억하는 게 좋다. 즉, 위에서 언급한 임시 파일이 아직 생성되지도 않았을 수도 있다는 것이다.

도움되는 help 메시지 출력

usage() {
  cat <<EOF
사용법: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]

스크립트 설명은 이곳에

...
EOF
  exit
}

usage()를 상단에 두어서 두 가지 효과를 누릴 수 있다:

  • 모든 설명을 읽는게 귀챃은 유저에게 도움말 제공*
  • 스크립트가 누군가에게 (예를 들면 2주 뒤의 당신) 수정되었을 때 약간의 설명을 제공

(*역주 : script.sh -h로 호출)

여기에 모든 디테일을 적어야 된다는 말은 아니다. 하지만 솔직히 말하자면, 괜찮은 스클비트들은 대개 usage 를 갖추고 있다.

가독성 좋은 메시지 출력

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

우선, 텍스트에 색깔 입히는게 싫으면 setup_colors()를 그냥 지우면 된다. 매번 구글링하는게 아니라면* 코드에 컬러를 쓰는 쪽을 선호해서 난 유지하는 쪽으로 선택했다.

(*역주 : 색을 지원하지 않는 출력 화면일 경우 - 예를 들면 jenkins - 색깔 변경을 위한 special sequences가 그대로 노출되어서, 복붙해서 구글링하기가 어려운 경우가 있다. 이 부분에 대한 표현인 듯)

두 번째로, msg() 함수를 쓸 때만 저 코드의 색이 반영된다. echo 같은건 안된다는 소리.

다시 말해, 스크립트의 결과물(output) 외의 모든 출력은 msg()를 쓰는 것을 권장한다. 이는 로그와 메시지를 포함하며, 당연히 에러도 포함한다. 12 Factor CLI Apps*을 인용하자면:

'output은 stdout으로, message는 stderr로'라고 요약할 수 있다
(Jeff Dickey, who knows a little about building CLI apps)

(*역주 : 12 Factor App은 '좋은 소프트웨어를 만드는 12가지 기준/원칙'으로 이루어진 방법론이다. 12 Factor CLI Apps는 그것의 CLI 버전)

따라서 일반적으로 stdout에 색을 사용하지 않는게 맞다.

msg()를 이용해 출력되는 메시지는 stderr 스트림으로 보내지고 여기선 (예를 들면 색깔 변경을 위한) special sequences를 지원한다. 그리고 stderr 출력이 interactive terminal이 아니거나, [표준 파라미터 중 하나]*가 사용되면 컬러가 반영되지 않을 것이다.

(*역주 : NO_COLOR를 말하고 있다. export NO_COLOR=1같은 선언을 통해 컬러 출력을 disabled 할 수 있다)

사용법:

msg "This is a ${RED}very important${NOFORMAT} message, but not a script output value!"

stderr가 interactive terminal이 아닐 때 어떤 결과가 나오는지 궁금하다면, 위 예제를 코드에 넣고 아래와 같이 실행해보자. 이 코드는 stderrstdout으로 redirect하고 그 결과를 cat으로 piping한다. pipe 연산자를 쓰면 output은 터미널로 곧바로 연결되는 대신 다음 명령어로 전달된다. 따라서 색이 비활성화된다.

$ ./test.sh 2>&1 | cat
This is a very important message, but not a script output value!

파라미터 파싱

parse_params() {
  # 파라미터로 변수의 기본값 설정
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # 필수 파라미터 & 아규먼트 체크
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

나는 개인적으로 스크립트에 파라미터로 뽑아낼 만한 대상이 있으면 그렇게 하는 편이다. 설령 그 스크립트가 딱 한 군데에서만 쓰이더라도 말이다. 복사해서 다른 곳에서 재사용하기 쉬워지기 때문인데, 실제로 그렇게 되는 경우가 대부분이다. 만약 뭔가가 정말로 하드코딩 되어야 한다면 아마 Bash script보다 고차원적인, 더 적절한 장소가 따로 있을 것이다.

CLI 파라미터는 세 종류*가 있다. flags, named parameters, positional arguments. parse_params()는 이 세 가지 방식을 모두 지원한다.

(*역주 : ls -l --block-size K ~/Documents에서 맨 앞의 ls를 제외하고, 순서대로 flag(스위치 역할), named parameter(플래그와 유사하지만 value가 필요함), positional parameter(위치로 구분되며 별도 플래그 없음))

일반적으로 사용되지는 여기서 지원하지 않는 패턴이 딱 하나 있는데 concatenated multiple single-letter flags* 방식이다. 만약 -a -b 대신 -ab 형태로 플래그를 전달하고 싶다면, 추가적인 코드를 작성해야 한다.

(*역주 : ls -a -l대신 ls -al처럼 쓰는 걸 말함)

while loop는 파라미터를 파싱하는 수동적인 방식이다. 많은 언어에서 당신은 이런 방식 대신 내장된 파서별도 라이브러리를 쓰겠지만, 음, Bash가 Bash했기 때문에 별 수 없다.

위 템플릿엔 예제로 -f라는 플래그 하나와, -p라는 named parameter가 준비되어 있다. 다른 플래그로 바꿔보거나, 복붙해서 파라미터를 추가하면 된다. 그리고 그럴 때마다 usage()를 업데이트하는 것도 잊지 말자.

당신이 Bash argument 파싱에 대해 구글링할 때 간과하는 한 가지는 unknown option을 받았을 때 에러를 던지는 것이다. 스크립트가 알 수 없는 옵션을 전달받았다는 것은 유저가 원했는데 스크립트가 그걸 할 수 없다는 뜻이다. 따라서 유저는 스크립트가 기존과는 다른 방식으로 행동하기를 원했을 텐데, 이 경우 그냥 조용히 실행하는 것보단 유저에게 상황을 알려주는게 나을 것이다. 본 스크립트는 그 부분까지 구현이 되어있다.

다른 형태의 Bash 파라미터 파싱 기법도 있다. getoptgetopts인데, 이걸 쓰냐 마냐에 대한 논쟁이 있다. 내 생각엔 별로 좋지 않은 것 같다. 왜냐하면 macOS에서는 디폴트 값으로 사용했을 때 getopt완전히 다르게 작동하기 때문이다. 그리고 getopts는 long parameter를 지원하지도 않는다 (예를 들면 --help).

템플릿 사용하기

복붙하면 된다. 인터넷에 있는 코드들과 마찬가지로.

아니 진심이다. Bash 세계에서는 npm install같은 통일된 뭔가가 없다구.

복사한 후에, 네 군데만 수정하자:

  • usage() 텍스트를 실제 수행할 스크립트 설명으로 수정
  • cleanup() 내용 채우기
  • parse_params()에서 (--help--no-color는 건드리지 말고) -f, -p를 실제 쓰는 파라미터로 업데이트 또는 제거
  • 실제 스크립트 로직 추가

이식성 (Portability)

이 템플릿은 macOS(archaic Bash 3.2) 및 여러 도커 이미지(데비안, 우분투, 센토스, 아마존 리눅스, 페도라)에서 테스트되었다.

당연히 Bash가 없는 알파인 리눅스 같은 경우엔 작동하지 않을 것이다. 알파인은 초경량 시스템을 위해 엄청나게 가벼운 ash(Almquist shell)를 사용한다.

Bourne_shell과 호환되는 스크립트를 작성하면 더 다양한 환경에서 구동될 거라고 생각할지도 모르겠다. 어, 내 생각엔 그렇지 않다. Bash는 훨씬 안전하고 강력하다 (물론 사용하기 어렵지만). 그래서 거의 마주칠 일 없는 소수의 리눅스 배포판은 그냥 지원하지 않는 게 나은 것 같다.

더 읽을 거리

Bash나 ~~훨씬 좋은~~ 다른 언어로 CLI script를 작성할 때, 항상 적용되는 유니버셜 룰들이 있다. 작은 당신만의 스크립트를 작성할 때나 거대하고 신뢰할 수 있는 CLI apps을 구축할 때나 아래 리소스들이 당신을 옳은 길로 인도할 것이다.

마치며

나는 Bash script template 최초로 만든 사람도 아니며 마지막으로 만든 사람도 아니다. 이 프로젝트도 꽤 괜찮다고 생각한다. 단지 내가 매일매일 쓰는 스크립트들에게는 조금 과한 느낌이 있다. 아무튼 내 목표는 가능한 한 작게 (그리고 간지나게) 만드는 것이었다.

Bash script를 작성할 때 SpellCheker linter가 적용되는 Jetbrains같은 IDE를 써라. 그렇게 하는게 당신을 온갖 문제들로부터 지켜줄 것이다.

내 스크립트는 Github Gist에도 있다 (MIT license): script-template.sh

만약 템플릿에 관련된 문제를 발견했다면 혹은 당신이 보기에 중요한 무언가가 빠졌다고 생각한다면 아래 댓글로 달아주시라.

후기 : 영어 공부 겸, bash 이해 겸 해서 블로그 번역을 처음으로 해봤음. 재밌네... 주석 달고 싶어서 딸린 링크도 전부 읽어봤는데 매우 흡족스럽다.

profile
2020/07 요즘 듣는 노래 : https://www.youtube.com/watch?v=QLRxO9AmNNo

0개의 댓글