1. 정의 / 결론

fork와 exec는 리눅스와 유닉스 계열 시스템에서 프로그램이 실행되는 방식을 이해할 때 반드시 알아야 하는 핵심 메커니즘이다. 많은 사람이 터미널에서 명령어를 입력하면 “쉘이 그 명령어를 실행한다”라고만 이해하지만, 실제 내부 동작은 그보다 한 단계 더 구조적이다. 쉘은 보통 명령어를 자기 자신 안에서 바로 실행하지 않는다. 먼저 fork를 통해 현재 프로세스를 복제해 자식 프로세스를 만들고, 그 다음 그 자식 프로세스 안에서 exec를 호출해 실행 대상을 원하는 프로그램으로 바꾼다.

이 점이 중요하다. 쉘은 명령어를 “직접 실행하는 존재”라기보다, 실행이 일어날 수 있는 환경을 준비하고 위임하는 조정자에 가깝다. 사용자가 ls, grep, sleep, python 같은 명령을 입력할 때마다, 쉘은 내부적으로 프로세스를 새로 만들고 그 프로세스에 실행 권한을 넘긴다. 그래서 명령이 끝난 뒤에도 쉘은 그대로 살아 있고, 사용자는 다시 프롬프트를 받는다.

결국 핵심은 하나다. 프로그램 실행은 단순 호출이 아니라, 프로세스 생성과 프로세스 교체의 결합 구조다. fork는 실행 환경을 복제하고, exec는 그 복제된 환경을 새로운 프로그램으로 바꾸며, 이 둘이 결합되어 쉘의 기본 실행 구조가 만들어진다. 이 관점을 잡지 못하면 파이프, 리다이렉션, 백그라운드 실행, 잡 컨트롤, 서브셸, 데몬화 같은 개념이 전부 따로따로 흩어진 문법처럼 보인다. 반대로 이 구조를 이해하면 쉘의 동작은 개별 명령어의 모음이 아니라 프로세스 제어 시스템으로 보이기 시작한다.

2. 핵심 요약

먼저 fork는 현재 실행 중인 프로세스를 복제하는 시스템 콜이다. 정확히 말하면 부모 프로세스의 실행 문맥을 바탕으로 새로운 자식 프로세스를 만든다. 이때 자식은 부모와 거의 같은 코드 흐름에서 출발하지만, PID가 다르고 반환값도 다르게 받는다. 부모는 자식의 PID를 받고, 자식은 0을 받는다. 그래서 같은 코드 안에서도 부모와 자식이 분기되어 서로 다른 일을 할 수 있다.

반면 exec는 새로운 프로세스를 만드는 함수가 아니다. 이미 존재하는 현재 프로세스의 프로그램 이미지를 다른 프로그램으로 교체하는 시스템 콜 계열이다. execve, execl, execvp 같은 여러 변형이 있지만 본질은 같다. 현재 프로세스의 코드, 데이터, 스택 같은 실행 이미지를 날리고, 새로운 프로그램을 그 자리에 올린다. PID는 그대로지만, 프로세스가 수행하는 내용은 완전히 달라진다.

따라서 쉘이 일반 명령어를 실행할 때의 전형적인 흐름은 아래와 같다.

사용자 입력
   ↓
쉘이 명령어 해석
   ↓
fork() 호출 → 자식 프로세스 생성
   ↓
자식 프로세스에서 exec() 호출
   ↓
실제 프로그램 실행
   ↓
부모 쉘은 wait()로 자식 종료 대기 또는 즉시 프롬프트 복귀

여기서 중요한 차이는 “실행”이라는 말을 하나로 뭉개면 안 된다는 점이다. 실제 시스템 내부에서는 두 단계가 분리된다.

  • fork → 실행 주체가 될 프로세스를 만든다
  • exec → 그 프로세스가 무엇을 실행할지를 바꾼다

즉, 핵심 차이는 명령 실행이 아니라 프로세스 생성 + 실행 대상 교체 구조라는 데 있다. 이 구조를 이해하면 왜 쉘이 죽지 않고 계속 남아 있는지, 왜 exec ls는 일반 ls와 다르게 동작하는지, 왜 sleep 10 &에서 프롬프트가 바로 돌아오는지, 왜 ls | grep txt가 두 프로그램으로 동작하는지 자연스럽게 설명할 수 있다.

3. 왜 필요한가

리눅스에서 프로그램 실행을 이해할 때 가장 먼저 버려야 하는 직관은 “명령어를 입력하면 쉘이 그 명령을 그냥 실행한다”는 생각이다. 이 표현은 사용자 입장에서는 편리하지만, 시스템 구조를 설명하기에는 너무 거칠다. 쉘도 결국 하나의 프로세스다. 이미 메모리에 올라와 있고, 사용자의 입력을 받고, 환경 변수와 현재 디렉터리 상태를 유지하면서 계속 살아 있는 장기 실행 프로세스다.

문제는 이 쉘이 다른 프로그램을 실행해야 한다는 점이다. 예를 들어 사용자가 ls를 입력했다고 해 보자. 쉘이 자기 자신을 그대로 ls로 바꿔버리면, 그 순간 쉘의 코드와 상태는 사라진다. ls가 끝난 뒤 돌아올 쉘이 없다. 명령은 한 번 실행할 수 있겠지만, 프롬프트는 다시 나타나지 않는다. 즉, “현재 프로세스를 바로 바꿔서 실행”하는 방식은 셸 같은 인터랙티브 프로그램에는 맞지 않는다.

그래서 필요한 것이 역할 분리다. 쉘은 계속 살아 있어야 하고, 실제 명령 실행은 별도의 실행 단위에서 일어나야 한다. 이때 등장하는 것이 fork다. 쉘은 먼저 자신을 복제해서 자식 프로세스를 만든다. 부모인 쉘은 원래 상태를 유지하고, 자식은 부모와 거의 같은 실행 상태에서 출발한다. 이렇게 하면 부모는 계속 셸 역할을 유지할 수 있고, 자식은 실제 명령 실행을 맡는 별도 실행 단위가 된다.

하지만 자식 프로세스는 아직 쉘과 거의 동일한 상태일 뿐이다. 단지 복제되었을 뿐, 아직 ls 자체는 아니다. 따라서 다음 단계가 필요하다. 자식은 exec를 호출해 자기 자신을 ls 프로그램으로 교체한다. 그 순간 자식은 더 이상 쉘 코드가 아니라 ls의 코드로 동작한다. 결과적으로 구조는 아래처럼 된다.

부모 프로세스: 쉘 유지
자식 프로세스: exec를 통해 실제 명령 실행

이 구조가 중요한 이유는 여기서 끝나지 않는다. 이 설계를 기반으로 파이프도 가능해지고, 리다이렉션도 가능해지고, 백그라운드 실행도 가능해지고, 잡 컨트롤도 가능해진다. 즉, forkexec는 단지 “명령 하나 실행하는 기술”이 아니라, 쉘 기능 전체를 성립시키는 기반 구조다.

예를 들어 파이프를 생각해 보면, ls | grep txt는 사실 한 프로그램 안에서 문자열을 넘기는 문법이 아니다. 쉘은 두 개의 자식 프로세스를 만들고, 둘 사이에 파이프용 파일 디스크립터를 연결한 뒤 각 자식에서 exec를 호출한다. 백그라운드 실행인 sleep 10 &도 마찬가지다. 쉘은 자식을 만들고 그 자식을 잡 테이블에 등록한 뒤, 부모는 기다리지 않고 바로 다음 입력을 받는다. 겉으로 보면 문법 차이처럼 보이지만, 내부적으로는 모두 프로세스 생성과 교체, 그리고 파일 디스크립터 연결의 문제다.

따라서 fork와 exec가 필요한 이유는 단순하다. 쉘을 유지하면서 명령을 실행하려면, 실행 주체와 제어 주체를 분리해야 하기 때문이다. 이것이 바로 리눅스 프로세스 모델의 핵심이고, 쉘을 진짜로 이해하는 출발점이다.

4. 예제

예제 1. 기본 명령 실행 구조

가장 단순한 예제는 그냥 명령 하나를 실행하는 경우다.

ls

사용자 입장에서는 현재 디렉터리 목록이 출력되는 것으로 끝난다. 하지만 내부적으로는 훨씬 더 많은 일이 일어난다. 쉘은 먼저 사용자가 입력한 ls를 파싱한다. 이 명령이 내장 명령어인지, 외부 실행 파일인지 확인한다. ls는 보통 외부 프로그램이므로, 쉘은 fork()를 호출해 자식 프로세스를 만든다.

그 다음 자식 프로세스는 exec 계열 함수를 호출한다. 실제 구현은 execve("/bin/ls", ...)에 가깝거나, PATH 검색을 포함한 execvp("ls", ...)일 수 있다. 이 호출이 성공하면 자식 프로세스는 이제 더 이상 쉘이 아니라 ls 프로그램이 된다. ls는 디렉터리 내용을 읽어 표준 출력으로 내보내고 종료한다. 부모 쉘은 자식이 끝날 때까지 wait() 또는 waitpid()로 대기하다가, 종료를 감지하면 다시 프롬프트를 출력한다.

이 구조를 아주 단순하게 표현하면 아래와 같다.

shell
 ├─ fork()
 │   └─ child
 │       └─ exec(ls)
 └─ wait(child)

이 예제가 중요한 이유는 “보이는 동작”과 “실제 구조”가 다르다는 점을 드러내기 때문이다. 사용자는 그냥 ls를 실행했다고 생각하지만, 시스템은 쉘 유지 + 자식 생성 + 자식 교체 + 종료 대기라는 단계를 거친다. 실무에서는 이 차이를 알아야 한다. 예를 들어 특정 명령의 실패가 셸 전체를 망가뜨리지 않는 이유도 여기에 있다. 외부 명령은 대개 별도 자식 프로세스에서 실행되므로, 명령 하나가 비정상 종료되더라도 부모 쉘 자체는 살아 있다.

예제 2. 백그라운드 실행과 fork

이번에는 명령 뒤에 &를 붙여 보자.

sleep 10 &

사용자에게 보이는 결과는 단순하다. 명령을 입력하자마자 프롬프트가 즉시 돌아온다. 그리고 sleep은 10초 동안 뒤에서 실행된다. 많은 사람이 여기서 &를 그냥 “뒤에서 실행”이라는 문법으로만 기억한다. 하지만 실제로는 부모가 기다리느냐, 기다리지 않느냐의 제어 차이가 핵심이다.

쉘은 이 경우에도 기본적으로 fork를 수행해 자식 프로세스를 만든다. 자식은 exec(sleep)를 통해 실제 sleep 프로그램이 된다. 차이는 부모의 행동이다. 포그라운드 실행이라면 부모가 wait()로 자식이 끝날 때까지 기다리겠지만, 백그라운드 실행에서는 부모가 그 자식을 잡 테이블에 등록한 뒤 즉시 프롬프트로 돌아온다.

shell
 ├─ fork()
 │   └─ child
 │       └─ exec(sleep 10)
 └─ no wait immediately
     └─ prompt returns

이 예제에서 fork 구조가 왜 중요한지 분명해진다. 만약 별도 프로세스를 만들지 않는 구조라면, 쉘이 직접 sleep 상태에 들어가 버리므로 백그라운드 실행 자체가 성립하지 않는다. 즉, 백그라운드 실행은 단순 편의 기능이 아니라 프로세스 분리 구조가 있기 때문에 가능한 실행 모드다.

실무에서도 이 차이는 중요하다. 배치 작업, 로그 수집 프로세스, 간단한 장시간 처리, 테스트 서버 실행처럼 터미널을 묶고 싶지 않은 작업은 전부 이 원리를 활용한다. 물론 진짜 운영 환경에서는 nohup, systemd, 프로세스 매니저를 쓰는 경우가 많지만, 기본 원리는 동일하다. 먼저 실행 단위를 분리하고, 부모는 제어권을 유지한다. 백그라운드 실행은 결국 fork + exec + no immediate wait의 조합이다.

예제 3. 파이프와 프로세스 체인

이제 조금 더 흥미로운 예제로 가 보자.

ls | grep txt

겉으로 보기에는 ls 결과를 grep에 넘기는 간단한 연결처럼 보인다. 하지만 내부적으로는 한 프로세스 안의 함수 호출이 아니다. 쉘은 먼저 파이프용 파일 디스크립터 쌍을 만든다. 보통 읽기 끝(read end)과 쓰기 끝(write end) 두 개가 생성된다. 그 다음 왼쪽 명령과 오른쪽 명령을 위해 각각 자식 프로세스를 만든다. 즉, 보통 두 번의 fork가 일어난다.

왼쪽 자식은 자신의 표준 출력(fd 1)을 파이프의 쓰기 끝에 연결하고 exec(ls)를 실행한다. 오른쪽 자식은 자신의 표준 입력(fd 0)을 파이프의 읽기 끝에 연결하고 exec(grep txt)를 실행한다. 그리고 부모 쉘은 양쪽 자식의 불필요한 파이프 디스크립터를 닫고, 자식들의 종료를 기다린다.

구조를 단순화하면 아래와 비슷하다.

pipe() -> [read_fd, write_fd]

shell
 ├─ fork() -> child1
 │    ├─ dup2(write_fd, STDOUT_FILENO)
 │    ├─ close(read_fd)
 │    └─ exec(ls)
 ├─ fork() -> child2
 │    ├─ dup2(read_fd, STDIN_FILENO)
 │    ├─ close(write_fd)
 │    └─ exec(grep txt)
 └─ close(read_fd, write_fd)
     wait(child1, child2)

이 예제의 핵심은 파이프가 단순 문자열 연결이 아니라는 점이다. 파이프는 프로세스 간 데이터 흐름을 커널 수준의 파일 디스크립터로 연결하는 구조다. ls는 그냥 stdout에 출력할 뿐이고, grep은 그냥 stdin에서 읽을 뿐이다. 둘을 연결해 주는 것은 쉘과 커널이다. 따라서 파이프를 제대로 이해하려면 “명령어를 연결한다”는 표현보다 “프로세스를 연결한다”는 관점이 더 정확하다.

실무적으로도 սա 중요하다. 예를 들어 로그 처리 파이프라인, ps aux | grep java, cat file | sort | uniq, journalctl | grep ERROR 같은 명령은 전부 독립 프로세스들이 각자 자기 입출력만 알고 동작하도록 구성된다. 이 구조 덕분에 유닉스 철학인 “작은 프로그램을 조합해 큰 일을 한다”가 가능해진다.

예제 4. exec로 현재 프로세스 교체

다음 예제는 일반 명령 실행과 다르게 보이는 대표 사례다.

exec ls

많은 사용자가 처음에는 이것도 그냥 ls를 실행하는 방식 중 하나라고 생각한다. 하지만 이 명령은 전혀 다르게 동작한다. 여기서는 fork가 없다. 현재 쉘 프로세스 자체가 ls로 교체된다. 따라서 ls가 종료하면 돌아올 쉘이 없다. 인터랙티브 셸에서 실행하면 그 셸 세션은 사실상 끝난다.

왜 이런 일이 생길까. exec는 본질적으로 “현재 프로세스를 다른 프로그램으로 바꾼다”는 동작이기 때문이다. 새 프로세스를 만드는 역할은 하지 않는다. 그래서 현재 셸 안에서 exec ls를 호출하면, 셸은 자기 자신을 버리고 ls가 된다. ls가 끝나는 순간 그 프로세스도 종료된다.

이 동작은 단순히 신기한 예제가 아니라 실무적으로도 꽤 유용하다. 예를 들어 셸 스크립트의 마지막 단계에서 다른 프로그램을 실행한 뒤 더 이상 셸이 남아 있을 필요가 없다면, 그냥 그 프로그램을 실행하는 대신 exec로 교체해 버릴 수 있다. 그러면 중간 셸 프로세스 하나를 줄일 수 있다.

예를 들어 아래처럼 쓸 수 있다.

#!/usr/bin/env bash
echo "starting application..."
exec java -jar app.jar

이 경우 스크립트 프로세스는 마지막에 Java 프로세스로 교체된다. 따라서 프로세스 트리에 불필요한 셸이 남지 않는다. 컨테이너 엔트리포인트 스크립트에서 이 패턴이 자주 쓰이는 이유도 여기에 있다. PID 1 관리, 시그널 전달, 종료 처리 같은 측면에서 중간 셸을 남겨 두는 것보다 exec로 최종 프로세스로 바꾸는 편이 더 깔끔한 경우가 많다.

5. 실무 적용

1) 프로세스 격리 구조 이해

스크립트나 운영 자동화에서 여러 명령을 연속 실행할 때, 많은 문제가 “각 명령이 어디서 실행되는가”를 제대로 이해하지 못해서 생긴다. 사용자는 보통 스크립트를 하나의 흐름으로 보지만, 실제로는 외부 명령 대부분이 별도 프로세스로 분리되어 실행된다. 따라서 한 명령의 실패와 셸 자체의 실패는 다르게 다뤄야 한다.

예를 들어 아래와 같은 스크립트를 생각해 보자.

#!/usr/bin/env bash

echo "backup start"
tar -czf backup.tar.gz /data
echo "backup end"

여기서 tar는 별도 프로세스로 실행된다. tar가 실패해도 셸 스크립트는 기본적으로 그 다음 줄을 계속 실행할 수 있다. 따라서 실무에서는 단순히 “명령을 순서대로 적는다”로 끝내면 안 되고, 종료 코드 기반 제어가 필요하다.

#!/usr/bin/env bash
set -e

echo "backup start"
tar -czf backup.tar.gz /data
echo "backup end"

또는 더 명시적으로 검사할 수 있다.

#!/usr/bin/env bash

echo "backup start"

if tar -czf backup.tar.gz /data; then
  echo "backup success"
else
  echo "backup failed" >&2
  exit 1
fi

이런 제어가 필요한 이유는 각 명령이 독립 실행 단위이기 때문이다. 구조를 모르면 “왜 실패했는데 스크립트가 계속 가지?” 같은 혼란이 생긴다. 구조를 알고 나면 종료 코드, 시그널, 대기 방식까지 자연스럽게 연결된다.

2) 백그라운드 작업 설계

장시간 실행 작업을 다룰 때는 포그라운드와 백그라운드의 차이를 알아야 한다. 단순히 &를 붙이는 것만 기억하면 실제 운영에서는 금방 한계에 부딪힌다. 중요한 것은 부모 셸이 자식을 기다리지 않는다는 점이고, 더 나아가 세션 종료 후에도 계속 살아 있어야 하는지까지 고려해야 한다는 점이다.

예를 들어 테스트 용도로는 아래처럼 충분할 수 있다.

python long_job.py &

하지만 터미널을 끊어도 계속 살아 있어야 한다면 이 정도로는 부족할 수 있다. 세션 종료 신호나 제어 터미널 문제를 고려해야 하기 때문이다. 그래서 다음 같은 방식이 등장한다.

nohup python long_job.py > app.log 2>&1 &

여기서도 밑바닥 구조는 같다. 자식 프로세스가 분리되어 실행되고, 부모 셸은 기다리지 않는다. 다만 추가로 nohup으로 SIGHUP 영향을 줄이고, 리다이렉션으로 출력을 파일에 보낸다. 즉, 실무의 비동기 실행도 결국 fork/exec 기반 구조 위에 잡 컨트롤과 시그널 처리가 얹혀 있는 것이다.

3) 파이프 기반 데이터 처리 설계

여러 명령을 이어 붙일 때는 파이프를 단순 문법이 아니라 프로세스 연결 모델로 봐야 한다. 그래야 어느 지점에서 병목이 생기는지, 어느 명령이 stdin을 소비하는지, 누가 stdout을 생성하는지 명확해진다.

예를 들어 아래 명령은 흔하지만, 내부 구조를 모르면 트러블슈팅이 어렵다.

cat app.log | grep ERROR | sort | uniq -c

이 한 줄은 사실 네 개의 프로그램이 동시에 엮인 구조다. 각 프로그램은 자기 앞뒤의 입출력만 본다. cat은 stdout으로 쓰고, grep은 stdin에서 읽고 stdout으로 쓰며, sort도 마찬가지고, uniq -c는 마지막 소비자다. 이 구조를 알고 있으면 어떤 명령이 버퍼링 문제를 일으키는지, 어떤 단계에서 출력이 늦어지는지, 왜 중간에 결과가 안 보이는지 분석할 수 있다.

또한 실무에서는 아래처럼 굳이 불필요한 cat을 빼기도 한다.

grep ERROR app.log | sort | uniq -c

이 판단 역시 “문자열 연결”이 아니라 “프로세스 수와 입출력 흐름”을 의식하기 때문에 가능해진다. 작은 차이 같지만, 대량 로그 처리나 배치 파이프라인에서는 의미가 커진다.

4) exec를 이용한 프로세스 교체

실무에서 exec는 종종 과소평가된다. 하지만 프로세스 트리를 깔끔하게 유지하고, 시그널 전달을 단순화하고, 중간 래퍼 프로세스를 제거하는 데 매우 유용하다. 특히 셸 스크립트가 “초기화 몇 줄만 하고 최종 애플리케이션을 실행하는 역할”일 때 그렇다.

컨테이너 환경을 예로 들면, 아래처럼 쓰는 경우가 많다.

#!/usr/bin/env bash
set -e

echo "preparing config..."
envsubst < /app/config.template > /app/config.yml

echo "starting app..."
exec /app/server --config /app/config.yml

여기서 마지막 줄이 그냥 /app/server --config /app/config.yml이면, 셸이 부모로 남고 실제 서버가 자식이 된다. 그러면 시그널 전달, 종료 처리, PID 1 동작에서 애매한 문제가 생길 수 있다. 반면 exec를 쓰면 셸이 서버로 교체되므로 프로세스 구조가 훨씬 단순해진다.

즉, exec는 단순히 “현재 셸이 사라지는 특이한 명령”이 아니라, 불필요한 프로세스를 제거하는 실전 도구다.

6. 흔한 실수

실수 1. fork와 exec를 같은 개념으로 이해

가장 흔한 오해는 forkexec를 둘 다 그냥 “프로그램 실행 함수” 정도로 묶어 버리는 것이다. 이 오해는 처음에는 편해 보이지만, 조금만 복잡한 상황으로 들어가면 바로 한계가 드러난다. 예를 들어 “왜 exec ls는 셸이 사라지지?”, “왜 파이프에서는 여러 프로세스가 생기지?”, “왜 백그라운드는 기다리지 않지?” 같은 질문에 답할 수 없게 된다.

forkexec는 역할이 완전히 다르다. fork는 프로세스를 복제한다. exec는 현재 프로세스를 교체한다. 이 둘은 협력 관계이지, 동의어가 아니다. 구조적으로 보면 fork는 실행 주체를 준비하는 단계이고, exec는 실행 대상을 결정하는 단계다. 따라서 둘을 분리해서 이해해야 한다.

실수 2. exec가 새로운 프로세스를 만든다고 생각

이 오해도 매우 흔하다. 이름만 보면 “execute”이니 새로 실행해 주는 함수처럼 들린다. 하지만 exec는 새 프로세스를 만들지 않는다. 이미 있는 현재 프로세스의 내용물을 갈아끼운다. 프로세스 생성은 fork의 역할이고, exec는 생성된 프로세스의 실행 대상을 바꾸는 역할이다.

이 차이를 모르고 있으면 프로세스 트리를 잘못 이해하게 된다. 예를 들어 스크립트에서 exec java -jar app.jar를 했을 때, “셸 하나, Java 하나”라고 تصور하기 쉽지만 실제로는 셸이 Java로 바뀐 것이다. 프로세스 하나가 대체된 것이지, 하나 더 늘어난 것이 아니다.

실수 3. 파이프를 단순 연결 문법으로 이해

|를 “앞 명령의 결과를 뒤 명령에 넘긴다” 정도로만 이해하면 초보 단계에서는 충분해 보인다. 하지만 이 설명만으로는 왜 여러 프로세스가 생기는지, 왜 파일 디스크립터가 필요한지, 왜 어떤 명령은 파이프에서 버퍼링 문제를 보이는지 설명할 수 없다.

파이프는 문자열 연결이 아니라 프로세스 간 입출력 연결이다. 쉘은 두 명령을 하나의 거대한 문장처럼 실행하는 것이 아니라, 각 명령에 대해 별도의 자식 프로세스를 만들고 그 사이를 커널 파이프로 연결한다. 이 전제를 가져야 파이프라인 동작을 제대로 이해할 수 있다.

실수 4. 백그라운드 실행을 단순 옵션으로 이해

&를 “뒤에서 실행하는 기호”라고만 외우면, 왜 프롬프트가 즉시 돌아오는지, 왜 잡 번호가 붙는지, 왜 세션 종료 후 같이 죽을 수 있는지 같은 중요한 부분을 놓치게 된다. 백그라운드 실행은 단지 실행 위치가 바뀌는 것이 아니라, 부모 셸의 대기 방식과 잡 관리 방식이 달라지는 것이다.

즉, &는 꾸밈 문자가 아니라 프로세스 제어 모드에 가깝다. 포그라운드인지 백그라운드인지에 따라 셸은 wait 여부, 터미널 제어권, 잡 테이블 등록, 신호 전달 방식을 다르게 처리한다. 이런 구조를 이해해야 실제 운영 환경에서 nohup, disown, screen, tmux, systemd 같은 도구와의 차이도 설명할 수 있다.

실수 5. 쉘이 직접 프로그램을 실행한다고 생각

이 표현은 완전히 틀린 말은 아니지만, 정확하지 않다. 쉘이 실행을 지시하는 것은 맞지만, 외부 프로그램의 코드가 쉘 안에서 그대로 실행되는 것은 아니다. 쉘은 보통 forkexec를 이용해 실행을 위임한다. 그래서 외부 명령은 별도 프로세스에서 돌고, 쉘은 자기 상태를 유지한다.

이 관점을 놓치면 “쉘이 왜 계속 남아 있지?”, “왜 환경 변수는 경우에 따라 부모에 안 반영되지?”, “왜 서브셸 안의 cd가 바깥 셸에 영향을 못 주지?” 같은 질문에 막히게 된다. 쉘은 실행의 조정자이고, 실제 외부 명령은 대부분 자식 프로세스에서 수행된다고 이해해야 전체 구조가 풀린다.

7. 관련 개념

fork와 exec를 이해했다면, 그다음에는 자연스럽게 몇 가지 개념이 이어진다. 첫 번째는 process다. 프로세스는 실행 중인 프로그램의 인스턴스이며, PID, 메모리 공간, 파일 디스크립터, 환경 변수, 시그널 상태 등을 가진다. fork와 exec는 결국 이 프로세스의 생성과 변형을 다루는 메커니즘이다.

두 번째는 job control이다. 사용자가 Ctrl+Z로 중단하고, bg로 백그라운드 재개하고, fg로 전경으로 가져오는 구조는 전부 쉘이 프로세스 그룹과 터미널 제어권을 관리하기 때문에 가능하다. 여기서도 fork/exec로 만들어진 프로세스들이 기본 단위가 된다.

세 번째는 signal이다. 프로세스는 단지 실행만 하는 것이 아니라 시그널을 받는다. SIGINT, SIGTERM, SIGSTOP, SIGCONT, SIGHUP 같은 신호는 실행 제어와 종료, 재개에 직접 연결된다. 특히 셸은 포그라운드 잡과 백그라운드 잡에 서로 다른 시그널 환경을 부여할 수 있다.

네 번째는 file descriptor다. 파이프와 리다이렉션은 전부 파일 디스크립터 조작으로 이루어진다. stdin은 0, stdout은 1, stderr는 2라는 번호를 가지며, 쉘은 dup2 같은 시스템 콜을 이용해 이 번호들이 가리키는 대상을 바꾼 뒤 exec를 호출한다. 그러면 새로 실행되는 프로그램은 자신이 특별한 작업을 하지 않아도 이미 원하는 입출력 환경을 가진 채 시작한다.

즉, fork와 exec는 독립된 개념이 아니라 process, job control, signal, file descriptor 같은 개념들의 중심축이다. 이것들을 함께 봐야 쉘과 운영체제의 동작이 하나의 구조로 연결된다.

8. 더 깊이 보기

좀 더 깊게 들어가면, fork는 단순한 “메모리 통째 복사”로만 이해하면 부족하다. 현대 운영체제는 보통 copy-on-write 방식으로 동작한다. 즉, fork 직후 부모와 자식이 사용하는 메모리 페이지를 당장 전부 복사하지 않는다. 일단은 같은 물리 페이지를 공유하다가, 어느 한쪽이 수정하려는 시점에만 실제 복사가 일어난다.

이 방식이 중요한 이유는 성능 때문이다. 만약 명령 하나 실행할 때마다 부모 프로세스의 메모리를 전부 즉시 복사해야 한다면 비용이 너무 크다. 하지만 copy-on-write 덕분에 fork는 “복제된 것처럼 보이지만 실제 복사는 지연되는” 효율적인 구조가 된다. 쉘이 명령어를 실행할 때마다 fork를 수행할 수 있는 것도 바로 이 최적화 덕분이다.

exec도 더 정확하게 이해할 필요가 있다. exec가 호출되면 현재 프로세스의 프로그램 이미지는 새로운 실행 파일로 대체된다. 코드 영역, 데이터 영역, 힙, 스택이 새 프로그램에 맞게 다시 구성된다. 성공한 exec는 돌아오지 않는다고 표현하는 이유도 여기에 있다. 호출한 뒤 다시 원래 코드로 복귀하는 것이 아니라, 그 시점부터 완전히 다른 프로그램이 되기 때문이다.

다만 모든 것이 초기화되는 것은 아니다. 중요한 예외가 바로 파일 디스크립터다. 기본적으로 열려 있는 파일 디스크립터는 exec 이후에도 유지될 수 있다. 물론 close-on-exec 플래그가 걸린 디스크립터는 닫히지만, 그렇지 않은 디스크립터는 새로운 프로그램까지 이어진다. 이 점이 파이프와 리다이렉션의 핵심이다. 쉘은 exec 전에 표준 입력, 출력, 에러가 가리키는 대상을 원하는 파일이나 파이프로 바꿔 두고, 그 상태에서 exec를 호출한다. 그러면 새 프로그램은 “그렇게 연결된 환경”을 물려받는다.

예를 들어 아래 명령을 보자.

grep ERROR app.log > errors.txt 2>&1

이 명령에서 쉘은 자식 프로세스를 만든 뒤, 자식 안에서 대략 다음 순서의 작업을 한다.

1. stdout(fd 1)을 errors.txt로 연결
2. stderr(fd 2)를 stdout이 가는 곳으로 맞춤
3. exec(grep)

그러면 grep은 그냥 평소처럼 stdout과 stderr에 출력할 뿐인데, 실제로는 둘 다 파일로 기록된다. grep이 리다이렉션 문법을 아는 것이 아니다. 쉘이 exec 이전에 파일 디스크립터 환경을 세팅해 준 것이다. 이 지점을 이해하면 리다이렉션과 exec의 관계가 훨씬 선명해진다.

구조적으로 정리하면 아래 세 가지가 합쳐져 쉘의 핵심 기능이 만들어진다.

fork  → 실행 환경 복제
exec  → 실행 대상 교체
fd 유지/재배치 → 입출력 연결 유지

파이프, 리다이렉션, 백그라운드 실행, 잡 컨트롤, 프로세스 치환 같은 기능은 결국 이 세 축 위에서 파생된다. 그래서 fork와 exec를 따로 떼어 암기하는 것보다, 쉘이 실행 환경을 만들고 연결한 뒤 프로그램으로 바꾼다는 큰 그림으로 이해하는 편이 훨씬 정확하다.

9. 코드로 보는 fork와 exec

실제 C 코드로 보면 개념이 더 분명해진다. 아래는 가장 기본적인 fork/exec 예제다.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main(void) {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork failed");
        return 1;
    }

    if (pid == 0) {
        // child process
        printf("child: my pid is %d\n", getpid());
        execlp("ls", "ls", "-l", NULL);

        // exec가 실패했을 때만 여기 도달
        perror("exec failed");
        exit(1);
    } else {
        // parent process
        printf("parent: child pid is %d\n", pid);
        wait(NULL);
        printf("parent: child finished\n");
    }

    return 0;
}

이 코드에서 중요한 부분은 두 가지다. 첫째, fork() 이후 부모와 자식이 같은 코드 아래에서 분기된다는 점이다. 둘째, 자식에서 execlp()가 성공하면 그 아래 코드는 실행되지 않는다는 점이다. 왜냐하면 자식은 그 시점부터 ls 프로그램으로 바뀌기 때문이다.

파이프까지 포함하면 구조는 더 분명해진다.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main(void) {
    int fd[2];
    pipe(fd);

    pid_t pid1 = fork();
    if (pid1 == 0) {
        dup2(fd[1], STDOUT_FILENO);
        close(fd[0]);
        close(fd[1]);
        execlp("ls", "ls", NULL);
        perror("exec ls failed");
        exit(1);
    }

    pid_t pid2 = fork();
    if (pid2 == 0) {
        dup2(fd[0], STDIN_FILENO);
        close(fd[1]);
        close(fd[0]);
        execlp("grep", "grep", "txt", NULL);
        perror("exec grep failed");
        exit(1);
    }

    close(fd[0]);
    close(fd[1]);

    wait(NULL);
    wait(NULL);

    return 0;
}

이 코드는 ls | grep txt의 본질을 그대로 보여 준다. 두 개의 프로세스를 만들고, 파이프를 연결하고, 각 프로세스에서 exec를 호출한다. 쉘이 내부에서 하는 일도 결국 이런 패턴의 조합이다. 실제 셸 구현은 훨씬 복잡하지만, 핵심 메커니즘은 다르지 않다.

10. 정리

fork는 프로세스를 복제한다. exec는 그 프로세스를 새로운 프로그램으로 교체한다. 쉘은 이 둘을 조합해서 명령을 실행한다. 이 문장을 그냥 요약처럼 넘기면 별 의미가 없어 보일 수 있지만, 실제로는 리눅스 실행 모델 전체를 관통하는 핵심이다.

우리가 터미널에서 보는 대부분의 현상은 여기서 시작된다. 일반 명령 실행은 fork + exec + wait 구조로 설명된다. 백그라운드 실행은 fork + exec + no immediate wait 구조로 설명된다. 파이프는 fork + fd 연결 + exec의 반복으로 설명된다. 리다이렉션은 exec 이전 fd 재배치로 설명된다. 컨테이너 엔트리포인트에서의 exec 활용은 “현재 프로세스를 최종 애플리케이션으로 교체한다”는 관점으로 설명된다.

결국 핵심은 하나다. 쉘은 프로그램을 실행하는 것이 아니라, 실행을 위한 구조를 만든다. 사용자가 보는 것은 명령 한 줄이지만, 시스템이 실제로 하는 일은 프로세스를 나누고, 연결하고, 교체하고, 기다리고, 다시 제어권을 회수하는 것이다. 이 구조를 이해하면 쉘은 더 이상 단순한 명령 입력기가 아니다. 그것은 프로세스를 조직하고 제어하는 인터페이스다.

그래서 fork와 exec는 단순한 시스템 콜 두 개가 아니다. 리눅스에서 “실행”이라는 말의 진짜 의미를 드러내는 가장 중요한 출발점이다.