File: 파일 디스크립터와 inode로 이해하는 파일 시스템 추상화
파일(File)은 운영체제가 저장 장치의 물리적 블록을 프로세스에게 바이트 스트림으로 추상화한 인터페이스다. 커널은 inode로 파일의 메타데이터와 데이터 블록 위치를 관리하고, 프로세스는 파일 디스크립터(File Descriptor)를 통해 커널이 관리하는 열린 파일 테이블에 접근한다. 이 글에서는 시스템 콜 수준에서 파일이 어떻게 열리고 읽히는지, in
By 송민성
1. 개념
파일은 이름을 가진 바이트의 연속(byte stream)이며, 운영체제는 이를 저장 장치의 물리적 배치와 무관하게 동일한 인터페이스(open, read, write, close)로 다룰 수 있게 추상화한다.
파일 시스템 관점에서 파일은 두 부분으로 나뉜다.
- 디렉터리 엔트리(dentry): 파일 이름과 inode 번호의 매핑. "이름"은 여기에만 존재한다.
- inode(index node): 파일의 실제 메타데이터(소유자, 권한, 크기, 타임스탬프, 데이터 블록 포인터)를 담는 구조체. 파일 이름은 포함하지 않는다.
즉 파일 이름은 inode를 가리키는 라벨일 뿐이고, 실제 파일의 정체성은 inode 번호로 결정된다.
2. 왜 사용하는가
디스크는 물리적으로 섹터와 블록의 배열일 뿐이다. 애플리케이션이 매번 "몇 번째 블록에 몇 바이트를 써라"라고 지시해야 한다면 프로그래밍이 사실상 불가능하다. 파일 추상화는 다음을 해결한다.
- 위치 독립성: 프로그램은 파일 경로만 알면 되고, 데이터가 디스크 어디에 흩어져 있는지 신경 쓰지 않는다.
- 동시 접근 제어: 여러 프로세스가 같은 파일을 열어도 커널이 오프셋과 잠금을 관리한다.
- 이름과 데이터의 분리: 같은 데이터(inode)를 여러 이름(하드링크)으로 참조할 수 있어 이름 변경이나 삭제가 데이터 자체에 영향을 주지 않는다.
3. 동작 원리
3.1 open()부터 read()까지의 경로
- 프로세스가
open("/home/user/data.txt")를 호출한다. - 커널은 경로를 디렉터리 엔트리 단위로 순회(path resolution)하며 최종 파일의 inode 번호를 찾는다.
- inode를 메모리로 읽어와 파일의 데이터 블록 위치, 권한을 확인한다.
- 커널 내부의 열린 파일 테이블(open file table)에 새 항목을 만든다. 여기에는 현재 읽기/쓰기 오프셋, 접근 모드(O_RDONLY 등)가 저장된다.
- 프로세스별 파일 디스크립터 테이블에 이 열린 파일 항목을 가리키는 작은 정수(파일 디스크립터, 예: 3)를 반환한다.
read(fd, buf, n)호출 시 커널은 오프셋 위치부터 inode가 가리키는 데이터 블록을 읽어 버퍼에 복사하고, 오프셋을 n만큼 전진시킨다.
3.2 세 개의 자료구조 계층
프로세스 A의 fd 테이블 열린 파일 테이블(커널 전역) inode 테이블
fd 3 -----------------> 오프셋=0, 모드=RDONLY --------> inode #4821
(크기, 블록포인터, 권한...)
프로세스 B의 fd 테이블
fd 5 -----------------> 오프셋=100, 모드=RDWR --------> inode #4821 (같은 파일)같은 파일을 두 프로세스가 각각 open하면 열린 파일 테이블 항목은 별도로 생기지만(오프셋이 독립적), inode는 공유한다. 반면 fork()로 자식 프로세스가 fd를 물려받으면 열린 파일 테이블 항목 자체를 공유하므로 오프셋도 함께 바뀐다.
3.3 하드링크와 심볼릭링크
- 하드링크: 같은 inode 번호를 가리키는 또 다른 디렉터리 엔트리. inode의 링크 카운트가 증가하며, 모든 하드링크가 삭제되어 카운트가 0이 될 때만 데이터 블록이 실제로 해제된다.
- 심볼릭링크: 별도의 inode를 가지며, 그 데이터에는 대상 경로 문자열이 저장된다. 대상 파일이 삭제되면 심볼릭링크는 깨진 링크(dangling link)가 된다.
4. 코드 예제
import os
# 파일 열기: 커널이 fd 테이블에 항목을 만들고 정수를 반환한다
fd = os.open("data.txt", os.O_RDWR | os.O_CREAT, 0o644)
print(f"file descriptor: {fd}") # 보통 3 (0,1,2는 stdin/stdout/stderr)
os.write(fd, b"hello os\n")
# 오프셋을 처음으로 되돌리지 않으면 write 이후 위치부터 읽는다
os.lseek(fd, 0, os.SEEK_SET)
data = os.read(fd, 1024)
print(data) # b'hello os\n'
# inode 번호 확인 (같은 inode = 같은 실제 파일)
stat_info = os.fstat(fd)
print("inode:", stat_info.st_ino, "link count:", stat_info.st_nlink)
os.close(fd)# 하드링크 생성: 같은 inode를 공유
echo "original" > file_a.txt
ln file_a.txt file_b.txt
stat -c '%i %n' file_a.txt file_b.txt
# 두 파일의 inode 번호가 동일하게 출력된다
# 심볼릭링크: 별도의 inode, 대상 경로만 저장
ln -s file_a.txt file_c.txt
stat -c '%i %n' file_c.txt
# file_c.txt는 다른 inode 번호를 가진다
# 하드링크 카운트 확인
ls -l file_a.txt
# -rw-r--r-- 2 user user ... file_a.txt <- 두 번째 필드(2)가 링크 카운트import os
# 두 프로세스가 같은 파일을 각각 open하면 오프셋은 독립적이다
fd1 = os.open("shared.txt", os.O_RDWR | os.O_CREAT)
fd2 = os.open("shared.txt", os.O_RDWR)
os.write(fd1, b"AAAA")
os.lseek(fd2, 0, os.SEEK_SET)
print(os.read(fd2, 4)) # b'AAAA' - 데이터는 공유되지만 오프셋은 별개
os.close(fd1)
os.close(fd2)5. 시간 복잡도 또는 성능 특성
- 경로 탐색(path resolution): 디렉터리를 순회하며 각 구성 요소마다 디스크 접근이 필요할 수 있다. 깊은 경로일수록 open() 지연이 커지며, 커널은 이를 완화하기 위해 dentry 캐시와 inode 캐시를 유지한다. 캐시 적중 시 메모리 접근만으로 처리되어 마이크로초 단위, 캐시 미스 시 디스크 I/O로 밀리초 단위까지 늘어난다.
- 순차 읽기 vs 임의 읽기: HDD 기준 순차 읽기는 디스크 헤드 이동(seek) 없이 이어지므로 수백 MB/s급 처리량을 낼 수 있지만, 임의 읽기는 매번 seek(평균 수 ms)이 발생해 처리량이 크게 떨어진다. SSD는 물리적 헤드가 없어 이 차이가 훨씬 작다.
- 버퍼링:
write()시스템 콜은 보통 즉시 디스크에 반영되지 않고 커널의 페이지 캐시에 먼저 쓰인다. 이 덕분에 반복적인 소량 쓰기의 지연이 크게 줄어들지만,fsync()를 호출하지 않으면 정전 시 유실될 수 있다. - inode 조회: inode 번호를 알고 있다면 조회는 인덱스 기반 접근에 가까워 상수 시간에 가깝다. 문제는 항상 "경로 → inode" 변환이 선행되어야 한다는 점이다.
6. 실무 사용 사례
- 로그 파일을 append 모드로 열 때
O_APPEND플래그를 쓰면 커널이 매 write마다 오프셋을 파일 끝으로 원자적으로 이동시켜, 여러 프로세스가 동시에 써도 로그가 섞이지 않는다. - 배포 스크립트에서 새 실행 파일을 원자적으로 교체할 때, 새 파일을 임시 이름으로 쓴 뒤
rename()으로 덮어쓴다. rename은 디렉터리 엔트리의 inode 포인터만 바꾸는 연산이라 원자적이며, 서비스가 파일을 읽는 도중에도 깨진 상태를 보지 않는다. - Nginx나 로그 로테이션 도구가 열려 있는 파일을 삭제해도 프로세스가 fd를 쥐고 있는 한 inode와 데이터 블록은 유지된다.
logrotate가 이 특성 때문에 종종copytruncate옵션을 쓰거나, 프로세스에 시그널을 보내 fd를 재오픈하게 한다.
7. 주의할 점
- 파일을 삭제(
rm)해도 어떤 프로세스가 여전히 fd로 열고 있다면 inode의 링크 카운트가 0이 되지 않아 디스크 공간이 즉시 회수되지 않는다. 디스크 사용량이 이상하게 줄지 않을 때lsof | grep deleted로 확인해볼 수 있다. - 파일 디스크립터는 프로세스당 개수 제한(
ulimit -n)이 있다. 열고 닫지 않는 습관은 "Too many open files" 오류로 이어진다. write()가 성공했다고 해서 데이터가 디스크에 physically 기록된 것은 아니다. 페이지 캐시에만 있을 수 있으므로 내구성이 중요한 데이터는fsync()를 명시적으로 호출해야 한다.- 심볼릭링크는 대상이 삭제되거나 이동해도 자동으로 갱신되지 않는다. 반면 하드링크는 대상이 이동해도(같은 파일 시스템 내에서) 깨지지 않는다. 다만 하드링크는 다른 파일 시스템(다른 디스크 파티션) 간에는 생성할 수 없다. 디렉터리에도 하드링크를 걸 수 없다.
8. 핵심 정리
파일은 "이름(디렉터리 엔트리) + 메타데이터/데이터(inode)"의 조합으로 구현되며, 프로세스는 파일 디스크립터라는 정수 핸들을 통해 커널이 관리하는 열린 파일 상태(오프셋 포함)에 접근한다. 이름과 데이터가 분리되어 있다는 사실이 하드링크, 원자적 rename, "삭제해도 열려 있으면 유지되는" 동작을 모두 설명한다. write() 이후 데이터의 실제 디스크 반영 시점은 페이지 캐시와 fsync에 달려 있다는 점을 이해하는 것이 내구성 있는 파일 처리의 핵심이다.