이 포스트는 https://betterdev.blog/minimal-safe-bash-script-template 를 허락받아 번역하였음.
대부분의 사람들이 언젠가는 하나씩 작성 하는 그것. 대부분의 사람들이 좋아하지 않는 그것. 그래서 대부분의 사람들이, 작성할 때 깊게 집중하지 않는 바로 그것 - 배쉬 스크립트*.
당신을 Bash 전문가로 키워낼 생각은 딱히 없다(나도 전문가는 아니다). 대신 간결하고 훨씬 안전한 템플릿 하나를 보여주겠다. 나에게 고마워 하지 않아도 된다. 미래의 당신이 지금의 당신에게 고마워 할테니까.
(*역주: 이하 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 답변)
거두절미하고 일단 결과를 보시라.
#!/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'라는 기초적인 목표가 무너진다. 그래서 가능한 한 짧게 만들어서, 복붙해서 바로 사용할 수 있는* 템플릿을 만드는 방식을 쓰는 결론에 도달했다.
(*역주 : 이 템플릿을 스크립트 파일에 넣고, 그 아랫줄부터 바로 본래 작성하려던 코드를 작성해나가면 되도록 구현했다는 말)
하나씩 자세히 살펴보자.
#!/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()
이 실행될 수 있다는 것을 기억하는 게 좋다. 즉, 위에서 언급한 임시 파일이 아직 생성되지도 않았을 수도 있다는 것이다.
usage() {
cat <<EOF
사용법: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]
스크립트 설명은 이곳에
...
EOF
exit
}
이 usage()
를 상단에 두어서 두 가지 효과를 누릴 수 있다:
(*역주 : 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이 아닐 때 어떤 결과가 나오는지 궁금하다면, 위 예제를 코드에 넣고 아래와 같이 실행해보자. 이 코드는 stderr
를 stdout
으로 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 파라미터 파싱 기법도 있다. getopt
와 getopts
인데, 이걸 쓰냐 마냐에 대한 논쟁이 있다. 내 생각엔 별로 좋지 않은 것 같다. 왜냐하면 macOS에서는 디폴트 값으로 사용했을 때 getopt
가 완전히 다르게 작동하기 때문이다. 그리고 getopts
는 long parameter를 지원하지도 않는다 (예를 들면 --help
).
복붙하면 된다. 인터넷에 있는 코드들과 마찬가지로.
아니 진심이다. Bash 세계에서는 npm install
같은 통일된 뭔가가 없다구.
복사한 후에, 네 군데만 수정하자:
usage()
텍스트를 실제 수행할 스크립트 설명으로 수정cleanup()
내용 채우기parse_params()
에서 (--help
와 --no-color
는 건드리지 말고) -f
, -p
를 실제 쓰는 파라미터로 업데이트 또는 제거이 템플릿은 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 이해 겸 해서 블로그 번역을 처음으로 해봤음. 재밌네... 주석 달고 싶어서 딸린 링크도 전부 읽어봤는데 매우 흡족스럽다.