Loki's Blog

터미널 명령어 실행 과정

  • CS
  • 운영체제
2025-11-23

들어가며

토이 셸을 만들다가 리다이렉션(ls > out.txt)을 구현할 차례였다. 표준출력을 파일로 바꿔치는 코드를 어디에 넣어야 할지 찾다 보니, 근본적인 질문 하나가 걸렸다. 새 프로그램을 실행하는 거라면 프로세스를 만들면서 그 프로그램으로 바로 시작하면 될 텐데, 유닉스 계열 OS는 왜 굳이 프로세스를 복제하는 fork()와 프로그램을 통째로 바꿔치는 exec()을 나눠뒀을까?

검색해보니 fork 직후, exec을 부르기 전의 그 짧은 틈에 자식 프로세스만 따로 손볼 수 있고 리다이렉션과 파이프가 그 틈에서 이뤄진다는 것이었다. 하지만 정확히 무슨 일이 일어나는지, fork와 exec 사이에 놓인 다른 시스템 콜(wait, 파일 시스템 접근)은 각각 무슨 역할인지는 여전히 흐릿했다. 그래서 터미널에 ls -l /home을 치고 엔터를 누르는 순간부터 결과가 화면에 찍히기까지를 시스템 콜 단위로 처음부터 끝까지 따라가 보기로 했다.

1. 쉘이 명령어를 읽어들이는 순간까지는 전부 유저 모드다

username@hostname:~$ ls -l /home

Enter를 누르기 전까지, 그리고 누른 직후 쉘이 문자열을 읽어 파싱하는 순간까지도 모든 작업은 유저 모드에서 일어난다. 유저 모드는 제한된 권한으로 실행되는 모드로, 메모리나 하드웨어에 직접 접근할 수 없고 자신의 메모리 공간만 접근할 수 있다. 시스템 자원이 필요하면 반드시 커널에 요청을 보내야 하는데, 이 요청이 유저 모드에서 커널 모드로 진입하는 인터페이스인 시스템 콜이다.

사용자가 Enter 키를 누르면 쉘은 입력된 문자열을 읽고 파싱해 명령어 타입을 확인한다. 이 확인 결과에 따라 이후 경로가 완전히 갈린다.

2. 내장 명령어는 쉘이 직접 처리하고, 외부 명령어는 PATH를 뒤진다

cd, exit, export, alias, source(또는 .) 같은 내장 명령어(Built-in Commands)는 쉘 자체에 구현되어 있어 쉘이 직접 처리한다. 새 프로세스를 만들 필요 없이, 쉘 내부에서 바로 해당 로직을 실행하고 끝난다.

// 의사 코드
if (is_builtin_command(command)) {
    execute_builtin(command, args);  // 쉘이 직접 실행
    return;  // fork/exec 불필요
}

반면 ls(/bin/ls), cat(/bin/cat), grep(/bin/grep) 같은 외부 명령어(External Commands)는 별도의 실행 파일로 존재하는 프로그램이라, 새 프로세스를 생성해야 실행할 수 있다. 쉘은 먼저 PATH 환경 변수에 나열된 디렉터리를 순서대로 뒤져 실행 파일의 위치를 찾는다.

# PATH 환경 변수에서 검색
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
 
# 각 디렉터리에서 "ls" 찾기
/usr/local/bin/ls  (없음)
/usr/bin/ls        (없음)
/bin/ls            (찾음!)

3. 외부 명령어 하나에 fork, exec, wait 세 번의 시스템 콜이 필요하다

경로를 찾고 나면 쉘은 fork()로 프로세스를 복제하고, 그 자식 프로세스에서 execve()로 실제 프로그램을 실행하며, 부모는 waitpid()로 자식이 끝나기를 기다린다.

// 쉘의 명령어 실행 의사 코드
pid_t pid = fork();  // 시스템 콜 1: 프로세스 생성
 
if (pid < 0) {
    perror("fork failed");
} else if (pid == 0) {
    // 자식 프로세스
    execve("/bin/ls", args, env);  // 시스템 콜 2: 프로그램 실행
    perror("exec failed");  // exec 실패 시에만 도달
    exit(1);
} else {
    // 부모 프로세스 (쉘)
    int status;
    waitpid(pid, &status, 0);  // 시스템 콜 3: 자식 종료 대기
}

세 함수가 전부 시스템 콜인 이유는 프로세스 생성이나 종료 대기가 커널만 관리할 수 있는 자원(프로세스 테이블, PID)을 다루기 때문이다. 그런데 왜 fork와 execve를 한 번에 묶은 시스템 콜 하나로 만들지 않고, 굳이 두 단계로 나눠뒀을까?

4. fork는 프로세스를 복제하고, 반환값으로 부모와 자식이 갈라진다

fork()가 호출되면 커널은 현재 프로세스(부모)를 복제해 새 프로세스(자식)를 만든다. 새 PID를 할당하고 프로세스 제어 블록(PCB)을 만들어 프로세스 테이블에 등록하며, 부모의 코드 / 데이터 / 힙 / 스택을 복사해 독립적인 메모리 공간을 마련한다. 리눅스 커널 문서(man7.org fork(2))에 따르면 이 복사는 실제로는 Copy-on-Write 방식으로 처리되어, 페이지 테이블만 먼저 복제하고 두 프로세스가 실제로 값을 다르게 쓰기 전까지는 메모리 자체를 공유한다. 열려 있는 파일 디스크립터, 환경 변수, 현재 작업 디렉터리, 시그널 핸들러도 함께 복사되고, 자식의 PPID는 부모의 PID로 설정되어 프로세스 계층 구조가 만들어진다.

fork()가 특이한 지점은 반환값이다. 부모 프로세스에서는 자식의 PID(0보다 큰 값)를, 자식 프로세스에서는 0을, 실패 시에는 음수를 반환한다. fork() 호출 이후의 코드는 부모와 자식 양쪽에서 각각 한 번씩, 총 두 번 실행되는 셈이고 이 반환값으로 어느 쪽인지 구분한다.

pid_t pid = fork();
 
// 부모 프로세스에서: pid > 0 (자식의 PID)
// 자식 프로세스에서: pid == 0
// 실패 시: pid < 0

5. fork 직후, exec 직전의 그 틈에서 리다이렉션이 끼어든다

자식 프로세스는 fork에서 복귀한 뒤 곧바로 execve()를 부르지 않아도 된다. 그 사이에 자식만의 코드를 몇 줄 끼워 넣을 수 있고, 쉘은 바로 이 틈에서 표준입출력을 다시 연결한다. ls > out.txt라면 자식은 out.txt를 열어 얻은 파일 디스크립터를 dup2()로 표준출력(fd 1) 자리에 덮어씌운 뒤 exec을 부른다.

} else if (pid == 0) {
    // 자식 프로세스 - fork 직후, exec 직전
    int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, STDOUT_FILENO);  // fd 1을 out.txt로 바꿔치기
    close(fd);
 
    execve("/bin/ls", args, env);  // 이 시점부터 ls는 이미 바뀐 fd 1을 물려받는다
}

이게 가능한 이유는 execve()가 파일 디스크립터를 건드리지 않기 때문이다. 리눅스 커널 문서(man7.org execve(2))는 파일 디스크립터가 기본적으로 execve()를 넘어서도 열린 채로 남고, close-on-exec으로 표시된 것만 닫힌다고 명시한다. ls 프로그램은 자신의 표준출력이 원래 터미널이었는지 파일로 바꿔치기 당했는지 전혀 알지 못한 채 그냥 fd 1에 쓴다. 파이프(ls | grep txt)도 원리는 같다. 파이프의 양쪽 끝을 만든 뒤 읽는 쪽 자식은 표준입력을, 쓰는 쪽 자식은 표준출력을 파이프 fd로 바꿔치고 각자 exec을 부른다.

fork와 exec이 한 번에 묶여 있었다면 이 틈 자체가 없었을 것이다. 프로세스를 복제하는 일과 프로그램을 새로 시작하는 일을 나눠둔 덕분에 새 프로그램은 자신이 리다이렉션됐는지 신경 쓸 필요 없이 그저 자기 코드를 실행하면 되고, 그 준비는 전부 부모 쉘의 몫으로 남는다.

6. exec은 프로세스의 메모리를 통째로 새 프로그램으로 바꾼다

리다이렉션 준비가 끝나면 자식 프로세스는 execve()를 호출한다. 커널은 먼저 /bin/ls 파일이 존재하는지, 실행 권한이 있는지, ELF 같은 실행 가능한 형식인지 검증한 뒤 기존 프로세스의 메모리 공간을 완전히 새 프로그램으로 교체한다. 리눅스 커널 문서(man7.org execve(2))는 이 과정을 새로 초기화된 스택, 힙, 데이터 세그먼트를 가진 새 프로그램으로 현재 실행 중인 프로그램을 교체하는 일이라고 설명한다.

실행 파일의 코드 섹션과 데이터 섹션을 메모리에 로드하고, 초기화되지 않은 전역 변수를 담는 BSS 섹션을 초기화하며, 프로그램의 진입점(보통 main() 함수)으로 이동해 명령줄 인자(argv)와 환경 변수(env)를 전달한다.

execve()가 성공하면 그 이후의 코드는 절대 실행되지 않는다. 리눅스 커널 문서는 execve()가 성공 시 반환하지 않는다고 명시한다. 앞선 의사 코드에서 execve() 다음 줄의 perror("exec failed")가 exec 실패 시에만 도달하는 것도 같은 이유다.

7. ls가 실제로 하는 일도 결국 시스템 콜의 연속이다

exec()로 교체된 ls 프로그램이 실제로 디렉터리 내용을 읽는 과정도 시스템 콜로 이루어진다. 디렉터리를 열고(open), 항목을 하나씩 읽고(readdir), 각 파일 정보를 확인하고(stat), 화면에 출력(write)한 뒤 디렉터리를 닫는(closedir) 흐름이다.

// ls 프로그램의 내부 동작 (단순화)
int fd = open("/home", O_RDONLY | O_DIRECTORY);  // 시스템 콜
if (fd < 0) {
    perror("open failed");
    exit(1);
}
 
struct dirent *entry;
DIR *dir = fdopendir(fd);  // 시스템 콜
 
while ((entry = readdir(dir)) != NULL) {  // 시스템 콜
    struct stat file_stat;
    stat(entry->d_name, &file_stat);  // 시스템 콜
    printf("%s\n", entry->d_name);  // write 시스템 콜 내부 사용
}
 
closedir(dir);  // 시스템 콜

이 과정에서 커널은 매번 프로세스가 해당 파일이나 디렉터리에 접근할 권한이 있는지(UID/GID, 접근 제어 목록) 확인하고, 디스크에서 데이터를 읽어오되 버퍼 캐시를 활용해 성능을 최적화하며, 파일 디스크립터와 메모리, 프로세스 스케줄링 같은 자원을 관리한다. 앞서 리다이렉션으로 fd 1을 파일로 바꿔뒀다면 이 write 호출은 자신이 파일에 쓰고 있다는 사실조차 모른 채 그대로 실행된다.

8. exit과 wait이 끝나야 프롬프트가 돌아온다

ls가 작업을 마치면 exit()을 호출해 종료한다.

exit(0);  // 정상 종료
// 또는
exit(1);  // 오류 종료

exit() 시스템 콜이 호출되면 커널은 프로세스가 쓰던 자원을 해제하고, 프로세스 상태를 좀비로 바꾼 뒤 부모에게 SIGCHLD 시그널을 보내고 종료 상태 코드를 보관해둔다. 리눅스 커널 문서(man7.org wait(2))는 좀비를 종료됐지만 아직 wait되지 않은 자식이 갖는 상태로, 커널은 PID·종료 상태·자원 사용량 같은 최소한의 정보만 유지한다고 설명한다. wait을 호출하지 않으면 이 좀비가 프로세스 테이블 슬롯을 계속 차지해, 테이블이 가득 찰 경우 새 프로세스를 만들 수 없게 된다.

// 부모 프로세스 (쉘)
int status;
pid_t result = waitpid(pid, &status, 0);  // 시스템 콜
 
if (WIFEXITED(status)) {
    int exit_code = WEXITSTATUS(status);
    // exit_code == 0: 정상 종료
    // exit_code != 0: 오류 발생
}

부모 쉘은 waitpid()에서 복귀하면 종료 상태를 확인하고 필요하면 출력한 뒤, 새 프롬프트를 띄우고 다음 명령어를 기다리는 유저 모드로 돌아간다.

# 자식 프로세스 종료 후
username@hostname:~$ _  # 커서가 다시 프롬프트에 표시

전체 흐름을 그림으로 정리하면 다음과 같다.

정리하며

프로세스를 만드는 일과 프로그램을 실행하는 일은 커널 안에서도 서로 다른 문제였다. fork가 프로세스만 복제해두고 exec을 미뤄두는 덕분에, 자식은 새 프로그램이 시작되기 전 그 짧은 틈에 파일 디스크립터를 바꿔치거나 파이프를 연결할 수 있다. exec이 파일 디스크립터를 닫지 않고 그대로 물려주기 때문에, 새 프로그램은 이 준비가 있었는지조차 모른 채 그 위에서 그냥 실행된다. 리다이렉션이든 파이프든 어디서 그 로직이 끼어드는지 궁금하다면, fork 직후와 exec 직전 사이부터 보면 된다.

관련 글