시스템 콜과 fork, exec, wait (System Call)

한눈에 보기

사용자 프로그램이 커널 기능을 쓰는 공식 통로인 시스템 콜과 프로세스를 만들고 실행하고 거두는 fork, exec, wait를 다룬다. 트랩을 통한 커널 진입, 쓰기 시 복사, 좀비와 고아 프로세스, 셸의 동작 원리까지 정리한다.

최종 업데이트 2026.09.17

시스템 콜(System Call)은 사용자 모드에서 실행되는 프로그램이 커널의 기능을 사용하기 위해 호출하는 공식 인터페이스이며, fork, exec, wait는 유닉스(Unix) 계열에서 프로세스를 만들고 실행하고 거두는 세 가지 핵심 시스템 콜이다. 응용 프로그램은 하드웨어와 다른 프로세스에 직접 손댈 수 없으므로, 파일 열기부터 프로세스 생성까지 특권이 필요한 모든 일을 시스템 콜로 요청한다. 셸이 명령어를 실행하는 과정이 바로 fork, exec, wait의 조합이다.

시스템 콜의 동작 원리

시스템 콜은 일반 함수 호출과 달리 CPU의 실행 모드를 사용자 모드에서 커널 모드로 바꾼다. 응용 프로그램이 라이브러리 함수(예: C 라이브러리의 read)를 부르면, 라이브러리는 시스템 콜 번호와 인자를 정해진 레지스터에 넣고 트랩(trap) 명령을 실행한다. CPU는 커널의 시스템 콜 진입점으로 점프하고, 커널은 번호로 시스템 콜 테이블을 조회해 해당 처리 함수를 실행한 뒤 결과를 들고 사용자 모드로 복귀한다. 진입 지점이 커널이 정한 곳 하나로 고정되어 있고 인자 검증을 커널이 수행하므로, 응용은 허용된 방식으로만 커널 기능을 쓸 수 있다. 이 모드 전환과 검증 때문에 시스템 콜은 일반 함수 호출보다 훨씬 비싸며, 그래서 라이브러리는 버퍼링 등으로 호출 횟수를 줄이려 한다.

시스템 콜의 종류는 크게 프로세스 제어(fork, exec, wait, exit), 파일 조작(open, read, write, close), 장치 관리, 정보 유지(getpid, 시간 조회), 통신(pipe, socket), 보호(chmod 등)로 분류된다. 리눅스에서 strace 명령으로 프로그램을 실행하면 그 프로그램이 호출하는 시스템 콜을 실시간으로 관찰할 수 있어, 어떤 라이브러리 함수가 어떤 시스템 콜로 이어지는지 확인하는 학습과 디버깅에 유용하다. 또한 현재 시각 조회처럼 아주 잦은 호출은 커널이 데이터를 사용자 공간에 매핑해 주는 vDSO를 통해 모드 전환 없이 처리되기도 한다.

fork: 프로세스 복제

fork는 호출한 프로세스(부모)를 복제해 새 프로세스(자식)를 만든다. 자식은 부모의 주소 공간, 열린 파일 디스크립터, 환경을 물려받은 거의 동일한 복사본이다. 가장 특이한 점은 반환값이다. fork는 한 번 호출되지만 두 번 반환하며, 부모에게는 자식의 프로세스 ID(PID)를, 자식에게는 0을 돌려준다. 이 값으로 두 프로세스가 각자의 길을 분기한다. 실제로는 주소 공간 전체를 즉시 복사하지 않고, 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유하다가 어느 쪽이든 쓰기를 시도할 때만 그 페이지를 복사하는 쓰기 시 복사(copy-on-write)로 구현되어 fork 자체는 매우 빠르다.

exec: 실행 이미지 교체

exec 계열(execve 등)은 새 프로세스를 만들지 않는다. 대신 현재 프로세스의 주소 공간을 지정한 실행 파일의 코드와 데이터로 통째로 갈아 끼운다. PID는 그대로 유지되고, 성공하면 이전 프로그램으로 돌아올 수 없으므로 exec는 성공 시 반환하지 않는다. 열린 파일 디스크립터는 기본적으로 유지되는데, 셸이 리다이렉션을 구현하는 비결이 여기 있다. fork 후 자식에서 표준 출력을 파일로 바꿔 놓고 exec하면, 새 프로그램은 아무것도 모른 채 파일로 출력하게 된다. exec 계열에는 인자를 배열로 받는 execv, 목록으로 받는 execl, 환경 변수를 함께 지정하는 execve, PATH에서 실행 파일을 찾아 주는 execvp 등 변형이 있으며 최종적으로는 모두 execve 시스템 콜로 수렴한다.

wait와 좀비, 고아 프로세스

wait(또는 waitpid)는 자식 프로세스가 종료될 때까지 부모를 재우고, 자식의 종료 상태를 회수한다. 자식이 exit로 종료해도 부모가 종료 상태를 읽어 갈 때까지 커널은 자식의 최소 정보(PCB 일부)를 남겨 두는데, 이 상태의 프로세스를 좀비(zombie)라고 한다. 부모가 wait를 호출하면 좀비가 정리된다. 부모가 wait 없이 방치하면 좀비가 쌓여 PID 자원을 소모하므로 버그로 취급된다. 반대로 부모가 자식보다 먼저 죽으면 자식은 고아(orphan)가 되고, init(또는 systemd) 같은 프로세스가 새 부모가 되어 대신 wait해 준다.

셸의 명령 실행 흐름

세 시스템 콜을 엮으면 셸의 골격이 된다.

while (1) {
    char *cmd = read_command();      // 프롬프트에서 명령 읽기
    pid_t pid = fork();              // 1. 자신을 복제
    if (pid == 0) {
        execvp(cmd, args);           // 2. 자식을 명령 프로그램으로 교체
        perror("exec failed");       // exec가 반환했다면 실패한 것
        exit(1);
    } else if (pid > 0) {
        int status;
        waitpid(pid, &status, 0);    // 3. 자식 종료를 기다리고 상태 회수
    }
}

fork와 exec가 분리되어 있는 덕분에, 그 사이에서 자식의 파일 디스크립터나 환경을 자유롭게 조작한 뒤 새 프로그램을 실행할 수 있다. 이것이 유닉스가 프로세스 생성을 하나의 시스템 콜로 합치지 않은 이유다. 한편 리눅스에는 fork보다 가벼운 대안으로 스레드 생성까지 아우르는 clone이 있고, POSIX에는 fork와 exec를 묶은 posix_spawn도 있다.

면접에서 이렇게 물어본다

  • 시스템 콜이 호출될 때 사용자 모드에서 커널 모드로 전환되는 과정을 설명해 보라.
  • fork가 부모와 자식에게 서로 다른 값을 반환하는 이유와 그 활용 방법은 무엇인가.
  • fork와 exec가 별도의 시스템 콜로 분리되어 있어서 얻는 이점을 셸 리다이렉션과 연결해 설명해 보라.
  • 좀비 프로세스는 왜 생기고 어떻게 예방하며 고아 프로세스와는 어떻게 다른가.
  • 쓰기 시 복사가 fork의 비용을 어떻게 줄이는지 설명해 보라.
운영체제 문서 전체 보기

이 개념, 내 코드에서는 어떻게 물어볼까요

데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.

공고 넣고 시작하기