셸 set 명령어 완벽 가이드: 위치 매개변수부터 strict mode까지

셸 set 명령어 완벽 가이드: 위치 매개변수부터 strict mode까지

셸 스크립트를 들여다보면 거의 항상 첫 줄에 이런 코드가 있습니다.

#!/usr/bin/env bash
set -euo pipefail

-euo pipefail이라는 옵션이 뭔지 모르고도 한참을 그냥 따라 쓰다가, 어느 날 set -a로 시작하는 .env 로딩 스크립트를 마주치면 또 한 번 당황하게 되는데요. 그러다 문득 “그러면 옵션 없이 그냥 set만 쓰면 어떻게 되지?” 하는 의문도 생깁니다.

set은 셸에서 가장 자주 쓰이는 명령어인데도 정작 그 정체가 뭔지 정리된 자료가 많지 않습니다. 이번 글에서는 set이 어떤 명령어이고, 왜 옵션이 헷갈리게 보이는지, 자주 쓰는 패턴은 무엇인지 하나씩 살펴보겠습니다.

set은 외부 명령어가 아닙니다

set을 처음 만나면 /usr/bin/set 어딘가에 실행 파일이 있을 것 같지만, set은 셸 빌트인(built-in) 명령어입니다. bash, zsh, sh 같은 셸 안에 내장되어 있어서 별도의 프로세스를 띄우지 않고 셸 자신이 직접 처리합니다.

직접 확인해보면 이렇게 나옵니다.

type set
# set is a shell builtin

which set
# (출력 없음)

whichPATH에서 실행 파일을 찾는 명령어인데, set은 외부 파일이 아니라서 아무것도 찾지 못합니다. 그래서 man set도 동작하지 않습니다. bash의 경우 help set으로 보거나 man bash에서 SHELL BUILTIN COMMANDS 섹션을 찾아야 합니다.

set이 빌트인이라는 사실은 단순한 트리비아가 아닙니다. 외부 명령어라면 셸의 옵션이나 변수 상태를 바꿀 수 없는데요. 자식 프로세스에서 부모 셸의 상태를 건드릴 방법이 없기 때문입니다. set은 셸 자신의 일부이기 때문에 셸의 동작을 직접 조작할 수 있습니다.

set이 하는 세 가지 일

set은 인자에 따라 완전히 다른 세 가지 일을 합니다. 같은 이름의 명령어가 서로 다른 기능을 모아놓은 형태라서 처음 보면 헷갈리기 쉽습니다.

인자 없이 호출하면 현재 셸에 정의된 모든 변수와 함수를 출력하고, -+로 시작하는 옵션과 함께 호출하면 셸의 동작 모드를 바꿉니다. -- 뒤에 일반 인자를 넘기면 위치 매개변수($1, $2, …)가 그 값으로 새로 설정됩니다.

여기서 가장 직관과 어긋나는 부분이 -+의 의미입니다. 보통 -는 빼기, +는 더하기를 떠올리지만 set에서는 정반대입니다.

set -e   # errexit 옵션을 켬
set +e   # errexit 옵션을 끔

옵션을 “켠다”는 게 옵션 이름 앞에 -를 붙이는 행위라고 외워두는 편이 마음 편합니다. 처음 만든 사람이 왜 이렇게 정했는지는 역사 속에 묻혀 있지만, 지금까지 그대로 굳어져서 모든 POSIX 셸에서 같은 규칙으로 동작합니다.

인자 없는 set: 변수 전체 보기

가장 단순한 사용법은 옵션 없이 그냥 set을 치는 것입니다.

set
# BASH=/bin/bash
# BASH_VERSION='5.2.21(1)-release'
# COLUMNS=120
# HOME=/Users/dale
# PATH=/usr/local/bin:/usr/bin:/bin
# PS1='\u@\h:\w\$ '
# ... (수백 줄)

비슷한 명령어로 env가 있는데 둘은 보여주는 범위가 다릅니다. env는 export된 환경변수만 보여주고, set은 export되지 않은 셸 변수와 함수까지 전부 보여줍니다.

LOCAL=hello       # 셸 변수 (export 안 됨)
export GLOBAL=world  # 환경변수

env | grep -E "LOCAL|GLOBAL"
# GLOBAL=world

set | grep -E "^LOCAL=|^GLOBAL="
# GLOBAL=world
# LOCAL=hello

스크립트 디버깅 중에 “내가 만든 변수가 제대로 들어갔나” 확인할 때 유용합니다. 다만 출력량이 워낙 많아서 보통은 grep이나 less로 필터링해서 봅니다.

위치 매개변수 재설정

set -- 뒤에 값을 나열하면 셸의 위치 매개변수($1, $2, $3, …)가 그 값으로 바뀝니다.

set -- apple banana cherry
echo "$1"   # apple
echo "$2"   # banana
echo "$#"   # 3
echo "$@"   # apple banana cherry

--를 붙이는 이유는 첫 번째 값이 -로 시작해도 옵션으로 해석되지 않게 하려는 안전장치입니다. set -e apple처럼 쓰면 errexit 옵션이 켜져버리겠지만, set -- -e apple이라고 쓰면 -e가 그냥 $1이 됩니다.

위치 매개변수를 비우고 싶을 때도 --만 단독으로 씁니다.

set --
echo "$#"   # 0

이게 실제로 어디 쓰이느냐 하면, 공백으로 구분된 출력을 단어 단위로 쪼개야 할 때입니다. 예를 들어 date 명령어의 출력을 파싱한다고 해봅시다.

set -- $(date)
echo "요일: $1"   # 요일: Mon
echo "월:   $2"   # 월:   May
echo "일:   $3"   # 일:   26
echo "시각: $4"   # 시각: 14:32:11

물론 요즘은 이런 용도라면 read -ra arr <<< "$(date)" 같은 더 안전한 방법이 있긴 하지만, 오래된 POSIX 셸 스크립트에서는 여전히 set -- 패턴을 흔하게 볼 수 있습니다.

set -a: 자동으로 export 하기

-a는 allexport의 약자입니다. 이 옵션을 켜두면 이후에 정의되는 모든 변수가 자동으로 export됩니다.

FOO=1            # 그냥 셸 변수
export BAR=2     # 명시적으로 환경변수 선언

set -a
BAZ=3            # 자동으로 export 됨
QUX=4            # 이것도 자동 export
set +a           # 다시 끔

env | grep -E "^(FOO|BAR|BAZ|QUX)="
# BAR=2
# BAZ=3
# QUX=4
# (FOO는 빠짐)

set -a의 진짜 활용처는 .env 파일 로딩입니다.

load-env.sh
set -a
source .env
set +a

.env 파일에 DATABASE_URL=postgres://... 같은 줄이 있을 때, 그냥 source .env만 하면 셸 변수로만 들어갑니다. 자식 프로세스에서 이 값을 보려면 일일이 export를 붙여야 하죠. set -a로 감싸주면 .env 안의 모든 줄이 자동으로 환경변수가 되어 자식 프로세스에 전달됩니다.

Node.js의 dotenv 라이브러리나 Python의 python-dotenv 같은 도구가 하는 일을 셸만으로 처리하는 셈입니다. CI 스크립트나 Docker entrypoint에서 이 패턴을 자주 볼 수 있습니다.

set -e: 에러 나면 즉시 종료

-e는 errexit의 약자로, 명령어가 0이 아닌 종료 코드를 반환하면 스크립트를 즉시 종료하라는 뜻입니다.

without-e.sh
#!/bin/bash
rm /존재하지않는파일
echo "여기는 실행됩니다"
# rm: cannot remove ...: No such file or directory
# 여기는 실행됩니다

-e 없이는 rm이 실패해도 다음 줄이 그대로 실행됩니다. 배포 스크립트에서 빌드가 실패했는데도 그 결과물을 그대로 서버에 올려버리는 사고가 이렇게 일어납니다.

with-e.sh
#!/bin/bash
set -e
rm /존재하지않는파일
echo "여기는 실행 안 됩니다"
# rm: cannot remove ...: No such file or directory
# (스크립트 종료)

다만 set -e는 만능이 아닙니다. 다음과 같은 경우에는 종료시키지 않습니다.

  • if, while, until 조건문 안의 명령어
  • &&||로 연결된 명령어 중 마지막을 제외한 것들
  • !로 부정된 명령어
  • 파이프라인의 중간 명령어 (마지막 명령어만 검사)

조건문 안에서 예외가 적용되는 건 의도된 동작입니다. if grep foo file.txt; then처럼 쓸 때 grep이 못 찾았다고 스크립트가 죽으면 곤란하니까요. 하지만 파이프라인의 중간 명령어는 따로 챙겨야 하는데, 이건 잠시 후에 나올 pipefail이 해결해줍니다.

set -u: 정의되지 않은 변수는 에러

-u는 nounset의 약자로, 정의되지 않은 변수를 참조하면 에러를 내고 종료합니다.

set -u
echo "$UNDEFINED"
# bash: UNDEFINED: unbound variable

이 옵션이 왜 중요한지는 다음 예시를 보면 바로 와닿습니다.

dangerous.sh
#!/bin/bash
rm -rf "$HOME_DIR/cache"

HOME_DIR이 정의되어 있어야 하는데 오타가 났거나 어딘가에서 빠뜨렸다면, $HOME_DIR은 빈 문자열로 확장됩니다. 결국 rm -rf /cache가 실행되어 루트 디렉터리의 /cache를 날려버리게 되죠. set -u만 켜두면 이런 사고를 막을 수 있습니다.

다만 의도적으로 비어있을 수 있는 변수를 다룰 때는 파라미터 확장으로 기본값을 명시해야 합니다.

set -u
echo "${OPTIONAL_VAR:-default}"   # 정의 안 되어 있으면 "default" 출력

${변수:-기본값} 문법을 쓰면 변수가 정의되지 않았어도 에러가 나지 않습니다.

set -o pipefail: 파이프 실패도 잡아내기

파이프를 사용한 명령어의 종료 코드는 기본적으로 마지막 명령어의 종료 코드입니다. 그래서 중간에 실패해도 마지막이 성공하면 전체가 성공으로 처리됩니다.

false | true
echo $?   # 0

false는 분명히 실패했는데 전체 결과는 0(성공)으로 나옵니다. 이게 실제 스크립트에서 사고로 이어지는 모습은 이렇습니다.

curl https://example.com/api | jq '.data'

curl이 네트워크 에러로 실패해서 빈 문자열을 내보내도, jq는 그 빈 입력을 처리하면서 0으로 종료할 수 있습니다. 결과적으로 데이터를 가져오는 데 실패했는데도 스크립트는 멀쩡히 다음 단계로 넘어가버립니다.

set -o pipefail을 켜면 파이프라인 중 하나라도 실패하면 전체가 실패로 처리됩니다.

set -o pipefail
false | true
echo $?   # 1

-o는 긴 이름 옵션을 켜는 플래그입니다. pipefail은 한 글자 약어가 없어서 -o pipefail로만 쓸 수 있습니다. 반대로 errexit이나 nounset은 짧은 옵션과 긴 옵션 둘 다 됩니다.

set -e               # 짧은 형태
set -o errexit       # 긴 형태 (동일)

set -u               # 짧은 형태
set -o nounset       # 긴 형태 (동일)

set -euo pipefail: bash strict mode

이제 처음에 봤던 그 코드를 다시 보면 의미가 분명해집니다.

set -euo pipefail

이 한 줄은 다음 세 옵션을 한꺼번에 켜는 것과 같습니다.

set -e             # 에러 나면 즉시 종료
set -u             # 미정의 변수 참조하면 에러
set -o pipefail    # 파이프 중간 실패도 잡아냄

짧은 옵션 여러 개는 하이픈 하나에 묶을 수 있어서 -eu가 되고, 그 뒤에 -o pipefail이 붙은 형태입니다. 이 세 개를 묶은 패턴을 흔히 bash strict mode라고 부릅니다.

스크립트 첫 줄에 이 한 줄을 적어두면 다음과 같은 사고가 대부분 막힙니다. 빌드가 실패했는데 배포가 그대로 진행되거나, 환경변수 오타로 엉뚱한 경로가 삭제되거나, API 응답이 실패했는데 빈 데이터를 저장해버리는 일들이죠.

물론 strict mode가 만능은 아닙니다. 앞서 본 것처럼 set -e는 조건문 안에서 적용되지 않고, set -u${VAR:-} 패턴으로 우회할 수 있습니다. 그래도 아무것도 안 켜는 것보다는 훨씬 안전합니다.

추가로 IFS(Internal Field Separator)까지 보수적으로 설정하는 더 엄격한 버전도 있습니다.

set -euo pipefail
IFS=$'\n\t'

이렇게 하면 단어 분리에서 공백이 빠져서, 파일명에 공백이 있어도 안전하게 처리할 수 있게 됩니다. 다만 IFS를 바꾸면 set -- $(date) 같은 공백 분리 패턴이 깨지므로 모든 스크립트에 무작정 적용하기는 어렵습니다.

마치며

set 한 명령어에 이렇게 많은 기능이 모여 있는 게 처음엔 좀 어색하지만, 정리해 보면 분기가 단순합니다. 옵션 없이 쓰면 변수 출력, --와 함께 쓰면 위치 매개변수 설정, -/+와 옵션 이름이면 셸 동작 모드 변경, 이 세 가지만 기억해두면 됩니다.

실무에서는 set -euo pipefail 한 줄만 잘 챙겨도 “조용히 실패하는 스크립트”가 일으키는 사고의 상당수가 막힙니다. 새 스크립트를 만들 때 #!/usr/bin/env bash 다음 줄에 이 옵션을 적어두는 습관을 들이면 두고두고 도움이 됩니다.

.env 파일을 다뤄야 한다면 set -a 패턴도 같이 알아두세요. 외부 라이브러리 없이 셸만으로 환경변수를 통째로 로딩할 수 있어서 Docker entrypoint나 CI 스크립트에서 자주 보입니다.

set의 모든 옵션에 대한 자세한 설명은 Bash 매뉴얼의 The Set Builtin 섹션에서 찾을 수 있습니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord