fork와 exec

Unix의 프로세스 생성 모델

Unix에서 새 프로세스는 fork()로 현재 프로세스를 복제한 뒤, exec()로 내용물을 다른 프로그램으로 교체하는 두 단계로 만들어진다. fork 시점에는 Copy-on-Write로 실제 메모리 복사를 미루고, 생성과 실행을 분리함으로써 그 사이에 리다이렉션이나 파이프 같은 준비를 끼워넣을 수 있는 유연함을 얻는다.

Process

개념

Unix 계열 OS는 프로세스를 만들 때 "빈 프로세스를 새로 생성"하지 않는다. 대신 두 단계로 나뉜다.

  1. fork() — 현재 프로세스를 통째로 복제해서 자식 프로세스를 만든다
  2. exec() — 자식 프로세스의 내용물(코드, 데이터, 스택)을 다른 프로그램으로 교체한다

셸에서 ls를 치면 실제로 이 과정이 일어난다. ls, grep, git 같은 명령어들은 /usr/bin/ 등에 있는 실행 파일(프로그램)이고, 셸은 이걸 직접 실행하는 게 아니라 자식 프로세스를 만들어서 거기서 실행시킨다.

왜 fork와 exec를 분리했나

exec()는 새 프로세스를 만드는 게 아니라 지금 프로세스를 덮어쓴다. 셸이 바로 exec("ls")를 하면 셸 자체가 ls로 변해버리고, ls가 끝나면 프로세스가 종료되어 터미널 프롬프트가 사라진다.

그래서 fork로 분신을 먼저 만들고, 분신한테 exec를 시킨다. 본체(셸)는 살아남아서 다음 명령을 받을 수 있다.

분리의 또 다른 이점은 fork와 exec 사이에 준비 작업을 끼워넣을 수 있다는 것이다.

ls > output.txt
  1. fork — 자식 생성
  2. 자식이 stdout을 output.txt 파일로 바꿈 ← fork와 exec 사이의 틈
  3. exec("ls") — ls는 자기가 파일로 출력되는지도 모르고 그냥 실행

리다이렉션, 파이프 연결, 환경변수 세팅이 전부 이 틈에서 일어난다. 생성과 실행이 한 덩어리였으면 이런 유연함이 없다.

셸에서 명령어를 칠 때 일어나는 일

ls
  1. 셸(zsh)이 fork() → 자식 zsh 프로세스 생성
  2. 자식이 exec("ls") → 자식이 ls 프로그램으로 변함
  3. ls가 파일 목록 출력하고 종료
  4. 부모 zsh가 다시 프롬프트를 띄움

명령어 하나 = 자식 프로세스 하나다. 파이프라인에서는 명령어마다 하나씩 생긴다.

cat file.txt | grep "hello"
# 자식 프로세스 2개: cat과 grep
# cat의 stdout이 grep의 stdin으로 연결됨

|(파이프)는 병렬 실행이 아니라 앞의 출력을 뒤의 입력으로 흘려보내는 연결이다. &&는 앞이 성공하면 다음을 실행하는 순차 제어.

Built-in 명령어는 예외

cd, export 같은 명령어는 외부 프로그램이 아니라 셸 자체에 내장(built-in)되어 있어서 fork 없이 셸이 직접 처리한다. cd를 자식 프로세스에서 실행하면 자식의 디렉토리만 바뀌고 부모 셸은 그대로라 의미가 없기 때문이다.

Copy-on-Write (COW)

fork()가 메모리를 통째로 복사하면 수백 MB짜리 프로세스를 명령어 칠 때마다 복사하게 되어 너무 느리다. 그래서 실제로는 복사하지 않는다.

fork 직후에는 부모와 자식이 같은 메모리를 함께 가리킨다. 둘 중 하나가 메모리에 쓰기를 하는 순간, 그 페이지만 그때 복사한다.

fork() 직후:
  부모 → [페이지 A][페이지 B][페이지 C]
  자식 → [페이지 A][페이지 B][페이지 C]  ← 같은 걸 가리킴

자식이 페이지 B에 쓰는 순간:
  부모 → [페이지 A][페이지 B ][페이지 C]
  자식 → [페이지 A][페이지 B'][페이지 C]  ← B만 복사

셸에서 명령어를 칠 때는 fork 직후 바로 exec로 메모리를 갈아치우니까, 부모 메모리를 읽을 일 자체가 없다. 복사가 거의 안 일어나서 효율적이다.

Git 브랜치도 같은 발상이다. 브랜치를 만들 때 파일을 전부 복사하지 않고 같은 커밋을 가리키다가, 변경이 생긴 파일만 새로 저장한다.

더 보기

sunshinemoon · 2026