Theme:

블로그: 가상 파일 시스템 설계에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 가상 파일 시스템 설계에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n이전 글에서 터미널 에뮬레이터의 입력·출력·히스토리를 만들었습니다. 근데 cd를 쳐도 갈 곳이 없고, ls를 쳐도 보여줄 게 없습니다. 터미널 뒤에 파일 시스템이 없기 때문입니다.

실제 서버가 아니니까 진짜 파일 시스템을 쓸 수는 없고, 블로그 포스트 데이터를 기반으로 브라우저 메모리에 가상 파일 시스템 을 만들기로 했습니다.


구조: 포스트가 파일이 된다

블로그 포스트의 카테고리 경로가 디렉토리가 되고, 글 자체가 .md 파일이 됩니다.

PLAINTEXT
~/posts/
├── 개발/
│   ├── 백엔드/
│   │   ├── 스프링/
│   │   │   ├── 스프링부트/
│   │   │   │   ├── Spring Boot 란.md
│   │   │   │   └── 빈(Bean) 이란.md
│   │   │   └── 스프링 시큐리티/
│   │   │       └── ...
│   │   └── 네티/
│   └── 프론트엔드/
├── 데브옵스/
└── 사이드 프로젝트/
    └── 블로그/
        └── 이 글.md

사용자는 cd 개발/백엔드/스프링으로 이동하고, ls로 하위 글 목록을 보고, cat 파일명.md로 해당 글로 이동할 수 있습니다.


VirtualFS 클래스

Post 배열을 받아서 트리 구조를 만드는 클래스입니다.

JAVASCRIPT
class VirtualFS {
    constructor(posts) {
        this._root = { name: '~', children: {}, type: 'dir' };

        // posts 디렉토리 생성
        this._root.children['posts'] = { name: 'posts', children: {}, type: 'dir' };

        for (const post of posts) {
            const segments = post.path.split('/');
            let node = this._root.children['posts'];

            // 카테고리 경로를 따라 디렉토리 생성
            for (let i = 0; i < segments.length - 1; i++) {
                const seg = segments[i];
                if (!node.children[seg]) {
                    node.children[seg] = { name: seg, children: {}, type: 'dir' };
                }
                node = node.children[seg];
            }

            // 마지막 세그먼트는 파일
            const fileName = segments[segments.length - 1] + '.md';
            node.children[fileName] = {
                name: fileName,
                type: 'file',
                post: post,
            };
        }
    }
}

Post의 path개발/백엔드/스프링/Spring Boot 란이라면:

  • 개발, 백엔드, 스프링은 디렉토리 노드
  • Spring Boot 란.md는 파일 노드 (원본 Post 객체 참조)

각 노드는 { name, children, type } 형태로 단순합니다. 파일 노드에는 post 속성으로 원본 데이터를 연결합니다.


경로 해석

Unix 경로를 해석하는 유틸리티입니다. 상대 경로, ., .., 절대 경로를 전부 처리합니다.

JAVASCRIPT
function resolve(cwd, target) {
    const parts = target.startsWith('~')
        ? target.split('/')
        : [...cwd.split('/'), ...target.split('/')];

    const result = [];
    for (const part of parts) {
        if (part === '' || part === '.') continue;
        if (part === '..') {
            if (result.length > 1) result.pop();  // ~ 위로는 못 감
        } else {
            result.push(part);
        }
    }

    return result.join('/') || '~';
}
PLAINTEXT
resolve('~/posts/개발', '..')       → '~/posts'
resolve('~/posts', '개발/백엔드')   → '~/posts/개발/백엔드'
resolve('~/posts', '~/about')       → '~/about'
resolve('~', '../../..')            → '~'

..을 아무리 많이 써도 ~(홈) 위로는 올라갈 수 없게 했습니다. 루트 아래로 빠지면 에러가 나니까요.


명령어 구현

cd — 디렉토리 이동

JAVASCRIPT
function cd(ctx, args) {
    const target = args[0] || '~';
    const resolved = resolve(ctx.cwd, target);
    const node = ctx.fs.getNode(resolved);

    if (!node) {
        ctx.push(`cd: ${target}: No such file or directory`);
    } else if (node.type !== 'dir') {
        ctx.push(`cd: ${target}: Not a directory`);
    } else {
        ctx.setCwd(resolved);
    }
}

실제 bash의 에러 메시지를 그대로 따라했습니다. 존재하지 않는 경로면 No such file or directory, 파일을 cd하려고 하면 Not a directory.

ls — 목록 보기

JAVASCRIPT
function ls(ctx, args) {
    const target = args[0] || ctx.cwd;
    const node = ctx.fs.getNode(resolve(ctx.cwd, target));

    if (!node) {
        ctx.push(`ls: ${target}: No such file or directory`);
        return;
    }

    if (node.type === 'file') {
        ctx.push(node.name);
        return;
    }

    for (const entry of Object.values(node.children)) {
        if (entry.type === 'dir') {
            ctx.push(`drwxr-xr-x  ${entry.name}/`);
        } else {
            ctx.push(`-rw-r--r--  ${entry.name}`);
        }
    }
}

drwxr-xr-x 같은 퍼미션 표시는 의미는 없지만, 진짜 ls -l 느낌을 주기 위해 넣었습니다. 디렉토리는 이름 뒤에 /를 붙여서 구분합니다.

cat — 파일 읽기 (글로 이동)

JAVASCRIPT
function cat(ctx, args) {
    if (!args[0]) {
        ctx.push('cat: missing operand');
        return;
    }

    const node = ctx.fs.getNode(resolve(ctx.cwd, args[0]));

    if (!node) {
        ctx.push(`cat: ${args[0]}: No such file or directory`);
    } else if (node.type === 'dir') {
        ctx.push(`cat: ${args[0]}: Is a directory`);
    } else if (node.post) {
        ctx.openPost(node.post);
    }
}

cat으로 파일을 읽으면 마크다운을 터미널에 출력하는 대신 해당 글의 상세 페이지로 이동 합니다. 터미널에서 마크다운 원문을 보는 건 가독성이 떨어지니까, 차라리 예쁘게 렌더링된 페이지를 보여주는 게 낫습니다.


탭 자동완성

현재 디렉토리의 자식 노드 이름을 기반으로 자동완성합니다.

JAVASCRIPT
getCompletions(partial, cwd) {
    const dir = this.getNode(cwd);
    if (!dir || dir.type !== 'dir') return [];

    return Object.keys(dir.children)
        .filter(name => name.startsWith(partial))
        .map(name => {
            const child = dir.children[name];
            return child.type === 'dir' ? name + '/' : name;
        });
}
  • cd 개 + Tab → cd 개발/
  • cat Sp + Tab → cat Spring Boot 란.md
  • 후보가 여러 개면 목록 출력

디렉토리는 자동으로 /가 붙어서 연속으로 Tab을 눌러 깊이 들어갈 수 있습니다.


정리

구성요소역할
VirtualFS포스트 데이터 → 트리 구조 변환
path-utils상대/절대 경로 해석
commandscd, ls, cat, pwd 등 구현

가상 파일 시스템이 있으니까 터미널이 진짜 터미널처럼 동작합니다. cd로 돌아다니고, ls로 내용물을 보고, cat으로 글을 열 수 있습니다. 실제 서버가 없어도 이 정도 경험을 만들 수 있다는 게 재밌었습니다.

댓글 로딩 중...