본문으로 건너뛰기
목록으로 돌아가기
스터디
13

UNIX 시스템 프로그래밍의 실제 모델: 파일 디스크립터, inode, 권한, 프로세스

파일 디스크립터에서 open file description과 inode로 이어지는 모델, 부분 I/O, unlink, 안전한 교체, 디렉터리 권한, umask, 경로 경쟁, 프로세스 그룹까지 운영 관점으로 정리합니다.

박효열 (Hyoyoul Park)
#UNIX#시스템 프로그래밍#파일 시스템#보안#프로세스

이 글은 개인 학습 아카이브의 네트워크·UNIX 시스템 프로그래밍 강의 자료와 예제를 교차 검토해 독립적으로 다시 쓴 학습 노트입니다. 오래된 API, 부정확한 설명, 안전하지 않은 예제는 현재의 시스템 설계 원칙에 맞춰 명시적으로 바로잡았으며 원본 파일, 내부 경로, 개인 식별 정보는 공개하지 않습니다.

먼저 결론

UNIX 시스템 프로그래밍은 함수 이름을 외우는 과목처럼 보이지만, 실제 핵심은 커널 객체의 수명과 참조 관계입니다.

  • 파일 디스크립터는 파일 자체가 아니라 프로세스 안의 작은 정수 인덱스입니다.
  • 여러 디스크립터가 같은 open file description을 공유하면 offset과 일부 상태도 공유합니다.
  • 파일명은 inode를 가리키는 directory entry이며, 파일명 삭제와 열린 파일 객체의 수명은 다릅니다.
  • 권한 검사는 파일 mode만이 아니라 경로의 모든 directory와 실행 시점의 credential에 좌우됩니다.
  • PID, process group, session은 작업 제어와 안전한 종료 범위를 결정합니다.

이 모델을 이해하면 “파일이 지워졌는데 디스크가 왜 안 줄지?”, “로그가 왜 섞였지?”, “권한은 있는데 왜 접근이 실패하지?”, “서버를 재시작했는데 자식이 왜 남았지?”를 훨씬 빨리 진단할 수 있습니다.

1. 파일 디스크립터는 세 단계로 본다

단순 설명은 open이 파일을 열고 정수를 돌려준다고 말합니다. 운영에서는 세 단계를 구분해야 합니다.

per-process fd table        system open-file state          filesystem object

fd 3 ---------------------> open file description --------> inode / device / socket
fd 4 -----------+             - current offset                - metadata
                +-----------> - status flags                  - data blocks
  • fd table entry: 프로세스별이며 close-on-exec 같은 descriptor flag를 가집니다.
  • open file description: 현재 offset과 O_APPEND 같은 file status flag를 가집니다.
  • inode 또는 커널 객체: 파일 metadata와 data, socket 같은 실제 대상입니다.

dupdup2로 만든 디스크립터는 같은 open file description을 가리키므로 offset을 공유합니다. fork 뒤 부모와 자식이 상속한 디스크립터도 같은 open file description을 가리킬 수 있습니다. 반면 같은 경로를 각각 open하면 보통 독립적인 open file description과 offset을 얻습니다.

2. read와 write는 요청한 양을 보장하지 않는다

read(fd, buf, n)은 최대 n바이트를 읽습니다. 반환값의 의미를 먼저 처리해야 합니다.

  • 양수: 실제 처리한 byte 수
  • 0: 일반 파일에서는 EOF, stream에서는 orderly shutdown을 뜻할 수 있음
  • -1: errno에 따라 재시도, 대기, 중단을 결정

write도 disk full, signal, non-blocking I/O, pipe/socket 상태 때문에 일부만 쓸 수 있습니다.

offset = 0
while offset < total:
    n = write(fd, buffer[offset:])
    if n > 0: offset += n
    else if interrupted: retry within deadline
    else: fail and preserve the original error

무조건 재시도하면 취소되지 않는 무한 루프가 됩니다. deadline, cancellation, EAGAIN 대기 방식, 최대 frame 크기까지 상위 정책과 연결해야 합니다.

또한 write 성공은 메모리에서 커널로 byte가 전달됐다는 뜻이지 전원 장애 후에도 남는다는 보장이 아닙니다. durable commit이 필요하면 파일과 metadata, directory entry의 flush 범위를 파일 시스템과 장애 모델에 맞게 정해야 합니다.

3. dup2는 redirection의 원리를 보여준다

표준 입력 0, 표준 출력 1, 표준 오류 2도 평범한 파일 디스크립터입니다.

int logfd = open("service.log", O_WRONLY | O_CREAT | O_APPEND, 0640);
if (logfd < 0) fail();
if (dup2(logfd, STDOUT_FILENO) < 0) fail();
if (logfd != STDOUT_FILENO) close(logfd);

이후의 표준 출력은 같은 open file description을 통해 파일로 갑니다. 실제 코드는 O_CLOEXEC 또는 FD_CLOEXEC를 사용해 exec 이후 필요 없는 descriptor가 자식 프로세스에 새지 않도록 해야 합니다.

redirection 버그를 볼 때는 “fd 번호가 무엇인가?”뿐 아니라 “어떤 fd들이 같은 offset과 status flag를 공유하는가?”를 확인해야 합니다.

4. 파일명과 파일 객체의 수명은 다르다

directory는 이름에서 inode로 가는 mapping을 가집니다. hard link는 같은 inode를 가리키는 다른 directory entry이고, symbolic link는 다른 경로 문자열을 담은 별도 파일입니다.

unlink(path)는 파일 내용을 즉시 지우는 명령이 아니라 directory entry 하나를 제거하고 link count를 줄이는 연산입니다. link count가 0이어도 어떤 프로세스가 파일을 열고 있으면 data는 마지막 open reference가 닫힐 때까지 남을 수 있습니다.

이것이 두 가지 운영 현상을 설명합니다.

  • 로그 파일을 삭제했는데 프로세스가 계속 열린 fd로 쓰면 경로에서는 안 보여도 공간은 회수되지 않습니다.
  • 실행 중인 binary나 임시 파일을 이름에서 제거해도 이미 열린 참조는 계속 사용할 수 있습니다.

반대로 오래된 자료의 “unlink로 비어 있지 않은 directory도 지울 수 있다”는 설명은 POSIX 파일 API에 맞지 않습니다. directory는 보통 rmdir 또는 목적을 명확히 한 unlinkat 계열로 다루며, 비어 있지 않은 directory 제거는 별도 재귀 정책이 필요합니다.

5. 안전한 파일 교체는 같은 directory에서 준비한다

설정이나 snapshot을 덮어쓰는 도중 process가 죽으면 반쪽 파일이 남을 수 있습니다. 일반적인 패턴은 다음과 같습니다.

  1. 목적 파일과 같은 directory에 충돌 방지 방식으로 임시 파일을 만듭니다.
  2. 전체 내용을 쓰고 길이·format을 검증합니다.
  3. 필요한 durability 수준에 따라 임시 파일을 flush합니다.
  4. 같은 filesystem 안에서 rename으로 목적 이름을 교체합니다.
  5. crash 후 directory entry 보존까지 요구하면 directory flush를 검토합니다.

같은 filesystem의 rename은 독자가 이전 파일 또는 새 파일을 보게 하는 atomic visibility에 유용합니다. 하지만 atomic visibility와 durable persistence는 같은 보장이 아닙니다.

예측 가능한 임시 파일명이나 “존재 확인 후 생성”을 쓰면 공격자가 그 사이에 symbolic link를 놓는 경쟁 조건이 생깁니다. 생성과 독점 확인을 한 system call 경계에서 수행해야 합니다.

6. directory의 r, w, x는 파일과 다르다

권한일반 파일directory
r내용 읽기entry 이름 목록 읽기
w내용 변경entry 생성·삭제·이름 변경에 관여
x실행경로를 통과하고 알려진 이름을 lookup

directory를 나열하려면 보통 r이 필요하고, 그 안의 알려진 파일에 접근하려면 경로 구성 요소마다 x가 필요합니다. directory 안 파일을 삭제할 수 있는지는 대상 파일의 w bit보다 부모 directory의 w+x와 sticky bit 같은 정책에 더 크게 좌우됩니다.

그래서 “파일은 read-only인데 삭제됐다”는 현상은 모순이 아닙니다. 파일 내용을 바꾸는 권한과 파일 이름을 directory에서 제거하는 권한은 다른 객체에 적용됩니다.

7. umask는 XOR가 아니다

새 객체의 최종 mode는 개념적으로 다음과 같습니다.

finalMode = requestedMode & ~umask

일반 파일은 보통 0666, directory는 0777을 요청한 뒤 umask가 금지할 bit를 제거합니다. umask가 실행 bit를 새로 만들어 주지는 않습니다.

예를 들어 umask 0022라면:

file:      0666 & ~0022 = 0644
directory: 0777 & ~0022 = 0755

오래된 강의 예시는 이를 XOR라고 적고 0022처럼 우연히 같은 결과가 나오는 값을 사용했습니다. XOR는 requested mode에 없던 bit를 켤 수 있으므로 일반식이 될 수 없습니다.

service가 secret, socket, key material을 만들 때는 process 기본값에만 기대지 말고 생성 call의 requested mode, umask, 생성 후 검증을 함께 관리합니다.

8. path를 검사한 뒤 다시 열면 경쟁이 생긴다

다음 흐름은 겉보기보다 위험합니다.

if access(path, W_OK) succeeds:
    open(path) and write

검사와 사용 사이에 path가 다른 파일이나 symbolic link로 바뀔 수 있습니다. 이것이 TOCTOU(time-of-check to time-of-use) 경쟁입니다.

안전한 방향은 이름 검사를 반복하는 대신 이미 연 directory handle을 기준으로 작업하는 것입니다.

  • 신뢰하는 base directory를 먼저 엽니다.
  • openat 계열과 directory fd를 사용해 lookup 범위를 고정합니다.
  • 필요하면 O_NOFOLLOW, exclusive create, type 검사를 사용합니다.
  • open 뒤 fstat로 실제 열린 객체의 owner, mode, type을 확인합니다.
  • stat은 link 대상, lstat은 symbolic link 자체를 본다는 차이를 의도적으로 선택합니다.

모든 platform과 filesystem이 같은 flag를 제공하지 않으므로 “path 문자열을 sanitize했다”는 주장보다 실제 system call의 원자성과 배포 대상의 semantics를 확인해야 합니다.

9. PID만으로는 프로세스 묶음을 제어하기 어렵다

UNIX는 관련 process를 process group으로, 여러 process group을 session으로 묶습니다.

session
  +-- process group A: shell pipeline workers
  +-- process group B: foreground job
  +-- process group C: background job
  • PID는 한 process를 식별합니다.
  • PGID는 signal과 job control의 단위를 만듭니다.
  • session은 controlling terminal과 여러 process group의 경계를 만듭니다.

service가 worker를 spawn한다면 종료 시 부모 PID 하나만 kill하는 것으로 충분하지 않을 수 있습니다. 자식이 다시 자식을 만들었거나 exec했을 수 있기 때문입니다. 생성 시 process group과 수명 정책을 정하고, graceful signal, deadline, 강제 종료, wait 회수 순서를 설계해야 합니다.

종료한 자식의 status를 부모가 wait 계열로 회수하지 않으면 zombie entry가 남습니다. 반대로 부모가 먼저 죽어 orphan이 됐을 때 누가 수명과 log를 소유할지도 supervisor 정책의 일부입니다.

10. 운영 장애를 이 모델로 읽기

디스크 공간이 줄지 않는다

  1. path에서 파일이 보이는지 확인합니다.
  2. 삭제된 파일을 계속 연 process가 있는지 확인합니다.
  3. fd가 닫히거나 process가 종료될 때 회수되는지 봅니다.

재시작 후 port bind가 실패한다

  1. 이전 listener를 상속한 child가 남았는지 봅니다.
  2. close-on-exec 누락과 process group 종료 범위를 확인합니다.
  3. 주소 재사용 옵션을 원인 은폐용으로 켜기 전에 실제 owner를 찾습니다.

로그가 예상치 못한 위치에서 시작한다

  1. 여러 fd가 같은 open file description과 offset을 공유하는지 봅니다.
  2. O_APPEND와 직접 seek/write의 조합을 확인합니다.
  3. fork 전후 buffer flush와 stdio/file-descriptor 혼용을 확인합니다.

권한 검사는 통과했는데 open이 실패한다

  1. 경로 구성 directory의 x 권한을 확인합니다.
  2. 검사와 open 사이 path 교체 가능성을 봅니다.
  3. real/effective credential과 container/mount 정책 차이를 확인합니다.

자료에서 바로잡은 핵심

단순화 또는 오류안전한 해석
fd가 곧 파일이다fd는 process-local entry이고 중간 open-file state를 참조한다
read/write는 요청 길이를 처리한다반환 byte 수와 error를 반복 처리해야 한다
unlink가 파일을 즉시 지운다이름을 제거하며 open reference가 수명을 연장할 수 있다
unlink로 비어 있지 않은 directory도 지운다일반 POSIX unlink는 directory 제거용이 아니다
umask는 XOR다requested mode와 inverse mask의 AND다
access 후 open하면 안전하다path가 바뀌는 TOCTOU 경쟁이 있다
파일 w 권한이 삭제를 결정한다삭제는 주로 부모 directory 권한의 문제다

마무리 체크리스트

  • fd, open file description, inode를 구분해 그렸는가?
  • partial I/O, EINTR, EOF, deadline을 처리하는가?
  • child exec에 불필요한 fd가 상속되지 않는가?
  • atomic visibility와 durability 요구를 구분했는가?
  • directory r/w/x와 sticky bit를 확인했는가?
  • mode를 requested & ~umask로 계산했는가?
  • path 검사와 사용을 한 원자적 경계에 가깝게 만들었는가?
  • process group 전체를 종료하고 child status를 회수하는가?

시스템 프로그래밍의 가장 강력한 도구는 system call 목록이 아니라 누가 어떤 커널 객체를 참조하고, 그 참조가 언제 끝나는지 그리는 습관입니다.