본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

셸이 명령을 실행하는 방식

작성 2026. 10. 14.추가
목차11개 항목
출처와 참고

팔만코딩경(42Seoul 커뮤니티 위키)의 「다사다난했던 pipex 여정」, 「The Hitchhiker's Guide to the BIGSHELL」을 바탕으로 재구성한 노트다. 셸 문법의 기준은 POSIX Shell Command Language와 GNU Bash 매뉴얼이다. 「터미널과 셸은 다르다」 절은 CrashCourse의 「Crash Course Computer Science」 강의를 바탕으로 보강했다.

셸은 무엇을 하는 프로그램인가

셸은 명령 한 줄을 읽어 프로세스를 만들고, 그 입출력을 연결해 주는 사용자 프로그램이다. 커널 기능을 새로 제공하지 않는다. fork, execve, wait, pipe, dup2, open 같은 시스템 콜을 조합할 뿐이다. 그래서 셸을 이해하는 것은 UNIX 프로세스 모델을 이해하는 것과 같다.

bash, zsh, dash는 모두 POSIX 셸 명세를 기준으로 각자 기능을 더한 구현이다.

터미널과 셸은 다르다

명령줄 인터페이스(CLI)는 사용자가 명령을 글자로 치고 엔터를 누르면 컴퓨터가 글자로 답하는, 글자를 주고받는 대화다. 이 대화에는 두 역할이 있다.

역할하는 일예
터미널(terminal)키 입력을 글자로 보내고, 받은 글자를 화면에 그린다. 명령의 뜻은 모른다macOS Terminal·iTerm2, Windows Terminal, GNOME Terminal, SSH 클라이언트 창
셸(shell)터미널에서 받은 한 줄을 해석해 프로그램을 실행하고, 결과를 터미널로 돌려보낸다bash, zsh, PowerShell
  • 처음의 터미널은 전신용 타자기인 텔레타이프(teletype) 였다. 글자를 종이에 찍던 장치가 화면으로 바뀐 뒤에도 같은 글자 프로토콜을 그대로 써서, 컴퓨터는 상대가 종이인지 화면인지 구분하지 못했다. 유닉스가 터미널 장치를 TTY(/dev/tty)라 부르는 이유다.
  • 지금의 터미널 앱은 그 장치를 소프트웨어로 흉내 낸 터미널 에뮬레이터다. 커널이 만든 의사 터미널(pseudo-terminal, /dev/pts/N) 한 쌍의 한쪽을 에뮬레이터가, 다른 쪽을 셸이 쥐고 글자를 주고받는다. Ctrl+C가 시그널이 되는 것도 이 터미널 계층이 하는 일이다(아래 「대화형 셸과 시그널」).
  • 명령줄에서 ls -l /tmp의 ls는 명령, -l과 /tmp는 인자(argument) 다. 프로그램이 실행되면 그 프로그램이 끝날 때까지 터미널의 입출력을 넘겨받는다.
  • 글자만 주고받으므로 명령을 이어 붙이고 스크립트로 자동화하기 쉽고, 멀리 있는 서버에도 같은 방식으로 접속한다(SSH). GUI와의 비교는 그래픽 사용자 인터페이스에 있다.

한 줄이 실행되기까지

flowchart LR
    in["입력"] --> tok["토큰화<br/>word·operator"] --> parse["파싱(트리)<br/>리스트·파이프라인·<br/>단순 명령·서브셸"] --> exp["확장<br/>$변수·$?·<br/>단어 분리·*"] --> redir["리다이렉션<br/>open·dup2<br/>heredoc"] --> run["실행<br/>빌트인 or<br/>fork+execve"]
단계하는 일예
토큰화따옴표를 고려해 단어(word)와 연산자(|, &&, ||, <, >>, ( …)로 나눈다echo "a b"|wc → echo, "a b", |, wc
파싱우선순위에 따라 트리를 만든다. &&·|| < 파이프라인 < 단순 명령a && b | c → a AND (b PIPE c)
확장변수·명령 치환 → 단어 분리 → 경로명 확장(*) → 따옴표 제거 순$HOME, $?, *.c
리다이렉션파일을 열고 fd를 바꾼다> out, 2>&1, << EOF
실행빌트인이면 셸 안에서, 아니면 PATH에서 찾아 자식 프로세스로cd는 빌트인, ls는 외부 명령

확장에서 자주 틀리는 지점

  • 따옴표는 파싱 단계에서 버리면 안 된다. 단어 분리와 * 확장을 할지 정하는 데 쓰이므로 확장이 끝난 뒤에 제거한다.
  • 따옴표 없는 변수는 IFS(기본: 공백·탭·줄바꿈) 기준으로 여러 단어로 쪼개진다. a="x y"일 때 cat $a는 인자 두 개, cat "$a"는 하나다.
  • 값이 빈 변수를 따옴표 없이 쓰면 인자 자체가 사라지고, 따옴표로 감싸면 빈 문자열 인자 하나가 남는다.
  • 작은따옴표 안에서는 아무것도 확장되지 않고, 큰따옴표 안에서는 $와 `만 확장된다.
  • $?는 가장 최근에 끝난 파이프라인의 종료 상태다. 파이프라인이면 마지막 명령의 상태다.

왜 cd는 빌트인인가

현재 디렉터리, 환경 변수, umask는 프로세스마다 있다. 자식 프로세스에서 chdir()을 하면 자식만 바뀌고 셸은 그대로다. 그래서 셸 자신의 상태를 바꾸는 명령은 셸 안에서 실행해야 한다.

빌트인이어야 하는 것이유
cd셸의 현재 디렉터리 변경. PWD·OLDPWD 갱신, cd -·cd ~ 처리
export·unset셸의 환경을 바꿔 이후 자식에게 물려줌
exit셸 프로세스 자체를 끝냄

echo·pwd는 외부 명령도 있지만 성능 때문에 빌트인으로도 둔다.

파이프 연결: pipe + fork + dup2

pipe(fd)는 커널 버퍼 하나와 fd 두 개를 만든다. fd[0]은 읽는 끝, fd[1]은 쓰는 끝이다. cmd1 | cmd2는 다음 순서로 만든다.

flowchart TB
    sh["셸: pipe(fd)"]
    sh -->|"fork"| c1["자식1: dup2(fd[1], 1); close(fd[0]); close(fd[1]); execve(cmd1)"]
    sh -->|"fork"| c2["자식2: dup2(fd[0], 0); close(fd[0]); close(fd[1]); execve(cmd2)"]
    sh --> p["셸: close(fd[0]); close(fd[1]); waitpid(자식1); waitpid(자식2)"]

N개 명령의 파이프라인은 파이프 N−1개를 만든다. 첫 명령은 입력 파일→파이프, 가운데는 이전 파이프→다음 파이프, 마지막은 파이프→출력 파일로 연결한다. 부모는 다음 자식을 만든 뒤 이전 파이프의 읽는 끝을 닫는다.

쓰지 않는 끝을 반드시 닫는 이유

닫지 않으면결과
읽는 쪽이 쓰는 끝(fd[1]) 사본을 들고 있음쓰는 끝이 모두 닫혀야 read가 EOF(0)를 받는다. 사본 하나라도 남으면 cat | wc의 wc가 영원히 기다린다
쓰는 쪽이 읽는 끝(fd[0]) 사본을 들고 있음읽는 쪽이 모두 끝나야 쓰기에 SIGPIPE가 간다. 사본이 남으면 yes | head -1의 yes가 끝나지 않는다
부모가 양 끝을 들고 있음위 두 문제가 동시에. fd도 샌다

lsof -p <pid>로 프로세스가 연 fd를 확인할 수 있다.

파이프라인은 동시에 실행된다

셸은 파이프라인의 명령을 전부 먼저 띄우고 나서 기다린다. 앞 명령이 끝나기를 기다렸다 다음 명령을 실행하지 않는다.

  • sleep 3 | sleep 3 | sleep 3은 9초가 아니라 3초 걸린다.
  • cat | ls는 ls 결과를 바로 출력하고, cat은 입력을 기다린다.
  • 순서가 필요한 곳은 자연스럽게 맞춰진다. 입력이 필요한 명령은 파이프에 데이터가 올 때까지 read에서 잠든다. 파이프 버퍼(Linux 기본 64KiB)가 차면 쓰는 쪽이 잠든다.
  • 그래서 명령마다 wait하며 하나씩 실행하는 구현은 틀렸다. 큰 출력은 파이프 버퍼가 차서 교착에 빠질 수도 있다.

모든 자식을 띄운 뒤 자식 수만큼 wait 하거나 PID별로 waitpid해서 전부 거둔다. 거두지 않고 셸이 먼저 끝나면 자식이 고아가 되고, 셸이 계속 살아 있으면 좀비가 쌓인다(UNIX 시그널).

heredoc과 서브셸

  • heredoc(<< EOF): 구분자 줄이 나올 때까지 입력을 받아 명령의 표준 입력으로 준다. 구현은 임시 파일이나 파이프에 내용을 쓰고, 그 읽는 끝을 fd 0에 붙인다. 임시 파일에 쓴 fd로는 오프셋이 끝에 있으므로 닫고 다시 열어 읽는다. 구분자를 따옴표로 감싸면(<< 'EOF') 본문의 $변수를 확장하지 않는다.
  • 서브셸(( … )): 셸을 fork한 자식에서 명령을 실행한다. (cd /tmp; ls)의 cd는 바깥 셸에 영향이 없다. (exit 3)은 서브셸만 끝내고 $?가 3이 된다. bash에서는 파이프라인의 각 명령도 서브셸에서 돈다. 그래서 exit | exit은 셸을 끝내지 않는다.

대화형 셸과 시그널

상황셸의 동작
프롬프트에서 Ctrl+C셸은 죽지 않고 새 프롬프트를 띄운다. $?는 130(128+2)
자식 실행 중 Ctrl+C자식만 기본 동작(종료)으로. 셸은 시그널을 무시하거나 기다리는 중
자식이 시그널 N으로 죽음$?는 128+N. SIGQUIT이면 Quit (core dumped) 출력
heredoc 입력 중 Ctrl+C입력을 버리고 명령을 실행하지 않는다

fork 뒤 execve 전에 자식의 시그널 처리를 기본값으로 되돌려야 자식이 정상적으로 시그널을 받는다.

정리

  • 셸 = 토큰화 → 파싱 → 확장 → 리다이렉션 → fork/execve의 반복이다.
  • 셸 자신의 상태를 바꾸는 cd·export·exit는 빌트인이어야 한다.
  • 파이프는 pipe + dup2로 잇고, 쓰지 않는 끝은 모든 프로세스에서 닫는다. EOF와 SIGPIPE가 그래야 동작한다.
  • 파이프라인은 동시에 띄우고, 다 띄운 뒤 모두 거둔다.
  • $?는 마지막 파이프라인의 상태이고, 시그널로 죽으면 128+N이다.

관련 노트: Bash 스크립트 작성법, UNIX 파일 입출력과 파일 디스크립터, 프로세스와 스레드, UNIX 시그널, 컴퓨터 시스템 구조와 인터럽트

원본 기록5

댓글

GitHub 계정으로 댓글을 남길 수 있어요.