쉘 내부 명령어와 외부 명령어
- CS
- 운영체제
들어가며
터미널에서 cd와 ls는 똑같이 명령어 한 줄을 실행하는 것처럼 보인다. 그런데 (cd /tmp && pwd)처럼 괄호로 서브셸을 열어 그 안에서 cd를 실행하면, 서브셸을 빠져나오는 순간 현재 디렉터리는 다시 원래 자리로 돌아와 있다. 같은 방식으로 (ls)를 실행해봐도 결과는 서브셸 밖에서 실행했을 때와 다르지 않다. cd와 ls는 정확히 어느 지점부터 다른 길을 타는 걸까?
쉘 프로세스 안에서 끝나는 cd, 새 프로세스를 만드는 ls
cd는 쉘(/bin/bash, /bin/zsh, /bin/sh 등) 프로그램 안에 함수나 로직으로 이미 구현되어 있는 내부 명령어(Built-in Commands)다. 쉘 실행 파일의 코드 일부로 포함되어 쉘이 시작될 때 메모리(RAM)에 함께 로드되고, 별도의 실행 파일로 존재하지 않는다. 그래서 새로운 프로세스를 만들지 않고 현재 쉘 프로세스 내부에서 직접 실행되며, fork/exec 과정이 필요 없다. PATH 환경 변수와도 무관하게 항상 사용할 수 있다. export, alias, history, set, source(.), exit, 그리고 bash 기준으로 echo와 pwd도 여기에 속한다.
반면 ls는 별도의 실행 파일(바이너리 또는 스크립트)로 디스크에 저장되어 있는 외부 명령어(External Commands)다. /bin, /usr/bin, /usr/local/bin 같은 위치에 독립적인 실행 파일로 존재하며, 실행할 때마다 디스크에서 읽어와야 한다. 쉘은 이 파일을 PATH 환경 변수가 가리키는 디렉터리 목록에서 검색해 찾은 뒤, 새로운 프로세스를 생성(fork + exec)해 별도의 메모리 공간에서 실행한다. cat, grep, find, gcc, python 같은 명령어가 여기에 해당한다.
여기서 등장하는 PATH는 쉘이 외부 명령어(실행 파일)를 찾을 때 검색하는 디렉터리 목록을 담고 있는 환경 변수다.
# PATH 내용 확인
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games콜론(:)으로 여러 디렉터리 경로를 구분하고, 왼쪽부터 오른쪽 순서로 검색한다.
PATH=/첫번째/디렉터리:/두번째/디렉터리:/세번째/디렉터리함수 호출 한 번으로 끝나는 것과, fork와 exec를 거치는 것
이 구분은 의사 코드로 옮기면 더 분명해진다. 쉘은 입력받은 명령어 문자열을 확인해, 내부 명령어 목록에 있으면 해당 함수를 직접 호출하고 아니면 외부 명령어 실행 경로로 넘긴다.
// bash 내부 구조 (단순화)
int main() {
while (1) {
char *command = read_command();
if (strcmp(command, "cd") == 0) {
// 내부 명령어 - 직접 실행
builtin_cd(args); // 함수 호출
} else if (strcmp(command, "export") == 0) {
builtin_export(args);
} else {
// 외부 명령어 - fork/exec
execute_external(command);
}
}
}
void builtin_cd(char *path) {
if (chdir(path) != 0) {
perror("cd failed");
}
// PWD 환경 변수 업데이트
update_pwd();
}내부 명령어는 이렇게 함수 호출 한 번으로 끝나지만, 외부 명령어는 PATH에서 실행 파일을 찾는 단계부터 시작해 훨씬 많은 절차를 거친다.
void execute_external(char *command, char **args) {
// PATH에서 실행 파일 찾기
char *path = find_in_path(command); // "/bin/ls"
pid_t pid = fork(); // 프로세스 복제
if (pid == 0) {
// 자식 프로세스
execve(path, args, env); // 프로그램 실행
perror("exec failed");
exit(1);
} else {
// 부모 프로세스 (쉘)
int status;
waitpid(pid, &status, 0); // 자식 종료 대기
}
}왜 내부 명령어와 외부 명령어를 구분할까?
일부 명령어는 반드시 현재 쉘 프로세스의 상태 자체를 변경해야 하는데, 이 지점에서 fork를 거치는 외부 명령어 방식은 애초에 성립하지 않는다.
cd를 예로 들면 이유가 바로 드러난다. cd가 만약 외부 명령어였다면, 자식 프로세스에서 chdir()을 호출해도 그 변경은 자식 프로세스의 작업 디렉터리에만 적용된다. 부모인 쉘의 현재 디렉터리는 그대로 남고, 자식이 종료되면 그 변경 사항조차 함께 사라진다.
# cd가 만약 외부 명령어라면?
$ pwd
/home/user
$ cd /tmp # 외부 프로세스에서 디렉터리 변경
$ pwd
/home/user # 변경되지 않음!cd가 내부 명령어로 구현되어 있기 때문에, chdir()은 쉘 프로세스 자체에서 호출되고 그 결과가 이후 명령어에도 그대로 이어진다.
$ pwd
/home/user
$ cd /tmp # 쉘 프로세스 자체에서 디렉터리 변경
$ pwd
/tmp # 변경됨!export도 같은 이유로 내부 명령어여야 한다. 환경 변수는 프로세스마다 독립적으로 관리되므로, 자식 프로세스가 환경 변수를 설정해도 부모 프로세스에는 아무 영향을 주지 못하고 자식이 종료되면 그 값도 함께 사라진다.
# export가 만약 외부 명령어라면?
$ echo $MY_VAR
(비어있음)
$ export MY_VAR=hello # 외부 프로세스에서 환경 변수 설정
$ echo $MY_VAR
(여전히 비어있음)export가 쉘 자체의 환경 변수를 직접 건드리는 내부 명령어이기 때문에, 설정한 값이 이후 명령어에서도 유지된다.
$ echo $MY_VAR
(비어있음)
$ export MY_VAR=hello # 쉘 자체의 환경 변수 설정
$ echo $MY_VAR
hello # 설정됨!쉘 상태를 바꿔야 하는 문제와는 별개로, 속도 차이도 이 구분을 유지하는 이유 중 하나다. 외부 명령어를 실행하려면 fork() 시스템 콜로 프로세스를 복제하고, PATH를 뒤져 디스크에서 파일을 찾아 읽은 뒤 메모리에 적재하고, exec()로 실행하고, 종료 후에는 exit()와 wait() 처리까지 거쳐야 한다. 내부 명령어는 이 과정 전부를 건너뛰고 메모리에서 함수를 직접 호출하는 것으로 끝난다.
RAM에 상주시키는 이유
내부 명령어가 메모리에서 직접 실행되어 빠르다는 건 결국 RAM과 디스크의 접근 속도 차이에서 나온다. RAM 접근 시간은 약 100나노초(0.0001밀리초), SSD는 약 0.1밀리초, HDD는 약 10밀리초 수준이라 RAM이 SSD보다 1,000배, HDD보다는 100,000배 빠르다.
이 차이는 같은 명령어를 반복 실행할 때 누적된다. ls를 100번 실행하면서 매번 디스크에서 /bin/ls를 새로 읽는다면(약 10ms), 총 1000ms 가까이 걸린다. 디스크 I/O가 매번 발생하고, HDD라면 헤드 이동 시간까지 더해지므로 자주 쓰는 명령어일수록 이 비효율이 그대로 체감된다.
# 사용자가 ls를 100번 실행한다면?
$ ls # 1번: 디스크에서 /bin/ls 읽기 (10ms)
$ ls # 2번: 디스크에서 /bin/ls 읽기 (10ms)
...
$ ls # 100번: 디스크에서 /bin/ls 읽기 (10ms)
# 총 1000ms (1초) 소요내부 명령어는 쉘이 시작될 때 /bin/bash 실행 파일과 함께 코드가 이미 RAM에 올라가 있으므로, 이후 몇 번을 호출하든 RAM에서 함수를 직접 부르는 것으로 끝난다. cd를 100번 실행해도 매번 0.0001ms 수준이라 총 시간은 0.01ms에 가깝다.
# 사용자가 cd를 100번 실행한다면?
$ cd /home # 1번: RAM에서 직접 실행 (0.0001ms)
$ cd /tmp # 2번: RAM에서 직접 실행 (0.0001ms)
$ cd /var # 3번: RAM에서 직접 실행 (0.0001ms)
...
# 총 0.01ms (거의 즉시)물론 이 방식에는 대가가 따른다. 쉘이 항상 RAM 공간을 차지하고, 쉘을 재시작하지 않고는 내부 명령어를 업데이트할 수 없다. 다만 직접 측정해보니(ps -o rss= -p $$), 로컬 bash 프로세스 하나의 RSS(실사용 메모리)는 1.5MB 안팎이었다. 내부 명령어를 포함해 쉘 전체를 항상 RAM에 띄워둬도 부담이 크지 않다는 뜻이다. 그래서 자주 쓰는 cd, export, alias, echo, pwd 같은 명령어는 내부 명령어로 RAM에 상주시켜 두고, find, gcc, ffmpeg처럼 가끔 쓰는 명령어는 외부 명령어로 디스크에 두었다가 필요할 때만 로드하는 균형을 택한 것으로 보인다.
다만 외부 명령어라고 해서 항상 느린 것도 아니다. OS의 페이지 캐시 덕분에, 자주 쓰는 외부 명령어도 처음 한 번만 디스크에서 읽고 나면 그 뒤로는 캐시에서 훨씬 빠르게 읽힌다.
첫 실행:
$ ls # 디스크에서 읽기 (10ms)
# OS가 /bin/ls를 페이지 캐시에 저장
이후 실행:
$ ls # 페이지 캐시에서 읽기 (0.1ms)
$ ls # 페이지 캐시에서 읽기 (0.1ms)외부 명령어를 처음 실행하면 디스크에서 읽어 OS가 자동으로 RAM의 페이지 캐시에 저장해두고, 이후 실행에서는 그 캐시를 읽어 디스크 접근을 피한다. 메모리가 부족해지면 캐시는 자동으로 제거된다.
정리하며
서브셸을 빠져나오면 cd로 옮긴 디렉터리만 원래대로 돌아오는 이유도 여기 있다. 서브셸도 fork로 만들어진 별도 프로세스라서, 그 안에서 실행한 cd는 서브셸 자신의 작업 디렉터리만 바꾸고, 서브셸이 종료되면 그 상태도 함께 사라진다. ls는 애초에 쉘의 상태를 바꾸지 않으니 서브셸 안에서 실행하든 밖에서 실행하든 결과가 같을 수밖에 없다.
명령어 하나를 마주쳤을 때 먼저 볼 기준은 이 동작이 새 프로세스 안에서 끝나도 되는지, 아니면 현재 쉘 프로세스의 상태 자체를 바꿔야 하는지다. 상태를 바꿔야 하는 동작을 fork로 만든 자식 프로세스에 맡기면 그 결과는 부모 쉘에 닿지 못한 채 프로세스와 함께 사라진다. 반대로 상태를 바꿀 필요가 없는 동작은 굳이 쉘 프로세스 안에 붙잡아 둘 이유가 없어서, 자주 쓰지 않는 이상 디스크에 남겨두고 필요할 때만 불러오는 쪽이 메모리 입장에서도 합리적이다.