우리가 입력한 한 줄의 명령 뒤에서 일어나는 일
터미널에서 명령을 입력하고 엔터를 누르면 프로그램이 실행된다. 이 과정은 너무 자연스럽기 때문에 대부분의 개발자는 그 내부에서 어떤 일이 일어나는지 거의 생각하지 않는다. 화면에 문자열을 입력하고 결과가 출력되는 단순한 인터페이스처럼 보이기 때문이다. 하지만 셸이 실제로 수행하는 작업을 조금만 들여다보면, 이 단순한 인터페이스 뒤에는 꽤 정교한 구조가 숨어 있다는 사실을 발견하게 된다. 우리가 평소 아무 생각 없이 입력하는 한 줄의 명령은 사실 운영체제 수준의 여러 메커니즘을 연속적으로 호출하는 일종의 작은 실행 파이프라인이다.
예를 들어 다음과 같은 명령을 생각해 보자.
grep ERROR log.txt > result.txt
이 명령은 로그 파일에서 ERROR라는 문자열을 찾아 그 결과를 파일로 저장한다. 우리는 이 문장이 무엇을 의미하는지 직관적으로 이해한다. grep 프로그램이 실행되고, 그 출력이 터미널이 아니라 파일로 저장된다는 것이다. 하지만 셸이 실제로 이 명령을 실행하는 과정을 자세히 살펴보면 이야기가 조금 달라진다. grep 프로그램은 처음부터 파일에 출력하도록 만들어진 것이 아니다. 프로그램은 단순히 표준 출력(stdout)에 데이터를 쓸 뿐이다. 셸이 프로그램을 실행하기 전에 그 출력이 어디로 흘러갈지를 미리 설정해 두기 때문에 결과적으로 파일에 기록되는 것이다.
조금 더 복잡한 예를 보자.
command > output.log 2>&1
이 문장은 표준 출력과 에러 출력을 같은 파일로 합친다. 많은 개발자들이 이 문법을 알고 있지만, 실제로 시스템 내부에서 어떤 일이 일어나는지까지 이해하고 사용하는 경우는 많지 않다. 표면적으로 보면 단순한 셸 문법처럼 보이지만, 이 문장은 사실 파일 디스크립터라는 Unix의 핵심 개념과 깊게 연결되어 있다. 셸은 프로그램이 실행되기 전에 파일 디스크립터의 연결 구조를 재구성하고, 그 이후에 프로그램을 실행한다. 프로그램은 자신이 어디로 출력하고 있는지 전혀 모른 채 단순히 데이터를 쓰기만 한다.
이 시점에서 자연스럽게 한 가지 질문이 떠오른다. 셸은 프로그램을 실행하기 전에 어떤 준비 작업을 하는 것일까? 단순히 프로그램을 실행하는 것이라면 운영체제가 직접 해도 될 것처럼 보이는데, 왜 셸이라는 프로그램이 그 중간에 존재하는 것일까? 이 질문을 따라가다 보면 결국 Unix 시스템의 가장 핵심적인 구조 중 하나인 프로세스 모델에 도달하게 된다. 그리고 그 중심에는 fork와 exec라는 두 개의 시스템 호출이 자리 잡고 있다.
이제 셸이 실제로 명령을 실행할 때 어떤 일을 하는지 조금 더 자세히 들여다보자. 우리가 터미널에 입력한 한 줄의 명령은 단순한 문자열이 아니라, 셸이 해석하고 실행해야 할 하나의 작업이다. 그리고 이 작업을 수행하기 위해 셸은 먼저 새로운 프로세스를 만들어야 한다.
셸은 단순한 명령 실행기가 아니다
많은 사람들은 셸을 단순히 명령을 실행하는 프로그램이라고 생각한다. 터미널에서 명령을 입력하면 셸이 그것을 읽고 해당 프로그램을 실행한다는 정도의 이해다. 하지만 실제로 셸이 수행하는 역할은 생각보다 훨씬 복잡하다. 셸은 사용자가 입력한 문자열을 해석하고, 그 명령을 실행하기 위한 전체 환경을 준비한 다음, 적절한 방식으로 프로그램을 실행하는 일종의 프로세스 관리자에 가깝다.
예를 들어 사용자가 다음과 같은 명령을 입력했다고 가정해 보자.
ls -l
겉보기에는 단순하다. 디렉터리 목록을 자세히 출력하는 명령이다. 하지만 셸은 이 명령을 그대로 운영체제에 전달하지 않는다. 먼저 문자열을 분석해 어떤 프로그램을 실행해야 하는지 파악하고, 인자들을 분리하고, 환경 변수를 확인하고, 필요한 경우 리디렉션이나 파이프 같은 추가적인 구조를 준비한다. 그 다음에야 실제 프로그램 실행 단계로 넘어간다. 즉 셸은 단순히 프로그램을 호출하는 것이 아니라, 프로그램이 실행될 환경 전체를 구성하는 역할을 한다.
이 과정에서 중요한 점은 셸이 자기 자신을 그대로 유지한 채 프로그램을 실행하지 않는다는 것이다. 만약 셸이 직접 프로그램을 실행한다면, 프로그램이 종료될 때까지 셸은 다른 작업을 할 수 없게 된다. 더 중요한 문제는 프로그램의 입출력 구조를 미리 설정하거나 파이프라인을 구성하는 것이 어려워진다는 점이다. 그래서 Unix는 프로그램을 실행할 때 독특한 방식을 사용한다. 바로 새로운 프로세스를 먼저 만든 다음, 그 프로세스에서 프로그램을 실행하는 방식이다.
이 구조는 셸이 프로그램을 실행하는 방식에 큰 유연성을 제공한다. 셸은 프로그램이 실행되기 전에 새로운 프로세스를 만들고, 그 프로세스 안에서 입출력 구조를 마음대로 설정할 수 있다. 표준 출력을 파일로 보내거나, 파이프를 연결하거나, 에러 출력을 다른 곳으로 보내는 작업이 모두 프로그램 실행 전에 이루어진다. 프로그램이 실제로 실행되는 순간에는 이미 모든 입출력 구조가 완성되어 있는 셈이다.
이 방식 덕분에 Unix 셸은 매우 강력한 인터페이스가 된다. 사용자는 단순한 문법으로 프로그램들을 연결할 수 있고, 셸은 그 문법을 해석해 실제 프로세스 구조를 만들어 낸다. 우리가 평소에 사용하는 파이프나 리디렉션 문법은 사실 셸이 내부적으로 수행하는 복잡한 작업을 매우 간결한 형태로 표현한 것에 불과하다.
이제 다음 질문이 자연스럽게 등장한다. 셸은 새로운 프로세스를 어떻게 만드는 것일까? 그리고 그 프로세스 안에서 프로그램은 어떻게 실행되는 것일까? 이 질문에 대한 답은 Unix 시스템 호출 중 하나인 fork에서 시작된다.
fork: 프로세스를 복제하는 Unix의 독특한 방식
Unix에서 새로운 프로그램을 실행하는 방식은 다른 운영체제와 비교했을 때 꽤 독특하다. 많은 시스템에서는 새로운 프로그램을 실행할 때 완전히 새로운 프로세스를 생성하는 별도의 시스템 호출을 사용한다. 하지만 Unix는 다른 접근 방식을 선택했다. 새로운 프로그램을 실행하기 전에 먼저 현재 프로세스를 복제하는 것이다. 이 작업을 수행하는 시스템 호출이 바로 fork다.
fork는 현재 실행 중인 프로세스를 거의 그대로 복제한다. 이 호출이 실행되면 부모 프로세스와 매우 비슷한 상태를 가진 자식 프로세스가 하나 더 만들어진다. 두 프로세스는 거의 동일한 실행 상태로 시작한다. 자식은 부모의 메모리 내용과 파일 디스크립터 상태를 상속받지만, 둘은 서로 별도의 프로세스이며 현대 시스템에서는 보통 copy-on-write 방식으로 효율적으로 복제된다. 물론 완전히 동일한 것은 아니다. 프로세스 ID는 서로 다르고, fork 호출의 반환값도 부모와 자식에서 다르게 나타난다. 하지만 기본적인 실행 환경은 거의 동일하다.
이 동작을 조금 단순화해서 표현하면 다음과 같은 구조가 된다.
shell
└─ fork()
└─ child process
셸은 fork를 호출해 자기 자신과 거의 동일한 자식 프로세스를 하나 만든다. 그리고 그 이후부터 부모 프로세스와 자식 프로세스는 각각 독립적으로 실행된다. 부모 프로세스는 여전히 셸로 남아 사용자 입력을 계속 처리하고, 자식 프로세스는 이후에 실행될 프로그램을 위한 준비를 하게 된다.
이 구조는 처음 보면 다소 비효율적으로 느껴질 수도 있다. 굳이 프로세스를 복제한 다음에 프로그램을 실행해야 할 이유가 무엇일까 하는 의문이 들기 때문이다. 하지만 이 설계에는 매우 중요한 장점이 있다. 바로 프로그램이 실행되기 전에 자식 프로세스의 환경을 마음대로 수정할 수 있다는 점이다.
예를 들어 셸이 어떤 프로그램의 출력을 파일로 보내고 싶다면, 자식 프로세스에서 파일 디스크립터를 먼저 변경한 다음 프로그램을 실행하면 된다. 파이프라인을 구성할 때도 마찬가지다. 셸은 여러 개의 자식 프로세스를 만든 다음 각 프로세스의 입출력을 서로 연결해 데이터 흐름을 만들어 낼 수 있다. 프로그램은 이러한 구조를 전혀 알 필요가 없다. 단순히 표준 입력에서 데이터를 읽고 표준 출력으로 데이터를 쓰기만 하면 된다.
하지만 fork만으로는 프로그램을 실행할 수 없다. fork는 단지 프로세스를 복제할 뿐이며, 복제된 프로세스는 여전히 셸 프로그램의 코드 위에서 실행되고 있기 때문이다. 실제 프로그램을 실행하려면 자식 프로세스의 내용을 완전히 다른 프로그램으로 바꿔야 한다. 그리고 바로 여기에서 Unix 프로세스 모델의 두 번째 핵심 시스템 호출인 exec가 등장한다.
exec: 프로세스를 프로그램으로 바꾸는 순간
앞에서 살펴본 것처럼 셸은 명령을 실행하기 위해 먼저 fork를 호출해 새로운 프로세스를 만든다. 이때 생성된 자식 프로세스는 부모 프로세스와 거의 동일한 상태로 시작한다. 메모리 구조도 비슷하고, 열려 있는 파일 디스크립터도 동일하며, 실행 중인 코드 역시 동일하다. 다시 말해 자식 프로세스는 여전히 셸 프로그램의 일부처럼 동작하고 있는 상태다. 하지만 우리가 원하는 것은 셸이 아니라 ls나 grep 같은 실제 프로그램을 실행하는 것이다. 그래서 Unix는 이 단계에서 또 하나의 핵심 시스템 호출을 사용한다. 그것이 바로 exec이다.
exec는 새로운 프로세스를 만드는 시스템 호출이 아니다. 오히려 그 반대에 가깝다. exec는 새 프로세스를 만드는 호출이 아니라, 현재 프로세스의 주소 공간을 새 프로그램으로 교체하는 호출이다. 프로세스 ID는 유지되지만, 실행 중인 코드와 메모리 구조는 새 프로그램 기준으로 다시 구성된다. 즉 기존 프로세스의 메모리 공간을 지우고 새로운 프로그램의 코드와 데이터로 채워 넣는다. 이 과정이 끝나면 해당 프로세스는 더 이상 셸의 일부가 아니라 완전히 새로운 프로그램처럼 동작하게 된다. 프로세스 ID는 그대로 유지되지만, 실행 중인 코드와 메모리 구조는 완전히 다른 프로그램으로 바뀌는 것이다.
이 과정을 구조적으로 표현하면 다음과 같은 흐름이 된다.
shell
└─ fork()
└─ child process
└─ exec(ls)
셸은 먼저 fork를 호출해 자식 프로세스를 만든다. 그 다음 자식 프로세스 안에서 exec를 호출해 실행 중인 프로그램을 ls로 교체한다. 그 순간부터 자식 프로세스는 ls 프로그램이 되어 디렉터리 목록을 출력하기 시작한다. 부모 프로세스는 여전히 셸로 남아 사용자 입력을 계속 기다리고 있고, 자식 프로세스는 독립적으로 프로그램을 실행한다. 이 구조 덕분에 셸은 프로그램 실행과 사용자 인터페이스를 동시에 유지할 수 있다.
이 방식이 중요한 이유는 프로그램 실행 전에 자식 프로세스의 환경을 마음대로 수정할 수 있기 때문이다. exec가 호출되는 순간 프로세스의 프로그램 코드가 교체되기 때문에, 그 이전 단계에서 입출력 구조를 변경하거나 파일 디스크립터를 재구성할 수 있다. 즉 셸은 프로그램이 실제로 시작되기 직전에 그 프로그램이 사용할 실행 환경을 완전히 설정할 수 있다. 리디렉션과 파이프 같은 셸 기능은 바로 이 지점에서 구현된다.
여기서 중요한 사실 하나를 기억할 필요가 있다. 프로그램은 자신이 리디렉션되었다는 사실을 전혀 모른다. 프로그램은 단순히 표준 입력에서 데이터를 읽고 표준 출력으로 데이터를 쓸 뿐이다. 그 데이터가 터미널로 가는지, 파일로 저장되는지, 다른 프로그램으로 전달되는지는 프로그램이 아니라 셸이 결정한다. 그리고 그 결정은 바로 exec가 호출되기 직전에 이루어진다.
이제 자연스럽게 다음 질문이 등장한다. 셸은 프로그램 실행 전에 정확히 무엇을 바꾸는 것일까? 프로그램이 사용하는 입출력 구조는 어떻게 설정되는 것일까? 이 질문에 답하려면 Unix 프로세스가 사용하는 가장 기본적인 I/O 구조인 파일 디스크립터 개념을 먼저 이해해야 한다.
Unix I/O 모델의 핵심: 파일 디스크립터
Unix에서 모든 입출력은 파일 디스크립터라는 추상적인 구조를 통해 이루어진다. 파일 디스크립터는 운영체제가 관리하는 작은 정수 값으로, 프로세스가 열어 놓은 파일이나 장치, 파이프, 소켓 등을 가리키는 일종의 핸들 역할을 한다. 프로그램은 실제 파일이나 장치의 내부 구조를 직접 다루지 않는다. 대신 파일 디스크립터라는 번호를 통해 데이터를 읽고 쓰는 방식으로 입출력을 수행한다.
모든 Unix 프로세스는 시작할 때 기본적으로 세 개의 파일 디스크립터를 가지고 있다. 각각 표준 입력, 표준 출력, 표준 에러를 의미한다. 이 세 개의 디스크립터는 각각 0, 1, 2라는 번호로 고정되어 있다. 프로그램이 표준 입력에서 데이터를 읽으면 실제로는 파일 디스크립터 0에서 데이터를 읽는 것이고, 표준 출력으로 데이터를 쓰면 파일 디스크립터 1에 데이터를 쓰는 것이다. 표준 에러 역시 마찬가지로 파일 디스크립터 2를 통해 출력된다.
이 디스크립터들은 기본적으로 터미널과 연결되어 있다. 그래서 우리가 프로그램을 실행하면 출력이 화면에 나타나는 것이다. 프로그램은 단순히 표준 출력에 데이터를 쓰고 있을 뿐인데, 그 데이터가 터미널에 연결되어 있기 때문에 결과적으로 화면에 표시되는 것이다. 이 구조 덕분에 프로그램은 자신이 어디로 출력하고 있는지 전혀 알 필요가 없다. 프로그램 입장에서는 단지 stdout에 데이터를 쓴다는 사실만 알면 된다.
예를 들어 프로그램이 다음과 같은 코드를 실행한다고 가정해 보자.
write(1, "hello\n", 6);
이 코드는 파일 디스크립터 1, 즉 표준 출력에 문자열을 쓰라는 의미다. 만약 stdout이 터미널에 연결되어 있다면 이 문자열은 화면에 나타난다. 하지만 stdout이 파일에 연결되어 있다면 같은 코드는 파일에 기록된다. 파이프에 연결되어 있다면 그 데이터는 다른 프로그램으로 전달된다. 프로그램의 코드는 전혀 변하지 않았는데도 출력의 목적지가 완전히 달라질 수 있는 것이다.
이 점이 Unix I/O 모델의 가장 중요한 특징이다. 프로그램은 단순히 파일 디스크립터에 데이터를 읽고 쓸 뿐이며, 그 디스크립터가 실제로 어디로 연결되어 있는지는 프로그램 외부에서 결정된다. 즉 프로그램의 입출력 구조는 프로그램 자체가 아니라 실행 환경에 의해 정의된다. 그리고 바로 그 실행 환경을 구성하는 역할을 하는 것이 셸이다.
셸은 프로그램을 실행하기 전에 파일 디스크립터의 연결 구조를 변경할 수 있다. 표준 출력을 파일로 연결할 수도 있고, 다른 프로세스의 입력으로 연결할 수도 있으며, 에러 출력을 별도의 파일로 분리할 수도 있다. 이러한 작업은 프로그램이 실행되기 전에 이루어지며, 프로그램은 그 결과만을 사용하게 된다.
이제 리디렉션 문법의 의미가 조금 더 명확해진다. 우리가 사용하는 >, <, 2> 같은 문법은 단순히 출력 위치를 지정하는 것이 아니라 파일 디스크립터의 연결 구조를 바꾸는 작업이다. 그리고 이 작업을 실제로 수행하는 단계가 바로 프로그램 실행 직전의 자식 프로세스에서 이루어진다.
그렇다면 셸은 파일 디스크립터의 연결을 어떻게 바꾸는 것일까? 이 질문의 답은 Unix 시스템 호출 중 하나인 dup2에서 찾을 수 있다.
리디렉션의 진짜 의미: 파일 디스크립터 재배선
셸 리디렉션 문법을 처음 배울 때 대부분의 사람들은 이를 단순한 편의 기능으로 이해한다. 예를 들어 ls > file.txt라는 명령은 단순히 출력 결과를 파일로 보내는 기능처럼 보인다. 실제로 사용자 입장에서 보면 그 이상의 의미를 생각할 필요가 없다. 하지만 시스템 내부에서 이 명령이 수행되는 과정을 보면 상황은 훨씬 더 흥미롭다. 리디렉션은 단순히 출력 위치를 바꾸는 기능이 아니라 파일 디스크립터의 연결을 재구성하는 작업이다.
예를 들어 다음 명령을 생각해 보자.
ls > file.txt
이 명령을 실행하면 ls 프로그램의 출력이 터미널이 아니라 file.txt라는 파일에 저장된다. 하지만 ls 프로그램 자체는 이러한 사실을 전혀 모른다. 프로그램은 여전히 표준 출력에 데이터를 쓰고 있을 뿐이다. 단지 그 표준 출력이 터미널이 아니라 파일로 연결되어 있기 때문에 결과적으로 파일에 기록될 뿐이다. 즉 리디렉션은 프로그램 내부가 아니라 프로그램 외부에서 입출력 구조를 바꾸는 작업이다.
셸은 이 명령을 실행할 때 다음과 같은 과정을 거친다. 먼저 file.txt 파일을 열어 새로운 파일 디스크립터를 얻는다. 그 다음 표준 출력에 해당하는 파일 디스크립터 1을 이 파일 디스크립터와 연결한다. 이렇게 하면 프로그램이 stdout에 데이터를 쓸 때 그 데이터는 터미널이 아니라 파일로 전달된다. 마지막으로 exec를 호출해 실제 프로그램을 실행한다.
이 과정을 구조적으로 표현하면 다음과 같은 형태가 된다.
stdout (fd 1)
↓
file.txt
즉 stdout이 가리키는 대상이 터미널에서 파일로 변경되는 것이다. 이 상태에서 프로그램이 실행되면 프로그램은 평소와 똑같이 stdout에 데이터를 쓰지만, 실제 데이터는 파일에 저장된다. 프로그램 입장에서는 아무것도 달라진 것이 없다. 하지만 실행 환경이 바뀌었기 때문에 결과는 완전히 달라진다.
이 방식이 중요한 이유는 프로그램과 입출력 구조를 완전히 분리할 수 있기 때문이다. 프로그램은 단순히 데이터를 읽고 쓰는 역할만 담당하고, 데이터의 흐름은 셸이 구성한다. 그래서 우리는 매우 단순한 프로그램들을 조합해 복잡한 데이터 처리 파이프라인을 만들 수 있다. 리디렉션과 파이프는 바로 이 철학 위에서 만들어진 기능이다.
하지만 여기에는 아직 한 가지 중요한 요소가 남아 있다. 파일 디스크립터의 연결을 실제로 변경하는 작업은 어떻게 이루어질까? 셸은 어떤 시스템 호출을 사용해 stdout이나 stderr의 연결을 바꾸는 것일까? 이 질문의 답이 바로 다음 섹션에서 등장할 dup2라는 시스템 호출이다.
dup2: 셸 리디렉션을 완성하는 마지막 조각
앞에서 살펴본 것처럼 셸 리디렉션의 본질은 파일 디스크립터의 연결을 바꾸는 작업이다. 프로그램이 어디로 데이터를 출력하는지는 프로그램 코드가 아니라 파일 디스크립터의 연결 구조에 의해 결정된다. 셸은 프로그램이 실행되기 전에 이 구조를 변경해 원하는 데이터 흐름을 만들어 낸다. 그런데 여기서 한 가지 질문이 남는다. 셸은 실제로 어떤 방식으로 파일 디스크립터의 연결을 변경할까. 단순히 변수 값을 바꾸는 것처럼 보이지만, 실제로는 운영체제 수준에서 수행되는 시스템 호출이 필요하다. 바로 그 역할을 하는 것이 dup2다.
dup2는 파일 디스크립터를 복제하는 시스템 호출이다. 조금 더 정확히 말하면, 하나의 파일 디스크립터가 가리키는 대상과 동일한 대상을 다른 파일 디스크립터가 가리키도록 만드는 기능을 한다. 예를 들어 어떤 파일이 fd라는 디스크립터로 열려 있다고 가정해 보자. 이때 dup2(fd, 1)을 호출하면 파일 디스크립터 1, 즉 stdout이 fd와 동일한 파일을 가리키게 된다. 그 결과 프로그램이 stdout으로 데이터를 출력하면 실제로는 fd가 가리키는 파일로 데이터가 전달된다. 기존에 stdout이 터미널을 가리키고 있었다면 그 연결은 끊어지고 새로운 파일이 stdout의 목적지가 된다.
이 동작은 셸 리디렉션의 핵심 메커니즘을 설명한다. 예를 들어 다음 명령을 생각해 보자.
ls > file.txt
셸은 이 명령을 실행하기 전에 먼저 file.txt를 열어 새로운 파일 디스크립터를 얻는다. 그 다음 dup2를 사용해 stdout을 해당 파일 디스크립터로 연결한다. 이 작업이 끝난 후 exec를 호출하면 프로그램은 이미 변경된 입출력 환경에서 실행된다. ls는 단순히 stdout에 데이터를 쓰고 있을 뿐이지만, 실제 데이터는 터미널이 아니라 file.txt로 전달된다.
이제 조금 더 복잡한 예를 보자.
command > file 2>&1
이 명령은 표준 출력과 표준 에러를 같은 파일로 보내는 리디렉션이다. 셸은 먼저 stdout을 file로 연결한다. 그 다음 stderr를 stdout과 동일한 대상으로 연결한다. 여기서 중요한 점은 2>&1이 “stderr를 stdout과 합친다”는 추상적 표현보다, 그 시점에 fd 1이 가리키는 대상을 fd 2에도 복제한다는 설명에 더 가깝다는 점이다. 내부적으로는 다음과 같은 시스템 호출과 거의 동일하다.
dup2(1, 2)
이 호출은 stderr가 stdout과 동일한 목적지를 가리키도록 만든다. 결과적으로 프로그램의 일반 출력과 오류 메시지가 모두 같은 파일에 기록된다. 중요한 점은 이 작업이 프로그램 실행 전에 이루어진다는 것이다. 프로그램은 stdout과 stderr에 데이터를 쓰고 있을 뿐이며, 그 데이터가 어디로 전달되는지는 전혀 알지 못한다.
여기서 흥미로운 사실 하나가 등장한다. 셸 리디렉션은 순서에 따라 결과가 달라질 수 있다. 예를 들어 다음 두 명령은 비슷해 보이지만 실제 동작은 다르다.
command > file 2>&1
command 2>&1 > file
첫 번째 명령은 stdout을 파일로 보내고 그 다음 stderr를 stdout에 연결한다. 결과적으로 두 출력이 모두 파일로 들어간다. 하지만 두 번째 명령은 먼저 stderr를 stdout에 연결한 다음 stdout을 파일로 보낸다. 이 경우 stderr는 여전히 터미널을 가리키고 있기 때문에 오류 메시지는 화면에 나타나게 된다. 이런 차이는 리디렉션이 단순한 문법이 아니라 실제 파일 디스크립터 조작이라는 사실을 보여준다.
이제 셸이 파일 디스크립터를 조작하는 방식이 어느 정도 보이기 시작한다. fork로 새로운 프로세스를 만들고, exec 전에 dup2로 파일 디스크립터를 재구성한 뒤 프로그램을 실행한다. 그런데 셸이 수행하는 작업은 여기서 끝나지 않는다. 리디렉션뿐만 아니라 파이프 역시 같은 방식으로 만들어지기 때문이다. 그리고 파이프는 Unix 철학을 가장 잘 보여주는 기능 중 하나다.
파이프라인은 어떻게 만들어지는가
Unix 셸에서 가장 상징적인 기능 중 하나는 파이프다. 우리는 종종 다음과 같은 명령을 사용한다.
ls | grep txt
이 명령은 ls의 출력 결과를 grep 프로그램의 입력으로 전달한다. 겉보기에는 단순한 연결처럼 보이지만, 실제로는 두 개의 독립적인 프로세스가 동시에 실행되며 데이터를 스트림 형태로 주고받는 구조다. ls는 디렉터리 목록을 출력하고, grep은 그 데이터를 읽어 특정 문자열이 포함된 줄만 걸러낸다. 두 프로그램은 서로의 존재를 전혀 알지 못하지만, 파이프라는 중간 통로를 통해 자연스럽게 연결된다.
이 구조를 만들기 위해 셸은 먼저 pipe 시스템 호출을 사용해 파이프를 생성한다. pipe()는 읽기 끝과 쓰기 끝, 두 개의 파일 디스크립터를 만든다. 한 프로세스가 쓰기 끝에 기록한 데이터는 다른 프로세스가 읽기 끝에서 순서대로 읽을 수 있다. 이 디스크립터들은 마치 파일처럼 사용할 수 있지만 실제로는 커널 내부의 버퍼에 연결되어 있다. 한 프로세스가 파이프에 데이터를 쓰면 다른 프로세스가 그 데이터를 읽을 수 있는 구조다.
셸은 다음 단계에서 두 개의 자식 프로세스를 생성한다. 첫 번째 프로세스는 ls 프로그램을 실행하고, 두 번째 프로세스는 grep 프로그램을 실행한다. 하지만 exec를 호출하기 전에 각각의 프로세스에서 파일 디스크립터를 재구성해야 한다. ls 프로세스에서는 stdout을 파이프의 쓰기 쪽 디스크립터에 연결한다. grep 프로세스에서는 stdin을 파이프의 읽기 쪽 디스크립터에 연결한다. 이 작업 역시 dup2를 통해 이루어진다.
이 과정을 구조적으로 표현하면 다음과 같은 데이터 흐름이 만들어진다.
ls stdout → pipe → grep stdin
이 상태에서 두 프로그램이 실행되면 ls는 디렉터리 목록을 출력하고 그 데이터는 파이프를 통해 grep으로 전달된다. grep은 그 데이터를 읽어 필터링한 뒤 결과를 출력한다. 두 프로그램은 서로의 존재를 알지 못하지만 파일 디스크립터를 통해 연결되어 하나의 데이터 처리 파이프라인을 형성한다.
이 구조는 Unix 철학을 매우 잘 보여준다. 프로그램들은 작고 단순한 기능만 수행한다. ls는 단순히 파일 목록을 출력하고, grep은 문자열 검색만 수행한다. 하지만 셸이 이 프로그램들을 파이프로 연결함으로써 복잡한 데이터 처리 작업을 수행할 수 있다. 즉 프로그램 자체보다 프로그램들을 연결하는 구조가 더 중요한 역할을 한다.
이제 셸이 실제로 무엇을 하고 있는지 조금 더 분명해진다. 셸은 단순히 프로그램을 실행하는 도구가 아니다. 셸은 프로세스를 생성하고, 파일 디스크립터를 재구성하고, 데이터 흐름을 설계하는 역할을 한다. 우리가 입력하는 짧은 명령은 사실 셸이 내부적으로 만들어 내는 작은 프로세스 네트워크의 설계도와도 같다.
셸은 작은 프로세스 오케스트레이터다
지금까지 살펴본 fork, exec, dup2, pipe 같은 시스템 호출을 하나로 묶어 보면 셸의 역할이 훨씬 명확해진다. 셸은 단순한 명령 인터프리터가 아니다. 셸은 프로세스를 생성하고, 입출력 구조를 설계하고, 프로그램들을 서로 연결하는 일종의 프로세스 오케스트레이터다. 우리가 터미널에서 입력하는 한 줄의 명령은 셸에게 작은 실행 계획을 전달하는 것과 같다.
예를 들어 다음 명령을 생각해 보자.
cat log.txt | grep ERROR | sort > result.txt
이 문장은 세 개의 프로그램을 연결한 파이프라인이다. 셸은 이 명령을 실행하기 위해 여러 개의 파이프를 만들고, 세 개의 자식 프로세스를 생성하고, 각 프로세스의 파일 디스크립터를 적절하게 연결한다. 첫 번째 프로세스의 stdout은 두 번째 프로세스의 stdin으로 연결되고, 두 번째 프로세스의 stdout은 세 번째 프로세스의 stdin으로 연결된다. 마지막 프로세스의 stdout은 파일로 리디렉션된다. 이 모든 작업이 프로그램 실행 전에 셸에 의해 설정된다.
이 과정을 조금 더 넓은 관점에서 보면 셸은 하나의 작은 데이터 처리 시스템을 구성하고 있는 셈이다. 프로그램들은 독립적인 노드처럼 동작하고, 파이프는 노드 사이의 데이터 스트림 역할을 한다. 셸은 이 노드들과 연결 구조를 생성한 뒤 실행을 시작한다. 프로그램이 실행되기 시작하면 데이터는 파이프를 따라 흐르고, 각 프로그램은 자신의 역할만 수행한다.
흥미로운 점은 이 구조가 매우 단순하면서도 강력하다는 것이다. 각각의 프로그램은 복잡한 기능을 가질 필요가 없다. 단지 표준 입력에서 데이터를 읽고 표준 출력으로 데이터를 쓰는 기능만 있으면 된다. 셸이 프로그램들을 연결해 주기 때문에 작은 프로그램들을 조합해 복잡한 작업을 수행할 수 있다. 이것이 바로 Unix 철학에서 자주 이야기되는 “작은 프로그램들의 조합”이라는 개념이다.
또한 이 구조는 놀라울 정도로 오래 살아남았다. fork, exec, pipe, dup2 같은 시스템 호출은 1970년대 Unix에서 이미 존재했다. 그럼에도 불구하고 이 모델은 여전히 현대 운영체제에서 그대로 사용되고 있다. Docker 컨테이너의 로그가 stdout과 stderr를 기반으로 수집되는 구조도 결국 같은 원리를 사용한다. Kubernetes나 CI 시스템에서도 프로그램의 출력 스트림을 기반으로 로그를 처리한다.
즉 우리가 지금 사용하는 현대적인 인프라의 많은 부분이 여전히 Unix가 설계한 I/O 모델 위에서 동작하고 있다. 셸이라는 작은 프로그램이 만든 실행 모델이 수십 년이 지난 지금까지도 그대로 사용되고 있는 것이다. 그리고 다음 글에서는 바로 이 모델의 또 다른 핵심 개념으로 이어진다. Unix가 왜 세상의 모든 것을 파일로 취급하게 되었는지, 그리고 그 설계가 어떤 철학에서 시작되었는지를 살펴보게 될 것이다.
50년 동안 변하지 않은 Unix 실행 모델
지금까지 우리는 셸이 명령을 실행할 때 어떤 일이 일어나는지 차근차근 따라왔다. 터미널에 입력된 한 줄의 문자열은 단순한 명령이 아니라, 셸이 해석하고 실행해야 할 작은 실행 계획이었다. 셸은 먼저 fork를 호출해 새로운 프로세스를 만들고, exec를 통해 그 프로세스를 실제 프로그램으로 바꾼다. 그 사이에서 dup2를 사용해 파일 디스크립터를 재구성하고, 필요하다면 pipe를 통해 여러 프로그램을 하나의 데이터 흐름으로 연결한다. 우리가 일상적으로 사용하는 >, |, 2>&1 같은 문법은 사실 이러한 시스템 호출들의 조합을 간결하게 표현한 것에 불과하다.
흥미로운 점은 이러한 실행 모델이 1970년대 초반 Unix에서 이미 완성되었다는 사실이다. fork, exec, pipe, dup2 같은 시스템 호출은 초기 Unix 커널에 존재했던 기능이며, 이후 수십 년 동안 거의 변하지 않았다. 많은 운영체제 구조가 시간이 지나면서 크게 바뀌었지만, Unix의 프로세스 실행 모델은 놀라울 정도로 안정적으로 유지되어 왔다. 그 이유는 이 모델이 매우 단순하면서도 강력하기 때문이다. 프로그램은 단지 표준 입력에서 데이터를 읽고 표준 출력으로 데이터를 쓰기만 하면 되고, 데이터 흐름은 셸이 구성한다. 이 단순한 분리 덕분에 프로그램은 작은 기능에 집중할 수 있고, 사용자는 셸을 통해 프로그램들을 자유롭게 조합할 수 있다.
이 구조는 단순히 과거의 유산으로 남아 있는 것이 아니다. 오히려 현대 소프트웨어 시스템에서도 여전히 중요한 역할을 하고 있다. 예를 들어 Docker 컨테이너의 로그 시스템을 생각해 보자. 대부분의 컨테이너 플랫폼은 애플리케이션 로그를 파일이 아니라 stdout과 stderr 스트림을 통해 수집한다. 컨테이너 내부에서 실행되는 프로그램은 단순히 표준 출력으로 로그를 남길 뿐이지만, 컨테이너 런타임은 그 출력을 받아 로그 시스템으로 전달한다. 이 구조는 Unix의 표준 출력 모델과 거의 동일하다. 프로그램은 출력 스트림에 데이터를 쓰고, 실행 환경은 그 스트림을 수집하고 처리한다.
Kubernetes 같은 컨테이너 오케스트레이션 시스템에서도 비슷한 방식이 사용된다. Pod 내부에서 실행되는 애플리케이션은 표준 출력으로 로그를 남기고, Kubernetes는 그 출력을 수집해 로그 저장 시스템으로 전달한다. CI/CD 파이프라인에서도 마찬가지다. 빌드 과정에서 실행되는 프로그램들은 stdout으로 결과를 출력하고, 파이프라인 시스템은 그 출력을 읽어 로그나 아티팩트로 저장한다. 우리가 현대적인 인프라라고 부르는 많은 시스템이 결국 stdout과 stderr라는 두 개의 스트림을 중심으로 동작하고 있는 셈이다.
이러한 사실은 Unix 설계가 얼마나 오래 지속되는 영향을 남겼는지를 보여준다. fork로 프로세스를 만들고, exec로 프로그램을 실행하고, dup2로 입출력 구조를 바꾸고, pipe로 프로세스를 연결하는 모델은 단순한 구현 방식이 아니라 하나의 철학에 가까웠다. 프로그램은 작고 단순해야 하며, 서로 독립적으로 동작해야 하고, 데이터는 스트림 형태로 흐르도록 설계되어야 한다는 철학이다. 그리고 셸은 이러한 프로그램들을 연결해 더 큰 시스템을 구성하는 인터페이스 역할을 한다.

우리가 터미널에서 입력하는 한 줄의 명령은 단순한 명령어가 아니라 작은 시스템을 구성하는 선언문에 가깝다. 셸은 그 선언을 해석해 프로세스들을 생성하고, 입출력 구조를 설계하고, 프로그램들을 연결한다. 그리고 프로그램들이 실행되기 시작하면 데이터는 파이프와 스트림을 따라 흐르며 하나의 작업이 완성된다. 이 모델은 매우 단순하지만, 동시에 놀라울 정도로 유연하다. 수십 년이 지난 지금도 여전히 많은 시스템이 같은 구조를 기반으로 동작하는 이유가 바로 여기에 있다.
다음 글에서는 이 모델의 또 다른 핵심 개념을 살펴보게 된다. 지금까지 우리는 파일 디스크립터와 스트림을 통해 프로그램의 입출력이 어떻게 연결되는지를 보았다. 그런데 Unix는 여기서 한 발 더 나아가 장치와 파이프, 심지어 네트워크까지도 모두 “파일”처럼 다루는 방식을 선택했다. 이 설계는 Unix의 가장 유명한 철학 중 하나인 “Everything is a File”이라는 문장으로 요약된다. 다음 글에서는 이 철학이 어떻게 등장했으며, 왜 Unix 시스템 전체를 관통하는 중요한 설계 원칙이 되었는지를 조금 더 깊이 살펴보게 될 것이다.