이미지 로딩 중...

Rust로 만드는 나만의 OS 디렉토리 구조 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 14. · 5 Views

Rust로 만드는 나만의 OS 디렉토리 구조 완벽 가이드

운영체제의 핵심인 디렉토리 구조를 Rust로 직접 구현하는 방법을 배웁니다. 파일 시스템의 기본 개념부터 디렉토리 엔트리, i-node 구조, 경로 탐색 알고리즘까지 실무에서 바로 활용할 수 있는 깊이 있는 내용을 다룹니다.


목차

  1. 디렉토리 엔트리 구조 - 파일 시스템의 기본 단위
  2. I-Node 구조체 - 파일의 메타데이터 저장소
  3. 경로 탐색 알고리즘 - 파일 경로에서 I-Node 찾기
  4. 디렉토리 블록 구조 - 엔트리 배열의 효율적 저장
  5. 파일 생성 함수 - 새 I-Node와 디렉토리 엔트리 할당
  6. 디렉토리 생성 함수 - 하위 디렉토리 구조 초기화
  7. 파일 삭제 함수 - I-Node 해제와 링크 카운트 관리
  8. 심볼릭 링크 구현 - 간접 참조와 순환 탐지

1. 디렉토리 엔트리 구조 - 파일 시스템의 기본 단위

시작하며

여러분이 운영체제를 개발하면서 "파일과 디렉토리를 어떻게 메모리에 저장하고 관리해야 할까?"라는 고민을 해본 적 있나요? ls 명령어를 입력했을 때 파일 목록이 어떻게 조회되는지, 파일 이름이 어떻게 실제 데이터와 연결되는지 궁금했던 경험이 있을 겁니다.

이런 문제는 운영체제 개발의 핵심입니다. 디렉토리 엔트리가 제대로 설계되지 않으면 파일 검색이 느려지거나, 메모리 낭비가 발생하고, 심지어 파일 시스템 전체가 불안정해질 수 있습니다.

바로 이럴 때 필요한 것이 디렉토리 엔트리(Directory Entry) 구조입니다. 파일 이름과 메타데이터를 효율적으로 저장하고, 빠른 검색을 가능하게 하며, 확장성 있는 파일 시스템의 기반을 제공합니다.

개요

간단히 말해서, 디렉토리 엔트리는 파일 이름과 해당 파일의 i-node 번호를 매핑하는 구조체입니다. 실제 운영체제에서 디렉토리는 특별한 파일로 취급되며, 그 내용은 여러 개의 디렉토리 엔트리로 구성됩니다.

예를 들어, /home/user/documents 디렉토리를 열면, 내부의 모든 파일과 서브디렉토리는 각각 하나의 디렉토리 엔트리로 표현됩니다. 기존 고수준 언어에서는 파일 시스템 API를 그냥 호출했다면, Rust로 OS를 만들 때는 바이트 수준에서 직접 구조를 설계하고 메모리 레이아웃을 제어해야 합니다.

디렉토리 엔트리의 핵심 특징은 세 가지입니다: 고정된 크기의 구조체로 메모리 정렬이 용이하고, i-node 번호를 통한 간접 참조로 유연성을 제공하며, 파일 타입 정보를 포함하여 빠른 파일 분류가 가능합니다. 이러한 특징들이 있어야 대용량 파일 시스템에서도 성능 저하 없이 동작할 수 있습니다.

코드 예제

// 디렉토리 엔트리 구조체 - 파일 이름과 i-node를 연결
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct DirEntry {
    pub inode: u32,           // i-node 번호 (파일의 실제 위치)
    pub rec_len: u16,         // 이 엔트리의 전체 길이
    pub name_len: u8,         // 파일 이름 길이
    pub file_type: u8,        // 파일 타입 (파일/디렉토리/심볼릭링크 등)
    pub name: [u8; 255],      // 파일 이름 (최대 255바이트)
}

impl DirEntry {
    // 새 디렉토리 엔트리 생성
    pub fn new(inode: u32, name: &str, file_type: FileType) -> Self {
        let mut entry = DirEntry {
            inode,
            rec_len: core::mem::size_of::<DirEntry>() as u16,
            name_len: name.len() as u8,
            file_type: file_type as u8,
            name: [0; 255],
        };
        // 파일 이름을 바이트 배열로 복사
        entry.name[..name.len()].copy_from_slice(name.as_bytes());
        entry
    }
}

설명

이것이 하는 일: 디렉토리 엔트리는 파일 이름을 실제 파일 데이터와 연결하는 중간 계층 역할을 합니다. 사용자가 "document.txt"라는 이름으로 파일을 찾으면, 디렉토리 엔트리가 해당 이름을 i-node 번호로 변환해주고, 그 i-node를 통해 실제 데이터 블록에 접근할 수 있게 됩니다.

첫 번째로, #[repr(C)] 속성은 이 구조체가 C 언어와 동일한 메모리 레이아웃을 갖도록 보장합니다. 왜 이렇게 하는지는 명확합니다 - 디스크에 저장할 때와 메모리에 로드할 때 바이트 순서와 패딩이 정확히 일치해야 데이터 손상을 방지할 수 있기 때문입니다.

Rust는 기본적으로 구조체 필드 순서를 최적화할 수 있지만, 파일 시스템처럼 바이트 레벨에서 정확성이 중요한 경우 이 속성이 필수입니다. 그 다음으로, inode 필드가 u32 타입인 이유를 살펴봅시다.

32비트 정수는 약 42억 개의 파일을 표현할 수 있습니다. rec_len과 name_len은 각각 엔트리 전체 크기와 파일 이름 길이를 저장하며, 이를 통해 디렉토리를 순회할 때 다음 엔트리로 빠르게 이동할 수 있습니다.

file_type 필드는 1바이트로 파일의 종류를 구분하여, 실제 i-node를 읽기 전에 파일 타입을 미리 알 수 있어 성능이 향상됩니다. 마지막으로, name 필드는 255바이트 고정 크기 배열입니다.

가변 길이 문자열 대신 고정 크기를 사용하는 이유는 메모리 할당 없이 스택에서 빠르게 처리할 수 있고, 디스크 I/O 시 정확한 오프셋 계산이 가능하기 때문입니다. 여러분이 이 코드를 사용하면 파일 이름 조회 성능이 향상되고, 메모리 안전성이 보장되며, 대용량 디렉토리에서도 안정적으로 동작하는 파일 시스템을 구축할 수 있습니다.

특히 Rust의 타입 시스템 덕분에 잘못된 i-node 참조나 버퍼 오버플로우 같은 치명적인 버그를 컴파일 타임에 방지할 수 있습니다.

실전 팁

💡 name 배열 크기를 255로 제한하는 것은 대부분의 UNIX 계열 파일 시스템 표준입니다. 더 긴 이름이 필요하다면 여러 엔트리로 분할하는 확장 메커니즘을 구현하세요.

💡 rec_len을 활용하여 삭제된 엔트리를 재사용할 수 있습니다. 엔트리를 삭제할 때 실제로 제거하지 않고 rec_len을 조정하여 다음 엔트리와 병합하면 디스크 단편화를 줄일 수 있습니다.

💡 디렉토리 엔트리를 읽을 때는 항상 name_len을 먼저 확인하여 버퍼 오버런을 방지하세요. Rust의 슬라이스 기능을 활용하면 안전하게 문자열을 추출할 수 있습니다.

💡 성능 최적화를 위해 자주 접근하는 엔트리는 해시맵으로 캐싱하세요. 대규모 디렉토리에서 선형 탐색은 O(n) 시간이 걸리지만, 캐시를 사용하면 O(1)로 개선됩니다.

💡 멀티스레드 환경에서는 디렉토리 엔트리 수정 시 RwLock을 사용하여 동시성 제어를 구현하세요. 읽기는 여러 스레드가 동시에 가능하지만, 쓰기는 배타적 잠금이 필요합니다.


2. I-Node 구조체 - 파일의 메타데이터 저장소

시작하며

여러분이 파일 시스템을 설계하면서 "파일의 크기, 생성 시간, 권한, 실제 데이터 위치를 어디에 어떻게 저장해야 할까?"라는 난관에 부딪힌 적 있나요? 파일 이름과 실제 데이터를 직접 연결하면 파일 이름을 바꿀 때마다 전체 데이터를 옮겨야 하는 비효율이 발생합니다.

이런 문제는 운영체제 설계의 고전적인 과제입니다. 파일의 논리적 정보(이름)와 물리적 정보(데이터 위치)를 분리하지 않으면, 하드링크를 구현할 수 없고, 파일 이름 변경이 비용이 많이 들며, 권한 관리도 복잡해집니다.

바로 이럴 때 필요한 것이 I-Node(Index Node)입니다. 파일의 메타데이터와 데이터 블록 위치를 중앙 집중식으로 관리하여, 파일 이름과 데이터를 분리하고, 효율적인 저장 공간 관리를 가능하게 합니다.

개요

간단히 말해서, I-Node는 파일의 모든 메타데이터를 담고 있는 구조체로, 파일 이름을 제외한 모든 정보를 저장합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 여러 디렉토리 엔트리가 같은 I-Node를 가리킬 수 있어 하드링크 구현이 가능해지고, 파일 이름 변경 시 디렉토리 엔트리만 수정하면 되므로 O(1) 시간에 완료됩니다.

예를 들어, /home/user/doc.txt와 /tmp/backup.txt가 같은 I-Node를 가리키면 실제로는 하나의 파일이지만 두 개의 이름으로 접근할 수 있습니다. 기존에는 파일 이름과 데이터를 직접 연결했다면, 이제는 파일 이름 → I-Node → 데이터 블록이라는 2단계 간접 참조를 사용할 수 있습니다.

I-Node의 핵심 특징은 세 가지입니다: 직접/간접 블록 포인터를 통한 유연한 크기 관리, 타임스탬프를 통한 파일 추적, 권한 비트를 통한 접근 제어입니다. 이러한 특징들이 현대 파일 시스템의 유연성과 보안성을 보장합니다.

코드 예제

// I-Node 구조체 - 파일의 모든 메타데이터 저장
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct INode {
    pub mode: u16,              // 파일 타입과 권한 (rwxrwxrwx)
    pub uid: u16,               // 소유자 ID
    pub size: u32,              // 파일 크기 (바이트)
    pub atime: u32,             // 마지막 접근 시간 (Unix timestamp)
    pub ctime: u32,             // 생성 시간
    pub mtime: u32,             // 마지막 수정 시간
    pub dtime: u32,             // 삭제 시간
    pub gid: u16,               // 그룹 ID
    pub links_count: u16,       // 하드링크 개수
    pub blocks: u32,            // 할당된 블록 개수
    pub flags: u32,             // 파일 플래그
    pub direct: [u32; 12],      // 직접 블록 포인터 (12개)
    pub indirect: u32,          // 1단계 간접 블록 포인터
    pub double_indirect: u32,   // 2단계 간접 블록 포인터
    pub triple_indirect: u32,   // 3단계 간접 블록 포인터
}

impl INode {
    // 파일 크기에 따라 필요한 블록 번호 계산
    pub fn get_block_number(&self, block_index: u32) -> Option<u32> {
        if block_index < 12 {
            // 직접 블록 사용 (48KB까지)
            Some(self.direct[block_index as usize])
        } else {
            // 간접 블록 사용 (대용량 파일)
            None // 실제 구현에서는 간접 블록을 읽어야 함
        }
    }
}

설명

이것이 하는 일: I-Node는 파일 시스템의 심장부로, 파일에 대한 모든 정보를 중앙 집중식으로 관리합니다. 사용자가 파일에 접근하려 할 때, 운영체제는 먼저 I-Node를 읽어서 권한을 확인하고, 파일 크기를 알아낸 다음, 데이터 블록 위치를 찾아서 실제 내용을 읽어옵니다.

첫 번째로, mode 필드는 16비트로 파일 타입(일반 파일/디렉토리/심볼릭 링크 등)과 9비트의 권한(소유자/그룹/기타 사용자의 읽기/쓰기/실행)을 저장합니다. 왜 이렇게 하는지는 간단합니다 - 비트 연산을 통해 빠르게 권한을 확인할 수 있고, 메모리를 절약할 수 있기 때문입니다.

예를 들어, mode & 0o400을 확인하면 소유자에게 읽기 권한이 있는지 즉시 알 수 있습니다. 그 다음으로, 블록 포인터 구조가 핵심입니다.

12개의 직접 블록 포인터는 작은 파일(4KB 블록 기준 48KB)을 빠르게 처리하고, 간접 블록 포인터는 대용량 파일을 지원합니다. 1단계 간접 블록은 1024개의 블록 번호를 담을 수 있어 4MB까지, 2단계는 약 4GB, 3단계는 4TB 이상을 표현할 수 있습니다.

이 설계는 작은 파일은 빠르게, 큰 파일은 유연하게 처리하는 균형 잡힌 접근법입니다. 세 번째로, 타임스탬프 필드들(atime, ctime, mtime)은 파일 추적과 백업 시스템에 필수적입니다.

atime은 마지막으로 파일을 읽은 시간, mtime은 내용을 수정한 시간, ctime은 I-Node 자체가 변경된 시간(권한 변경, 링크 추가 등)을 기록합니다. dtime은 파일이 삭제된 시간으로, 파일 복구 기능을 구현할 때 유용합니다.

여러분이 이 코드를 사용하면 하드링크를 통한 공간 절약이 가능하고(같은 데이터를 여러 이름으로 접근), 파일 권한 관리가 세밀해지며, 소형 파일은 빠르게 대형 파일은 효율적으로 처리할 수 있습니다. 특히 links_count 필드를 활용하면 하드링크가 모두 삭제되었을 때만 실제 데이터를 해제하는 안전한 파일 삭제 로직을 구현할 수 있습니다.

실전 팁

💡 I-Node 번호는 0번부터 시작하지 마세요. 일반적으로 0번과 1번은 특수 용도로 예약되고, 루트 디렉토리는 2번 I-Node를 사용하는 것이 관례입니다.

💡 atime 업데이트는 성능에 큰 영향을 줍니다. 읽기 작업마다 I-Node를 디스크에 쓰면 느려지므로, relatime 옵션처럼 조건부 업데이트를 구현하는 것이 좋습니다.

💡 간접 블록을 읽을 때는 캐싱이 필수입니다. 대용량 파일을 순차 읽기할 때 매번 간접 블록을 디스크에서 읽으면 성능이 급격히 저하됩니다.

💡 links_count가 0이 되면 즉시 데이터를 삭제하지 말고 일정 기간 보관하세요. 파일 복구 기능을 제공할 수 있고, 실수로 삭제한 파일을 복원할 수 있습니다.

💡 mode 필드를 다룰 때는 비트마스크 상수를 정의하세요. const S_IRUSR: u16 = 0o400 같은 상수를 사용하면 코드 가독성이 크게 향상됩니다.


3. 경로 탐색 알고리즘 - 파일 경로에서 I-Node 찾기

시작하며

여러분이 "/home/user/documents/report.txt"라는 경로를 입력했을 때, 운영체제가 어떻게 이 파일을 찾는지 궁금한 적 있나요? 단순히 문자열을 파싱하는 것이 아니라, 여러 단계의 디렉토리를 순회하면서 각 단계마다 I-Node를 읽고 권한을 확인해야 합니다.

이런 문제는 파일 시스템 성능의 핵심입니다. 경로 탐색이 비효율적이면 모든 파일 작업이 느려지고, 권한 확인을 건너뛰면 보안 취약점이 발생하며, 심볼릭 링크 처리를 잘못하면 무한 루프에 빠질 수 있습니다.

바로 이럴 때 필요한 것이 경로 탐색 알고리즘입니다. 경로 문자열을 컴포넌트로 분해하고, 각 디렉토리를 순차적으로 탐색하며, 권한과 심볼릭 링크를 적절히 처리하여 최종 I-Node를 안전하게 찾아냅니다.

개요

간단히 말해서, 경로 탐색은 파일 경로 문자열을 루트부터 시작하여 각 디렉토리 컴포넌트를 순회하면서 최종 파일의 I-Node 번호를 찾는 과정입니다. 실무에서 이 알고리즘은 모든 파일 작업의 출발점입니다.

open(), stat(), chmod() 같은 시스템 콜은 모두 먼저 경로 탐색을 수행합니다. 예를 들어, "/home/user/doc.txt"를 열려면 먼저 "/" 디렉토리의 I-Node를 읽고, 그 안에서 "home" 엔트리를 찾아서 I-Node를 얻고, 그 I-Node에서 "user"를 찾고, 최종적으로 "doc.txt"의 I-Node를 얻는 4단계 과정을 거칩니다.

기존 추상화된 파일 시스템 라이브러리를 사용했다면, 이제는 바이트 수준에서 디렉토리 블록을 읽고 엔트리를 파싱하는 저수준 작업을 직접 구현해야 합니다. 이 알고리즘의 핵심 특징은 세 가지입니다: 단계별 권한 확인으로 보안 보장, 심볼릭 링크 추적 횟수 제한으로 무한 루프 방지, 현재 디렉토리와 부모 디렉토리 처리로 상대 경로 지원입니다.

이러한 특징들이 안전하고 유연한 파일 접근을 가능하게 합니다.

코드 예제

// 경로 탐색 함수 - 경로 문자열에서 I-Node 번호 찾기
pub struct PathResolver {
    root_inode: u32,
    max_symlink_depth: u8,
}

impl PathResolver {
    // 경로를 탐색하여 최종 I-Node 번호 반환
    pub fn resolve_path(&self, path: &str) -> Result<u32, FsError> {
        // 절대 경로는 루트부터, 상대 경로는 현재 디렉토리부터 시작
        let mut current_inode = if path.starts_with('/') {
            self.root_inode
        } else {
            self.get_current_dir_inode()
        };

        // 경로를 '/'로 분리하여 각 컴포넌트 순회
        for component in path.split('/').filter(|s| !s.is_empty()) {
            // 특수 디렉토리 처리
            match component {
                "." => continue,                              // 현재 디렉토리
                ".." => current_inode = self.get_parent(current_inode)?, // 부모 디렉토리
                name => {
                    // 현재 디렉토리에서 name 엔트리 검색
                    let dir_entries = self.read_directory(current_inode)?;
                    current_inode = dir_entries
                        .iter()
                        .find(|e| e.get_name() == name)
                        .ok_or(FsError::NotFound)?
                        .inode;
                }
            }

            // 권한 확인: 디렉토리 실행 권한이 있어야 탐색 가능
            self.check_permission(current_inode, Permission::Execute)?;
        }

        Ok(current_inode)
    }
}

설명

이것이 하는 일: 경로 탐색 알고리즘은 사용자가 제공한 경로 문자열(예: "/home/user/doc.txt")을 받아서, 루트 디렉토리부터 시작하여 각 경로 컴포넌트를 하나씩 따라가면서 최종 파일의 I-Node 번호를 찾아내는 과정입니다. 각 단계에서 권한을 확인하고, 심볼릭 링크를 처리하며, 존재하지 않는 경로는 에러를 반환합니다.

첫 번째로, 경로가 절대 경로('/'로 시작)인지 상대 경로인지 구분합니다. 왜 이렇게 하는지는 명확합니다 - 절대 경로는 항상 루트 I-Node(보통 2번)부터 시작하지만, 상대 경로는 현재 작업 디렉토리의 I-Node부터 시작해야 하기 때문입니다.

프로세스마다 현재 디렉토리가 다르므로, PCB(Process Control Block)에 저장된 current_dir_inode를 사용합니다. 그 다음으로, split('/')로 경로를 분해한 후 filter로 빈 문자열을 제거합니다.

"/home//user///doc.txt"처럼 연속된 슬래시가 있어도 올바르게 파싱됩니다. 각 컴포넌트를 순회하면서 "."은 현재 디렉토리를 의미하므로 건너뛰고, ".."은 부모 디렉토리로 이동하며, 일반 이름은 현재 디렉토리의 엔트리 목록에서 검색합니다.

read_directory() 함수는 I-Node의 데이터 블록을 읽어서 DirEntry 배열로 파싱하는 작업을 수행합니다. 세 번째로, 매 단계마다 check_permission()을 호출하여 디렉토리 실행 권한(x)을 확인합니다.

디렉토리의 실행 권한은 "탐색 가능" 권한을 의미하므로, 이 권한이 없으면 하위 파일에 접근할 수 없습니다. 예를 들어, /home 디렉토리에 실행 권한이 없으면 /home/user/doc.txt에 접근할 수 없습니다.

이는 중요한 보안 메커니즘입니다. 여러분이 이 코드를 사용하면 안전한 파일 접근 제어가 가능하고, 상대 경로와 절대 경로를 모두 지원하며, ".."와 "." 같은 특수 디렉토리 표기를 올바르게 처리할 수 있습니다.

또한 Result 타입을 반환하므로 에러 처리가 명확하고, 파일이 없거나 권한이 부족한 경우를 호출자가 적절히 대응할 수 있습니다.

실전 팁

💡 경로 탐색 결과를 캐싱하면 성능이 크게 향상됩니다. 특히 같은 디렉토리 내 여러 파일에 접근할 때 디렉토리 I-Node를 재사용하세요.

💡 심볼릭 링크를 처리할 때는 최대 깊이 제한(보통 40)을 두어야 합니다. 무한 루프(A→B→A)를 방지하기 위해 추적 횟수를 세고 초과 시 ELOOP 에러를 반환하세요.

💡 ".."를 처리할 때 루트 디렉토리의 부모는 자기 자신입니다. "/.."는 "/"와 동일하게 처리해야 합니다.

💡 성능 최적화를 위해 경로 정규화(normalization)를 먼저 수행하세요. "/home/./user/../user/doc.txt"를 "/home/user/doc.txt"로 변환하면 불필요한 I-Node 읽기를 줄일 수 있습니다.

💡 멀티스레드 환경에서는 경로 탐색 중 디렉토리가 삭제되거나 이름이 변경될 수 있습니다. 각 단계마다 I-Node가 여전히 유효한지 확인하는 검증 로직이 필요합니다.


4. 디렉토리 블록 구조 - 엔트리 배열의 효율적 저장

시작하며

여러분이 디렉토리에 파일이 1000개 있을 때, 이 모든 엔트리를 어떻게 디스크에 저장해야 할까요? 한 블록에 다 들어가지 않으면 어떻게 해야 하고, 파일을 추가하거나 삭제할 때 공간을 어떻게 관리해야 할까요?

이런 문제는 대용량 디렉토리 성능에 직결됩니다. 디렉토리 블록 구조가 비효율적이면 파일 검색이 느려지고, 공간 단편화가 발생하며, 디렉토리 확장이 어려워집니다.

특히 많은 파일이 생성/삭제되는 임시 디렉토리에서는 더욱 심각한 문제가 됩니다. 바로 이럴 때 필요한 것이 디렉토리 블록 구조입니다.

가변 크기 엔트리를 효율적으로 패킹하고, 삭제된 공간을 재사용하며, 여러 블록에 걸친 디렉토리를 지원하여 확장 가능한 디렉토리 관리를 제공합니다.

개요

간단히 말해서, 디렉토리 블록은 여러 개의 가변 크기 디렉토리 엔트리를 연속적으로 배치한 4KB(또는 설정된 블록 크기) 데이터 블록입니다. 실무에서 디렉토리는 특별한 파일로 취급되며, 그 내용은 DirEntry 구조체의 연속입니다.

각 엔트리의 rec_len 필드가 다음 엔트리로의 오프셋을 나타내므로 가변 크기 엔트리를 순회할 수 있습니다. 예를 들어, 파일 이름이 3바이트인 엔트리와 255바이트인 엔트리가 같은 블록에 효율적으로 저장됩니다.

기존에는 고정 크기 엔트리 배열을 사용했다면, 이제는 가변 크기 엔트리를 링크드 리스트처럼 연결하여 공간 효율을 극대화할 수 있습니다. 디렉토리 블록의 핵심 특징은 세 가지입니다: rec_len 기반 순회로 가변 크기 지원, 삭제 시 rec_len 조정으로 공간 재사용, 블록 체인을 통한 대용량 디렉토리 지원입니다.

이러한 특징들이 디렉토리 공간 관리의 유연성을 제공합니다.

코드 예제

// 디렉토리 블록 읽기 및 엔트리 추출
pub struct DirectoryBlock {
    data: [u8; 4096],  // 4KB 블록
}

impl DirectoryBlock {
    // 블록에서 모든 엔트리를 추출하는 이터레이터
    pub fn entries(&self) -> DirEntryIterator {
        DirEntryIterator {
            data: &self.data,
            offset: 0,
        }
    }

    // 새 엔트리 추가 - 삭제된 공간 재사용
    pub fn add_entry(&mut self, entry: &DirEntry) -> Result<(), FsError> {
        let entry_size = entry.rec_len as usize;

        // 충분한 공간을 가진 삭제된 엔트리 찾기
        for (offset, existing) in self.entries().enumerate() {
            if existing.inode == 0 {  // 삭제된 엔트리
                let available_space = existing.rec_len as usize;
                if available_space >= entry_size {
                    // 엔트리 복사
                    unsafe {
                        let ptr = self.data.as_mut_ptr().add(offset);
                        core::ptr::copy_nonoverlapping(
                            entry as *const _ as *const u8,
                            ptr,
                            entry_size
                        );
                    }
                    return Ok(());
                }
            }
        }

        Err(FsError::NoSpace)
    }
}

// 디렉토리 엔트리 이터레이터
pub struct DirEntryIterator<'a> {
    data: &'a [u8],
    offset: usize,
}

impl<'a> Iterator for DirEntryIterator<'a> {
    type Item = &'a DirEntry;

    fn next(&mut self) -> Option<Self::Item> {
        if self.offset >= self.data.len() {
            return None;
        }

        // 현재 오프셋에서 DirEntry 읽기
        let entry = unsafe {
            &*(self.data.as_ptr().add(self.offset) as *const DirEntry)
        };

        // rec_len만큼 오프셋 이동
        self.offset += entry.rec_len as usize;

        Some(entry)
    }
}

설명

이것이 하는 일: 디렉토리 블록 구조는 4KB 블록 내에 여러 개의 디렉토리 엔트리를 가변 크기로 패킹합니다. 각 엔트리는 실제 파일 이름 길이에 맞춰 공간을 차지하며, rec_len 필드가 다음 엔트리의 시작 위치를 가리켜서 블록 전체를 순회할 수 있게 합니다.

첫 번째로, entries() 메서드는 Rust의 이터레이터 패턴을 활용하여 안전하게 엔트리를 순회합니다. 왜 이렇게 하는지는 메모리 안전성 때문입니다.

직접 포인터 연산을 하면 버퍼 오버런이 발생할 수 있지만, 이터레이터는 offset을 추적하여 블록 끝을 넘지 않도록 보장합니다. next() 메서드는 현재 오프셋에서 DirEntry를 읽고, rec_len만큼 오프셋을 증가시켜 다음 엔트리로 이동합니다.

그 다음으로, add_entry() 메서드는 공간 재사용 전략을 구현합니다. 파일이 삭제되면 실제로 엔트리를 제거하지 않고 inode 필드를 0으로 설정합니다.

새 파일을 추가할 때는 먼저 inode가 0인 삭제된 엔트리를 찾고, 충분한 공간(rec_len)이 있으면 그 자리에 새 엔트리를 덮어씁니다. 이렇게 하면 디스크 I/O 없이 블록 내에서 공간을 재활용할 수 있어 성능이 향상됩니다.

세 번째로, unsafe 블록을 사용하는 이유는 저수준 메모리 조작이 필요하기 때문입니다. copy_nonoverlapping()은 바이트 배열에 구조체를 직접 쓰는 가장 빠른 방법입니다.

Rust의 타입 시스템으로는 이런 저수준 작업을 표현할 수 없지만, 우리가 오프셋과 크기를 정확히 계산했다면 안전합니다. 단, unsafe 블록은 최소화하고 주변 로직으로 안전성을 보장해야 합니다.

여러분이 이 코드를 사용하면 디렉토리 공간을 효율적으로 관리할 수 있고(삭제된 공간 재사용), 대용량 디렉토리를 지원하며(블록 체인), 이터레이터로 안전하게 순회할 수 있습니다. 특히 이터레이터 패턴은 for 루프와 함께 사용하면 간결하고 안전한 코드를 작성할 수 있습니다.

실전 팁

💡 엔트리를 삭제할 때는 이전 엔트리의 rec_len을 확장하여 병합하세요. 여러 개의 작은 삭제된 공간보다 하나의 큰 공간이 재사용하기 쉽습니다.

💡 디렉토리 블록이 가득 차면 새 블록을 할당하고 I-Node의 direct 또는 indirect 포인터에 추가하세요. 블록 체인을 구성하여 대용량 디렉토리를 지원합니다.

💡 성능을 위해 자주 접근하는 디렉토리 블록은 페이지 캐시에 보관하세요. 같은 디렉토리 내 여러 파일에 접근할 때 디스크 읽기를 줄일 수 있습니다.

💡 디렉토리 엔트리가 정렬되어 있지 않으므로 검색은 O(n)입니다. 대용량 디렉토리는 해시 테이블이나 B-트리 같은 인덱스 구조를 추가로 구현하는 것이 좋습니다.

💡 rec_len 값이 비정상적으로 크거나 작으면 파일 시스템 손상을 의미합니다. 이터레이터에서 검증 로직을 추가하여 조기에 감지하세요.


5. 파일 생성 함수 - 새 I-Node와 디렉토리 엔트리 할당

시작하며

여러분이 touch 명령어나 open() 시스템 콜로 새 파일을 만들 때, 운영체제가 내부적으로 어떤 작업을 수행하는지 생각해본 적 있나요? 단순히 빈 파일을 만드는 것처럼 보이지만, 실제로는 I-Node 할당, 디렉토리 엔트리 추가, 권한 설정, 타임스탬프 기록 등 여러 단계를 거쳐야 합니다.

이런 작업은 파일 시스템의 일관성을 유지하는 데 중요합니다. I-Node를 할당했지만 디렉토리 엔트리를 추가하지 못하면 공간이 누수되고, 권한 설정을 빠뜨리면 보안 문제가 발생하며, 원자적으로 처리하지 않으면 시스템 충돌 시 불일치 상태가 됩니다.

바로 이럴 때 필요한 것이 체계적인 파일 생성 함수입니다. 모든 단계를 순서대로 수행하고, 에러 발생 시 롤백하며, 파일 시스템의 일관성을 보장합니다.

개요

간단히 말해서, 파일 생성 함수는 비어 있는 I-Node를 할당하고, 초기 메타데이터를 설정한 후, 부모 디렉토리에 새 엔트리를 추가하는 일련의 작업을 수행합니다. 실무에서 파일 생성은 원자적 작업이어야 합니다.

부분적으로만 완료되면 파일 시스템이 불일치 상태가 되어 fsck 같은 복구 도구가 필요합니다. 예를 들어, /home/user/new.txt를 만들 때 I-Node는 할당했는데 디렉토리 엔트리 추가에 실패하면, 그 I-Node는 영원히 사용할 수 없는 "고아" 상태가 됩니다.

기존 고수준 API에서는 fopen()이나 File::create()를 호출하면 끝났다면, 이제는 비트맵에서 빈 I-Node를 찾고, 구조체를 초기화하고, 부모 디렉토리를 수정하는 저수준 작업을 직접 구현해야 합니다. 파일 생성 함수의 핵심 특징은 세 가지입니다: I-Node 비트맵을 통한 빠른 할당, 트랜잭션 방식의 에러 처리, 부모 디렉토리 mtime 자동 업데이트입니다.

이러한 특징들이 안전하고 일관된 파일 생성을 보장합니다.

코드 예제

// 파일 생성 함수 - I-Node 할당 및 디렉토리 엔트리 추가
pub struct FileSystem {
    inode_bitmap: Bitmap,
    block_bitmap: Bitmap,
}

impl FileSystem {
    // 새 파일 생성
    pub fn create_file(
        &mut self,
        parent_inode: u32,
        name: &str,
        mode: u16,
        uid: u16,
        gid: u16
    ) -> Result<u32, FsError> {
        // 1. 빈 I-Node 할당
        let new_inode_num = self.inode_bitmap
            .find_free()
            .ok_or(FsError::NoInodes)?;

        // 2. I-Node 초기화
        let now = self.get_current_time();
        let mut new_inode = INode {
            mode,
            uid,
            gid,
            size: 0,
            atime: now,
            ctime: now,
            mtime: now,
            dtime: 0,
            links_count: 1,
            blocks: 0,
            flags: 0,
            direct: [0; 12],
            indirect: 0,
            double_indirect: 0,
            triple_indirect: 0,
        };

        // 3. 부모 디렉토리에 엔트리 추가
        let entry = DirEntry::new(new_inode_num, name, FileType::RegularFile);
        self.add_dir_entry(parent_inode, &entry)?;

        // 4. 부모 디렉토리 mtime 업데이트
        let mut parent = self.read_inode(parent_inode)?;
        parent.mtime = now;
        self.write_inode(parent_inode, &parent)?;

        // 5. 새 I-Node를 디스크에 쓰기
        self.write_inode(new_inode_num, &new_inode)?;

        // 6. 비트맵 업데이트
        self.inode_bitmap.set(new_inode_num, true);

        Ok(new_inode_num)
    }
}

설명

이것이 하는 일: 파일 생성 함수는 새 파일을 만들기 위한 모든 단계를 조율합니다. 먼저 사용 가능한 I-Node를 찾고, 초기 메타데이터를 설정한 후, 부모 디렉토리에 파일 이름을 등록하고, 모든 변경 사항을 디스크에 쓰는 작업을 원자적으로 수행합니다.

첫 번째로, inode_bitmap.find_free()는 I-Node 비트맵을 순회하여 0인 비트를 찾습니다. 왜 비트맵을 사용하는지는 효율성 때문입니다.

모든 I-Node를 순회하여 사용 중인지 확인하는 것보다, 비트맵의 한 바이트(8개 I-Node)를 한 번에 확인하는 것이 훨씬 빠릅니다. 또한 비트맵은 메모리에 캐싱하기 쉬워서 대부분의 경우 디스크 I/O 없이 할당할 수 있습니다.

그 다음으로, 새 I-Node를 초기화할 때 모든 필드를 적절히 설정합니다. mode는 파일 타입과 권한, uid/gid는 소유자, size는 0(빈 파일), links_count는 1(하나의 이름), 타임스탬프는 현재 시간으로 설정합니다.

특히 중요한 것은 direct, indirect 포인터를 모두 0으로 초기화하는 것입니다. 초기화하지 않으면 쓰레기 값이 들어가 파일 시스템 손상을 일으킬 수 있습니다.

세 번째로, add_dir_entry()는 부모 디렉토리의 블록을 읽어서 새 엔트리를 추가합니다. 이 작업이 실패하면 이미 할당한 I-Node를 해제해야 합니다(롤백).

실제 프로덕션 구현에서는 트랜잭션 로그나 저널링을 사용하여 원자성을 보장합니다. 성공하면 부모 디렉토리의 mtime을 업데이트하여 "이 디렉토리가 변경되었다"는 사실을 기록합니다.

여러분이 이 코드를 사용하면 파일 생성 작업의 모든 단계를 제어할 수 있고, 에러 처리를 명확히 할 수 있으며, 파일 시스템 일관성을 보장할 수 있습니다. Result 타입을 반환하므로 호출자는 성공 시 새 I-Node 번호를 얻거나, 실패 시 적절한 에러 메시지를 받아 사용자에게 전달할 수 있습니다.

실전 팁

💡 파일 생성 실패 시 이미 할당한 자원을 반드시 해제하세요. RAII 패턴이나 defer 메커니즘을 활용하면 에러 경로에서 누수를 방지할 수 있습니다.

💡 부모 디렉토리에 쓰기 권한이 있는지 먼저 확인하세요. 권한 없이 I-Node를 할당하면 롤백이 필요하므로 비효율적입니다.

💡 파일 이름이 이미 존재하는지 확인하는 것이 중요합니다. 같은 이름으로 덮어쓰지 않도록 add_dir_entry() 내부에서 중복 검사를 수행하세요.

💡 대량 파일 생성 시 비트맵 캐싱이 중요합니다. 비트맵을 메모리에 유지하고 일정 개수마다 디스크에 플러시하면 성능이 크게 향상됩니다.

💡 멀티스레드 환경에서는 같은 부모 디렉토리에 동시에 파일을 생성할 수 있으므로 디렉토리 수정 시 뮤텍스를 사용하세요.


6. 디렉토리 생성 함수 - 하위 디렉토리 구조 초기화

시작하며

여러분이 mkdir 명령어로 새 디렉토리를 만들 때, 일반 파일 생성과 어떻게 다른지 생각해본 적 있나요? 디렉토리도 I-Node를 할당하고 엔트리를 추가하는 것은 같지만, "."과 ".." 엔트리를 자동으로 생성하고, 부모 디렉토리의 링크 카운트를 증가시켜야 하는 등 추가 작업이 필요합니다.

이런 차이는 디렉토리의 특수한 성격 때문입니다. 디렉토리는 다른 파일을 담는 컨테이너이고, 계층 구조를 형성하며, "."과 ".."를 통해 트리 탐색이 가능해야 합니다.

이 초기화를 빠뜨리면 cd 명령어가 작동하지 않거나, 링크 카운트가 꼬여서 삭제가 불가능해집니다. 바로 이럴 때 필요한 것이 전용 디렉토리 생성 함수입니다.

일반 파일과 공통 부분은 재사용하되, 디렉토리 특화 초기화를 추가로 수행하여 올바른 계층 구조를 만듭니다.

개요

간단히 말해서, 디렉토리 생성은 파일 생성과 유사하지만, I-Node 모드를 디렉토리 타입으로 설정하고, "."과 ".." 엔트리를 자동 추가하며, 부모 디렉토리의 링크 카운트를 증가시키는 추가 단계를 포함합니다. 실무에서 디렉토리는 파일 시스템의 구조적 요소입니다.

"." 엔트리는 자기 자신을 가리키고, ".." 엔트리는 부모를 가리켜서 상대 경로 탐색이 가능해집니다. 예를 들어, /home/user/docs 디렉토리를 만들면, docs 내부에 자동으로 "." → docs 자신, ".." → user 디렉토리를 가리키는 엔트리가 생성됩니다.

기존에는 일반 파일과 디렉토리를 구분하지 않았다면, 이제는 디렉토리 특화 로직을 별도로 구현하여 파일 시스템의 계층 구조를 올바르게 유지할 수 있습니다. 디렉토리 생성의 핵심 특징은 세 가지입니다: 자동 "."과 ".." 엔트리 생성, 부모 links_count 관리, 디렉토리 타입 플래그 설정입니다.

이러한 특징들이 파일 시스템 트리의 무결성을 보장합니다.

코드 예제

// 디렉토리 생성 함수 - 계층 구조 초기화 포함
impl FileSystem {
    // 새 디렉토리 생성
    pub fn create_directory(
        &mut self,
        parent_inode: u32,
        name: &str,
        mode: u16,
        uid: u16,
        gid: u16
    ) -> Result<u32, FsError> {
        // 1. 디렉토리 타입으로 I-Node 할당
        let dir_mode = mode | S_IFDIR;  // 디렉토리 플래그 추가
        let new_dir_inode = self.inode_bitmap
            .find_free()
            .ok_or(FsError::NoInodes)?;

        // 2. 디렉토리 I-Node 초기화 (links_count는 2로 시작)
        let now = self.get_current_time();
        let mut dir_inode = INode {
            mode: dir_mode,
            uid,
            gid,
            size: 0,
            links_count: 2,  // "." 자신 + 부모의 "name" 엔트리
            // ... 나머지 필드 초기화
        };

        // 3. 디렉토리용 첫 번째 블록 할당
        let first_block = self.block_bitmap.find_free()
            .ok_or(FsError::NoSpace)?;
        dir_inode.direct[0] = first_block;
        dir_inode.blocks = 1;

        // 4. "." 엔트리 추가 (자기 자신)
        let dot_entry = DirEntry::new(
            new_dir_inode,
            ".",
            FileType::Directory
        );
        self.write_dir_entry(first_block, 0, &dot_entry)?;

        // 5. ".." 엔트리 추가 (부모)
        let dotdot_entry = DirEntry::new(
            parent_inode,
            "..",
            FileType::Directory
        );
        let offset = dot_entry.rec_len as usize;
        self.write_dir_entry(first_block, offset, &dotdot_entry)?;

        // 6. 부모 디렉토리에 새 디렉토리 엔트리 추가
        let entry = DirEntry::new(new_dir_inode, name, FileType::Directory);
        self.add_dir_entry(parent_inode, &entry)?;

        // 7. 부모 links_count 증가 (새 디렉토리의 ".." 때문)
        let mut parent = self.read_inode(parent_inode)?;
        parent.links_count += 1;
        parent.mtime = now;
        self.write_inode(parent_inode, &parent)?;

        // 8. 새 디렉토리 I-Node 저장
        self.write_inode(new_dir_inode, &dir_inode)?;
        self.inode_bitmap.set(new_dir_inode, true);
        self.block_bitmap.set(first_block, true);

        Ok(new_dir_inode)
    }
}

설명

이것이 하는 일: 디렉토리 생성 함수는 새 디렉토리를 위한 I-Node와 블록을 할당하고, 계층 구조 탐색에 필요한 특수 엔트리("."과 "..")를 자동으로 생성하며, 부모 디렉토리와의 링크 관계를 올바르게 설정합니다. 첫 번째로, dir_mode에 S_IFDIR 플래그를 추가하여 이 I-Node가 디렉토리임을 명시합니다.

왜 이렇게 하는지는 파일 타입 구분 때문입니다. mode 필드의 상위 4비트는 파일 타입(일반 파일, 디렉토리, 심볼릭 링크 등)을 나타내고, 하위 12비트는 권한을 나타냅니다.

stat() 시스템 콜이나 ls 명령어는 이 플래그를 보고 파일 타입을 판단합니다. 그 다음으로, links_count를 2로 초기화하는 것이 중요합니다.

이는 디렉토리 특유의 규칙입니다. 새 디렉토리 "docs"를 만들면, 부모 디렉토리의 "docs" 엔트리가 1개의 링크이고, 새 디렉토리 내부의 "." 엔트리가 또 다른 1개의 링크이므로 총 2입니다.

이후 하위 디렉토리를 만들 때마다 ".." 엔트리가 추가되므로 links_count가 증가합니다. 세 번째로, "."과 ".." 엔트리를 명시적으로 작성합니다.

"." 엔트리의 inode는 new_dir_inode(자기 자신), ".." 엔트리의 inode는 parent_inode입니다. 이 두 엔트리는 모든 디렉토리에 자동으로 존재해야 하며, 사용자가 cd ..

명령어를 실행하면 ".." 엔트리의 inode를 따라 부모로 이동합니다. 루트 디렉토리는 예외로 ".."도 자기 자신을 가리킵니다.

네 번째로, 부모 디렉토리의 links_count를 증가시킵니다. 왜냐하면 새 디렉토리의 ".." 엔트리가 부모를 가리키므로, 부모로의 링크가 하나 더 생기기 때문입니다.

이는 디렉토리 삭제 시 중요한데, links_count가 2보다 크면 하위 디렉토리가 있다는 의미이므로 삭제를 거부해야 합니다. 여러분이 이 코드를 사용하면 올바른 계층 구조의 디렉토리 트리를 만들 수 있고, cd 명령어와 상대 경로가 정상 작동하며, 링크 카운트 기반 참조 추적으로 안전한 삭제가 가능합니다.

특히 links_count를 정확히 관리하면 rmdir 명령어가 비어 있지 않은 디렉토리를 삭제하는 것을 방지할 수 있습니다.

실전 팁

💡 루트 디렉토리는 특수 케이스입니다. ".."가 자기 자신을 가리키고, links_count는 하위 디렉토리 개수 + 1로 계산됩니다.

💡 디렉토리를 삭제할 때는 links_count가 정확히 2인지 확인하세요(자신의 "."과 부모의 엔트리만 남음). 더 크면 하위 디렉토리가 있다는 의미입니다.

💡 "."과 ".." 엔트리는 숨김 파일이 아니라 실제 디렉토리 엔트리입니다. ls -a 명령어는 이들을 포함하여 표시합니다.

💡 디렉토리 rename 시 ".." 엔트리를 업데이트해야 합니다. 디렉토리를 다른 부모로 옮기면 모든 하위 디렉토리의 ".." 엔트리가 새 부모를 가리켜야 합니다.

💡 멀티스레드 환경에서 같은 디렉토리를 동시에 수정할 때는 디렉토리 수준 잠금이 필요합니다. 엔트리 추가와 links_count 업데이트는 원자적이어야 합니다.


7. 파일 삭제 함수 - I-Node 해제와 링크 카운트 관리

시작하며

여러분이 rm 명령어로 파일을 삭제할 때, 디스크에서 데이터가 즉시 지워진다고 생각하나요? 실제로는 훨씬 더 복잡합니다.

디렉토리 엔트리를 제거하고, 링크 카운트를 감소시키고, 0이 되면 I-Node와 데이터 블록을 해제하는 다단계 프로세스가 필요합니다. 이런 복잡성은 하드링크 때문입니다.

같은 파일이 여러 이름으로 존재할 수 있으므로, 하나의 이름을 삭제했다고 해서 실제 데이터를 바로 지우면 안 됩니다. links_count가 0이 될 때까지 데이터는 보존되어야 하고, 마지막 링크가 제거될 때 비로소 공간을 회수합니다.

바로 이럴 때 필요한 것이 체계적인 파일 삭제 함수입니다. 링크 카운트 기반 참조 추적으로 안전한 삭제를 보장하고, 여전히 사용 중인 파일은 보호하며, 마지막 참조가 사라질 때 자원을 회수합니다.

개요

간단히 말해서, 파일 삭제는 디렉토리 엔트리를 제거하고, I-Node의 links_count를 감소시킨 후, 0이 되면 데이터 블록과 I-Node를 비트맵에서 해제하는 작업입니다. 실무에서 삭제는 단순히 공간을 되돌리는 것 이상입니다.

파일이 열려 있는 상태(프로세스가 파일 디스크립터를 유지)에서 삭제되면, links_count는 0이지만 실제 해제는 파일이 닫힐 때까지 지연됩니다. 예를 들어, 프로세스가 로그 파일을 열어둔 상태에서 rm으로 삭제하면, 디렉토리에서는 사라지지만 프로세스는 계속 쓸 수 있고, 파일을 닫을 때 비로소 공간이 회수됩니다.

기존 고수준 API에서는 unlink()나 remove()를 호출하면 끝났다면, 이제는 디렉토리 순회, 엔트리 제거, 링크 카운트 관리, 블록 해제까지 저수준 작업을 직접 구현해야 합니다. 파일 삭제의 핵심 특징은 세 가지입니다: 링크 카운트 기반 참조 추적, 열린 파일 보호(unlinked but open), 블록 해제 지연으로 충돌 복구 가능입니다.

이러한 특징들이 안전하고 유연한 파일 삭제를 제공합니다.

코드 예제

// 파일 삭제 함수 - 링크 카운트 기반 참조 관리
impl FileSystem {
    // 파일 삭제 (실제로는 unlink - 링크 제거)
    pub fn unlink(
        &mut self,
        parent_inode: u32,
        name: &str
    ) -> Result<(), FsError> {
        // 1. 부모 디렉토리에서 해당 이름의 엔트리 찾기
        let entry = self.find_dir_entry(parent_inode, name)
            .ok_or(FsError::NotFound)?;
        let target_inode = entry.inode;

        // 2. 디렉토리 엔트리 제거 (inode를 0으로 설정)
        self.remove_dir_entry(parent_inode, name)?;

        // 3. 부모 디렉토리 mtime 업데이트
        let now = self.get_current_time();
        let mut parent = self.read_inode(parent_inode)?;
        parent.mtime = now;
        self.write_inode(parent_inode, &parent)?;

        // 4. 대상 I-Node의 links_count 감소
        let mut target = self.read_inode(target_inode)?;
        target.links_count -= 1;

        // 5. links_count가 0이 되면 실제 삭제
        if target.links_count == 0 {
            // 5-1. 열린 파일 디스크립터가 있는지 확인
            if self.is_file_open(target_inode) {
                // 열려 있으면 dtime만 설정하고 지연 삭제
                target.dtime = now;
                self.write_inode(target_inode, &target)?;
            } else {
                // 완전히 닫혀 있으면 즉시 삭제
                self.free_inode_blocks(target_inode)?;
                self.inode_bitmap.set(target_inode, false);
            }
        } else {
            // links_count > 0이면 I-Node 업데이트만
            self.write_inode(target_inode, &target)?;
        }

        Ok(())
    }

    // I-Node의 모든 데이터 블록 해제
    fn free_inode_blocks(&mut self, inode_num: u32) -> Result<(), FsError> {
        let inode = self.read_inode(inode_num)?;

        // 직접 블록 해제
        for &block in inode.direct.iter() {
            if block != 0 {
                self.block_bitmap.set(block, false);
            }
        }

        // 간접 블록 해제 (재귀적으로)
        if inode.indirect != 0 {
            self.free_indirect_block(inode.indirect)?;
        }

        // 2단계/3단계 간접 블록도 마찬가지로 처리
        // ...

        Ok(())
    }
}

설명

이것이 하는 일: 파일 삭제 함수는 논리적 삭제(디렉토리에서 이름 제거)와 물리적 삭제(데이터 블록 해제)를 분리하여, 하드링크와 열린 파일을 안전하게 처리합니다. 링크 카운트가 핵심 역할을 하며, 0이 될 때까지 실제 데이터는 보존됩니다.

첫 번째로, unlink라는 이름이 중요합니다. UNIX 계열 시스템에서 "삭제"는 사실 "링크 제거"입니다.

왜 이렇게 하는지는 하드링크 때문입니다. /home/user/doc.txt와 /tmp/backup.txt가 같은 I-Node를 가리킨다면, 하나를 삭제해도 다른 하나로 여전히 접근할 수 있어야 합니다.

remove_dir_entry()는 엔트리의 inode 필드를 0으로 설정하여 논리적으로 제거하지만, 실제 I-Node는 아직 유효합니다. 그 다음으로, links_count 감소와 0 확인이 핵심입니다.

links_count -= 1 후 0이 되면 "더 이상 이 파일을 가리키는 이름이 없다"는 의미이므로 삭제 후보가 됩니다. 하지만 즉시 삭제하지 않고 is_file_open()으로 파일 디스크립터 테이블을 확인합니다.

어떤 프로세스라도 이 파일을 열어둔 상태면 아직 사용 중이므로, dtime만 설정하고 실제 삭제는 마지막 close() 시스템 콜까지 지연됩니다. 세 번째로, free_inode_blocks()는 I-Node가 가리키는 모든 블록을 재귀적으로 해제합니다.

직접 블록은 간단히 비트맵에서 해제하면 되지만, 간접 블록은 먼저 블록을 읽어서 포함된 블록 번호 목록을 얻고, 각각을 해제한 후, 마지막으로 간접 블록 자체를 해제합니다. 2단계/3단계 간접 블록은 재귀적으로 처리하여 모든 블록을 누수 없이 회수합니다.

여러분이 이 코드를 사용하면 하드링크가 있는 파일을 안전하게 관리할 수 있고(하나의 이름만 삭제하고 데이터는 유지), 열린 파일을 보호할 수 있으며(삭제했지만 닫을 때까지 접근 가능), 모든 블록을 누수 없이 회수할 수 있습니다. 특히 dtime 필드를 활용하면 파일 복구 기능을 구현할 수 있습니다.

실전 팁

💡 디렉토리는 일반 파일과 다르게 삭제해야 합니다. rmdir 시스템 콜은 links_count가 2인지(비어 있음) 확인하고, "."과 ".." 외 다른 엔트리가 있으면 ENOTEMPTY 에러를 반환해야 합니다.

💡 삭제 후 즉시 블록을 0으로 덮어쓰지 마세요. 성능 저하가 크고, 보안이 중요한 경우에만 secure delete 옵션으로 제공하는 것이 좋습니다.

💡 간접 블록 해제는 깊이 우선 탐색으로 구현하세요. 재귀 호출로 간결하게 작성할 수 있지만, 스택 오버플로우를 방지하려면 반복문으로 구현하는 것이 더 안전합니다.

💡 파일 시스템 저널링을 사용한다면 삭제 작업도 트랜잭션에 포함시키세요. 삭제 중 충돌이 발생하면 재부팅 후 복구할 수 있습니다.

💡 ext4의 undelete 기능처럼 dtime이 설정된 I-Node를 일정 기간 보관하면 실수로 삭제한 파일을 복구할 수 있습니다. 백그라운드 프로세스가 주기적으로 오래된 것만 영구 삭제하도록 구현하세요.


8. 심볼릭 링크 구현 - 간접 참조와 순환 탐지

시작하며

여러분이 ln -s 명령어로 심볼릭 링크를 만들 때, 하드링크와 어떤 차이가 있는지 아시나요? 하드링크는 같은 I-Node를 가리키지만, 심볼릭 링크는 문자열 경로를 저장하여 간접적으로 참조합니다.

이 차이가 파일 시스템 간 링크, 디렉토리 링크, 깨진 링크 감지 같은 기능을 가능하게 합니다. 이런 유연성에는 복잡도가 따릅니다.

심볼릭 링크를 따라가다 무한 루프에 빠지거나(A→B→A), 대상 파일이 삭제되어 깨진 링크가 되거나, 여러 단계를 거쳐 성능이 저하될 수 있습니다. 안전한 구현을 위해서는 순환 탐지와 깊이 제한이 필수입니다.

바로 이럴 때 필요한 것이 체계적인 심볼릭 링크 구현입니다. 경로 문자열을 I-Node에 저장하고, 탐색 시 재귀적으로 추적하며, 최대 깊이 제한으로 무한 루프를 방지합니다.

개요

간단히 말해서, 심볼릭 링크는 대상 파일의 경로를 문자열로 저장하는 특별한 파일 타입으로, 접근 시 운영체제가 자동으로 대상 경로를 읽어서 다시 탐색을 수행합니다. 실무에서 심볼릭 링크는 매우 유용합니다.

서로 다른 파일 시스템 간 링크를 만들 수 있고(/mnt/usb/file → /home/user/link), 디렉토리도 링크할 수 있으며, 대상이 없어도 링크 자체는 생성됩니다. 예를 들어, 소프트웨어 버전 관리를 위해 /usr/bin/python → /usr/bin/python3.11 같은 심볼릭 링크를 만들어 버전 교체를 쉽게 할 수 있습니다.

하드링크는 같은 I-Node를 직접 가리켰다면, 심볼릭 링크는 문자열 경로를 저장하여 간접 참조하므로 더 유연하지만 한 단계 더 느립니다. 심볼릭 링크의 핵심 특징은 세 가지입니다: 문자열 경로 저장으로 파일 시스템 독립성, 최대 추적 깊이 제한(보통 40)으로 순환 방지, 대상 존재 여부와 무관하게 생성 가능입니다.

이러한 특징들이 유연하면서도 안전한 간접 참조를 제공합니다.

코드 예제

// 심볼릭 링크 생성 및 추적 구현
impl FileSystem {
    // 심볼릭 링크 생성
    pub fn create_symlink(
        &mut self,
        parent_inode: u32,
        link_name: &str,
        target_path: &str,
        uid: u16,
        gid: u16
    ) -> Result<u32, FsError> {
        // 1. 심볼릭 링크용 I-Node 할당
        let symlink_inode = self.inode_bitmap.find_free()
            .ok_or(FsError::NoInodes)?;

        // 2. I-Node 초기화 (S_IFLNK 플래그)
        let now = self.get_current_time();
        let mut inode = INode {
            mode: S_IFLNK | 0o777,  // 심볼릭 링크는 보통 777 권한
            uid,
            gid,
            size: target_path.len() as u32,
            links_count: 1,
            // ... 타임스탬프 등
        };

        // 3. 대상 경로 저장 (작으면 I-Node 내부, 크면 별도 블록)
        if target_path.len() <= 60 {
            // Fast symlink: direct 배열에 직접 저장
            let bytes = target_path.as_bytes();
            unsafe {
                let ptr = inode.direct.as_mut_ptr() as *mut u8;
                core::ptr::copy_nonoverlapping(
                    bytes.as_ptr(),
                    ptr,
                    bytes.len()
                );
            }
        } else {
            // Slow symlink: 별도 블록에 저장
            let block = self.block_bitmap.find_free()
                .ok_or(FsError::NoSpace)?;
            self.write_block(block, target_path.as_bytes())?;
            inode.direct[0] = block;
            inode.blocks = 1;
            self.block_bitmap.set(block, true);
        }

        // 4. 부모 디렉토리에 엔트리 추가
        let entry = DirEntry::new(symlink_inode, link_name, FileType::Symlink);
        self.add_dir_entry(parent_inode, &entry)?;

        self.write_inode(symlink_inode, &inode)?;
        self.inode_bitmap.set(symlink_inode, true);

        Ok(symlink_inode)
    }

    // 심볼릭 링크 추적 (재귀적)
    pub fn follow_symlink(
        &self,
        inode_num: u32,
        depth: u8
    ) -> Result<u32, FsError> {
        if depth > MAX_SYMLINK_DEPTH {
            return Err(FsError::TooManyLinks);  // 순환 또는 너무 깊음
        }

        let inode = self.read_inode(inode_num)?;

        // 심볼릭 링크가 아니면 그대로 반환
        if (inode.mode & S_IFMT) != S_IFLNK {
            return Ok(inode_num);
        }

        // 대상 경로 읽기
        let target_path = self.read_symlink_target(inode_num)?;

        // 대상 경로 탐색
        let target_inode = self.resolve_path(&target_path)?;

        // 재귀적으로 추적 (대상도 심볼릭 링크일 수 있음)
        self.follow_symlink(target_inode, depth + 1)
    }
}

설명

이것이 하는 일: 심볼릭 링크 구현은 대상 파일의 경로를 문자열로 저장하고, 접근 시 그 경로를 다시 탐색하여 최종 I-Node를 찾습니다. Fast symlink 최적화로 짧은 경로는 I-Node 내부에 저장하여 블록 할당을 생략합니다.

첫 번째로, S_IFLNK 플래그를 mode에 설정하여 이 I-Node가 심볼릭 링크임을 표시합니다. 왜 권한이 0o777인지는 관례 때문입니다.

심볼릭 링크 자체의 권한은 사용되지 않고, 대상 파일의 권한이 확인되므로 링크는 모든 권한을 갖습니다. ls -l 명령어는 심볼릭 링크를 "lrwxrwxrwx"로 표시합니다.

그 다음으로, Fast symlink 최적화가 핵심입니다. 대부분의 심볼릭 링크는 경로가 짧으므로("/etc/config" 같은), I-Node의 direct 배열(48바이트)에 직접 저장하면 블록 할당을 생략할 수 있습니다.

60바이트 이하면 fast symlink, 이상이면 slow symlink로 별도 블록에 저장합니다. 이는 공간 절약뿐 아니라 성능 향상(블록 읽기 생략)에도 도움이 됩니다.

세 번째로, follow_symlink()는 재귀적으로 심볼릭 링크를 추적합니다. depth 매개변수로 추적 깊이를 세어서 MAX_SYMLINK_DEPTH(보통 40)를 넘으면 ELOOP 에러를 반환합니다.

이는 A→B→C→A 같은 순환 링크를 탐지하거나, 악의적으로 깊은 체인을 만들어 시스템을 느리게 하는 공격을 방지합니다. 각 단계에서 resolve_path()를 호출하여 대상 경로를 탐색하고, 그 결과가 또 심볼릭 링크면 재귀적으로 계속 추적합니다.

네 번째로, 심볼릭 링크는 대상이 없어도 생성할 수 있습니다. create_symlink()는 target_path의 유효성을 확인하지 않고 그냥 문자열로 저장합니다.

나중에 접근할 때 resolve_path()가 실패하면 "깨진 링크(broken link)"가 되지만, 링크 자체는 유효합니다. 이는 대상 파일을 생성하기 전에 링크를 먼저 만들거나, 마운트 포인트를 가리키는 링크를 만들 때 유용합니다.

여러분이 이 코드를 사용하면 파일 시스템 경계를 넘는 링크를 만들 수 있고, 디렉토리도 링크할 수 있으며, 순환 링크로부터 보호받을 수 있습니다. 특히 fast symlink 최적화로 대부분의 경우 추가 블록 I/O 없이 심볼릭 링크를 처리할 수 있어 성능이 우수합니다.

실전 팁

💡 심볼릭 링크는 상대 경로와 절대 경로를 모두 저장할 수 있습니다. "../config.txt" 같은 상대 경로는 링크가 있는 디렉토리 기준이므로, 링크를 이동하면 깨질 수 있습니다.

💡 readlink() 시스템 콜은 심볼릭 링크를 추적하지 않고 저장된 경로 문자열만 반환합니다. lstat()도 마찬가지로 링크 자체의 정보를 반환하고, stat()는 대상을 추적합니다.

💡 심볼릭 링크 체인을 최적화하려면 경로 캐싱을 구현하세요. 같은 심볼릭 링크를 여러 번 추적하는 경우 결과를 캐시하면 성능이 향상됩니다.

💡 순환 탐지를 더 효율적으로 하려면 방문한 I-Node 집합을 유지하세요. 같은 I-Node를 두 번 방문하면 즉시 순환을 탐지할 수 있습니다.

💡 보안을 위해 심볼릭 링크가 가리키는 대상의 권한을 확인하세요. 링크 자체는 권한이 없지만, 대상 파일의 권한은 검사해야 합니다.


#Rust#FileSystem#DirectoryStructure#INode#PathTraversal#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.