[CS300 #115] 아이노드와 디렉터리 — 파일의 정체와 이름은 따로 산다
컴퓨터공학 300 주제 시리즈의 115번째 글이다. 전체 지도는 여기.
한 줄 요약
유닉스 계열 파일 시스템에서 파일의 실체는 번호로 식별되는 아이노드(메타데이터 + 데이터 위치)이고, 디렉터리는 “이름 → 아이노드 번호” 표를 담은 특수한 파일일 뿐이어서, 하나의 파일이 여러 이름을 가질 수도(하드 링크) 이름 없이 살아 있을 수도 있다.
왜 필요한가
다음 현상들은 모두 “이름과 파일은 다르다”는 한 가지 사실에서 나온다.
rm으로 큰 로그를 지웠는데 디스크 공간이 돌아오지 않는다.mv는 같은 파일 시스템 안에서 수 GB 파일도 즉시 끝나는데, 다른 파일 시스템으로는 한참 걸린다.- 설정 파일을 에디터로 고쳤더니 그 파일을 감시하던 프로그램이 변경을 못 알아챈다.
- 디렉터리에 파일이 수백만 개 있으면
ls가 느리다.
아이노드와 디렉터리 구조를 알면 이 현상들이 모두 예측 가능해진다.
핵심 개념
아이노드에 들어 있는 것
아이노드(inode, index node)는 파일 하나에 대한 메타데이터 구조체다. 파일 시스템 안에서 아이노드 번호로 식별된다.
| 들어 있는 것 | 예 |
|---|---|
| 파일 종류와 권한 | 일반 파일/디렉터리/심볼릭 링크/장치, rwxr-xr-x |
| 소유자 | UID, GID |
| 크기 | 바이트 수 |
| 시각 | atime(접근), mtime(내용 수정), ctime(아이노드 변경) |
| 링크 수 | 이 아이노드를 가리키는 디렉터리 항목 수 |
| 데이터 위치 | 블록 포인터 또는 익스텐트 |
들어 있지 않은 것이 중요하다. 바로 파일 이름이다. 이름은 디렉터리에 있다. inode(7) 매뉴얼이 이 필드들을 stat 구조체와 함께 정리하고 있다.
디렉터리는 표다
디렉터리도 아이노드를 가진 파일이다. 그 내용이 이름과 아이노드 번호의 목록일 뿐이다.
디렉터리 /home/u (아이노드 500) 의 내용
+------------+--------------+
| 이름 | 아이노드 번호 |
+------------+--------------+
| . | 500 | 자기 자신
| .. | 2 | 부모
| notes.txt | 7731 |
| backup.txt | 7731 | 같은 아이노드를 가리키는 두 번째 이름
| docs | 812 | 하위 디렉터리
+------------+--------------+
항목이 적을 때는 단순 선형 목록이면 되지만, 수만~수백만 개가 되면 이름 찾기가 느려진다. ext4 는 이를 위해 이름의 해시로 정렬한 트리 인덱스(htree)를 디렉터리에 둘 수 있다. 커널의 ext4 문서에 형식이 정리되어 있다.
경로 탐색
open("/home/u/notes.txt") 를 하면 커널은
- 루트 디렉터리 아이노드(ext 계열에서는 2번)에서 시작해
home을 찾고, - 그 아이노드의 데이터에서
u를 찾고, - 다시
notes.txt를 찾아 아이노드 7731 에 도달한다.
각 단계마다 디렉터리 실행 권한(x, 탐색 권한)을 검사한다. 이 과정을 매번 디스크에서 하면 느리므로, 리눅스는 이름→아이노드 결과를 dentry 캐시에 보관한다. 상세 규칙은 path_resolution(7) 에 있다.
하드 링크와 심볼릭 링크
| 하드 링크 | 심볼릭 링크 | |
|---|---|---|
| 정체 | 같은 아이노드를 가리키는 또 하나의 디렉터리 항목 | 대상 경로 문자열을 내용으로 가진 별도 아이노드 |
| 링크 수 증가 | 한다 | 하지 않는다 |
| 원본 이름 삭제 시 | 데이터 그대로, 다른 이름으로 접근 가능 | 끊어진 링크(dangling)가 된다 |
| 파일 시스템 경계 | 넘을 수 없다(아이노드 번호는 FS 안에서만 의미) | 넘을 수 있다 |
| 디렉터리 대상 | 일반적으로 금지(순환 방지) | 가능 |
삭제의 진짜 의미: unlink
rm 이 부르는 시스템 콜은 unlink 다. 이름은 “삭제”가 아니라 디렉터리 항목 하나를 지우고 링크 수를 1 줄이는 것이다. 커널은 다음 두 조건이 모두 성립할 때에만 아이노드와 데이터 블록을 실제로 해제한다.
- 링크 수가 0 이다.
- 그 파일을 열고 있는 프로세스가 없다.
그래서 프로세스가 열고 있는 로그 파일을 지우면 이름은 사라지지만 공간은 그대로다.
rename 은 원자적이다
같은 파일 시스템 안의 rename 은 디렉터리 항목만 바꾸므로 파일 크기와 무관하게 빠르고, POSIX 는 대상 이름이 이미 있을 때 그 교체가 원자적이도록 규정한다. “임시 파일에 쓰고 fsync 한 뒤 rename 으로 덮어쓰기”가 설정 파일을 안전하게 바꾸는 표준 방법인 이유다. 다른 파일 시스템으로의 mv 는 rename 이 EXDEV 로 실패하므로 복사 후 삭제로 처리된다.
직접 해 보기
하드 링크, 심볼릭 링크, 열린 채로 지운 파일을 차례로 관찰한다.
import os, tempfile, stat
d = tempfile.mkdtemp(); os.chdir(d)
open("a.txt", "w").write("hello\n")
os.link("a.txt", "b.txt") # 하드 링크: 같은 아이노드에 이름 하나 더
os.symlink("a.txt", "s.txt") # 심볼릭 링크: 경로 문자열을 담은 별도 아이노드
def info(name):
s = os.lstat(name) # lstat: 링크를 따라가지 않는다
kind = "symlink" if stat.S_ISLNK(s.st_mode) else "file"
print(f"{name}: inode {s.st_ino} links {s.st_nlink} type {kind} size {s.st_size}")
for n in ("a.txt", "b.txt", "s.txt"): info(n)
print("디렉터리 항목 :", [(e.name, e.inode()) for e in os.scandir(".")])
print("'.' 의 링크 수:", os.stat(".").st_nlink)
os.unlink("a.txt") # 원래 이름 삭제
print("a 삭제 후 b 읽기:", open("b.txt").read().strip(), "/ links", os.stat("b.txt").st_nlink)
print("심볼릭 링크 따라가기:", os.path.exists("s.txt"), "(대상 이름이 사라짐)")
# 열린 채로 지운 파일: 이름은 없지만 아이노드와 데이터는 살아 있다
f = open("b.txt")
os.unlink("b.txt")
print("이름 다 지운 뒤 디렉터리:", os.listdir("."), "/ 열린 fd 로 읽기:", f.read().strip(),
"/ links", os.fstat(f.fileno()).st_nlink)
print("/proc 에서 보이는 모습:", os.readlink(f"/proc/self/fd/{f.fileno()}"))
f.close()
ext4 에서의 결과다.
a.txt: inode 3801115 links 2 type file size 6
b.txt: inode 3801115 links 2 type file size 6
s.txt: inode 3801116 links 1 type symlink size 5
디렉터리 항목 : [('a.txt', 3801115), ('b.txt', 3801115), ('s.txt', 3801116)]
'.' 의 링크 수: 2
a 삭제 후 b 읽기: hello / links 1
심볼릭 링크 따라가기: False (대상 이름이 사라짐)
이름 다 지운 뒤 디렉터리: ['s.txt'] / 열린 fd 로 읽기: hello / links 0
/proc 에서 보이는 모습: /tmp/tmpvs6q7hbm/b.txt (deleted)
a.txt와b.txt는 아이노드 번호가 같다. 둘은 “원본과 사본”이 아니라 대등한 두 이름이다.- 심볼릭 링크의 크기 5 는 담고 있는 문자열
a.txt의 길이다. - 빈 디렉터리의 링크 수 2 는 부모 디렉터리의 항목과 자기 안의
.이다(ext4 기준, 하위 디렉터리가 생기면 각자의..만큼 늘어난다). - 마지막 장면이 핵심이다. 이름이 하나도 없고 링크 수가 0 인데 열린 파일 디스크립터로 내용을 읽을 수 있다.
/proc/self/fd는 이 상태를(deleted)로 표시한다.
현업에서는
- 지웠는데 공간이 안 돌아올 때:
lsof +L1은 링크 수가 1 미만(즉 0)인데 열려 있는 파일을 보여 준다. 로그 로테이션 후 애플리케이션이 옛 파일을 계속 쥐고 있는 경우가 대부분이다. 프로세스에 로그 재열기 시그널을 보내거나(많은 데몬이SIGHUP을 쓴다), 급하면/proc/PID/fd/N을truncate해서 공간을 되찾는다. - 쿠버네티스 ConfigMap 볼륨의 심볼릭 링크: ConfigMap 을 볼륨으로 마운트하면 파일들이 숨김 디렉터리를 가리키는 심볼릭 링크로 보이고, 갱신 시 디렉터리 링크를 원자적으로 바꿔 끼운다. 그래서 파일의 아이노드를 직접 감시하는 프로그램은 변경을 놓칠 수 있고,
subPath로 마운트한 파일은 아예 갱신되지 않는다는 제약이 문서에 적혀 있다. - 원자적 설정 교체: 임시 파일 작성 →
fsync→rename패턴을 쓰면 읽는 쪽은 옛 파일 아니면 새 파일만 보고, 반쯤 쓰인 파일은 절대 보지 않는다. - 아이노드 고갈: 작은 파일을 대량으로 만드는 워크로드(빌드 캐시, 메일 큐)는
df -i를 모니터링 대상에 넣는다.
확인 문제
- 아이노드에 저장되지 않는 대표적인 파일 정보는 무엇이며, 어디에 저장되는가?
- 하드 링크가 다른 파일 시스템의 파일을 가리킬 수 없는 이유는?
- 열린 로그 파일을
rm했는데 공간이 돌아오지 않는 이유를 링크 수와 연결해 설명하라. - 같은 파일 시스템 안에서 10GB 파일의
mv가 즉시 끝나는 이유는? - 심볼릭 링크의 대상 파일을 지우면 어떻게 되는가?
풀이
- 파일 이름이다. 이름은 부모 디렉터리의 데이터(이름 → 아이노드 번호 항목)에 저장된다.
- 하드 링크는 아이노드 번호를 가리키는데, 아이노드 번호는 각 파일 시스템 안에서만 고유하기 때문이다.
rm은 링크 수를 0 으로 만들 뿐이고, 커널은 링크 수 0 과 “여는 프로세스 없음”이 모두 만족될 때만 블록을 해제한다. 프로세스가 아직 열고 있으므로 해제되지 않는다.rename은 디렉터리 항목만 바꾸고 데이터 블록은 옮기지 않기 때문이다.- 심볼릭 링크는 경로 문자열만 가지고 있으므로 그대로 남지만, 따라가면 대상이 없어 끊어진 링크가 된다.
더 읽을거리 (References)
- OSTEP, Interlude: Files and Directories (PDF)
- Linux man-pages, inode(7), path_resolution(7)
- POSIX.1-2024, rename()
- Linux kernel documentation, ext4 Directory Entries