Introduction

부트캠프에서 이런 질문을 받았다. "Linux Shell이 명령을 해석하고 실행하는 과정(파싱, 환경 변수 확장, 프로세스 생성)을 설명하고, bash와 zsh 같은 셸 환경 차이가 실무 자동화에 어떤 영향을 미치는지 서술하시오." 터미널에 한 줄을 입력하고 엔터를 치는 그 짧은 순간에, 셸은 문자열을 토큰으로 쪼개고, 문법 구조로 조립하고, 아홉 단계의 확장을 거쳐, 마침내 프로세스를 만들어 낸다. 이 글은 그 전 과정을 따라간 뒤, bash와 zsh의 차이가 자동화 스크립트에서 왜 문제가 되는지 살펴본다.

리눅스는 1960년대에 시작된 유닉스(Unix) 운영체제의 후신으로, 셸(shell)을 비롯해 유닉스로부터 정말 많은 부분을 이어받은 운영체제다. 리눅스는 GUI(Graphical User Interface, 그래픽 사용자 인터페이스)가 상당히 보급되어 가던 1991년에 만들어졌지만, 여전히 유닉스의 변종이기 때문에 터미널을 사용하는 기존 구조를 그대로 물려받았다.

그림 1. 1978년에 출시된 DEC사의 VT100 터미널. 원래 터미널(terminal)은 사용자가 시스템에 접속하기 위해 사용하는 말단(terminal) 지점의 장비, 즉 물리적인 기계를 가리키는 이름이었다. 오늘날 우리가 아는 터미널이 "에뮬레이터"(emulator)인 이유도 여기에 있다.

컴퓨팅 시스템의 역사에서 셸은 인간과 커널(kernel) 사이의 가장 근원적인 인터페이스로 존재해 왔다. GUI가 일반 사용자 영역을 장악한 현대에도 서버 관리, 클라우드 인프라 프로비저닝, CI/CD(지속적 통합/배포) 파이프라인 구축 등 시스템 엔지니어링의 핵심 영역은 여전히 텍스트 기반의 셸 환경에 절대적으로 의존하고 있다. 셸은 단순한 명령 실행기가 아니다. 그것은 고유한 문법과 제어 구조, 변수 스코프, 프로세스 제어 메커니즘을 갖춘 완전한 프로그래밍 언어이자, 운영체제의 시스템 호출(system call)을 추상화하여 제공하는 핵심 미들웨어이다.

양파 모델

셸이 존재하는 이유는 유닉스, 그리고 유닉스를 계승한 리눅스의 설계 구조를 통해 이해할 수 있다. 유닉스와 리눅스는 이른바 양파 모델(onion model)이라 불리는 구조로 설계되었다.

그림 2. 유닉스와 리눅스의 내부 구조는 양파처럼 하드웨어를 커널, 셸, 응용프로그램이 감싸는 형태로 설계되었다.

그림 3. 양파 구조가 실제로 구현된 모습. 셸과 응용프로그램을 묶어 그 위에 사용자를 두기도 하는데, 응용프로그램이 직접 시스템 호출을 사용한다면 셸을 건너뛸 수 있다.

이 모델을 통해 리눅스는 시스템 자원을 안전하게 관리하고, 사용자 작업과 하드웨어 제어 간의 충돌을 방지한다. 응용프로그램은 자신의 요청을 셸로 전달하고, 셸은 그 요청을 커널로 보내고, 커널은 하드웨어를 다루며 요청을 수행한다. 그 결과는 다시 커널에서 셸을 거쳐 응용프로그램으로 전달된다.

하드웨어

모델의 중심에는 하드웨어가 있다. 컴퓨터 운영체제의 핵심은 하드웨어와 사용자를 연결하는 것이다.

커널

커널은 운영체제의 핵심이자, 하드웨어와 가장 가깝고 하드웨어를 가장 자유롭게 이용할 수 있는 시스템 소프트웨어이다.

셸은 커널과 응용프로그램 사이에서 이용자의 명령을 해석하고 그 처리 결과를 반환하는 시스템 소프트웨어이다. 사용자가 입력한 명령을 해석해 커널에게 전달하고, 커널이 처리한 결과를 다시 사용자에게 보여주는 중간 번역자이자 조정자 역할을 수행한다.

응용프로그램

응용프로그램은 사용자와 소통하는 최전선에 위치한 소프트웨어이다. 우리에게 익숙한 각종 프로그램을 포함해 GUI 인터페이스 그 자체도 응용프로그램에 속한다.

셸이 명령을 수행하는 과정

이제 앞서 소개한 두 가지 내용을 전제로 셸이 명령을 어떻게 수행하는지 알아보자.

입력

셸이 명령을 수행하기 위해서는 먼저 사용자로부터 텍스트를 입력받아야 한다. 입력을 받는 과정은 대략 다음과 같다.

그림 4. 리눅스에서 터미널이 사용자의 입력을 받기 위해 대기하는 구조. gettylogin은 로그인 셸 호출과 로그인에 사용된다.1

그림 5. 리눅스에서 입출력이 이루어지는 대략적인 과정.

가장 먼저 모든 프로세스의 조상이 되는 init 프로세스가 생성된다. 터미널 창을 하나 켤 때마다 fork()를 통해 이 프로세스가 복제되어 로그인 창을 호출하고, 로그인이 완료되면 터미널 기기와 로그인 셸이 연결된다. 터미널 기기는 드라이버를 통해 로그인 셸과 직접적으로 연결되어 있고, 이때 stdin(표준 입력, 파일 서술자(file descriptor, fd) 0번), stdout(표준 출력, 파일 서술자 1번), stderr(표준 오류 출력, 파일 서술자 2번)이 기본적인 입출력 채널로 사용된다.

그림 6. 0번, 1번, 2번 파일 서술자와 표준 입출력 사이의 관계.

셸은 기본적으로 표준 입력(stdin)이나 스크립트 파일로부터 문자열 스트림을 읽어 들인다. 대화형 모드에서는 readline 라이브러리(bash)나 ZLE(Zsh Line Editor)를 통해 사용자 입력을 처리하며, 엔터 키(개행 문자, \n)가 입력되면 버퍼에 담긴 문자열 처리를 시작한다. 이때 셸은 입력된 문자열을 단순한 문자의 나열이 아니라 잠재적인 명령어의 집합으로 인식하기 위한 준비를 수행한다.

인용 부호 처리

입력을 읽는 단계에서 셸은 특정 문자를 특별한 기능을 가진 메타문자(metacharacter)로 식별한다. 공백(space), 탭(tab), 개행 문자, 파이프(|), 리다이렉션(>, <), 세미콜론(;), 앰퍼샌드(&), 괄호(()) 등이 메타문자에 해당한다. 인용 부호(quotes)는 이러한 메타문자의 특별한 의미를 제거(escape)하여 일반 리터럴 문자로 취급하게 하는 메커니즘이다.

인용 부호 종류기능 및 특징bash/zsh 공통 동작
백슬래시 (\)바로 뒤따르는 문자의 특수 의미를 제거한다.\ 는 공백을, \$는 달러 기호를 리터럴로 취급하게 한다. 개행 문자 앞에 오면 줄 바꿈을 무시하고 라인을 이어 간다(line continuation).
작은따옴표 (')가장 강력한 인용 부호. 내부의 모든 문자를 리터럴로 취급한다.변수 확장($VAR)이나 명령 치환이 전혀 발생하지 않는다. 작은따옴표 내부에는 다른 작은따옴표를 포함할 수 없다.
큰따옴표 (")부분적인 인용. 대부분의 메타문자를 리터럴로 취급하지만 일부 확장은 허용한다.$(변수 확장, 명령 치환)와 백틱(구식 명령 치환), \(특정 문자 이스케이프)는 여전히 해석된다.
ANSI-C 인용 ($'...')C 언어 스타일의 이스케이프 시퀀스를 해석한다.\n, \t, \xHH(16진수) 등의 제어 문자를 표현할 때 사용된다.

그림 7. zsh가 닫히지 않은 인용 부호를 감지해 보조 프롬프트를 띄운 모습. 여기서 dquote는 쌍따옴표(double quote)를 의미한다.

또한 셸은 입력 스트림에서 닫히지 않은 인용 부호를 감지하면 명령이 완료되지 않았다고 판단하고, 보조 프롬프트(PS2, 기본값 >)를 띄워 추가 입력을 요구한다. 이는 파싱의 전제 조건인 '완전한 문장'을 구성하기 위함이다.

토큰 인식(Token Recognition)

입력된 문자열은 토크나이저(tokenizer)에 의해 토큰(token)이라는 의미 단위로 분해된다. 토큰은 크게 단어와 연산자로 나뉜다.

  • 단어(word): 명령의 이름, 인자(argument), 파일 경로 등 일반적인 문자열이다.
  • 제어 연산자(control operator): 명령의 흐름을 제어하는 연산자를 뜻한다. 제어 연산자에는 ||, &&, &, ;, ;;, |, |&, (, )가 있다.
  • 리다이렉션 연산자(redirection operator): 입출력 흐름을 제어하는 연산자를 뜻한다. 리다이렉션 연산자에는 >, <, >>, <<, >&, <&가 있다.

인용되지 않은(unquoted) 메타문자가 토큰의 경계를 형성한다. 예를 들어 echo "hello world" | grep foo && barecho, hello world(따옴표로 하나의 토큰), |, grep, foo, &&, bar의 7개 토큰으로 분리된다.

파싱(Parsing)

토큰화된 결과는 셸의 문법 규칙에 따라 구문 분석되어 AST(Abstract Syntax Tree, 추상 구문 트리) 형태나 내부적인 명령 구조체로 변환된다.

입력: VAR=hello echo $VAR && ls -la | grep txt

Parsing result (AST):

              AND_OR (&&)
           /              \
     SIMPLE_CMD          PIPELINE
    +----------+        /       \
    |prefix:   |  SIMPLE_CMD  SIMPLE_CMD
    | VAR=hello|  +--------+  +----------+
    |cmd: echo |  | ls -la |  | grep txt |
    |args: $VAR|  +--------+  +----------+
    +----------+

셸의 파서(parser)는 다음과 같은 구조적 요소를 식별한다.

  • 단순 명령(simple command): 일련의 단어들로 구성되며, 첫 번째 단어는 명령(또는 함수)의 이름이 되고 나머지는 인자가 된다. 변수 할당(VAR=val)이 명령 앞에 올 수 있다.
  • 파이프라인(pipeline): 하나 이상의 단순 명령이 | 제어 연산자로 연결된 시퀀스.
  • 리스트(list): 하나 이상의 파이프라인이 ;, &, &&, ||로 연결된 시퀀스.
  • 복합 명령(compound command): if, for, while, until, case, select 등의 제어 구조와 그룹 명령 { list; }, 서브셸 ( list )가 있다.

확장(Expansion)

셸 확장shell expansion은 사용자가 입력한 추상적이고 간결한 표현식을 운영체제가 이해할 수 있는 구체적인 문자열(들)로 변환하는 과정이다. 이 과정은 엄격하게 정의된 순서에 따라 수행되며, 이 순서를 이해하지 못하면 스크립트에서 예측 불가능한 동작을 경험하게 된다.

bash와 POSIX 표준에 따른 확장 순서는 다음과 같으며, zsh 또한 이 기본 틀을 따르되 일부 단계에서 독자적인 동작을 수행한다.

  1. 중괄호 확장(brace expansion)
  2. 물결표 확장(tilde expansion)
  3. 파라미터 및 변수 확장(parameter and variable expansion)
  4. 명령 치환(command substitution)
  5. 산술 확장(arithmetic expansion)
  6. 프로세스 치환(process substitution) — 시스템이 지원할 때에만
  7. 단어 분할(word splitting) — bash만 이 단계를 거친다
  8. 경로명 확장(pathname expansion / globbing)
  9. 인용 부호 제거(quote removal)

중괄호 확장(Brace Expansion)

가장 먼저 수행되는 확장이다. 중괄호 {} 안에 쉼표로 구분된 문자열이나 .. 범위 연산자를 사용하여 임의의 문자열 집합을 생성한다.

# 입력
echo file{1..3}.txt

# 출력
file1.txt file2.txt file3.txt

다수의 디렉토리를 한번에 생성하거나, 원본과 백업 파일을 동시에 생성할 때 유용하다.

물결표 확장(Tilde Expansion)

단어가 ~ 문자로 시작할 때 수행된다. ~는 현재 사용자의 홈 디렉토리($HOME 환경 변수 값)로, ~username은 특정 사용자 username의 홈 디렉토리로 치환된다. ~+는 현재 작업 디렉토리($PWD), ~-는 이전 작업 디렉토리($OLDPWD)로 확장되는 기능도 제공한다.

# 입력
echo ~

# 출력 (홈 디렉토리의 절대 경로)
/home/username

파라미터 및 변수 확장(Parameter and Variable Expansion)

$ 문자로 시작하는 변수나 파라미터를 그 값으로 치환한다. ${parameter} 형식을 기본으로 하며, 단순 치환을 넘어 다양한 문자열 조작 기능을 제공한다.

  • 기본값 처리: ${VAR:-default}(값이 unset이거나 null일 때), ${VAR-default}(unset일 때만), ${VAR:=default}(할당까지 수행).
  • 문자열 조작: ${VAR:offset:length}(부분 문자열), ${VAR#pattern}(prefix 제거), ${VAR%pattern}(suffix 제거), ${VAR/pattern/string}(문자열 치환).
  • 길이 및 배열: ${#VAR}(문자열 길이), ${#ARRAY[@]}(배열 길이).
  • 간접 참조: ${!VAR}(bash), ${(P)VAR}(zsh).

명령 치환(Command Substitution)

명령의 실행 결과를 그 자리에 문자열로 대치한다. $(command) 또는 역따옴표를 사용하는 형식이 있다. 셸은 서브셸(subshell)을 생성하고, 해당 서브셸 내에서 명령을 실행한 뒤, 파이프를 통해 표준 출력(stdout)을 캡처하여 원본 명령행에 삽입한다. 이때 출력의 마지막에 있는 개행 문자(trailing newline)는 모두 제거된다.

# 입력
echo "Today's date is $(date)"

# 출력
Today's date is Fri Oct 18 11:34:56 UTC 2024

$(...) 형식은 괄호의 중첩을 자동으로 처리하여 $(cmd1 $(cmd2))와 같은 구문을 쉽게 작성할 수 있지만, 역따옴표 방식은 내부의 역따옴표를 이스케이프해야 하므로 가독성과 유지보수성이 떨어져 현대 스크립팅에서는 지양된다.

산술 확장(Arithmetic Expansion)

$(( expression )) 형식을 사용하여 내부적으로 수치 연산을 수행하고 그 결과를 문자열로 치환한다. bash는 기본적으로 정수 연산만을 지원하기 때문에 소수점 연산이 필요한 경우 bc, awk 등의 외부 도구를 사용해야 한다. 반면 zsh는 실수(floating-point) 연산을 기본적으로 지원하고, zsh/mathfunc 모듈을 로드하면 sin, cos, log, sqrt 등의 공학 함수도 라이브러리 수준에서 직접 사용할 수 있다. 그래서 수치 계산이 필요한 스크립트에서는 bash가 zsh보다 불리하다.

프로세스 치환(Process Substitution)

<(command) 또는 >(command) 형식을 사용하여 명령의 입출력을 파일처럼 취급할 수 있게 한다. 셸은 명령을 비동기적으로 실행하고, 그 입출력을 명명된 파이프(named pipe/FIFO)2 또는 /dev/fd/N 형태의 파일 서술자 경로로 연결한다.

그림 8. 일반적인 파이프(pipe)의 동작 방식. 셸에서 ls | wc -l을 실행시켰을 때 파이프가 어떻게 동작하는지 보여 준다.

그림 9. 어떤 프로세스가 셸을 통해 명령을 실행시키는 모습. /bin/sh는 sh(Bourne Shell) 셸 프로그램을 뜻한다.

diff <(ls dir1) <(ls dir2)와 같이 파일 입력을 요구하는 명령어에 명령의 출력을 직접 전달할 때 유용하다. 여기에 zsh는 =(command) 형식을 추가로 지원한다. 이는 파이프 대신 임시 파일(temporary file)을 생성하여 명령의 출력을 저장하고 그 파일 경로를 반환하는데, 파이프를 지원하지 않고 lseek(파일 탐색)을 요구하는 명령어에 유용하다.

단어 분할(Word Splitting)

파라미터 및 변수 확장, 명령 치환, 그리고 산술 확장은 동시에 이루어지는데, 만약 그 결과가 인용 부호로 감싸지지 않았다면(unquoted) 확장의 결과 문자열을 IFS(Internal Field Separator, 기본값은 공백·탭·개행) 문자를 기준으로 쪼갠다.

이 단계는 bash에서만 수행되며 zsh는 거치지 않는다. 예를 들어 변수에 a b가 담겨 있을 때 ls $file이 bash에서는 ls "a" "b"(두 개의 파일로 인식, 오류 발생 가능)로 변환된다면, zsh에서는 ls "a b"(하나의 파일로 인식, 의도대로 동작)로 변환된다. bash의 이러한 동작은 초기 sh(Bourne Shell)의 유산으로, 원래는 리스트 처리를 위한 기능이었으나 파일명에 공백이 포함되는 현대 컴퓨팅 환경에서는 수많은 버그의 원인이 되었다. zsh는 이를 개선하여 기본적으로 분할을 수행하지 않음으로써 안전성을 확보했다.

경로명 확장(Pathname Expansion / Globbing)

단어 분할 후, 단어에 *, ?, [...] 등의 와일드카드 패턴이 포함되어 있으면 셸은 이를 현재 파일 시스템에서 매칭되는 파일 이름 목록으로 확장한다. bash에서는 매칭되는 파일이 없을 경우 기본적으로 패턴 문자열 자체를 그대로 명령 인자로 넘긴다. 예를 들어 *.txt에 매칭되는 파일이 없으면 bash의 ls *.txtls "*.txt"를 실행하고 "No such file" 에러를 낸다. 반면 zsh에서는 기본적으로 매칭되는 파일이 없으면 오류(zsh: no matches found)를 발생시키고 명령 자체를 실행하지 않는다. 이는 의도치 않은 명령 실행을 방지하는 안전장치이다.

인용 부호 제거(Quote Removal)

마지막으로 셸은 확장이 끝난 후, 처음에 문법적 의미로 사용되었던 인용 부호(', ", \)를 제거하고 최종 인자 목록(argument list)을 완성한다. 이 결과가 최종적으로 명령을 실행하기 위한 시스템 호출에 인자로 전달된다.

리다이렉션 처리

명령 실행 직전에 셸은 파싱 단계에서 식별한 리다이렉션 정보를 바탕으로 파일 서술자를 조작한다. 시스템 호출로 대상 파일을 열어 새로운 파일 서술자를 얻으면, 이 파일 서술자를 표준 입력(0), 표준 출력(1), 표준 오류(2)의 위치로 복제한다. 예를 들어 command > file의 경우 file의 파일 서술자를 1번 파일 서술자로 복제하고 원본 파일 서술자는 닫는다.

그림 10. 리다이렉션 처리를 거친 파일 서술자 테이블의 모습.

실행

확장이 완료된 후, 셸은 명령을 실행한다. 이 과정은 명령의 종류에 따라 내장 명령어(built-in)와 외부 명령어(external)로 나뉘며, 근본적으로 다른 실행 경로를 거친다. 참고로 내장 명령어의 경우 현재 셸 프로세스의 파일 서술자를 직접 변경하므로 명령 실행 후 원래대로 복원하기 위해 기존의 파일 서술자를 미리 백업해 두고, 외부 명령어는 자식 프로세스에서 변경하므로 복원이 필요 없다.

내장 명령어는 별도의 프로세스를 생성하지 않고 현재 셸 프로세스 내부에서 직접 실행된다. cd, echo, read, export, alias, source 등이 셸 자체 코드 내에 함수 형태로 구현되어 있다. fork()exec() 오버헤드가 없어 실행 속도가 매우 빠르고, 따라서 반복문 내에서 빈번히 호출되는 작업은 내장 명령어를 사용하는 것이 성능상 유리하다.

외부 명령어, 즉 /bin/ls, /usr/bin/grep, /usr/bin/python처럼 파일 시스템에 존재하는 실행 파일들은 유닉스 프로세스 관리의 정석인 Fork-Exec-Wait 패턴을 통해 실행된다.

먼저 fork()로 생성된 자식 프로세스는 부모의 메모리 공간, 환경 변수, 열린 파일 서술자 테이블, 시그널 마스크 등을 상속받는다. 이때 리눅스 커널은 효율성을 위해 COW(Copy-on-Write, 기록 시 복사) 기법을 사용한다. 자식 프로세스가 생성될 때 메모리를 물리적으로 즉시 복사하지 않고, 부모와 같은 물리 메모리 페이지를 공유하다가 어느 한쪽이 데이터를 수정할 때 비로소 복사를 수행하는 것이다. fork()는 부모에게는 자식의 PID를, 자식에게는 0을 반환하여 실행 흐름을 분기한다.

그림 11. COW 기법에서 부모와 자식이 물리 메모리 페이지를 공유하는 모습.

이어서 자식 프로세스는 execve(path, argv, envp) 시스템 호출을 실행하여 자신의 메모리 공간을 새로운 프로그램(예: /bin/ls)의 코드와 데이터로 완전히 덮어쓴다. 이때 셸이 파싱 및 확장 단계를 통해 준비한 인자 목록(argv)과 환경 변수 배열(envp)이 새로운 프로그램의 main(int argc, char *argv[], char *envp[]) 함수로 전달된다. 셸 스크립트 파일의 경우, 커널은 파일 첫 줄의 셔뱅(shebang, #!/bin/bash)을 읽고 해당 인터프리터를 실행하여 스크립트 파일을 인자로 전달한다.

그림 12. 셸이 완성하는 최종 인자 목록. 이 목록은 exec()에 인자로 넘겨진다.

그림 13. exec()이 실행되는 과정.

그림 14. exec()의 실행 결과로 만들어진 새로운 프로세스.

bash vs zsh

bash와 zsh는 모두 Bourne Shell(sh)을 계승하며 POSIX 표준을 상당 부분 준수하지만, 설계 철학과 세부 구현에서 뚜렷한 차이를 보인다. bash는 표준 준수와 호환성을, zsh는 사용자 편의성과 확장성을 최우선 가치로 둔다.

배열 처리 방식의 근본적 차이

배열 인덱싱 방식의 차이는 두 셸을 오가는 엔지니어들에게 가장 큰 혼란을 주는 요소이다.

특징bashzsh비고
인덱스 시작0-based (0부터 시작)1-based (1부터 시작)zsh에서 KSH_ARRAYS 옵션을 켜면 0-based로 전환 가능.
첫 요소 참조${arr[0]}${arr[1]}같은 코드가 서로 다른 요소를 가리키게 된다.
전체 참조${arr[@]}${arr} 또는 ${arr[@]}zsh는 인덱스 없이 이름만으로 전체 배열 참조 가능.
희소 배열지원 (인덱스가 연속적이지 않아도 됨)미지원 (일반 배열), 지원 (연관 배열)bash는 arr[100]=val 할당 시 0~99를 비워 둠.
문자열 길이${#string}${#string}배열 길이와 문법이 비슷하여 혼동 주의.
배열 길이${#arr[@]}${#arr}bash에서 ${#arr}은 첫 번째 요소의 길이임.

강력한 경로명 확장: Globbing과 Qualifiers

zsh는 강력한 Glob Qualifiers를 내장하고 있어, find 명령어를 사용해야만 했던 복잡한 파일 검색 작업을 셸 문법만으로 처리할 수 있다.

  • 재귀적 탐색 (**): zsh는 ls **/*.log(현재 및 하위 디렉토리의 모든 로그 파일)를 1990년대부터 지원해 왔다. bash는 4.0 버전부터 지원하며, shopt -s globstar 옵션을 켜야만 **를 사용할 수 있다.
  • Glob Qualifiers(zsh 전용): 패턴 뒤에 (...)를 붙여 메타데이터 필터링을 수행한다. (.)은 일반 파일만, (/)은 디렉토리만, (@)은 심볼릭 링크만 매칭하고, (om)은 수정 시간 순으로 정렬하며, (Lk+100)은 크기가 100KB 이상인 파일을, (u:username:)은 특정 사용자가 소유한 파일을 매칭한다. 예를 들어 "최근 수정된 로그 파일 5개만 삭제"는 zsh에서 rm *.log(om[1,5]) 한 줄이면 되지만, bash에서는 ls -t *.log | head -n 5 | xargs rm처럼 파이프라인을 엮어야 하고 파일 이름에 공백이 있을 경우 매우 위험하다.

모듈 시스템과 확장성

bash는 기능 확장을 위해 주로 외부 명령어에 의존하거나 소스 코드를 패치해야 하지만, zsh는 동적 로딩이 가능한 모듈 시스템(zmodload)을 갖추고 있다.

  • zsh/net/tcp: netcat이나 telnet 없이 셸 내장 기능만으로 TCP 연결을 맺고 데이터를 주고받을 수 있다. 외부 도구 설치가 제한된 컨테이너 환경에서 포트 헬스 체크를 수행할 때 매우 유용하다.
  • zsh/zpty: 가상 터미널(pseudo-terminal)을 제어하여 expect와 유사한 상호작용 자동화를 구현할 수 있다.
  • zsh/mapfile: 파일 내용을 변수에 매핑하여 읽기/쓰기를 간편하게 한다.
  • zsh/mathfunc: 고성능 수학 함수를 지원한다.

초기화 파일의 로딩 순서

모드bash 로딩 순서zsh 로딩 순서비고
로그인 셸 (SSH, 콘솔)/etc/profile~/.bash_profile~/.bash_login~/.profile/etc/zshenv~/.zshenv/etc/zprofile~/.zprofile/etc/zshrc~/.zshrc/etc/zlogin~/.zloginbash는 로그인 셸에서 .bashrc를 기본적으로 로드하지 않지만(명시적인 source 실행 필요), zsh는 항상 .zshrc를 로드한다.
비로그인 인터랙티브 (새 터미널 탭)/etc/bash.bashrc~/.bashrc/etc/zshenv~/.zshenv/etc/zshrc~/.zshrcmacOS 터미널은 기본적으로 로그인 셸로 실행되어, bash 사용 시 .bashrc가 무시됨.
스크립트 실행 (비인터랙티브)BASH_ENV 환경 변수가 가리키는 파일 (있다면)/etc/zshenv~/.zshenvzsh는 스크립트 실행 시에도 zshenv를 로드하므로, 여기에 출력을 넣으면 scpsftp가 깨질 수 있음.

셸 환경 차이가 실무 자동화에 미치는 영향

스크립트 이식성(Portability)과 표준 준수

CI/CD 파이프라인(Jenkins, GitHub Actions), 컨테이너 초기화 스크립트(Docker Entrypoint), 시스템 부트 스크립트는 반드시 bash 또는 POSIX sh(#!/bin/sh)로 작성해야 한다. Alpine Linux(ash/dash 기반)3를 제외한 거의 모든 리눅스 배포판(Ubuntu, CentOS, RHEL)에 bash가 기본 셸로 설치되어 있는 반면, zsh는 기본 설치되지 않은 경우가 많아 스크립트 실행을 위해 zsh 설치라는 불필요한 의존성을 추가해야 하기 때문이다. 구글의 셸 스타일 가이드 역시 실행 가능한 셸 스크립트에 대해 반드시 bash를 사용하고 이식성을 위해 #!/bin/bash를 명시할 것을 권장하며, 100줄이 넘어가는 복잡한 로직은 Python이나 Go로 작성할 것을 제안한다.

반대로 로컬 개발 환경의 초기화 스크립트(setup.sh)나 zsh가 보장된 환경(macOS 관리 스크립트)에서는 zsh의 강력한 기능을 활용하여 생산성을 높일 수 있다.

오류 처리 및 안전성 패턴(Safety Patterns)

자동화 스크립트의 핵심은 '실패 시 멈추는 것'이다. 여기서 두 셸의 미묘한 차이가 발생한다.

set -e(Errexit)와 서브셸

set -e는 명령이 0이 아닌 종료 코드를 반환하면 스크립트를 즉시 종료한다. 함정은 서브셸((...))이나 파이프라인 내부에서의 실패가 부모 셸을 종료시키지 못하는 경우가 있다는 것이다. zsh는 ERR_EXIT 옵션 동작이 bash보다 조금 더 엄격하게 적용되는 경향이 있으나 버전별로 상이하므로, set -e에만 의존하기보다 명시적인 에러 핸들링(command || exit 1)을 사용하는 것이 안전하다.

파이프라인 실패 감지(pipefail)

cmd1 | cmd2에서 cmd1이 실패하고 cmd2가 성공하면 전체 파이프라인의 종료 코드는 0(성공)이 된다. 이는 데이터 손실을 감지하지 못하게 하므로, set -o pipefail을 사용하여 파이프라인 중 하나라도 실패하면 전체를 실패로 처리해야 한다. 파이프라인 내 각 명령의 종료 코드를 확인해야 할 때 bash는 ${PIPESTATUS[@]}(대문자) 배열을, zsh는 ${pipestatus[@]}(소문자) 배열을 사용한다는 변수명 차이도 크로스 플랫폼 스크립트 작성 시 주요 장애물이다.

성능

반복문 내에서 cat, grep, cut, date 등 외부 명령어를 과도하게 호출하면 fork()/exec() 비용이 누적되어 성능이 급격히 저하된다. 단순 스크립트 실행 속도는 bash(또는 더 가벼운 dash)가 zsh보다 빠르다. zsh는 초기화 시 많은 모듈과 설정을 로드하므로 상대적으로 무겁고, 따라서 수천 번 반복되는 고성능 루프나 수 밀리초가 중요한 초경량 컨테이너 환경에서는 bash가 선호된다.

반면 zsh는 정규식 매칭(=~), 날짜 처리, 소켓 통신 등을 내장 모듈로 처리할 수 있어, 외부 프로세스 호출을 제거함으로써 전체적인 작업 속도를 획기적으로 개선할 수 있다. 예를 들어 수만 개의 파일에 대한 메타데이터 처리는 zsh의 Glob Qualifiers가 find 명령어 호출보다 효율적일 수 있다.

결론

셸이 명령을 해석하고 실행하는 과정은 결국 하나의 파이프라인이다. 입력된 문자열은 인용 부호 처리를 거쳐 토큰으로 분해되고, 문법 규칙에 따라 AST로 조립되며, 아홉 단계의 확장을 통해 구체적인 인자 목록으로 변환된 뒤, 내장 명령어라면 셸 프로세스 안에서, 외부 명령어라면 fork()exec()로 만들어진 새 프로세스에서 실행된다. 이 파이프라인의 골격은 bash와 zsh가 공유하지만, 단어 분할의 유무, 매칭 실패 시의 동작, 배열 인덱스, 초기화 파일 로딩처럼 세부 동작이 갈라지는 지점들이 있고, 바로 그 지점들이 실무 자동화에서 "내 컴퓨터에서는 되는데" 문제의 근원이 된다. 그래서 여러 환경에서 실행될 스크립트는 bash/POSIX sh로 보수적으로 작성하고, zsh의 강력함은 zsh가 보장된 환경에서 생산성 도구로 활용하는 것이 실무의 정석이다.


출처

*[GUI]: Graphical User Interface *[POSIX]: Portable Operating System Interface

Footnotes

  1. getty는 터미널 회선을 열고 로그인 프롬프트를 띄우는 프로세스이고, login은 입력받은 계정 정보를 검증한 뒤 해당 사용자의 로그인 셸을 실행하는 프로그램이다.

  2. 명명된 파이프(named pipe/FIFO)는 파일 시스템에 이름을 가지고 존재하는 파이프다. 익명 파이프와 달리 혈연관계가 없는 프로세스들도 경로를 통해 같은 파이프에 접근할 수 있다.

  3. ash와 dash는 POSIX 표준에 집중한 초경량 셸이다. bash의 확장 문법(bashism)을 지원하지 않는 대신 크기가 작고 시작이 빨라, Alpine Linux 같은 경량 배포판과 데비안 계열의 /bin/sh로 채택되어 있다.