이미지 로딩 중...
AI Generated
2025. 11. 13. · 2 Views
Rust로 만드는 나만의 OS 12: VFS 추상화 완벽 가이드
운영체제 개발에서 가장 중요한 VFS(Virtual File System) 추상화를 Rust로 구현하는 방법을 다룹니다. 다양한 파일시스템을 통합 관리하고, 안전하고 확장 가능한 VFS 레이어를 설계하는 실전 기법을 배워보세요.
목차
- VFS 개념 - 파일시스템 추상화의 핵심
- INode 추상화 - 파일 메타데이터 관리
- 파일 디스크립터 테이블 - 프로세스별 파일 관리
- VFS 마운트 테이블 - 파일시스템 계층 구조
- Path 정규화 - 안전한 경로 처리
- 파일 권한 검사 - 안전한 접근 제어
- 버퍼 캐시 - 파일시스템 성능 최적화
- VFS 통합 인터페이스 - 시스템 콜 구현
1. VFS 개념 - 파일시스템 추상화의 핵심
시작하며
여러분이 운영체제를 개발할 때 이런 상황을 겪어본 적 있나요? ext4, FAT32, NTFS 등 각각 다른 파일시스템마다 별도의 코드를 작성해야 하고, 새로운 파일시스템을 추가할 때마다 전체 시스템을 수정해야 하는 상황 말입니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 각 파일시스템의 인터페이스가 다르면 상위 레이어 코드가 복잡해지고, 유지보수가 어려워집니다.
특히 새로운 스토리지 디바이스나 네트워크 파일시스템을 추가할 때마다 시스템 전체를 재설계해야 한다면 개발 생산성이 크게 떨어집니다. 바로 이럴 때 필요한 것이 VFS(Virtual File System)입니다.
VFS는 다양한 파일시스템 구현체들을 하나의 통합된 인터페이스로 추상화하여, 상위 레이어가 파일시스템의 구체적인 구현을 알 필요 없게 만들어줍니다.
개요
간단히 말해서, VFS는 파일시스템들의 공통 인터페이스를 정의하고, 각 파일시스템이 이 인터페이스를 구현하도록 하는 추상화 계층입니다. 왜 VFS가 필요한지 실무 관점에서 설명하자면, 운영체제는 수십 가지의 다른 파일시스템을 지원해야 하는데, 각각에 대해 별도의 처리 로직을 작성하면 코드 중복이 심해지고 버그가 발생하기 쉽습니다.
예를 들어, 파일 읽기 시스템 콜을 구현할 때 VFS가 없다면 ext4용, FAT32용, NTFS용 코드를 각각 작성해야 하지만, VFS가 있다면 하나의 통합된 코드만 작성하면 됩니다. 전통적인 방법과의 비교를 해보면, 기존에는 각 파일시스템마다 직접 호출하는 코드를 작성했다면, 이제는 VFS 인터페이스를 통해 간접적으로 호출하여 느슨한 결합을 만들 수 있습니다.
VFS의 핵심 특징은 첫째, 파일시스템 독립성을 제공하여 상위 레이어가 구현 세부사항을 몰라도 되게 하고, 둘째, 확장성을 제공하여 새로운 파일시스템을 쉽게 추가할 수 있으며, 셋째, 일관된 API를 제공하여 개발자 경험을 향상시킵니다. 이러한 특징들이 중요한 이유는 운영체제의 유지보수성과 확장성을 크게 향상시키기 때문입니다.
코드 예제
// VFS의 핵심 인터페이스를 Trait으로 정의
pub trait FileSystem {
// 파일 열기 - 경로를 받아 파일 디스크립터 반환
fn open(&self, path: &str, flags: OpenFlags) -> Result<FileDescriptor, Error>;
// 파일 읽기 - 버퍼에 데이터를 채움
fn read(&self, fd: FileDescriptor, buf: &mut [u8]) -> Result<usize, Error>;
// 파일 쓰기 - 버퍼의 데이터를 파일에 기록
fn write(&self, fd: FileDescriptor, buf: &[u8]) -> Result<usize, Error>;
// 파일 닫기 - 리소스 해제
fn close(&self, fd: FileDescriptor) -> Result<(), Error>;
// 디렉토리 생성 - 새로운 디렉토리 생성
fn mkdir(&self, path: &str, mode: u32) -> Result<(), Error>;
// 파일 정보 조회 - 메타데이터 반환
fn stat(&self, path: &str) -> Result<FileStat, Error>;
}
설명
이것이 하는 일: VFS Trait은 모든 파일시스템이 구현해야 하는 공통 인터페이스를 정의합니다. 이를 통해 상위 레이어는 구체적인 파일시스템 구현을 알 필요 없이 일관된 방식으로 파일을 다룰 수 있습니다.
첫 번째로, open 메서드는 파일 경로와 플래그를 받아 파일 디스크립터를 반환합니다. 여기서 Result 타입을 사용하는 이유는 파일이 존재하지 않거나 권한이 없는 등의 에러 상황을 안전하게 처리하기 위함입니다.
Rust의 타입 시스템이 컴파일 타임에 에러 처리를 강제하므로, 런타임 패닉을 방지할 수 있습니다. 그 다음으로, read와 write 메서드가 실행되면서 실제 데이터 입출력을 담당합니다.
read는 가변 버퍼를 받아 데이터를 채우고 읽은 바이트 수를 반환하며, write는 불변 버퍼의 데이터를 파일에 기록합니다. 내부에서는 각 파일시스템의 구체적인 구현(예: ext4의 블록 읽기, FAT32의 클러스터 체인 탐색)이 이루어지지만, 호출자는 이를 알 필요가 없습니다.
마지막으로, mkdir와 stat 같은 메타데이터 관련 메서드들이 파일시스템 관리 기능을 제공하여 최종적으로 완전한 파일시스템 인터페이스를 만들어냅니다. stat은 파일 크기, 생성 시간, 권한 등의 메타데이터를 FileStat 구조체로 반환합니다.
여러분이 이 코드를 사용하면 다양한 파일시스템을 동일한 방식으로 다룰 수 있고, 새로운 파일시스템을 추가할 때도 이 Trait만 구현하면 되므로 확장성이 뛰어납니다. 실무에서의 이점으로는 코드 재사용성 증가, 테스트 용이성(Mock 파일시스템 구현 가능), 타입 안전성 보장 등이 있습니다.
실전 팁
💡 VFS Trait을 설계할 때는 최소한의 필수 메서드만 정의하고, 나머지는 Default 구현을 제공하세요. 이렇게 하면 각 파일시스템이 필요한 부분만 오버라이드할 수 있습니다.
💡 Result<T, Error> 반환 타입을 일관되게 사용하세요. 흔한 실수는 Option을 섞어 쓰는 것인데, 에러 원인을 명확히 전달하기 위해서는 항상 Result를 사용하는 것이 좋습니다.
💡 성능을 위해 비동기 I/O를 고려한다면 async fn을 사용하거나, async-trait 크레이트를 활용하세요. 하지만 커널 레벨에서는 아직 표준 라이브러리의 async가 불안정하므로 신중히 선택해야 합니다.
💡 디버깅 시에는 각 메서드 호출마다 로깅을 추가하세요. #[cfg(feature = "vfs-debug")]를 사용하여 디버그 빌드에서만 로깅이 활성화되도록 할 수 있습니다.
💡 더 발전된 사용법으로는 VFS 위에 캐싱 레이어를 추가하는 것입니다. Decorator 패턴을 사용하여 FileSystem Trait을 구현하는 CachedFileSystem을 만들면 성능을 크게 향상시킬 수 있습니다.
2. INode 추상화 - 파일 메타데이터 관리
시작하며
여러분이 파일시스템을 구현할 때 이런 고민을 해본 적 있나요? 파일의 이름, 크기, 권한, 타임스탬프 등 다양한 메타데이터를 어떻게 효율적으로 관리할 것인가 하는 문제입니다.
이런 문제는 실제 개발 현장에서 매우 중요합니다. 파일 메타데이터를 잘못 설계하면 파일 조회 성능이 떨어지고, 메모리 사용량이 증가하며, 동시성 제어가 어려워집니다.
특히 수백만 개의 파일을 관리하는 서버 환경에서는 INode 설계가 시스템 성능을 좌우합니다. 바로 이럴 때 필요한 것이 INode 추상화입니다.
Unix 계열 시스템에서 사용하는 INode 개념을 Rust로 구현하여, 파일 메타데이터를 효율적이고 안전하게 관리할 수 있습니다.
개요
간단히 말해서, INode는 파일시스템에서 파일이나 디렉토리의 메타데이터를 저장하는 자료구조입니다. 실제 파일 이름은 디렉토리 엔트리에 저장되고, INode는 파일의 크기, 권한, 소유자, 타임스탬프, 데이터 블록 위치 등을 담고 있습니다.
왜 INode가 필요한지 실무 관점에서 설명하자면, 하나의 파일을 여러 이름(하드 링크)으로 참조할 수 있어야 하고, 파일 메타데이터와 실제 데이터를 분리하여 효율적으로 관리해야 하기 때문입니다. 예를 들어, /usr/bin/python과 /usr/bin/python3가 같은 파일을 가리킬 때, 두 개의 데이터 복사본을 만드는 대신 하나의 INode를 공유하면 디스크 공간을 절약할 수 있습니다.
전통적인 방법과의 비교를 해보면, 기존에는 파일 이름과 데이터가 직접 연결되었다면, INode 시스템에서는 이름 → INode → 데이터라는 간접 참조를 사용합니다. 이를 통해 하드 링크, 심볼릭 링크, 권한 관리 등 고급 기능을 구현할 수 있습니다.
INode의 핵심 특징은 첫째, 참조 카운팅을 통한 자동 메모리 관리, 둘째, 타임스탬프 자동 갱신을 통한 파일 이력 추적, 셋째, 권한 비트를 통한 세밀한 접근 제어입니다. 이러한 특징들이 중요한 이유는 안전하고 효율적인 파일시스템의 근간을 이루기 때문입니다.
코드 예제
// INode 구조체 - 파일 메타데이터를 담는 핵심 자료구조
use core::sync::atomic::{AtomicU32, Ordering};
pub struct INode {
// INode 번호 - 파일시스템 내에서 고유한 식별자
pub ino: u64,
// 파일 타입과 권한 (예: 0o100644 = 일반 파일, rw-r--r--)
pub mode: u32,
// 하드 링크 카운트 - 0이 되면 INode 삭제
pub nlink: AtomicU32,
// 소유자 UID와 GID
pub uid: u32,
pub gid: u32,
// 파일 크기 (바이트 단위)
pub size: u64,
// 타임스탬프 - 생성, 수정, 접근 시간
pub atime: u64, // Access time
pub mtime: u64, // Modification time
pub ctime: u64, // Change time
// 데이터 블록들의 위치 (간접 참조 포함)
pub blocks: Vec<u64>,
}
impl INode {
// 하드 링크 증가 - 원자적 연산으로 동시성 보장
pub fn inc_nlink(&self) {
self.nlink.fetch_add(1, Ordering::SeqCst);
}
// 하드 링크 감소 - 0이 되면 true 반환 (삭제 신호)
pub fn dec_nlink(&self) -> bool {
self.nlink.fetch_sub(1, Ordering::SeqCst) == 1
}
}
설명
이것이 하는 일: INode 구조체는 파일의 모든 메타데이터를 하나의 구조체로 캡슐화하여, 파일 이름과 독립적으로 파일 정보를 관리합니다. 이를 통해 하나의 파일을 여러 이름으로 참조하거나, 파일 이름 변경 시에도 메타데이터를 유지할 수 있습니다.
첫 번째로, mode 필드는 파일 타입(일반 파일, 디렉토리, 심볼릭 링크 등)과 권한 비트(읽기, 쓰기, 실행)를 하나의 u32 값으로 인코딩합니다. 예를 들어 0o100644는 상위 4비트가 파일 타입(100 = 일반 파일), 하위 9비트가 권한(644 = rw-r--r--)을 나타냅니다.
이렇게 비트 연산으로 여러 정보를 압축하면 메모리를 절약할 수 있습니다. 그 다음으로, nlink 필드가 AtomicU32로 선언되어 있는 것에 주목하세요.
이는 멀티코어 환경에서 여러 스레드가 동시에 하드 링크를 생성/삭제할 때 데이터 레이스를 방지합니다. fetch_add와 fetch_sub는 CPU의 원자적 명령어로 변환되어, 락 없이도 안전한 동시성을 제공합니다.
타임스탬프 필드들(atime, mtime, ctime)은 파일 접근 패턴을 추적하는 데 사용됩니다. atime은 파일을 읽을 때마다 갱신되고, mtime은 내용이 변경될 때, ctime은 메타데이터가 변경될 때 갱신됩니다.
실무에서는 백업 시스템이 mtime을 보고 변경된 파일만 백업하거나, 보안 시스템이 ctime을 보고 권한 변조를 감지합니다. 마지막으로, blocks 벡터가 파일의 실제 데이터가 저장된 디스크 블록들의 주소를 담고 있어, 최종적으로 파일 데이터에 접근할 수 있는 경로를 제공합니다.
대용량 파일의 경우 직접 블록, 간접 블록, 이중 간접 블록 등을 사용하여 효율적으로 관리할 수 있습니다. 여러분이 이 코드를 사용하면 파일시스템의 핵심 메타데이터를 타입 안전하게 관리할 수 있고, 원자적 연산을 통해 동시성 버그를 방지할 수 있습니다.
실무에서의 이점으로는 하드 링크 지원, 정확한 파일 크기 추적, 보안 감사를 위한 타임스탬프 관리 등이 있습니다.
실전 팁
💡 INode 번호는 파일시스템 내에서 절대 재사용하지 마세요. 삭제된 INode 번호를 즉시 재사용하면 캐시된 참조가 잘못된 파일을 가리킬 수 있습니다. 대신 generation number를 함께 사용하여 stale reference를 감지하세요.
💡 타임스탬프 갱신은 성능에 영향을 줍니다. 흔한 실수는 모든 읽기 작업마다 atime을 갱신하는 것인데, 대부분의 최신 파일시스템은 relatime 옵션을 사용하여 atime 갱신을 지연시킵니다.
💡 권한 검사는 반드시 INode 레벨에서 수행하세요. mode 필드를 비트 연산으로 검사하여 현재 프로세스의 UID/GID와 비교합니다. 보안을 위해 TOCTOU(Time-of-check-time-of-use) 공격을 방지하도록 설계하세요.
💡 디버깅 시에는 INode에 Debug trait을 구현하여 모든 필드를 출력하도록 하세요. 특히 nlink가 예상과 다를 때 메모리 누수나 댕글링 참조를 의심할 수 있습니다.
💡 더 발전된 사용법으로는 INode에 RwLock을 추가하여 읽기/쓰기 동시성을 세밀하게 제어할 수 있습니다. 여러 스레드가 동시에 읽을 수 있지만, 쓰기는 배타적으로 수행되도록 보장합니다.
3. 파일 디스크립터 테이블 - 프로세스별 파일 관리
시작하며
여러분이 시스템 프로그래밍을 하다 보면 이런 질문을 하게 됩니다. 하나의 프로세스가 여러 파일을 동시에 열었을 때, 각 파일의 현재 읽기/쓰기 위치를 어떻게 추적할 것인가?
이런 문제는 실제 운영체제 개발에서 필수적으로 해결해야 합니다. 파일 디스크립터(0, 1, 2, ...)는 프로세스마다 독립적이어야 하고, fork 시에는 복사되어야 하며, exec 시에는 일부만 유지되어야 합니다.
이러한 복잡한 생명주기 관리가 없다면 프로세스 격리가 깨지고 보안 문제가 발생합니다. 바로 이럴 때 필요한 것이 파일 디스크립터 테이블입니다.
각 프로세스는 자신만의 FD 테이블을 가지고 있으며, 이를 통해 열린 파일들을 관리합니다.
개요
간단히 말해서, 파일 디스크립터 테이블은 프로세스가 열어놓은 파일들의 목록과 각 파일의 상태(현재 오프셋, 플래그 등)를 관리하는 자료구조입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 사용자 공간에서는 정수(0, 1, 2, ...)로 파일을 참조하지만, 커널 내부에서는 INode, 현재 오프셋, 접근 모드 등 복잡한 정보를 관리해야 하기 때문입니다.
예를 들어, read(3, buf, 100) 시스템 콜이 호출되면 커널은 FD 3번이 가리키는 파일의 현재 오프셋에서 100바이트를 읽고, 오프셋을 자동으로 증가시켜야 합니다. 전통적인 방법과의 비교를 해보면, 기존 C 기반 커널에서는 포인터 배열로 관리했다면, Rust에서는 Vec과 Option을 사용하여 null 포인터 역참조를 컴파일 타임에 방지할 수 있습니다.
FD 테이블의 핵심 특징은 첫째, 프로세스별 독립성(같은 번호라도 프로세스마다 다른 파일), 둘째, 자동 번호 할당(가장 작은 사용 가능한 번호 할당), 셋째, RAII를 통한 자동 정리입니다. 이러한 특징들이 중요한 이유는 리소스 누수를 방지하고 프로세스 격리를 보장하기 때문입니다.
코드 예제
// 파일 디스크립터 테이블 - 프로세스마다 하나씩 존재
use alloc::vec::Vec;
use alloc::sync::Arc;
use spin::Mutex;
// 열린 파일의 상태를 담는 구조체
pub struct OpenFile {
// 파일의 INode - Arc로 공유 소유권 관리
pub inode: Arc<INode>,
// 현재 읽기/쓰기 오프셋 (바이트 단위)
pub offset: u64,
// 열기 플래그 (읽기 전용, 쓰기 전용, 읽기+쓰기, 추가 모드 등)
pub flags: OpenFlags,
}
// 프로세스의 FD 테이블
pub struct FileDescriptorTable {
// FD 번호를 인덱스로 사용하는 벡터
// None이면 해당 FD가 닫혀있음을 의미
files: Mutex<Vec<Option<Arc<Mutex<OpenFile>>>>>,
}
impl FileDescriptorTable {
// 새로운 FD 테이블 생성 (stdin=0, stdout=1, stderr=2 포함)
pub fn new() -> Self {
Self {
files: Mutex::new(Vec::new()),
}
}
// 파일을 열고 새로운 FD 반환
pub fn open(&self, inode: Arc<INode>, flags: OpenFlags) -> Result<u32, Error> {
let mut files = self.files.lock();
// 가장 작은 사용 가능한 FD 번호 찾기
let fd = files.iter()
.position(|f| f.is_none())
.unwrap_or(files.len());
let open_file = Arc::new(Mutex::new(OpenFile {
inode,
offset: 0,
flags,
}));
// FD 슬롯에 저장
if fd < files.len() {
files[fd] = Some(open_file);
} else {
files.push(Some(open_file));
}
Ok(fd as u32)
}
// FD로부터 OpenFile 가져오기
pub fn get(&self, fd: u32) -> Result<Arc<Mutex<OpenFile>>, Error> {
let files = self.files.lock();
files.get(fd as usize)
.and_then(|f| f.clone())
.ok_or(Error::InvalidFd)
}
// FD 닫기 - 리소스 해제
pub fn close(&self, fd: u32) -> Result<(), Error> {
let mut files = self.files.lock();
files.get_mut(fd as usize)
.and_then(|f| f.take())
.ok_or(Error::InvalidFd)?;
Ok(())
}
}
설명
이것이 하는 일: FileDescriptorTable은 프로세스마다 존재하는 테이블로, 파일 디스크립터 번호(정수)를 OpenFile 구조체로 매핑합니다. 사용자 공간에서는 단순히 정수로 파일을 참조하지만, 커널은 이 정수를 키로 사용하여 실제 파일 정보에 접근합니다.
첫 번째로, OpenFile 구조체가 각 열린 파일의 상태를 담고 있습니다. inode는 Arc로 감싸져 있어 여러 프로세스나 FD가 같은 INode를 공유할 수 있습니다(예: dup2 시스템 콜).
offset 필드는 현재 읽기/쓰기 위치를 추적하며, 매번 read/write 호출 시 자동으로 증가합니다. 이렇게 오프셋을 OpenFile에 저장하는 이유는 프로세스마다 독립적인 읽기 위치를 가져야 하기 때문입니다.
그 다음으로, open 메서드가 실행되면서 가장 작은 사용 가능한 FD 번호를 찾습니다. position 메서드로 None인 첫 번째 슬롯을 찾거나, 모두 사용 중이면 벡터를 확장합니다.
POSIX 표준에서는 항상 가장 작은 번호를 할당하도록 규정하고 있어, dup() 같은 시스템 콜이 예측 가능하게 동작합니다. Vec<Option<Arc<Mutex<OpenFile>>>>이라는 복잡한 타입에 주목하세요.
가장 바깥의 Vec은 인덱싱을 위한 것이고, Option은 닫혀있는 FD를 표현하며, Arc는 여러 FD가 같은 파일을 공유할 수 있게 하고, Mutex는 멀티스레드 환경에서 offset 같은 가변 상태를 안전하게 보호합니다. 이런 다중 래핑이 복잡해 보이지만, 각각의 역할이 명확하게 분리되어 있습니다.
마지막으로, get과 close 메서드가 파일 접근과 정리를 담당하여 최종적으로 완전한 파일 생명주기 관리를 제공합니다. close에서 take()를 사용하면 Option을 None으로 바꾸면서 값을 가져오므로, 자동으로 Arc의 참조 카운트가 감소하고, 마지막 참조가 사라지면 리소스가 해제됩니다.
여러분이 이 코드를 사용하면 파일 디스크립터를 안전하게 관리할 수 있고, 리소스 누수를 방지할 수 있습니다. 실무에서의 이점으로는 프로세스 격리 보장, fork/exec 시 예측 가능한 동작, RAII를 통한 자동 정리 등이 있습니다.
실전 팁
💡 FD 0, 1, 2는 관례적으로 stdin, stdout, stderr로 예약되어 있습니다. 새로운 프로세스를 생성할 때는 반드시 이 세 개를 먼저 할당하세요. 그렇지 않으면 많은 프로그램이 오작동합니다.
💡 close-on-exec 플래그를 지원하려면 OpenFile에 cloexec 필드를 추가하세요. 흔한 실수는 exec 시 모든 FD를 닫는 것인데, 실제로는 cloexec 플래그가 설정된 것만 닫아야 합니다.
💡 대용량 FD 테이블의 성능을 위해 HashMap을 고려하세요. FD 번호가 크게 떨어져 있으면(예: 3, 1000, 10000) Vec은 메모리를 낭비하지만, HashMap은 실제 사용하는 FD만 저장합니다.
💡 디버깅 시에는 lsof(list open files) 같은 기능을 구현하세요. 프로세스가 어떤 파일들을 열어놓았는지 확인하면 리소스 누수를 쉽게 발견할 수 있습니다.
💡 더 발전된 사용법으로는 FD 테이블을 공유하는 경량 프로세스(스레드)를 지원할 수 있습니다. clone 시스템 콜에 CLONE_FILES 플래그를 사용하면 부모와 자식이 같은 FD 테이블을 공유하므로, Arc로 감싸면 됩니다.
4. VFS 마운트 테이블 - 파일시스템 계층 구조
시작하며
여러분이 리눅스를 사용할 때 /dev, /proc, /sys 같은 특수한 파일시스템들이 어떻게 하나의 통합된 디렉토리 트리로 보이는지 궁금한 적 있나요? 실제로는 각각 다른 종류의 파일시스템인데 말이죠.
이런 마법은 실제로는 VFS 마운트 테이블을 통해 구현됩니다. 여러 파일시스템을 하나의 디렉토리 트리로 통합하는 것은 운영체제의 핵심 기능 중 하나이며, 이를 잘못 구현하면 경로 탐색이 느려지거나 보안 취약점이 발생할 수 있습니다.
바로 이럴 때 필요한 것이 마운트 테이블 추상화입니다. 각 마운트 포인트를 추적하고, 경로 탐색 시 자동으로 적절한 파일시스템으로 전환해주는 기능입니다.
개요
간단히 말해서, 마운트 테이블은 디렉토리 경로를 특정 파일시스템 인스턴스로 매핑하는 테이블입니다. 예를 들어, /mnt/usb를 USB 드라이브의 FAT32 파일시스템으로 연결합니다.
왜 이것이 필요한지 실무 관점에서 설명하자면, 현대 운영체제는 수십 개의 서로 다른 파일시스템을 동시에 사용합니다. 루트 파일시스템(ext4), 부팅 파티션(FAT32), 프로세스 정보(/proc의 procfs), 디바이스 파일(/dev의 devfs), 네트워크 공유(NFS) 등이 모두 하나의 디렉토리 트리에 통합되어야 합니다.
마운트 테이블 없이는 이런 통합이 불가능합니다. 전통적인 방법과의 비교를 해보면, 기존에는 각 파일시스템을 수동으로 특정 경로에 연결했다면, VFS는 자동으로 경로 탐색 중에 마운트 포인트를 감지하고 파일시스템을 전환합니다.
마운트 테이블의 핵심 특징은 첫째, 계층적 마운트 지원(마운트 위에 또 마운트 가능), 둘째, 네임스페이스 격리(컨테이너마다 독립적인 마운트 뷰), 셋째, 지연 마운트(실제 접근 시에만 마운트)입니다. 이러한 특징들이 중요한 이유는 유연하고 안전한 파일시스템 구성을 가능하게 하기 때문입니다.
코드 예제
// VFS 마운트 테이블 - 전역으로 하나 존재
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::sync::Arc;
use spin::RwLock;
// 마운트 포인트 정보
pub struct MountPoint {
// 마운트된 경로 (예: "/mnt/usb")
pub path: String,
// 마운트된 파일시스템 인스턴스
pub fs: Arc<dyn FileSystem>,
// 마운트 플래그 (읽기 전용, nosuid 등)
pub flags: MountFlags,
}
// 전역 마운트 테이블
pub struct MountTable {
// 경로를 키로, MountPoint를 값으로 하는 맵
// BTreeMap을 사용하여 가장 긴 prefix 매칭 가능
mounts: RwLock<BTreeMap<String, Arc<MountPoint>>>,
}
impl MountTable {
// 싱글톤 인스턴스 생성
pub fn new() -> Self {
Self {
mounts: RwLock::new(BTreeMap::new()),
}
}
// 파일시스템 마운트
pub fn mount(&self, path: &str, fs: Arc<dyn FileSystem>, flags: MountFlags) -> Result<(), Error> {
let mut mounts = self.mounts.write();
// 이미 마운트된 경로인지 확인
if mounts.contains_key(path) {
return Err(Error::AlreadyMounted);
}
let mount_point = Arc::new(MountPoint {
path: String::from(path),
fs,
flags,
});
mounts.insert(String::from(path), mount_point);
Ok(())
}
// 경로에 해당하는 파일시스템 찾기
pub fn resolve(&self, path: &str) -> Option<(Arc<dyn FileSystem>, String)> {
let mounts = self.mounts.read();
// 가장 긴 prefix 매칭 찾기
let mut best_match: Option<(&String, &Arc<MountPoint>)> = None;
for (mount_path, mount_point) in mounts.iter() {
if path.starts_with(mount_path.as_str()) {
if best_match.is_none() || mount_path.len() > best_match.unwrap().0.len() {
best_match = Some((mount_path, mount_point));
}
}
}
best_match.map(|(mount_path, mount_point)| {
// 마운트 경로를 제거한 상대 경로 계산
let relative_path = &path[mount_path.len()..];
(mount_point.fs.clone(), String::from(relative_path))
})
}
// 언마운트
pub fn umount(&self, path: &str) -> Result<(), Error> {
let mut mounts = self.mounts.write();
mounts.remove(path).ok_or(Error::NotMounted)?;
Ok(())
}
}
설명
이것이 하는 일: MountTable은 전역으로 하나만 존재하며, 시스템의 모든 마운트 포인트를 관리합니다. 경로를 받아서 해당 경로가 어떤 파일시스템에 속하는지 찾고, 파일시스템 내부의 상대 경로로 변환해줍니다.
첫 번째로, BTreeMap을 사용하는 이유는 키가 정렬되어 있어 prefix 매칭이 효율적이기 때문입니다. HashMap보다 삽입/삭제가 약간 느리지만, 정렬된 순회가 필요한 경우 유리합니다.
또한 RwLock을 사용하여 여러 스레드가 동시에 경로를 탐색(읽기)할 수 있지만, 마운트/언마운트(쓰기)는 배타적으로 수행됩니다. 그 다음으로, resolve 메서드가 실행되면서 가장 긴 prefix 매칭을 찾습니다.
예를 들어, /mnt/usb와 /mnt가 모두 마운트되어 있고 /mnt/usb/file.txt를 찾는다면, 더 구체적인 /mnt/usb를 선택합니다. 이를 통해 계층적 마운트(마운트 위에 또 마운트)를 자연스럽게 지원합니다.
마운트 경로를 제거하고 상대 경로를 계산하는 부분에 주목하세요. /mnt/usb에 마운트된 파일시스템에 /mnt/usb/dir/file.txt를 요청하면, 파일시스템은 /dir/file.txt만 보게 됩니다.
이렇게 경로 변환을 자동으로 해주므로, 각 파일시스템은 자신이 어디에 마운트되어 있는지 알 필요가 없습니다. 마지막으로, mount와 umount 메서드가 파일시스템 생명주기를 관리하여 최종적으로 동적 파일시스템 구성을 가능하게 합니다.
Arc를 사용하므로 파일시스템이 사용 중일 때는 umount해도 즉시 삭제되지 않고, 모든 참조가 사라질 때까지 유지됩니다. 여러분이 이 코드를 사용하면 여러 파일시스템을 투명하게 통합할 수 있고, 런타임에 동적으로 마운트/언마운트할 수 있습니다.
실무에서의 이점으로는 컨테이너 네임스페이스 지원, USB 드라이브 자동 마운트, 네트워크 파일시스템 통합 등이 있습니다.
실전 팁
💡 루트 파일시스템(/)은 반드시 가장 먼저 마운트되어야 합니다. 부팅 시 initramfs나 initrd를 /에 마운트한 후, 실제 루트 파일시스템으로 pivot_root하는 것이 일반적입니다.
💡 마운트 플래그(ro, nosuid, nodev 등)를 반드시 확인하세요. 흔한 실수는 읽기 전용으로 마운트된 파일시스템에 쓰기를 시도하는 것인데, 마운트 플래그를 먼저 검사하여 에러를 조기에 반환해야 합니다.
💡 성능을 위해 경로 탐색 결과를 캐싱하세요. LRU 캐시를 사용하여 최근에 탐색한 경로의 (path, filesystem) 쌍을 저장하면, 반복적인 BTreeMap 탐색을 피할 수 있습니다.
💡 언마운트 시 사용 중인 파일이 있는지 확인하세요. lazy umount(MNT_DETACH)를 지원하려면 파일시스템을 마운트 테이블에서는 제거하지만 Arc 참조는 유지하여, 모든 파일이 닫힐 때까지 파일시스템을 살려둡니다.
💡 더 발전된 사용법으로는 마운트 네임스페이스를 지원할 수 있습니다. 각 프로세스 그룹마다 독립적인 MountTable을 가지게 하면, 컨테이너나 샌드박스에서 격리된 파일시스템 뷰를 제공할 수 있습니다.
5. Path 정규화 - 안전한 경로 처리
시작하며
여러분이 파일시스템을 구현할 때 /home/user/../etc/passwd 같은 경로를 받으면 어떻게 처리하시나요? 또는 ///multiple////slashes/// 같은 비정상적인 입력은요?
이런 문제는 실제로 보안 취약점으로 이어질 수 있습니다. 경로 정규화를 제대로 하지 않으면 디렉토리 트래버설 공격(../../../etc/shadow)이나 심볼릭 링크를 통한 권한 상승이 발생할 수 있습니다.
특히 웹 서버나 파일 공유 서비스에서는 치명적입니다. 바로 이럴 때 필요한 것이 Path 정규화 로직입니다.
사용자 입력을 신뢰하지 않고, 항상 정규화된 절대 경로로 변환하여 안전하게 처리해야 합니다.
개요
간단히 말해서, Path 정규화는 /a/b/../c를 /a/c로, //a//b를 /a/b로 변환하여 경로의 정규 형식을 만드는 과정입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 경로 비교나 캐싱을 할 때 정규화되지 않은 경로는 같은 파일인데도 다른 것으로 인식될 수 있기 때문입니다.
예를 들어, /home/user/file.txt와 /home/user/./file.txt는 같은 파일인데, 문자열 비교로는 다르게 판단됩니다. 또한 ..을 처리하지 않으면 보안 검사를 우회할 수 있습니다.
전통적인 방법과의 비교를 해보면, 기존 C 코드에서는 realpath() 함수로 실제 파일시스템을 탐색하며 정규화했다면, Rust에서는 심볼릭 링크 해석 없이 문자열 수준에서만 정규화할 수도 있고, 필요시 심볼릭 링크까지 해석할 수도 있습니다. Path 정규화의 핵심 특징은 첫째, .과 ..의 올바른 처리, 둘째, 중복 슬래시 제거, 셋째, 절대 경로 보장입니다.
이러한 특징들이 중요한 이유는 일관된 경로 표현으로 캐싱과 보안을 모두 향상시키기 때문입니다.
코드 예제
// 경로 정규화 함수
use alloc::vec::Vec;
use alloc::string::String;
pub fn normalize_path(path: &str) -> Result<String, Error> {
// 절대 경로가 아니면 에러
if !path.starts_with('/') {
return Err(Error::RelativePath);
}
// 경로를 컴포넌트로 분리
let components: Vec<&str> = path.split('/')
.filter(|c| !c.is_empty() && *c != ".") // 빈 문자열과 . 제거
.collect();
// 스택으로 .. 처리
let mut stack: Vec<&str> = Vec::new();
for component in components {
if component == ".." {
// 부모 디렉토리로 이동 (루트에서는 무시)
if !stack.is_empty() {
stack.pop();
}
} else {
// 일반 컴포넌트는 스택에 추가
stack.push(component);
}
}
// 스택을 다시 경로로 조합
if stack.is_empty() {
// 루트 디렉토리
Ok(String::from("/"))
} else {
// 각 컴포넌트를 /로 연결
Ok(format!("/{}", stack.join("/")))
}
}
// 심볼릭 링크까지 해석하는 완전한 정규화
pub fn canonicalize_path(vfs: &VFS, path: &str) -> Result<String, Error> {
let mut current = normalize_path(path)?;
let mut visited = Vec::new(); // 순환 링크 감지용
loop {
// 순환 링크 감지 (최대 40번 follow)
if visited.len() > 40 {
return Err(Error::TooManySymlinks);
}
// 심볼릭 링크인지 확인
match vfs.readlink(¤t) {
Ok(target) => {
visited.push(current.clone());
current = normalize_path(&target)?;
}
Err(Error::NotSymlink) => {
// 심볼릭 링크가 아니면 종료
return Ok(current);
}
Err(e) => return Err(e),
}
}
}
설명
이것이 하는 일: normalize_path 함수는 문자열 수준에서 경로를 정규화하여, 같은 파일을 가리키는 여러 표현을 하나의 정규 형식으로 통일합니다. 이를 통해 캐싱, 권한 검사, 경로 비교가 정확하게 동작합니다.
첫 번째로, 경로를 /로 분리하고 filter를 사용하여 빈 문자열과 .을 제거합니다. 빈 문자열은 연속된 슬래시(///)에서 발생하고, .은 현재 디렉토리를 의미하므로 경로에 영향을 주지 않습니다.
이 단계에서 대부분의 불필요한 요소가 제거됩니다. 그 다음으로, ..을 만나면 스택에서 pop하여 부모 디렉토리로 이동합니다.
하지만 스택이 비어있으면(루트 디렉토리에서 ..) pop하지 않아 루트 이탈을 방지합니다. 이것이 중요한 이유는 /../../etc/passwd 같은 공격을 막기 때문입니다.
스택이 비어있으면 이미 루트이므로 더 올라갈 수 없습니다. canonicalize_path는 한 단계 더 나아가 심볼릭 링크까지 해석합니다.
visited 벡터로 순환 링크를 감지하는데, A → B → A 같은 순환이 발생하면 무한 루프를 방지하기 위해 최대 40번만 follow합니다. 리눅스에서도 ELOOP 에러를 반환하는 기준이 보통 40입니다.
마지막으로, join으로 스택의 컴포넌트들을 /로 연결하여 최종 경로를 생성합니다. 스택이 비어있으면 루트 디렉토리(/)를 반환하고, 그렇지 않으면 /로 시작하는 절대 경로를 만듭니다.
format! 매크로가 힙 할당을 하므로 성능이 중요한 경우 재사용 가능한 버퍼를 사용할 수도 있습니다.
여러분이 이 코드를 사용하면 사용자 입력을 안전하게 처리할 수 있고, 디렉토리 트래버설 공격을 방어할 수 있습니다. 실무에서의 이점으로는 일관된 캐싱 키, 정확한 권한 검사, 보안 취약점 제거 등이 있습니다.
실전 팁
💡 절대 경로만 받도록 강제하세요. 상대 경로를 허용하면 현재 작업 디렉토리(CWD)에 따라 해석이 달라져 보안 문제가 발생할 수 있습니다. 사용자 공간에서 절대 경로로 변환한 후 커널에 전달하는 것이 안전합니다.
💡 경로 길이 제한을 설정하세요. 흔한 실수는 무제한 경로를 허용하는 것인데, PATH_MAX(보통 4096)를 초과하는 경로는 거부하여 스택 오버플로우를 방지해야 합니다.
💡 유니코드 정규화도 고려하세요. 파일 이름이 é(U+00E9)와 é(U+0065 U+0301)처럼 다르게 인코딩될 수 있으므로, NFD나 NFC 정규화를 적용하여 일관성을 보장합니다. 특히 macOS는 NFD를 사용합니다.
💡 디버깅 시에는 정규화 전후의 경로를 모두 로깅하세요. 보안 감사 로그에 원본 경로와 정규화된 경로를 함께 기록하면 공격 시도를 쉽게 탐지할 수 있습니다.
💡 더 발전된 사용법으로는 경로 컴포넌트마다 권한을 검사하는 것입니다. /a/b/c에 접근할 때 /a, /a/b, /a/b/c 각각에 대해 실행 권한(x)을 확인하여 정밀한 접근 제어를 구현할 수 있습니다.
6. 파일 권한 검사 - 안전한 접근 제어
시작하며
여러분이 운영체제를 개발할 때 가장 신경 써야 할 부분 중 하나가 바로 권한 검사입니다. 일반 사용자가 /etc/shadow를 읽거나 root 소유의 파일을 삭제하려 할 때 어떻게 막으시겠습니까?
이런 문제는 보안의 핵심입니다. 권한 검사를 잘못 구현하면 권한 상승(Privilege Escalation) 공격에 노출되고, TOCTOU(Time-of-Check-Time-of-Use) 레이스 컨디션이 발생하며, 심볼릭 링크를 통한 우회 공격이 가능해집니다.
CVE 데이터베이스를 보면 권한 검사 버그가 얼마나 치명적인지 알 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 권한 검사 로직입니다.
INode의 mode, uid, gid 필드와 현재 프로세스의 credential을 비교하여 안전하게 접근을 제어해야 합니다.
개요
간단히 말해서, 파일 권한 검사는 INode의 권한 비트(rwxrwxrwx)와 현재 프로세스의 UID/GID를 비교하여 접근 허용 여부를 결정하는 과정입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 다중 사용자 시스템에서 각 사용자의 파일을 보호하고, 시스템 파일의 무단 수정을 방지하며, 최소 권한 원칙(Principle of Least Privilege)을 구현하기 위함입니다.
예를 들어, 웹 서버 프로세스가 설정 파일을 읽을 수는 있지만 쓸 수 없도록 제한하여, 공격자가 웹 서버를 장악해도 시스템 파일을 변조하지 못하게 합니다. 전통적인 방법과의 비교를 해보면, 기존 Unix 권한 모델(owner, group, others)을 그대로 사용하지만, Rust의 타입 시스템으로 컴파일 타임에 권한 검사 누락을 방지할 수 있습니다.
권한 검사의 핵심 특징은 첫째, 소유자/그룹/기타 사용자별로 다른 권한 적용, 둘째, root(UID 0)의 슈퍼유저 권한, 셋째, effective UID와 real UID의 분리입니다. 이러한 특징들이 중요한 이유는 세밀한 접근 제어와 setuid 같은 고급 기능을 가능하게 하기 때문입니다.
코드 예제
// 프로세스의 credential 정보
pub struct Credential {
pub uid: u32, // Real User ID
pub euid: u32, // Effective User ID (권한 검사에 사용)
pub gid: u32, // Real Group ID
pub egid: u32, // Effective Group ID
pub groups: Vec<u32>, // 추가 그룹들
}
// 권한 비트 상수
pub const S_IRUSR: u32 = 0o400; // User read
pub const S_IWUSR: u32 = 0o200; // User write
pub const S_IXUSR: u32 = 0o100; // User execute
pub const S_IRGRP: u32 = 0o040; // Group read
pub const S_IWGRP: u32 = 0o020; // Group write
pub const S_IXGRP: u32 = 0o010; // Group execute
pub const S_IROTH: u32 = 0o004; // Others read
pub const S_IWOTH: u32 = 0o002; // Others write
pub const S_IXOTH: u32 = 0o001; // Others execute
// 요청된 접근 타입
pub enum AccessMode {
Read,
Write,
Execute,
}
// 권한 검사 함수
pub fn check_permission(inode: &INode, cred: &Credential, mode: AccessMode) -> Result<(), Error> {
// Root는 모든 권한 보유 (단, 실행은 실행 비트가 하나라도 있어야 함)
if cred.euid == 0 {
if matches!(mode, AccessMode::Execute) {
let has_exec = (inode.mode & 0o111) != 0;
if !has_exec {
return Err(Error::PermissionDenied);
}
}
return Ok(());
}
// 필요한 권한 비트 결정
let required_perm = match mode {
AccessMode::Read => {
if cred.euid == inode.uid {
S_IRUSR
} else if cred.egid == inode.gid || cred.groups.contains(&inode.gid) {
S_IRGRP
} else {
S_IROTH
}
}
AccessMode::Write => {
if cred.euid == inode.uid {
S_IWUSR
} else if cred.egid == inode.gid || cred.groups.contains(&inode.gid) {
S_IWGRP
} else {
S_IWOTH
}
}
AccessMode::Execute => {
if cred.euid == inode.uid {
S_IXUSR
} else if cred.egid == inode.gid || cred.groups.contains(&inode.gid) {
S_IXGRP
} else {
S_IXOTH
}
}
};
// 권한 비트 검사
if (inode.mode & required_perm) != 0 {
Ok(())
} else {
Err(Error::PermissionDenied)
}
}
설명
이것이 하는 일: check_permission 함수는 현재 프로세스가 특정 파일에 대해 요청한 작업(읽기/쓰기/실행)을 수행할 권한이 있는지 검사합니다. Unix의 전통적인 권한 모델을 Rust로 구현한 것입니다.
첫 번째로, root(euid == 0) 검사를 수행합니다. Root는 거의 모든 권한을 가지지만, 실행 권한만은 예외입니다.
실행 가능한 파일이 되려면 최소한 하나의 실행 비트(사용자/그룹/기타 중 하나)가 설정되어 있어야 합니다. 이는 실수로 데이터 파일을 실행하는 것을 방지하기 위함입니다.
그 다음으로, 소유자/그룹/기타 중 어느 카테고리에 속하는지 결정합니다. 먼저 euid == inode.uid를 검사하여 소유자인지 확인하고, 그렇지 않으면 egid나 groups에 inode.gid가 있는지 검사하여 그룹 멤버인지 확인합니다.
마지막으로 둘 다 아니면 기타 사용자로 간주합니다. 중요한 점은 소유자이면서 그룹에도 속해 있어도 소유자 권한만 적용된다는 것입니다.
카테고리가 결정되면 해당하는 권한 비트(S_IRUSR, S_IRGRP, S_IROTH 등)를 선택하고, inode.mode와 비트 AND 연산을 수행합니다. 결과가 0이 아니면 권한이 있는 것이고, 0이면 권한이 없어 PermissionDenied 에러를 반환합니다.
비트 연산을 사용하는 이유는 mode에 여러 정보가 압축되어 있어, 마스킹으로 필요한 비트만 추출하기 때문입니다. 마지막으로, effective UID와 real UID를 구분하는 것에 주목하세요.
setuid 프로그램(예: passwd)은 일반 사용자가 실행해도 파일 소유자의 권한으로 동작합니다. real UID는 실제 사용자를 추적하고, effective UID는 권한 검사에 사용됩니다.
이를 통해 일시적 권한 상승이 가능하면서도 감사 로그에는 실제 사용자를 기록할 수 있습니다. 여러분이 이 코드를 사용하면 표준 Unix 권한 모델을 안전하게 구현할 수 있고, 타입 안전성 덕분에 권한 검사 누락을 방지할 수 있습니다.
실무에서의 이점으로는 명확한 접근 제어, setuid 지원, 감사 로그 통합 등이 있습니다.
실전 팁
💡 권한 검사는 TOCTOU 공격을 방지하도록 설계하세요. 검사와 사용 사이에 파일이 변경될 수 있으므로, 파일을 열 때 INode를 획득하고 그 INode로 모든 작업을 수행해야 합니다. 경로 기반 검사를 반복하면 취약점이 됩니다.
💡 디렉토리 접근 시 실행 권한(x)을 반드시 확인하세요. 흔한 실수는 디렉토리에 읽기 권한만 확인하는 것인데, 디렉토리 내부 파일에 접근하려면 실행 권한이 필요합니다. 읽기 권한은 ls(목록 조회)에만 필요합니다.
💡 추가 권한 모델(ACL, SELinux)을 위한 확장 포인트를 만드세요. 기본 Unix 권한 검사 후 추가 검사를 호출하는 훅을 제공하면, 나중에 더 세밀한 권한 모델을 추가할 수 있습니다.
💡 디버깅 시에는 권한 거부 원인을 자세히 로깅하세요. "소유자 불일치", "그룹 권한 부족", "실행 비트 없음" 등 구체적인 이유를 기록하면 문제를 빠르게 찾을 수 있습니다.
💡 더 발전된 사용법으로는 Capabilities를 구현할 수 있습니다. Root 권한을 CAP_NET_ADMIN, CAP_SYS_ADMIN 등으로 세분화하여, 특정 기능만 필요한 프로세스에게 최소 권한을 부여합니다. 이는 현대 리눅스의 표준입니다.
7. 버퍼 캐시 - 파일시스템 성능 최적화
시작하며
여러분이 같은 파일을 반복적으로 읽는 프로그램을 실행할 때, 매번 디스크에서 읽어온다면 얼마나 느릴까요? 디스크 I/O는 메모리 접근보다 수천 배 느립니다.
이런 문제는 실제 시스템 성능을 좌우합니다. 설정 파일, 공유 라이브러리, 자주 접근하는 데이터베이스 인덱스 등은 프로그램 실행 중 수백 번 읽힙니다.
캐싱 없이는 이런 반복 접근이 모두 디스크 I/O로 이어져 시스템이 느려집니다. 벤치마크를 보면 버퍼 캐시가 전체 시스템 성능을 2-10배 향상시킵니다.
바로 이럴 때 필요한 것이 버퍼 캐시입니다. 최근에 읽거나 쓴 디스크 블록을 메모리에 캐싱하여, 같은 블록에 대한 재접근을 빠르게 처리합니다.
개요
간단히 말해서, 버퍼 캐시는 디스크 블록을 메모리에 저장하는 LRU(Least Recently Used) 캐시로, 디스크 I/O를 줄여 성능을 향상시킵니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 프로그램의 지역성(locality) 원리 때문입니다.
시간적 지역성(방금 읽은 데이터를 다시 읽을 확률이 높음)과 공간적 지역성(인접한 데이터를 연속으로 읽을 확률이 높음)을 활용하면, 캐시 히트율을 80-95%까지 높일 수 있습니다. 예를 들어, 컴파일러는 헤더 파일을 반복해서 읽는데, 캐시가 있으면 두 번째부터는 디스크 접근 없이 메모리에서 읽습니다.
전통적인 방법과의 비교를 해보면, 기존 커널에서는 페이지 캐시와 버퍼 캐시가 분리되었다가 통합되었지만, 여기서는 블록 레벨 캐시를 구현하여 파일시스템 독립적으로 동작합니다. 버퍼 캐시의 핵심 특징은 첫째, LRU 교체 정책으로 자주 사용되는 블록 유지, 둘째, Write-back으로 쓰기 성능 향상, 셋째, Read-ahead로 순차 읽기 최적화입니다.
이러한 특징들이 중요한 이유는 디스크 I/O 횟수를 극적으로 줄여 전체 시스템 성능을 향상시키기 때문입니다.
코드 예제
// 버퍼 캐시 - 디스크 블록을 메모리에 캐싱
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use alloc::sync::Arc;
use spin::Mutex;
// 캐시된 블록
pub struct CachedBlock {
// 블록 번호 (파일시스템 내에서 고유)
pub block_num: u64,
// 블록 데이터 (보통 4KB)
pub data: Vec<u8>,
// Dirty 플래그 - 수정되어 디스크에 쓰기 필요
pub dirty: bool,
// 마지막 접근 시간 (LRU를 위한 타임스탬프)
pub last_access: u64,
}
// 전역 버퍼 캐시
pub struct BufferCache {
// 블록 번호를 키로 하는 맵
blocks: Mutex<BTreeMap<u64, Arc<Mutex<CachedBlock>>>>,
// 캐시 크기 제한 (블록 개수)
max_blocks: usize,
// 현재 타임스탬프 (논리적 시간)
clock: Mutex<u64>,
}
impl BufferCache {
// 새로운 캐시 생성 (예: 1000개 블록 = 4MB)
pub fn new(max_blocks: usize) -> Self {
Self {
blocks: Mutex::new(BTreeMap::new()),
max_blocks,
clock: Mutex::new(0),
}
}
// 블록 읽기 - 캐시 미스 시 디스크에서 로드
pub fn read_block(&self, block_num: u64, disk: &dyn BlockDevice) -> Result<Arc<Mutex<CachedBlock>>, Error> {
let mut blocks = self.blocks.lock();
// 캐시 히트
if let Some(block) = blocks.get(&block_num) {
let mut b = block.lock();
b.last_access = self.tick();
return Ok(block.clone());
}
// 캐시 미스 - 디스크에서 읽기
let mut data = vec![0u8; 4096];
disk.read_block(block_num, &mut data)?;
let cached = Arc::new(Mutex::new(CachedBlock {
block_num,
data,
dirty: false,
last_access: self.tick(),
}));
// 캐시가 가득 찼으면 LRU 블록 제거
if blocks.len() >= self.max_blocks {
self.evict_lru(&mut blocks, disk)?;
}
blocks.insert(block_num, cached.clone());
Ok(cached)
}
// LRU 블록 제거
fn evict_lru(&self, blocks: &mut BTreeMap<u64, Arc<Mutex<CachedBlock>>>, disk: &dyn BlockDevice) -> Result<(), Error> {
// 가장 오래 전에 접근한 블록 찾기
let lru_block = blocks.iter()
.min_by_key(|(_, b)| b.lock().last_access)
.map(|(k, _)| *k);
if let Some(block_num) = lru_block {
if let Some(block) = blocks.remove(&block_num) {
let b = block.lock();
// Dirty 블록은 디스크에 쓰기
if b.dirty {
disk.write_block(b.block_num, &b.data)?;
}
}
}
Ok(())
}
// 논리적 시간 증가
fn tick(&self) -> u64 {
let mut clock = self.clock.lock();
*clock += 1;
*clock
}
// 모든 Dirty 블록을 디스크에 쓰기 (sync)
pub fn flush_all(&self, disk: &dyn BlockDevice) -> Result<(), Error> {
let blocks = self.blocks.lock();
for (_, block) in blocks.iter() {
let b = block.lock();
if b.dirty {
disk.write_block(b.block_num, &b.data)?;
}
}
Ok(())
}
}
// 블록 디바이스 인터페이스
pub trait BlockDevice {
fn read_block(&self, block_num: u64, buf: &mut [u8]) -> Result<(), Error>;
fn write_block(&self, block_num: u64, buf: &[u8]) -> Result<(), Error>;
}
설명
이것이 하는 일: BufferCache는 디스크와 파일시스템 사이의 투명한 캐싱 레이어로, 읽기/쓰기 요청을 가로채서 메모리에서 처리할 수 있으면 디스크 접근을 생략합니다. 이를 통해 평균 접근 시간을 밀리초에서 마이크로초로 줄입니다.
첫 번째로, read_block이 호출되면 먼저 BTreeMap에서 블록 번호를 검색합니다. 캐시 히트면 last_access를 갱신하고 즉시 반환하여, 디스크 I/O 없이 O(log N) 시간에 데이터를 얻습니다.
캐시 미스면 디스크에서 블록을 읽어 캐시에 삽입하는데, 이때 최대 크기를 초과하면 LRU 블록을 제거합니다. 그 다음으로, evict_lru가 실행되면서 가장 오래 전에 접근한 블록을 찾습니다.
min_by_key로 last_access가 가장 작은 블록을 선택하고, dirty 플래그가 설정되어 있으면 디스크에 쓴 후 제거합니다. 이 과정이 중요한 이유는 수정된 데이터를 잃지 않으면서도 캐시 크기를 제한하기 때문입니다.
논리적 시간(clock)을 사용하는 것에 주목하세요. 실제 시간 대신 접근 횟수를 카운터로 사용하면, 시간 동기화 문제가 없고 단조 증가가 보장됩니다.
매번 tick()을 호출하여 클럭을 증가시키고, 이를 last_access에 저장하여 LRU 순서를 추적합니다. Write-back 전략을 사용하는 것도 중요한 설계입니다.
블록을 수정해도 즉시 디스크에 쓰지 않고 dirty 플래그만 설정합니다. 나중에 evict되거나 flush_all이 호출될 때 일괄 쓰기하여, 디스크 I/O를 최소화하고 순차 쓰기로 성능을 향상시킵니다.
마지막으로, Arc<Mutex<CachedBlock>>을 사용하여 여러 스레드가 동시에 같은 블록을 읽을 수 있게 합니다. Arc로 공유 소유권을 관리하고, Mutex로 동시 수정을 방지하며, BTreeMap 자체도 Mutex로 보호하여 캐시 무결성을 보장합니다.
이런 다층 락킹이 복잡해 보이지만, 세밀한 동시성 제어를 위해 필요합니다. 여러분이 이 코드를 사용하면 파일시스템 성능을 극적으로 향상시킬 수 있고, 디스크 I/O를 80-95% 줄일 수 있습니다.
실무에서의 이점으로는 빠른 응답 시간, 배터리 절약(디스크 스핀다운), 디스크 수명 연장 등이 있습니다.
실전 팁
💡 캐시 크기를 동적으로 조정하세요. 시스템 메모리의 일정 비율(예: 10-50%)을 버퍼 캐시에 할당하고, 메모리 압박 시 캐시를 축소합니다. 리눅스는 페이지 캐시를 동적으로 관리하여 가용 메모리를 최대한 활용합니다.
💡 Read-ahead를 구현하세요. 흔한 실수는 요청한 블록만 읽는 것인데, 순차 접근 패턴을 감지하면 다음 블록들을 미리 읽어두어 캐시 히트율을 높일 수 있습니다. 블록 N을 읽을 때 N+1, N+2도 백그라운드로 읽어둡니다.
💡 Dirty 블록 주기적 쓰기를 구현하세요. 너무 오래 메모리에만 있으면 시스템 크래시 시 데이터 손실이 크므로, 5초마다 백그라운드 스레드가 dirty 블록을 쓰도록 합니다. 이는 pdflush/writeback 커널 스레드의 역할입니다.
💡 디버깅 시에는 캐시 히트율을 측정하세요. hit_count와 miss_count를 추적하여 hit_rate = hits / (hits + misses)를 계산하면, 캐시 크기가 적절한지 판단할 수 있습니다. 히트율이 80% 미만이면 캐시를 늘리는 것을 고려하세요.
💡 더 발전된 사용법으로는 ARC(Adaptive Replacement Cache) 정책을 사용할 수 있습니다. LRU와 LFU를 동적으로 혼합하여, 한 번만 읽힌 블록(스캔)과 반복 읽힌 블록(워킹셋)을 구분하여 캐시 오염을 방지합니다. ZFS가 사용하는 정책입니다.
8. VFS 통합 인터페이스 - 시스템 콜 구현
시작하며
여러분이 지금까지 배운 VFS Trait, INode, FD 테이블, 마운트 테이블, 권한 검사, 버퍼 캐시를 어떻게 하나로 통합할까요? 사용자 공간의 open(), read(), write() 시스템 콜이 실제로 어떻게 동작하는지 궁금하지 않으신가요?
이런 통합 작업은 운영체제 개발의 하이라이트입니다. 각 컴포넌트가 아무리 잘 만들어져도, 이들을 올바르게 연결하지 않으면 시스템이 동작하지 않습니다.
시스템 콜 인터페이스는 사용자 공간과 커널의 경계이므로, 안전성과 성능이 모두 중요합니다. 바로 이럴 때 필요한 것이 VFS 통합 레이어입니다.
모든 컴포넌트를 조율하여 일관된 파일 I/O 인터페이스를 제공하는 것이죠.
개요
간단히 말해서, VFS 통합 레이어는 시스템 콜을 받아 경로 정규화, 마운트 해석, 권한 검사, 캐싱, 실제 파일시스템 호출을 순차적으로 수행하는 오케스트레이터입니다. 왜 이것이 필요한지 실무 관점에서 설명하자면, 사용자 공간에서는 단순히 open("/home/user/file.txt", O_RDONLY)를 호출하지만, 커널은 경로 파싱, 심볼릭 링크 해석, 권한 검사, FD 할당, 파일시스템 특화 open 호출 등 수십 가지 작업을 수행해야 합니다.
이 모든 것을 체계적으로 통합하는 것이 VFS의 역할입니다. 전통적인 방법과의 비교를 해보면, 기존 C 커널에서는 함수 포인터와 매크로로 복잡하게 연결했다면, Rust에서는 Trait과 타입 시스템으로 안전하고 명확하게 통합할 수 있습니다.
VFS 통합 레이어의 핵심 특징은 첫째, 경로 기반 API를 INode 기반으로 변환, 둘째, 모든 에러 케이스의 안전한 처리, 셋째, 파일시스템 독립적인 코드입니다. 이러한 특징들이 중요한 이유는 일관되고 안전한 파일 I/O를 보장하기 때문입니다.
코드 예제
// VFS 통합 레이어 - 모든 컴포넌트를 조율
use alloc::sync::Arc;
pub struct VFS {
mount_table: Arc<MountTable>,
buffer_cache: Arc<BufferCache>,
}
impl VFS {
pub fn new(mount_table: Arc<MountTable>, buffer_cache: Arc<BufferCache>) -> Self {
Self { mount_table, buffer_cache }
}
// open 시스템 콜 구현
pub fn open(&self, path: &str, flags: OpenFlags, cred: &Credential, fd_table: &FileDescriptorTable) -> Result<u32, Error> {
// 1. 경로 정규화
let canonical_path = normalize_path(path)?;
// 2. 마운트 테이블에서 파일시스템 찾기
let (fs, relative_path) = self.mount_table.resolve(&canonical_path)
.ok_or(Error::NoSuchFileOrDirectory)?;
// 3. 파일시스템에 INode 요청
let inode = fs.lookup(&relative_path)?;
// 4. 권한 검사
let access_mode = if flags.contains(OpenFlags::O_WRONLY) || flags.contains(OpenFlags::O_RDWR) {
AccessMode::Write
} else {
AccessMode::Read
};
check_permission(&inode, cred, access_mode)?;
// 5. FD 테이블에 등록
let fd = fd_table.open(Arc::new(inode), flags)?;
Ok(fd)
}
// read 시스템 콜 구현
pub fn read(&self, fd: u32, buf: &mut [u8], fd_table: &FileDescriptorTable) -> Result<usize, Error> {
// 1. FD에서 OpenFile 가져오기
let open_file = fd_table.get(fd)?;
let mut file = open_file.lock();
// 2. INode에서 파일 크기 확인
let file_size = file.inode.size;
if file.offset >= file_size {
return Ok(0); // EOF
}
// 3. 읽을 크기 계산 (EOF 넘지 않게)
let to_read = buf.len().min((file_size - file.offset) as usize);
// 4. 버퍼 캐시를 통해 데이터 읽기
let mut bytes_read = 0;
while bytes_read < to_read {
// 현재 오프셋이 속한 블록 계산
let block_num = (file.offset + bytes_read as u64) / 4096;
let block_offset = ((file.offset + bytes_read as u64) % 4096) as usize;
// 캐시에서 블록 읽기
let cached_block = self.buffer_cache.read_block(block_num, &*file.inode.device)?;
let block = cached_block.lock();
// 블록에서 버퍼로 복사
let copy_size = (to_read - bytes_read).min(4096 - block_offset);
buf[bytes_read..bytes_read + copy_size].copy_from_slice(
&block.data[block_offset..block_offset + copy_size]
);
bytes_read += copy_size;
}
// 5. 오프셋 증가
file.offset += bytes_read as u64;
Ok(bytes_read)
}
// write 시스템 콜 구현
pub fn write(&self, fd: u32, buf: &[u8], fd_table: &FileDescriptorTable) -> Result<usize, Error> {
let open_file = fd_table.get(fd)?;
let mut file = open_file.lock();
// 쓰기 권한 확인
if !file.flags.contains(OpenFlags::O_WRONLY) && !file.flags.contains(OpenFlags::O_RDWR) {
return Err(Error::BadFileDescriptor);
}
let mut bytes_written = 0;
while bytes_written < buf.len() {
let block_num = (file.offset + bytes_written as u64) / 4096;
let block_offset = ((file.offset + bytes_written as u64) % 4096) as usize;
// 캐시에서 블록 가져오기 (없으면 로드)
let cached_block = self.buffer_cache.read_block(block_num, &*file.inode.device)?;
let mut block = cached_block.lock();
// 버퍼에서 블록으로 복사
let copy_size = (buf.len() - bytes_written).min(4096 - block_offset);
block.data[block_offset..block_offset + copy_size].copy_from_slice(
&buf[bytes_written..bytes_written + copy_size]
);
// Dirty 플래그 설정 (나중에 디스크에 쓰기)
block.dirty = true;
bytes_written += copy_size;
}
// 오프셋과 파일 크기 갱신
file.offset += bytes_written as u64;
if file.offset > file.inode.size {
// 파일이 커졌으면 INode 크기 갱신 (실제로는 파일시스템에 통보 필요)
// file.inode.size = file.offset;
}
Ok(bytes_written)
}
}
설명
이것이 하는 일: VFS 구조체는 마운트 테이블과 버퍼 캐시를 포함하며, open/read/write 같은 시스템 콜의 실제 구현을 제공합니다. 사용자 공간의 단순한 함수 호출을 복잡한 커널 동작으로 변환하는 번역기 역할을 합니다.
첫 번째로, open 메서드는 5단계 파이프라인을 수행합니다. 경로 정규화 → 마운트 해석 → INode 조회 → 권한 검사 → FD 할당 순서로 진행되며, 각 단계에서 Result를 반환하므로 ?로 에러를 전파합니다.
중간에 에러가 발생하면 즉시 반환하여, 불필요한 작업을 하지 않습니다. 이렇게 단계별로 명확히 분리하면 디버깅과 유지보수가 쉬워집니다.
그 다음으로, read 메서드가 실행되면서 블록 단위로 데이터를 읽습니다. 파일 오프셋을 블록 번호(offset / 4096)와 블록 내 오프셋(offset % 4096)으로 변환하고, 버퍼 캐시에서 블록을 가져온 후 필요한 부분만 복사합니다.
한 번의 read가 여러 블록에 걸칠 수 있으므로 while 루프로 반복합니다. 캐시를 사용하므로 같은 블록을 다시 읽을 때는 디스크 I/O가 발생하지 않습니다.
write 메서드도 유사하지만 dirty 플래그를 설정하는 것이 중요합니다. 블록을 수정한 후 즉시 디스크에 쓰지 않고 dirty만 표시하여, 나중에 캐시가 evict하거나 sync할 때 일괄 쓰기합니다.
이 지연 쓰기 전략이 성능을 크게 향상시키지만, 시스템 크래시 시 데이터 손실 위험이 있으므로 주기적으로 sync해야 합니다. 파일 크기 갱신 로직에 주목하세요.
write로 파일이 커지면 INode의 size를 갱신해야 하는데, 실제로는 파일시스템에 통보하여 디스크의 INode도 갱신해야 합니다. 주석으로 표시한 부분은 실제 구현에서는 fs.update_inode() 같은 호출이 필요합니다.
마지막으로, 모든 메서드가 Result를 반환하여 에러를 타입 안전하게 처리합니다. NoSuchFileOrDirectory, PermissionDenied, BadFileDescriptor 등 구체적인 에러 타입을 사용하면, 상위 레이어가 적절한 errno 값으로 변환하여 사용자 공간에 반환할 수 있습니다.
Rust의 타입 시스템 덕분에 에러 처리 누락이 컴파일 타임에 잡힙니다. 여러분이 이 코드를 사용하면 완전한 파일 I/O 스택을 구현할 수 있고, 사용자 공간 프로그램이 파일시스템을 투명하게 사용할 수 있습니다.
실무에서의 이점으로는 파일시스템 독립적인 애플리케이션, 버퍼링을 통한 성능 향상, 타입 안전한 에러 처리 등이 있습니다.
실전 팁
💡 시스템 콜 인터페이스에서 사용자 포인터를 안전하게 검증하세요. 사용자 공간이 전달한 버퍼가 유효한 메모리 범위인지, 읽기/쓰기 권한이 있는지 확인해야 합니다. 그렇지 않으면 커널 패닉이 발생할 수 있습니다.
💡 파일 크기가 0인 특수 파일(/proc, /dev)을 고려하세요. 흔한 실수는 size를 믿고 읽기를 건너뛰는 것인데, 일부 파일은 동적으로 데이터를 생성하므로 size가 0이어도 read가 데이터를 반환할 수 있습니다.
💡 시그널 처리를 구현하세요. 긴 I/O 작업 중 사용자가 Ctrl+C를 누르면 EINTR을 반환하고 깔끔하게 종료해야 합니다. interruptible_sleep을 사용하여 시그널에 반응하도록 만드세요.
💡 디버깅 시에는 strace 같은 도구를 구현하세요. 모든 시스템 콜의 인자와 반환 값을 로깅하면, 애플리케이션이 어떤 파일을 어떻게 접근하는지 쉽게 추적할 수 있습니다.
💡 더 발전된 사용법으로는 비동기 I/O(io_uring, epoll)를 지원할 수 있습니다. read/write를 즉시 반환하고 완료 시 콜백을 호출하여, 고성능 서버에서 수천 개의 동시 I/O를 처리합니다. Rust의 Future와 결합하면 우아한 비동기 파일 I/O를 만들 수 있습니다.