파일 시스템 — inode, 디렉터리, 디스크 배치와 캐시
1. 파일과 inode
앞 글까지는 메모리를 봤습니다. 가상 메모리 덕분에 프로세스는 넓은 주소 공간을 쓸 수 있었습니다. 그런데 메모리에는 큰 약점이 하나 있습니다. 전원이 꺼지면 내용이 전부 사라집니다. 오늘 작성한 보고서를 내일도 열려면 전원이 꺼져도 남는 곳에 적어 두어야 합니다. 그곳이 디스크 같은 저장 장치이고, 그 위에 데이터를 이름 붙여 담는 단위가 파일입니다. 운영체제에서 파일을 만들고 찾고 지우는 부분을 파일 시스템이라고 합니다. 시리즈의 마지막 주제입니다.
파일은 바이트가 한 줄로 늘어선 배열입니다. 운영체제는 그 안에 무엇이 들었는지 따지지 않습니다. 사진이든 글이든 똑같이 바이트의 줄입니다. 우리는 파일을 report.txt 같은 이름으로 부릅니다. 그런데 파일 시스템 안쪽에서는 파일마다 번호가 하나 붙습니다. 이 번호가 가리키는 것이 색인 노드입니다. 영어로 inode라고 씁니다. inode는 파일 하나에 대한 정보를 모아 둔 작은 기록입니다. 크기, 주인, 권한, 그리고 내용이 디스크 어디에 있는지가 여기 적힙니다. 파일 내용과 파일에 대한 정보는 이렇게 따로 보관됩니다. 파일에 대한 정보를 담은 이런 구조를 일반적으로는 파일 제어 블록이라고 부르고, 유닉스에서는 inode가 그 역할을 합니다.
파일의 속성
inode에 적힌 정보를 속성이라고 합니다. 프로그램은 stat이라는 시스템 콜로 이 속성을 읽습니다.
- inode 번호, 바이트 단위의 크기
- 주인 사용자와 그룹, 누가 읽고 쓰고 실행할 수 있는지 적은 권한
- 이 파일을 가리키는 이름이 몇 개인지 세는 링크 수
- 시각 세 가지 — 마지막으로 읽은 때, 내용을 마지막으로 고친 때, 속성을 마지막으로 바꾼 때
이 가운데 권한은 chmod, 주인은 chown이라는 시스템 콜로 바꿉니다.
2. 파일을 여닫는 연산
open으로 파일을 열면 운영체제가 작은 정수 하나를 돌려줍니다. 이 번호를 파일 디스크립터라고 합니다. 이후의 read와 write는 이름 대신 이 번호로 파일을 가리킵니다. 열린 파일에는 지금 읽고 쓰는 위치가 따라다니는데, 이것을 오프셋이라고 합니다. read로 100바이트를 읽으면 오프셋이 100만큼 파일 끝 쪽으로 나아갑니다. lseek은 이 오프셋을 원하는 자리로 옮깁니다. 다 쓰고 나면 close로 닫습니다.
한 가지 주의할 점이 있습니다. write가 끝났다고 곧바로 디스크에 적힌 것은 아닙니다. 운영체제는 쓴 내용을 메모리에 잠시 모았다가 나중에 한꺼번에 디스크로 보냅니다. 데이터베이스처럼 지금 당장 확실히 남아야 하는 프로그램은 fsync를 부릅니다. fsync는 모아 둔 내용을 즉시 디스크에 적게 합니다.
열린 파일 표
파일을 열면 운영체제 안에 기록이 두 겹으로 생깁니다. 하나는 시스템 전체에 하나 있는 열린 파일 표입니다. open을 한 번 부를 때마다 칸이 하나씩 생기고, 칸에는 오프셋과 메모리로 읽어 온 그 파일의 inode, 그리고 이 칸을 가리키는 수가 들어 있습니다. 다른 하나는 프로세스마다 가진 디스크립터 표입니다. 디스크립터 번호로 이 표를 찾으면 시스템 전체 표의 칸을 가리키는 화살표가 나옵니다.
3편에서 본 fork로 자식 프로세스를 만들면 자식은 부모의 디스크립터 표를 그대로 물려받습니다. 그래서 부모와 자식이 같은 칸, 같은 오프셋을 함께 씁니다. 자식이 lseek으로 오프셋을 10으로 옮기면, 부모가 나중에 확인한 오프셋도 10입니다.
3. 디렉터리
이름에서 inode로 가는 길을 쥔 것이 디렉터리입니다. 디렉터리도 파일입니다. 내용은 사람이 읽는 이름과 inode 번호의 짝을 줄줄이 적은 목록입니다. 모든 디렉터리에는 .(점 하나)과 ..(점 두 개)이라는 항목이 있습니다. 점 하나는 자기 자신, 점 두 개는 한 단계 위 디렉터리를 가리킵니다.
디렉터리 안에 디렉터리를 넣을 수 있어서 전체는 나무 모양이 됩니다. 맨 꼭대기를 루트 디렉터리라고 하고 빗금 하나(/)로 씁니다. 루트에서 파일까지 이름을 빗금으로 이어 쓴 것이 경로입니다. /foo/bar.txt와 /bar/foo/bar.txt는 이름 끝이 같아도 경로가 달라서 서로 다른 파일입니다. 프로그램이 디렉터리를 읽을 때는 read 대신 opendir와 readdir라는 함수로 한 항목씩 꺼냅니다.
4. 하드 링크와 심볼릭 링크
하드 링크와 지우기
디렉터리 항목은 이름과 inode 번호의 짝일 뿐입니다. 그러니 같은 inode 번호를 적은 항목을 하나 더 만들 수 있습니다. 이것을 하드 링크라고 하고, link라는 시스템 콜로 만듭니다. file을 file2로 하드 링크하면 두 이름은 완전히 대등합니다. 어느 쪽이 원본인지 구별할 수도 없습니다. 이때 inode의 링크 수가 1에서 2로 오릅니다.
그러면 파일을 지우는 일은 어떻게 될까요. 지우는 시스템 콜 이름이 unlink인 데 답이 있습니다. unlink는 이름 하나를 디렉터리에서 떼어 내고 링크 수를 1 줄입니다. 링크 수가 0이 되고 파일을 연 프로세스도 없을 때에야 inode와 데이터 블록을 실제로 풀어 줍니다. 이름을 세 개 만들었다면 세 번 지워야 파일이 사라집니다.
심볼릭 링크
하드 링크에는 한계가 둘 있습니다. 디렉터리에는 걸 수 없고, 다른 디스크에 있는 파일에도 걸 수 없습니다. inode 번호는 그 번호를 매긴 디스크 안에서만 뜻이 있기 때문입니다. 그래서 심볼릭 링크가 있습니다. 심볼릭 링크는 자기 inode를 따로 가진 작은 파일이고, 내용으로 대상의 경로를 적어 둡니다. 여는 순간 그 경로를 따라가는 방식입니다. 경로만 적으니 디렉터리에도, 다른 디스크의 파일에도 걸 수 있습니다. 대신 대상 파일을 지우면 링크는 없는 경로를 가리킨 채 남습니다. 이것을 끊어진 링크라고 합니다.
하드 링크는 inode를 함께 쓰고, 심볼릭 링크는 경로를 적어 둔다는 점이 두 링크를 가릅니다.
5. 저장 장치와 블록
이제 이 파일들이 실제로 놓이는 곳으로 내려갑니다. 요즘 컴퓨터의 저장 장치는 대개 하드 디스크 아니면 SSD 같은 비휘발성 메모리입니다. 운영체제는 두 장치를 똑같은 모양으로 봅니다. 번호가 매겨진 블록들이 한 줄로 늘어선 배열입니다. 블록은 한 번에 읽고 쓰는 가장 작은 단위입니다. 블록 번호를 논리 블록 주소라고 하고, 영어 약자로 LBA라고 씁니다. 번호를 실제 원판의 어느 자리, 칩의 어느 칸으로 바꾸는 일은 장치가 맡습니다.
디스크 하나는 여러 구역으로 나눌 수 있고, 이 구역을 파티션이라고 합니다. 파일 시스템이 올라간 구역은 볼륨이라고 부릅니다. 볼륨은 쓰기 전에 디렉터리 나무의 한 자리에 붙여야 합니다. 이것을 마운트라고 하고, 붙인 자리를 마운트 지점이라고 합니다.
6. 작은 파일 시스템 설계하기
작은 파일 시스템을 하나 직접 설계해 봅니다. 블록 하나는 4킬로바이트, 블록은 모두 64개이고 0번부터 63번까지 번호를 붙입니다.
| 블록 | 용도 |
|---|---|
| 0 | 슈퍼블록 |
| 1 | inode 비트맵 |
| 2 | 데이터 비트맵 |
| 3–7 | inode 표 (5블록) |
| 8–63 | 데이터 영역 (56블록) |
가장 많은 자리는 파일 내용에 줍니다. 8번부터 63번까지 56개 블록이 데이터 영역입니다. 다음은 inode를 둘 자리입니다. 3번부터 7번까지 다섯 블록을 inode 표로 씁니다. inode 하나를 256바이트로 잡으면 4킬로바이트 블록 하나에 16개가 들어갑니다. 다섯 블록이면 inode가 80개입니다. inode 하나가 파일 하나이니, 이 파일 시스템에는 파일을 80개까지만 만들 수 있습니다.
비트맵과 슈퍼블록
남은 0번부터 2번 블록에는 관리용 정보를 둡니다. 먼저 어느 inode와 어느 데이터 블록이 비어 있는지 알아야 합니다. 여기에 비트맵을 씁니다. 비트맵은 칸마다 비트 하나를 두고, 비었으면 0, 쓰는 중이면 1을 적는 표입니다. inode용 비트맵은 1번 블록, 데이터 블록용 비트맵은 2번 블록에 둡니다. inode는 80개이니 80비트, 곧 10바이트면 충분합니다. 블록 하나에는 3만 2천 비트가 넘게 들어가니 여유가 많습니다.
맨 앞 0번 블록은 슈퍼블록입니다. 슈퍼블록에는 이 파일 시스템 전체의 정보가 있습니다. inode가 몇 개인지, inode 표가 어디서 시작하는지 같은 것입니다. 운영체제는 마운트할 때 슈퍼블록부터 읽고 나머지 배치를 알아냅니다.
7. inode 번호로 자리 찾기
배치가 정해지면 inode 번호만으로 그 inode가 디스크 어디에 있는지 계산할 수 있습니다. 32번 inode를 찾아 봅니다. 먼저 inode 표 안에서 얼마나 들어가야 하는지 구합니다. 32 곱하기 256바이트는 8192바이트, 곧 8킬로바이트입니다. inode 표는 3번 블록, 곧 12킬로바이트 지점에서 시작합니다. 그러니 32번 inode는 12 더하기 8, 20킬로바이트 지점에 있습니다. 블록 하나가 4킬로바이트이니 20을 4로 나누면 5번 블록의 맨 앞입니다. inode 표 안에서 0번부터 세면 2번 블록입니다. 디스크는 블록 단위로 읽으니, 실제로는 5번 블록을 통째로 읽어서 그 맨 앞 256바이트를 꺼냅니다.
연습문제 1
배치는 같습니다. 블록은 4킬로바이트이고, inode는 256바이트입니다. inode 표는 3번 블록, 12킬로바이트 지점에서 시작합니다. 70번 inode는 디스크의 몇 킬로바이트 지점에 있습니까. 그리고 몇 번 블록에 들어 있습니까. 표 안에서 들어가는 거리부터 구해 보십시오.
풀이
70 곱하기 256은 17920바이트입니다. 1024로 나누면 17.5킬로바이트입니다. inode 표가 12킬로바이트 지점에서 시작하니, 여기에 17.5를 더해 29.5킬로바이트가 답입니다. 블록 번호는 29.5를 4로 나눈 몫이니 7입니다. 7번 블록은 28킬로바이트에서 시작하므로, 그 안에서 1.5킬로바이트 들어간 자리입니다.
다르게 셀 수도 있습니다. 블록 하나에 inode가 16개이니 70을 16으로 나누면 몫이 4, 나머지가 6입니다. 몫 4는 inode 표의 4번 블록, 곧 디스크의 7번 블록이라는 뜻입니다. 나머지 6은 그 블록 안에서 앞에 inode가 여섯 개 있다는 뜻입니다. 6 곱하기 256이 1.5킬로바이트이니 두 계산이 맞아떨어집니다.
8. 내용 블록을 가리키는 법
이제 inode에서 파일 내용으로 갑니다. 파일 내용을 데이터 블록에 어떻게 배치할지가 할당 방식입니다. 메모리의 연속 할당처럼 파일마다 이어진 블록 한 덩어리를 주면 어떨까요. 파일이 생기고 지워지다 보면 디스크에도 외부 단편화가 생깁니다. 파일이 커질 때 뒤쪽 블록이 이미 차 있으면 옮겨야 합니다.
그래서 페이징과 같은 생각을 씁니다. 블록은 아무 데나 흩어 두고, 파일의 블록 번호를 차례로 목록에 적어 둡니다. 이 방식을 인덱스 할당이라고 합니다. 목록만 담아 두는 블록은 인덱스 블록이라고 부릅니다. 유닉스는 목록의 앞부분을 inode 안에 바로 적습니다. 이 칸들을 직접 포인터라고 합니다. 작은 파일은 직접 포인터만으로 끝나서 인덱스 블록을 따로 둘 필요가 없습니다.
간접 포인터
큰 파일은 직접 포인터만으로 모자랍니다. 그래서 inode에는 간접 포인터가 있습니다. 단일 간접 포인터는 데이터 블록이 아니라 주소만 가득 적힌 블록 하나를 가리킵니다. 이중 간접 포인터는 그런 주소 블록들의 주소를 적은 블록을 가리킵니다. 삼중은 한 단계 더 들어갑니다.
수치로 가늠해 봅니다. 직접 포인터가 12개이고 블록 주소 하나가 4바이트라고 합시다. 직접 포인터만으로는 12 곱하기 4킬로바이트, 48킬로바이트까지 담습니다. 4킬로바이트 블록에는 주소가 1024개 들어갑니다. 그래서 단일 간접 포인터 하나가 4메가바이트를 더합니다. 이중 간접은 1024의 제곱개 블록이니 4기가바이트입니다. 삼중 간접까지 쓰면 4테라바이트 수준이 됩니다. 작은 파일은 빨리 찾고 큰 파일도 담을 수 있는 구조입니다.
9. 파일 하나를 읽는 길
지금까지 만든 구조로 /foo/bar 파일을 열고 읽으며 디스크에 몇 번 가는지 세어 봅니다. 읽든 쓰든 한 번 갈 때마다 하나로 셉니다.
open은 경로를 앞에서부터 따라갑니다. 루트 디렉터리의 inode 번호는 미리 정해져 있어서 루트 inode부터 읽습니다. 그 inode로 루트의 데이터 블록을 찾아 읽고, 거기서 foo의 inode 번호를 얻습니다. foo의 inode를 읽고, foo의 데이터 블록을 읽어 bar의 inode 번호를 얻습니다. 마지막으로 bar의 inode를 읽습니다. open 한 번에 디스크 읽기가 다섯 번입니다.
그다음 read로 블록 하나를 읽을 때마다 세 번 디스크에 갑니다. bar의 inode를 읽어 블록 위치를 찾고, 그 데이터 블록을 읽고, 마지막으로 읽은 시각을 고쳐 inode를 다시 씁니다.
연습문제 2
경로가 한 단계 깊어졌습니다. /a/b/c 파일을 열고, 블록 4개를 차례로 읽습니다. 규칙은 방금과 같습니다. open은 경로의 디렉터리마다 inode와 데이터 블록을 읽고, 마지막 파일은 inode만 읽습니다. read는 블록 하나에 세 번 디스크에 갑니다. 캐시는 없다고 합니다. 모두 몇 번 디스크에 갑니까.
풀이
먼저 open입니다. 디렉터리는 루트와 a와 b, 셋입니다. 셋 모두 inode와 데이터 블록을 읽으니 여섯 번입니다. 여기에 c의 inode 한 번을 더해 open은 일곱 번입니다. 다음은 read입니다. 블록 하나에 세 번이고 블록이 4개이니 12번입니다. 합치면 19번입니다.
쓰기는 더 비쌉니다. 새 파일 /foo/bar를 만드는 데만 열 번 디스크에 갑니다. 빈 inode를 찾으려고 inode 비트맵을 읽고 쓰는 일, 디렉터리에 새 항목을 적는 일이 더해지기 때문입니다. 블록 하나를 쓸 때도 데이터 비트맵을 읽고 쓰는 일이 붙어 다섯 번입니다.
10. 캐시로 줄이기
이렇게 디스크를 자주 가면 느립니다. 그래서 캐시를 여기서도 씁니다. 최근에 읽거나 쓴 디스크 블록을 메모리에 남겨 두는 버퍼 캐시입니다. 자리가 차면 앞 글에서 본 LRU, 가장 오랫동안 손대지 않은 블록부터 내보냅니다.
예전에는 메모리의 10%쯤을 버퍼 캐시로 딱 떼어 두었습니다. 그런데 이렇게 고정해 두면 파일을 거의 안 쓸 때도 그 자리가 놀고, 파일을 많이 쓸 때는 모자랍니다. 그래서 요즘은 가상 메모리와 버퍼 캐시를 합친 페이지 캐시를 씁니다. 물리 프레임 하나에 프로세스의 페이지가 들어갈 수도, 파일 블록이 들어갈 수도 있습니다. 둘의 몫은 그때그때 쓰는 양에 따라 달라집니다. 앞에서 write가 곧바로 디스크에 닿지 않는다고 했습니다. 쓴 내용이 잠시 머무는 곳이 바로 이 캐시입니다.
정리
파일은 바이트의 줄이고, inode가 그 파일의 속성과 블록 위치를 적어 둡니다. 디렉터리는 이름과 inode 번호의 짝이고, 경로를 따라 inode를 차례로 읽어 파일에 닿습니다. 디스크 위에는 슈퍼블록, 비트맵, inode 표, 데이터 영역이 놓이고, inode의 직접 포인터와 간접 포인터가 흩어진 블록을 잇습니다.
열두 편 동안 운영체제가 한 일은 결국 한 가지였습니다. 프로세서, 메모리, 저장 장치라는 한정된 하드웨어를 여러 프로그램이 안전하게 나눠 쓰게 하는 것입니다. 각자 기계를 혼자 쓰는 것처럼 보이게 하는 그 장치들을 이제 하나씩 떠올릴 수 있을 것입니다.