개발자 쿠키

[Linux] shell에 명령어를 치면 무슨 일이 일어날까? ls, pwd로 보는 builtin과 외부 명령어 본문

카테고리 없음

[Linux] shell에 명령어를 치면 무슨 일이 일어날까? ls, pwd로 보는 builtin과 외부 명령어

개발자 쿠키 2024. 10. 29. 12:24

터미널에 ls를 치면 파일 목록이 나오고, pwd를 치면 현재 경로가 나옵니다. 너무 당연해서 한 번도 의심해본 적이 없는 동작이죠. 그런데 이 두 명령어는 사실 동작 방식이 완전히 다릅니다. 하나는 별개의 프로그램이고, 하나는 셸이 직접 처리하거나 프로그램을 부르거나 둘 다 가능합니다.

이 차이를 따라가다 보면 프로세스 생성, 시스템 콜, 파일시스템 구조, 그리고 왜 cd는 실행 파일이 없을까라는 질문까지 자연스럽게 이어집니다.

 

1. 셸은 ls라는 글자를 보고 무엇을 하는가

셸은 입력받은 단어를 아무렇게나 실행하지 않습니다. bash 기준으로 다음 순서를 밟습니다.

먼저 alias를 확장합니다. 대부분의 배포판에서 ls는 이미 ls --color=auto로 별칭이 걸려 있습니다. 그다음 셸 함수를 찾고, 없으면 builtin(내장 명령어) 목록을 뒤지고, 그래도 없으면 마지막으로 PATH 환경변수에 나열된 디렉터리들을 순서대로 탐색합니다.


이 순서는 직접 코드레벨에서 확인할 수 있습니다.

 
$ type -a ls
ls is aliased to `ls --color=auto'
ls is /usr/bin/ls

$ type -a pwd
pwd is a shell builtin
pwd is /usr/bin/pwd

$ type -a cd
cd is a shell builtin

ls는 실행 파일만 있고, pwd는 builtin과 실행 파일이 둘 다 있고, cd는 builtin만 있습니다.
bash는 PATH 탐색 결과를 해시 테이블에 캐싱합니다. 매번 수십 개 디렉터리를 찾으면 비효율적이기 때문입니다.

 
$ hash
hits	command
   3	/usr/bin/ls
   1	/usr/bin/git

 

 

2. fork와 exec — 왜 두 단계로 나뉘어 있는가

ls가 외부 프로그램이라는 걸 확인했으니, 셸은 이제 그 프로그램을 실행해야 합니다. 그런데 셸 자신이 ls가 되어버리면 안 됩니다. ls가 끝나면 셸도 같이 죽어버릴 테니까요.

그래서 fork()로 자기 자신을 복제한 뒤, 자식 쪽에서만 execve()로 프로세스 이미지를 /usr/bin/ls로 통째로 갈아끼웁니다. 부모는 wait()로 자식이 끝나기를 기다리다가 프롬프트를 다시 띄웁니다.

여기서 자연스럽게 나오는 의문이 있습니다. 어차피 덮어쓸 건데 왜 복사부터 하는가? 통째로 복사한 다음 바로 버리는 건 낭비처럼 보입니다.

두 가지 답이 있습니다. 하나는 성능 쪽인데, 요즘 fork()는 메모리를 실제로 복사하지 않습니다. 페이지 테이블만 복사하고 모든 페이지를 읽기 전용으로 표시해두는 COW(Copy-On-Write) 방식이라, 쓰기가 발생하는 페이지만 그때그때 복제됩니다. 어차피 곧바로 execve로 주소 공간을 갈아엎을 거라 실제 복사 비용은 거의 발생하지 않습니다.

더 중요한 건 두 번째 답입니다. fork와 exec 사이의 그 짧은 구간이 셸의 핵심 기능들이 구현되는 자리입니다. 리다이렉션과 파이프를 생각해보면 명확합니다.

$ ls > out.txt

셸은 fork로 자식을 만든 뒤, 아직 ls가 되기 전인 그 자식에서 out.txt를 열고 dup2()로 표준 출력(fd 1)에 꽂아버립니다. 그러고 나서 execve를 호출합니다. ls는 자기가 파일로 출력되는지 터미널로 출력되는지 전혀 모른 채 그냥 fd 1에 씁니다. 프로세스 생성과 프로그램 적재를 분리해두었기 때문에 가능한 설계입니다.

execve 이후에는 커널이 ELF 헤더를 읽어 어떤 형식인지 판단하고, 동적 링크 바이너리라면 인터프리터(/lib64/ld-linux-x86-64.so.2)를 먼저 올려서 공유 라이브러리를 매핑한 뒤 진입점으로 점프합니다. 파일 첫 두 바이트가 #!이면 셔뱅 처리로 넘어가고요.

 

3. ls는 어떻게 파일 목록을 알아내는가

프로그램이 떴습니다. 이제 ls는 무슨 수로 디렉터리 내용을 알아낼까요. strace로 들여다보면 답이 나옵니다.

$ strace -e trace=openat,getdents64,statx ls -l
openat(AT_FDCWD, ".", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
getdents64(3, 0x55d3f8a2b0d0 /* 12 entries */, 32768) = 384
getdents64(3, 0x55d3f8a2b0d0 /* 0 entries */, 32768) = 0
statx(3, "main.go", ...) = 0
statx(3, "go.mod", ...) = 0
...

핵심은 디렉터리도 그냥 파일이라는 것입니다. open으로 열 수 있고, 다만 일반 read 대신 getdents64라는 전용 시스템 콜로 읽습니다. 디렉터리 파일의 내용물은 파일 이름 → inode 번호 매핑 테이블입니다.

여기서 파일시스템에 대한 중요한 사실이 하나 따라 나옵니다. 파일 이름은 inode 안에 없습니다. inode에는 크기, 권한, 소유자, 타임스탬프, 데이터 블록 위치가 들어있고, 이름은 오직 디렉터리 엔트리에만 존재합니다. 그래서 하나의 inode에 여러 이름을 붙이는 하드 링크가 가능하고, 같은 파일시스템 안에서의 mv가 데이터 이동 없이 엔트리만 고쳐 쓰는 값싼 연산인 것입니다.

이제 ls와 ls -l의 성능 차이가 설명됩니다. 그냥 ls는 getdents64 몇 번이면 끝나지만, -l을 붙이는 순간 크기·권한·소유자·시간을 알아야 하므로 엔트리 하나마다 statx를 호출합니다. 파일이 10만 개면 시스템 콜이 10만 번 더 나가고, 대부분 캐시에 없는 랜덤 I/O입니다.

실무에서 로그가 수십만 개 쌓인 디렉터리에 ls를 쳤다가 터미널이 멈추는 경험, 다들 한 번쯤 있을 겁니다. ls는 기본적으로 모든 엔트리를 메모리에 읽어 들인 뒤 이름순으로 정렬하고 나서야 출력을 시작하기 때문에, 파일이 많으면 첫 글자가 나오기까지 한참 걸립니다. 이럴 땐 정렬을 끄는 게 답입니다.

$ ls -f | head -20      # 정렬 없이 디렉터리 순서 그대로
$ ls -U | head -20

참고로 getdents64는 엔트리마다 d_type 필드로 파일인지 디렉터리인지를 함께 돌려줍니다. 그래서 ls가 디렉터리를 파란색으로 칠하는 정도의 작업에는 stat이 필요 없습니다. 타입 정보만 쓰는 코드를 짤 때 이걸 알고 있으면 불필요한 stat 호출을 걷어낼 수 있습니다.

 

4. pwd 같은 이름의 두 프로그램

type -a pwd에서 봤듯이 pwd는 builtin과 실행 파일이 공존합니다. 그리고 이 둘은 때때로 다른 답을 내놓습니다.

$ mkdir -p /tmp/real
$ ln -s /tmp/real ~/link
$ cd ~/link

$ pwd
/home/chanwoong/link      # 셸 builtin: PWD 변수를 그대로 출력

$ pwd -P
/tmp/real                 # 물리 경로: getcwd() 결과

셸 builtin pwd는 시스템 콜을 부르지 않습니다. cd할 때마다 셸이 직접 갱신해온 PWD 환경변수를 그대로 뱉을 뿐입니다. 심볼릭 링크를 타고 들어온 경로 그대로, 즉 사용자가 인식하는 논리 경로를 보여줍니다.

반면 -P를 주거나 /usr/bin/pwd -P를 실행하면 getcwd() 시스템 콜이 호출됩니다. 이건 커널에게 직접 묻는 것이라 심볼릭 링크가 모두 풀린 실제 경로가 나옵니다.

그럼 커널은 현재 디렉터리를 어떻게 알고 있을까요. 커널은 프로세스마다 task_struct를 갖고 있고, 그 안의 fs_struct에 현재 작업 디렉터리를 가리키는 dentry 포인터가 들어 있습니다. 문자열 경로를 저장하고 있는 게 아닙니다. getcwd()가 호출되면 커널은 그 dentry에서 시작해 부모 포인터(d_parent)를 따라 루트까지 거슬러 올라가며 이름 조각들을 역순으로 이어 붙여 경로를 조립합니다.

$ mkdir /tmp/gone && cd /tmp/gone
$ rmdir /tmp/gone
$ pwd
/tmp/gone                 # builtin은 PWD 변수만 보므로 태연하게 출력
$ /usr/bin/pwd
/usr/bin/pwd: error retrieving current directory: ... No such file or directory

프로세스는 여전히 그 디렉터리에 "있지만", 그 디렉터리는 더 이상 부모 디렉터리에서 도달할 수 없습니다. 조립할 경로가 없으니 커널은 실패를 반환합니다. /proc/self/cwd를 보면 /tmp/gone (deleted)로 표시됩니다.

이건 컨테이너 환경에서도 그대로 이어집니다. chroot나 마운트 네임스페이스 안에서는 경로 조립이 그 네임스페이스의 루트에서 멈추기 때문에, 컨테이너 안에서 보는 /app과 호스트에서 보는 실제 경로가 전혀 다릅니다. 같은 파일인데 프로세스마다 다른 이름으로 부르는 셈이죠.

 

5. 그럼 cd는 왜 실행 파일이 없을까

디렉터리를 바꾸는 시스템 콜은 chdir()입니다. 그런데 이건 호출한 프로세스 자신의 fs_struct만 바꿉니다. 다른 프로세스에는 아무 영향이 없습니다.

만약 cd가 /usr/bin/cd라는 외부 프로그램이었다고 가정해봅시다. 셸은 fork로 자식을 만들고, 그 자식이 cd가 되어 chdir("/tmp")를 호출하고, 성공적으로 디렉터리를 옮긴 뒤, 종료합니다. 자식 프로세스와 함께 그 변경사항도 사라집니다. 부모 셸은 여전히 원래 자리에 있습니다.

즉 cd는 외부 프로그램으로는 원리적으로 구현이 불가능합니다. 셸 프로세스 자신이 직접 chdir()을 호출해야만 하고, 그래서 builtin일 수밖에 없습니다.

같은 논리가 적용되는 명령어들이 있습니다. export(환경변수는 프로세스별), ulimit(리소스 제한은 프로세스별), umask, exec, source 전부 셸 자신의 상태를 바꿔야 하므로 builtin입니다. "이 명령어가 바꾸려는 게 셸 프로세스 자신의 상태인가?" — 이 질문 하나로 builtin 여부를 거의 정확히 예측할 수 있습니다.

그런데 /usr/bin/pwd는 왜 존재할까요? 셸 없이 호출되는 경우가 있기 때문입니다. find . -exec pwd \;처럼 다른 프로그램이 직접 실행하거나, sh 없이 동작하는 스크립트 환경에서는 실행 파일이 필요합니다. POSIX가 표준 유틸리티로 규정해둔 것도 이유고요.