다음에 올 것을 만들어가는 기술에 대한 심층 기사.

셸을 만들며 배운 유닉스의 핵심 원리

셸을 처음부터 만들어 보면 프로세스, 파일 디스크립터, 파이프, 시그널 등 유닉스의 동작 원리가 보입니다. 핵심 개념을 단계별로 짚어봅니다.

바다 조개껍데기 단면 안에 파이프, 밸브, 레버가 빛을 나르는 제어실이 보이는 그림

매일 셸을 쓰는 개발자는 많지만, 셸이 실제로 무엇을 하는지 아는 사람은 드뭅니다. 셸은 겉보기엔 명령어를 입력하면 실행해 주는 애플리케이션처럼 보입니다. 하지만 실제로는 유닉스 운영체제의 기본 요소들을 얇게 감싼 계층에 가깝습니다. 셸을 처음부터 직접 만들어 보는 것은 프로세스, 파일 디스크립터, 파이프, 시그널을 이해하는 가장 좋은 방법 중 하나입니다. 이 개념들은 웹 서버부터 Docker 컨테이너까지 거의 모든 것의 바탕이 되니까요.

기본적인 셸은 놀랍도록 단순합니다. 핵심 루프는 이렇습니다. 입력 한 줄을 읽고, 명령어와 인자로 파싱하고, fork로 자식 프로세스를 만들고, 자식에서 명령을 실행한 뒤, 끝날 때까지 기다립니다. C로 치면 50줄 남짓입니다. 복잡해지는 이유는 우리가 당연하게 여기는 기능들 때문입니다. 파이프, 리다이렉션, 백그라운드 실행, 시그널 처리, 잡 제어 같은 것들이죠.

REPL 구조 이해하기

셸의 본질은 REPL입니다. 입력을 읽고, 평가(실행)하고, 결과를 출력하고, 다시 반복합니다. 여기서 '출력' 부분은 명령어 자체가 담당합니다. 셸은 그 명령어들이 돌아갈 환경을 제공할 뿐입니다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
// The simplest possible shell
int main(void) {
char line[1024];
while (1) {
printf("$ ");
if (!fgets(line, sizeof(line), stdin))
break;  // EOF (Ctrl+D)
// Remove trailing newline
line[strcspn(line, "\n")] = '\0';
// Fork a child process
pid_t pid = fork();
if (pid == 0) {
// Child: execute the command
execlp(line, line, NULL);  // This only handles single-word commands
perror("exec");
exit(1);
}
// Parent: wait for child to finish
waitpid(pid, NULL, 0);
}
return 0;
}

이 25줄짜리 셸은 실제로 동작합니다. ls, pwd, date 같은 명령을 실행할 수 있습니다. 하지만 인자, 파이프, 리다이렉션 등 우리가 기대하는 기능은 전혀 없습니다. 대신 fork, exec, wait라는 기본 패턴을 잘 보여 줍니다.

Fork와 Exec: 유닉스의 프로세스 모델

fork/exec 분리는 유닉스의 가장 독특한 설계 결정이며, 셸을 만들어 보면 왜 이렇게 설계됐는지 자연스럽게 이해하게 됩니다.

fork()는 현재 프로세스의 정확한 복사본을 만듭니다. 자식은 같은 메모리, 같은 열린 파일, 같은 환경 변수를 가집니다. exec()는 자식의 프로그램을 새 프로그램으로 교체합니다. 이 둘이 별도의 연산인 이유는, fork와 exec 사이의 틈, 즉 fork 이후 exec 이전에 셸이 자식의 실행 환경을 설정하기 때문입니다.

여기가 핵심입니다. ls > output.txt를 입력하면, 셸은 fork한 뒤 자식에서(exec 전에) output.txt를 열고 표준 출력을 그 파일로 돌린 다음 ls를 exec합니다. ls는 리다이렉션에 대해 전혀 모릅니다. 평소처럼 표준 출력에 쓸 뿐이고, fork와 exec 사이에 이루어진 파일 디스크립터 조작이 그 출력을 파일로 보내는 것입니다.

// How 'ls > output.txt' works
pid_t pid = fork();
if (pid == 0) {
// Child process — between fork and exec
// Open the output file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// Redirect stdout (fd 1) to the file
dup2(fd, STDOUT_FILENO);  // Now fd 1 points to output.txt
close(fd);                 // Close the original fd (no longer needed)
// exec ls — it writes to stdout, which now goes to output.txt
execlp("ls", "ls", NULL);
perror("exec");
exit(1);
}
waitpid(pid, NULL, 0);

이 설계가 우아한 이유는 조합이 자유롭기 때문입니다. 자식은 exec 전에 파일 리다이렉션, 디렉터리 변경, 환경 변수 수정, 리소스 제한, 사용자 ID 변경 등 어떤 환경이든 미리 구성할 수 있습니다. 실행되는 프로그램은 그런 설정을 알 필요 없이 그대로 상속받습니다. 모든 명령은 미리 구성된 환경에서 실행되고, 그 환경을 구성하는 주체가 바로 셸입니다.

파이프: 프로세스 연결하기

파이프는 유닉스에서 가장 강력한 조합 메커니즘이며, 직접 구현해 보면 그 밑바탕이 얼마나 단순한지 알 수 있습니다.

pipe() 시스템 콜은 읽기용과 쓰기용 파일 디스크립터 한 쌍을 만듭니다. 쓰기 쪽에 기록된 데이터는 읽기 쪽에서 나옵니다. ls | grep foo를 구현하려면, 셸이 파이프를 만들고 두 번 fork한 뒤 ls의 표준 출력은 쓰기 쪽에, grep의 표준 입력은 읽기 쪽에 연결합니다.

// How 'ls | grep foo' works
int pipefd[2];
pipe(pipefd);  // pipefd[0] = read end, pipefd[1] = write end
pid_t pid1 = fork();
if (pid1 == 0) {
// First child: ls
close(pipefd[0]);              // Don't need read end
dup2(pipefd[1], STDOUT_FILENO); // stdout → pipe write end
close(pipefd[1]);
execlp("ls", "ls", NULL);
exit(1);
}
pid_t pid2 = fork();
if (pid2 == 0) {
// Second child: grep
close(pipefd[1]);              // Don't need write end
dup2(pipefd[0], STDIN_FILENO);  // stdin → pipe read end
close(pipefd[0]);
execlp("grep", "grep", "foo", NULL);
exit(1);
}
// Parent: close both pipe ends and wait
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

사용하지 않는 파일 디스크립터 끝을 신중하게 닫는 점에 주목하세요. 이것이 매우 중요합니다. 부모가 파이프의 양쪽 끝을 모두 닫지 않으면, 부모에 쓰기 쪽이 아직 열려 있으므로 grep은 표준 입력에서 EOF를 영원히 받지 못하고 무한정 멈춰 있게 됩니다. 파이프 체인에서의 파일 디스크립터 누수는 셸을 만들 때 가장 흔한 버그 중 하나입니다.

내장 명령어

일부 명령어는 외부 프로그램으로 만들 수 없습니다. 대표적인 예가 cd입니다. 셸이 자식을 fork하고 자식이 chdir()을 호출하면, 바뀌는 것은 자식의 작업 디렉터리뿐입니다. 부모의 디렉터리는 그대로이고, 자식이 종료된 후에도 셸은 같은 디렉터리에 머뭅니다. cd가 제대로 동작하려면 셸이 fork 없이 자기 프로세스 안에서 직접 실행해야 합니다.

그 밖의 내장 명령어로는 export(셸의 환경 변수 수정), exit(셸 프로세스 종료), source(현재 셸 컨텍스트에서 스크립트 실행) 등이 있습니다. 모두 셸 자신의 상태를 바꾸는 명령이므로, 반드시 셸 자신의 프로세스 안에서 실행되어야 합니다.

어떤 명령이 내장이고 왜 그런지 이해하면, 프로세스 격리에 대한 근본적인 사실을 알게 됩니다. 자식 프로세스는 부모를 바꿀 수 없습니다. 이는 보안과 안정성을 위한 기능이며, 때로는 불편하기도 하지만 유닉스 프로세스가 동작하는 방식의 핵심입니다.

시그널과 잡 제어

터미널에서 Ctrl+C를 누르면 실행 중인 명령이 멈춥니다. 단순해 보이지만, 터미널, 셸, 시그널, 프로세스 그룹 사이에서 꽤 복잡한 상호작용이 일어납니다.

Ctrl+C는 포그라운드 프로세스 그룹에 SIGINT를 보냅니다. 이를 처리하는 주체는 셸이 아니라 터미널 드라이버입니다. 셸의 역할은 각 명령을 자기만의 프로세스 그룹에 넣어서 SIGINT가 셸이 아니라 명령에 전달되도록 하는 것입니다. 셸이 프로세스 그룹을 올바르게 설정하지 않으면, Ctrl+C는 실행 중인 명령 대신 셸을 죽여 버립니다.

잡 제어(백그라운드 프로세스 &, fg, bg, Ctrl+Z)는 한 층 더 복잡합니다. 셸은 어떤 프로세스가 어떤 잡에 속하는지 추적하고, 포그라운드와 백그라운드 그룹을 관리하며, SIGTSTP(Ctrl+Z, 일시 정지)와 SIGCHLD(자식 프로세스 종료) 같은 시그널을 처리해야 합니다. 이를 올바르게 구현하는 것이 셸을 만들 때 가장 어려운 부분입니다.

배울 수 있는 것들

셸을 만들면, 이후에 시스템 코드를 다시 작성하지 않더라도 소프트웨어 개발 전반에서 계속 마주치는 개념들을 배우게 됩니다.

  • 파일 디스크립터는 범용 인터페이스입니다. 파일, 파이프, 소켓, 터미널 모두 파일 디스크립터입니다. 리다이렉션, 파이프, 네트워크 통신은 모두 같은 기반 메커니즘을 사용합니다. 이를 이해하면 Docker 네트워킹, 유닉스 도메인 소켓, 리눅스 시스템 API까지 한결 명확해집니다.
  • 프로세스 격리는 기본 원칙입니다. 자식 프로세스는 부모를 수정할 수 없습니다. 환경 변수, 작업 디렉터리, 열린 파일은 공유되는 참조가 아니라 상속받은 복사본입니다. 그래서 서브셸의 cd는 부모에게 영향을 주지 않고, Docker 컨테이너도 호스트의 환경을 상속받지만 공유하지는 않습니다.
  • 기능보다 조합이 강하다. 유닉스에는 '패턴에 맞는 파일을 찾아 개수를 세는' 명령이 따로 없습니다. 대신 find, grep, wc가 있고, 이들을 파이프로 연결하면 됩니다. 셸의 파이프 메커니즘 덕분에 단순한 도구들을 복잡한 작업 흐름으로 조합할 수 있습니다. 작은 도구를 표준 인터페이스로 연결하는 이 설계 철학은 마이크로서비스, 유닉스 소켓, API 설계의 사상적 뿌리입니다.
  • 에러 처리는 대부분 파일 디스크립터 문제입니다. 파이프가 끊어지든, 자식 프로세스가 죽든, 표준 입력이 다 소진되든, 근본 원인은 항상 파일 디스크립터가 닫히거나, 시그널이 전달되거나, 프로세스가 상태 코드와 함께 종료되는 것입니다. 파일 디스크립터 모델을 이해하고 나면, 유닉스 시스템 전반의 에러 처리 패턴이 자연스럽게 읽힙니다.

시작하는 법

셸을 직접 만들고 싶다면 위의 25줄짜리 버전에서 출발해 기능을 하나씩 추가해 보세요. 순서는 이렇습니다. 먼저 인자 파싱(입력을 공백으로 나누기), 다음으로 입출력 리다이렉션(>와 <), 그다음 파이프, 그리고 내장 명령어(cd, exit), 마지막으로 환경 변수입니다. 기능 하나하나가 새로운 유닉스 개념을 가르쳐 주고, 각각이 독립적인 연습 과제가 됩니다.

시스템 콜과 가장 직접적으로 대응하려면 C를 사용하세요. Python이나 Rust로도 셸을 만들 수 있지만, C로 구현하면 기저의 시스템 콜이 명시적으로 드러납니다. fork(), exec(), dup2(), pipe()를 직접 호출하기 때문에 각각이 정확히 무엇을 하는지 눈으로 확인할 수 있습니다.

실서비스에 쓸 셸을 만들 필요는 없습니다. 기본 명령, 파이프, 리다이렉션만 처리하는 장난감 셸이라도, 몇 년간 기존 셸을 쓰면서 얻는 것보다 유닉스 동작 원리를 더 많이 가르쳐 줍니다. 셸은 가장 중요한 운영체제 인터페이스들을 다루는 가장 단순한 프로그램이고, 그 인터페이스를 이해하면 어떤 언어나 플랫폼에서 일하든 더 나은 개발자가 됩니다.