[OS] 시스템 콜 학습 — fork, exec, wait 그리고 CoW
운영체제를 공부하며 직접 고민하고 질문했던 내용들을 바탕으로 정리한 포스트입니다.
1. 시스템 콜(System Call)이란?
프로세스는 기본적으로 사용자 공간(User Space)에서 실행된다.
하지만 파일을 읽거나, 새 프로세스를 생성하거나, 메모리를 할당하는 등 OS 커널의 권한이 필요한 작업은 직접 수행할 수 없다.
이때 프로세스가 OS에게 “이 작업 좀 해줘”라고 요청하는 인터페이스가 바로 시스템 콜(System Call)이다.
💡 핵심 개념
시스템 콜은 사용자 프로그램 ↔ 운영체제 커널 사이의 공식 소통이다.
프로세스가 하드웨어나 자원에 직접 접근하지 못하도록 막고, 반드시 OS를 통하게 만드는 보안·안정성의 핵심이다.
2. 시스템 콜의 6가지 분류
공룡책(Silberschatz, Operating System Concepts)에서 정의한 표준 분류는 아래와 같고,
오늘은 이 중 ① 프로세스 제어의 세 가지 명령어, fork / exec / wait에 대해 알아보도록 하자.
| # | 분류 | 설명 | 대표 함수 |
|---|---|---|---|
| ① | 프로세스 제어 | 프로세스의 생성·실행·종료 등 생로병사 관리 | fork(), exec(), wait(), exit(), kill() |
| ② | 파일 조작 | 파일의 생성·삭제·읽기·쓰기 등 | open(), close(), read(), write(), lseek() |
| ③ | 장치 관리 | 하드웨어 장치 제어 및 상태 정보 획득 | ioctl(), read(), write() |
| ④ | 정보 유지 | 시간·날짜·프로세스 정보 획득 및 설정 | getpid(), alarm(), sleep(), time() |
| ⑤ | 통신(IPC) | 프로세스 간 데이터 송수신 | pipe(), shm_open(), mmap() |
| ⑥ | 보호 | 파일·자원의 접근 권한 제어 | chmod(), chown(), umask() |
3. 프로세스 제어의 핵심
3-1. fork() — 복사본 프로세스 생성
현재 실행 중인 프로세스(부모)와 메모리 상태·변수 값·코드 위치가 동일한 새로운 프로세스(자식)를 만든다.
동작 방식
fork()가 호출된 바로 그 다음 줄부터 부모와 자식이 동시에 실행을 시작한다.
리턴값으로 부모·자식 구분
fork() 이후 부모와 자식은 동일한 코드를 실행하기 때문에, 운영체제는 리턴값을 다르게 주어 서로를 구분할 수 있게 한다.
| 프로세스 | fork()의 리턴값 | 의미 |
|---|---|---|
| 부모 | 자식의 PID (양수) | 자식이 태어났고, 그 ID가 이것 |
| 자식 | 0 | 나는 자식이다 |
| 오류 시 | -1 | 프로세스 생성 실패 |
✏️ fork()의 헷갈리는 포인트
Q1. 자식 프로세스는 코드의 처음(main 함수 첫 줄)부터 실행된다?
**아닙니다.** 자식 프로세스는 `fork()`가 호출된 **바로 그 다음 줄부터** 실행을 시작합니다. 부모 프로세스가 이미 실행한 코드는 건너뛰고, fork() 시점의 메모리 상태를 그대로 복사해서 이어받기 때문입니다.
Q2. fork() 이후 부모와 자식 중 누가 먼저 실행되나?
**알 수 없습니다 (Non-deterministic).** 어느 쪽이 먼저 CPU를 점유할지는 **OS 스케줄러가 결정**하며, 실행할 때마다 순서가 바뀔 수 있습니다. 실행 순서를 보장하고 싶다면 `wait()`를 사용해야 합니다.
Q3. 리눅스에서 새 프로세스를 만드는 방법은 fork() 말고도 여러 가지가 있을까?
**사실상 fork() 하나입니다.** 리눅스/유닉스 계열에서 새로운 프로세스를 만드는 거의 유일한 기본 방법이 `fork()`입니다. 터미널에서 `ls`, `mkdir` 같은 명령어를 입력할 때도 쉘이 내부적으로 `fork()`를 호출해서 자식 프로세스를 만들고 그 명령어를 실행합니다.
3-2. wait() — 자식 프로세스 종료 대기
부모 프로세스가 자식보다 먼저 종료되거나 다음 단계로 넘어가는 것을 막기 위해 사용한다.
동작 방식
- 부모 코드에서
wait(NULL)을 만나면, 부모는 Blocked 상태로 전환되어 자식이 종료될 때까지 멈춘다. - 자식이 종료되면 부모가 깨어나 이후 코드를 실행한다.
효과
- 실행 순서 보장: 원래는 스케줄러 마음이지만,
wait()덕분에 자식이 반드시 먼저 끝나도록 동기화가 가능하다. - 자원 회수: 자식이 종료될 때 남긴 종료 상태 정보를 부모가 받는다.
✏️ wait()의 헷갈리는 포인트
Q4. 프로세스들이 번갈아가며 실행되는 멀티태스킹 스위칭도 wait()로 이루어질까?
**아닙니다. wait()와 멀티태스킹 스위칭은 완전히 다른 메커니즘입니다.** | 구분 | 방식 | |------|------| | **멀티태스킹 스위칭** | 하드웨어 **타이머 인터럽트(Timer Interrupt)**가 주기적으로 발생 → OS 스케줄러가 강제로 CPU 점유권을 빼앗아 다음 프로세스에게 넘김 | | **`wait()`** | 부모가 **특정 자식 프로세스 하나**의 종료를 명시적으로 기다리는 용도 | 프로세스들은 자발적으로 멈추는 것이 아니라, **OS에 의해 강제로 밀려나고 다시 들어오는 것**입니다. `wait()`는 그 흐름과 무관하게, 부모-자식 사이의 동기화에만 쓰입니다.
Q5. 부모가 wait()를 호출하지 않으면 어떤 문제가 발생할까?
**좀비 프로세스(Zombie Process)가 발생합니다.** 자식이 종료되면 종료 상태 정보가 시스템에 남습니다. 부모가 `wait()`로 이 정보를 수거하지 않으면, 자식은 이미 종료됐음에도 프로세스 테이블에 남아있는 **좀비 프로세스**가 됩니다. 좀비 프로세스는 시스템 자원을 낭비하므로 반드시 `wait()`로 처리해야 합니다.
3-3. exec() — 완전히 다른 프로그램으로 교체
자식 프로세스에게 부모와 똑같은 일 말고 완전히 다른 프로그램을 실행시키고 싶을 때 사용한다.
동작 방식
exec()계열 함수가 호출되면, 현재 프로세스의 메모리 공간(코드·데이터 등)에 새 프로그램의 내용을 통째로 덮어씌웁니다.- 새 프로세스를 만드는 것이 아니라, 기존 프로세스의 껍데기는 유지하고 알맹이(프로그램)만 교체합니다.
✏️ exec()의 헷갈리는 포인트
Q7. exec()로 실행되는 새 프로그램의 코드는 부모 프로세스에게서 넘어오는 걸까?
**아닙니다. 부모와는 전혀 무관합니다.** 새 프로그램의 코드와 데이터는 **보조 기억 장치(SSD/HDD)에 저장되어 있던 실행 파일**로부터 OS가 직접 읽어옵니다. ``` [ SSD / HDD ] └── 새 프로그램 실행 파일 │ │ exec() 호출 시 OS가 직접 읽어옴 ▼ [ RAM ] → 자식 프로세스 메모리 공간에 새로 채워짐 (부모 메모리와 완전히 무관) ``` 부모는 단지 OS에게 "이 자식 프로세스에게 저 실행 파일 실행시켜줘"라고 부탁(시스템 콜)한 중매쟁이일 뿐입니다.
Q8. exec() 이후 자식 프로세스는 부모로부터 완전히 독립되나? 부모가 자식을 제어할 수 없나?
**반은 독립적이고, 반은 여전히 통제 가능합니다.** | 구분 | 내용 | |------|------| | **독립적인 것** | 메모리 공간이 완전히 분리되므로 부모가 자식 내부 변수에 직접 간섭 불가 | | **여전히 통제 가능한 것** | 부모는 자식의 PID로 `kill()` 시그널을 보내 강제 종료 가능 | | **여전히 의무인 것** | 자식이 종료되면 부모가 `wait()`로 종료 상태를 반드시 수거해야 함 | `exec()` 이후 자식은 자기 갈 길을 가며 독립적으로 실행되지만, **생사여탈권이나 종료 확인 같은 최소한의 통제권은 여전히 부모에게 귀속**됩니다.
4. 세 가지 시스템 콜의 협력 시나리오
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
부모 프로세스 실행 중
│
▼
[fork() 호출]
┌─────────────────────────┐
│ │
▼ ▼
부모 프로세스 자식 프로세스
(rc = 자식 PID) (rc = 0)
│ │
[wait() 호출] [exec() 호출]
→ 자식이 끝날 → 새 프로그램으로
때까지 대기 메모리 교체
│ │
│ 새 프로그램 실행 중...
│ │
│ [프로그램 종료]
│ │
└──────┬─────────────┘
▼
wait() 리턴
부모 계속 실행
5. 깊이 이해하기 — Copy-on-Write (CoW)
왜 이것이 필요한가?
fork()는 부모의 메모리를 복사해서 자식을 만드는데, 부모 프로세스가 수백 MB~수 GB라면 복사 비용이 엄청나다.
게다가 자식은 exec()를 바로 호출해서 그 복사본을 즉시 버릴 텐데, 이는 명백히 낭비다.
현대 운영체제는 이를 가상 메모리(Virtual Memory) + Copy-on-Write 기법으로 해결한다.
Copy-on-Write 동작 단계
1단계 — fork() 직후: 실제 복사 없음
OS는 실제 메모리를 복사하지 않고, 자식에게 부모 메모리를 가리키는 가상 주소(페이지 테이블)만 넘겨준다.
1
2
3
4
5
부모 프로세스 자식 프로세스
[가상 주소 A] ──┐ [가상 주소 A'] ──┐
[가상 주소 B] ──┤ │
└──▶ [ 실제 RAM 데이터 ] ◀──┘
(부모·자식이 같은 물리 메모리를 공유해서 읽기)
2단계 — 쓰기 발생 시: 그때서야 복사
부모나 자식 중 어느 한쪽이 메모리를 수정(Write)하려고 할 때, OS가 개입하여 수정하려는 그 부분만 실제로 복사한다.
이것이 Copy-on-Write(쓰기 시 복사)의 핵심.
3단계 — exec() 실행 시: 연결 끊고 새 프로그램 로드
자식이 exec()를 호출하는 순간:
- OS가 자식의 가상 주소와 부모 메모리 간의 연결을 즉시 회수·폐기
- 부모의 데이터는 그대로 부모 공간에 남는다.
- OS가 SSD에서 새 프로그램 코드를 읽어와 자식의 텅 빈 메모리 공간에 새로 채워 넣는다.
결론
| 단계 | 실제 일어나는 일 |
|---|---|
fork() 직후 | 물리 메모리 복사 없음. 가상 주소(대출증)만 공유 |
| 쓰기 발생 시 | 해당 페이지만 물리 메모리 실제 복사 (CoW) — fork() 후 exec() 전에 쓰기가 없으면 발생하지 않음 |
exec() 호출 시 | 가상 주소 연결 폐기 → SSD에서 새 프로그램 로드 |
fork() → exec()패턴은, 자식이 쓰기를 하기도 전에 exec()가 메모리를 교체해버리기 때문에 CoW의 실제 복사가 일어날 틈이 없어 극도로 빠릅니다.
✏️ CoW의 헷갈리는 포인트
Q9. 부모 프로세스가 10GB라면, fork() 시점에 자식도 10GB를 차지?
**아닙니다. Copy-on-Write(CoW) 덕분에 거의 0에 가깝습니다.** OS는 fork() 시점에 실제 메모리를 복사하지 않고, 자식에게 **부모 메모리를 가리키는 가상 주소(페이지 테이블)만** 넘겨줍니다. 자식이 실제로 차지하는 물리 메모리는 **페이지 테이블 몇 KB 수준**에 불과합니다. 실제 복사는 부모나 자식이 **데이터를 수정(Write)하려는 순간**, 그 부분만 일어납니다.
Q10. fork() 직후 exec()를 바로 호출하면, 부모의 10GB 데이터가 자식에게 복사됐다가 지워질까?
**아닙니다. 복사 자체가 일어나지 않습니다.** - `fork()` 직후: CoW로 인해 메모리 복사 없이 가상 주소만 공유 중 - `exec()` 호출 시: OS가 가상 주소 연결을 즉시 폐기 → 부모 데이터가 단 1바이트도 복사된 적 없는 상태로 연결이 끊김 - 그 후: SSD에서 새 프로그램을 읽어와 자식 메모리 공간에 새로 로드 결과적으로 **부모의 방대한 코드와 데이터는 자식에게 단 한 번도 실제로 복사되지 않은 채** 깔끔하게 새 프로그램으로 교체됩니다.
Q11. "가상 메모리를 복사하지 않는다"는 것과 "물리 메모리를 복사하지 않는다"는 것은 같은 말인가?
**다른 말입니다. CoW에서 복사하지 않는 것은 물리 메모리입니다.** | 개념 | 설명 | |------|------| | **물리 메모리 (RAM)** | 실제 하드웨어에 존재하는 메모리 공간 | | **가상 메모리** | 각 프로세스가 자신만의 주소 공간을 갖는 것처럼 보이게 하는 추상화 | | **페이지 테이블** | 가상 주소 → 물리 주소를 변환하는 매핑 테이블 (= "대출증") | CoW에서 "복사하지 않는다"는 것은 **물리 메모리(RAM)를 복사하지 않는다**는 의미입니다. 가상 주소(페이지 테이블, 대출증)는 자식에게 새로 만들어 주되, 그것이 가리키는 **실제 데이터(물리 메모리)는 복사하지 않고 공유**합니다.
6. 개념 간 혼동 정리
① 멀티 프로세스 vs 멀티 스레드
| 구분 | 생성 방법 | 메모리 공유 여부 | 오버헤드 |
|---|---|---|---|
| 멀티 프로세스 | fork() | 독립된 메모리 공간 | 크다 |
| 멀티 스레드 | pthread_create() 등 | 메모리 공유 (스택만 독립) | 작다 |
새 프로세스를 만드는 것은 무조건 fork() 계열 시스템 콜입니다.
Q12. "처음부터 새 프로그램을 실행하면 되는데, 왜 굳이 fork()로 복사본을 만들었다가 exec()로 교체할까?"
**부모가 세팅해 둔 안전한 환경을 그대로 물려받기 위해서입니다.** 새 프로그램을 맨바닥에서 만들려면 메모리 할당, 환경 변수 설정, 파일 디스크립터 관리 등을 모두 새로 세팅해야 합니다. `fork()`를 먼저 하면 **부모가 세팅해 둔 안전한 환경(환경 변수, 권한, 파일 디스크립터 등)이 그대로 복사**됩니다. 그 상태에서 `exec()`로 알맹이(코드)만 교체하면 훨씬 깔끔하고 효율적입니다. 게다가 CoW 덕분에 이 "복사" 과정에서 실제 메모리 비용도 거의 발생하지 않습니다.
7. 보충해야 할 개념
지금까지 배운 내용을 더 깊이 이해하려면 아래 개념들을 추가로 공부하면 좋습니다.
| 개념 | 왜 필요한가 |
|---|---|
| 컨텍스트 스위칭 (Context Switching) | 멀티태스킹이 실제로 어떻게 동작하는지 이해하기 위해 |
| 타이머 인터럽트 (Timer Interrupt) | OS가 어떻게 프로세스를 강제로 스위칭하는지 이해하기 위해 |
| 가상 메모리 (Virtual Memory) | CoW의 동작 원리를 정확히 이해하기 위해 |
| 페이지 테이블 (Page Table) | 가상 주소가 물리 주소로 어떻게 변환되는지 이해하기 위해 |
| IPC (Inter-Process Communication) | exec() 이후 독립된 프로세스들이 어떻게 통신하는지 이해하기 위해 |
| 좀비 프로세스 / 고아 프로세스 | wait()를 쓰지 않을 경우 발생하는 문제를 이해하기 위해 |
| 시그널 (Signal) | 부모가 자식을 강제 종료하는 메커니즘(kill 등)을 이해하기 위해 |
8. 최종 요약
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────┐
│ 프로세스 제어 시스템 콜 │
├──────────┬──────────────────────────────────────────────────┤
│ fork() │ 부모 프로세스의 복사본(자식)을 생성 │
│ │ → 리턴값으로 부모(PID)와 자식(0) 구분 │
│ │ → CoW로 메모리는 실제 복사하지 않음 │
├──────────┼──────────────────────────────────────────────────┤
│ exec() │ 자식의 메모리를 새 프로그램으로 완전 교체 │
│ │ → 새 코드는 SSD에서 직접 로드 (부모와 무관) │
│ │ → 호출 이후 기존 코드는 사라짐 │
├──────────┼──────────────────────────────────────────────────┤
│ wait() │ 자식이 종료될 때까지 부모를 Blocked 상태로 대기 │
│ │ → 실행 순서 동기화 │
│ │ → 좀비 프로세스 방지를 위한 자원 수거 │
└──────────┴──────────────────────────────────────────────────┘
멀티태스킹 스위칭은 wait()가 아닌
타이머 인터럽트 + OS 스케줄러가 담당!

