In-Memory File System

LLD

Understand it step by step (한국어로 이해 → 영어로 말하기)

메모리 안에서 동작하는 파일 시스템을 설계합니다. 디렉토리(폴더)와 파일을 트리 구조로 저장하고, ls(목록 보기), mkdir(폴더 만들기), addContentToFile(파일에 내용 추가), readContentFromFile(파일 읽기) 네 가지 동작을 지원합니다. 경로는 "/a/b/c.txt" 처럼 슬래시로 구분하고, 중간에 없는 폴더는 자동으로 만들어 줍니다.
  1. 1

    먼저 요구사항과 엔티티를 정합니다. 파일 시스템은 트리입니다. 노드는 두 종류뿐입니다: 폴더(디렉토리)와 파일. 폴더는 자식을 가지고, 파일은 내용을 가집니다. 둘은 이름과 타입을 공통으로 가지므로 공통 부모 추상 클래스 FsNode를 둡니다.

    ⚖️ Trade-off: 노드를 한 클래스로 만들고 boolean isFile 플래그로 구분할 수도 있고, 폴더와 파일을 따로 클래스로 나눌 수도 있습니다.

    ✅ Fix: 한 클래스에 다 넣으면 파일에도 children 맵이 생겨 의미가 없는 필드가 남습니다. 그래서 추상 부모 FsNode를 두고 FileNode, DirNode로 나눠 각자 필요한 필드만 갖게 합니다.

    🔧 도구:composition-over-inheritanceSOLID/SRP

    enum NodeType { FILE, DIRECTORY }
    
    abstract class FsNode {
        final String name;
        final NodeType type;
        FsNode(String name, NodeType type) { this.name = name; this.type = type; }
        boolean isDirectory() { return type == NodeType.DIRECTORY; }
    }
    🗣 영어로 말해

    A file system is a tree, so I model nodes with a common abstract parent.

    checking microphone…

  2. 2

    이제 파일 노드를 만듭니다. 파일은 내용을 가집니다. 내용은 계속 이어 붙일 수 있어야 하므로 StringBuilder로 들고 있습니다. append로 추가하고 read로 전체를 읽습니다.

    🔧 도구:SOLID/SRPencapsulation

    final class FileNode extends FsNode {
        private final StringBuilder content = new StringBuilder();
        FileNode(String name) { super(name, NodeType.FILE); }
        void append(String data) { content.append(data); }
        String read() { return content.toString(); }
    }
    🗣 영어로 말해

    A file just holds its content, which we can append to and read back.

    checking microphone…

  3. 3

    다음은 폴더 노드입니다. 폴더는 자식들을 이름으로 빠르게 찾아야 하므로 Map<String, FsNode>로 들고 있습니다. ls 결과를 이름순으로 보여주고 싶으면 TreeMap을 쓰면 정렬이 공짜로 됩니다.

    ⚖️ Trade-off: HashMap은 조회가 평균 O(1)이지만 순서가 없고, TreeMap은 O(log n)이지만 키가 정렬됩니다.

    ✅ Fix: ls는 이름순 정렬 출력이 자연스럽고 한 폴더의 자식 수는 보통 많지 않으므로, 정렬을 직접 안 해도 되는 TreeMap을 선택합니다.

    🔧 도구:composition-over-inheritance

    final class DirNode extends FsNode {
        final Map<String, FsNode> children = new TreeMap<>();
        DirNode(String name) { super(name, NodeType.DIRECTORY); }
    }
    🗣 영어로 말해

    A directory keeps its children in a sorted map keyed by name.

    checking microphone…

  4. 4

    핵심 동작들이 공통으로 쓰는 부분은 경로 탐색입니다. 루트 폴더를 하나 두고, 경로 문자열을 슬래시로 잘라 한 칸씩 내려갑니다. 중간 노드가 없을 때 만들지 말지를 create 플래그로 제어해 mkdir과 ls가 같은 코드를 재사용합니다.

    ⚖️ Trade-off: 탐색, 생성, 검증을 각 메서드마다 따로 짤 수도 있지만 코드가 중복됩니다.

    ✅ Fix: traverse 하나로 모읍니다. create=true면 없는 폴더를 만들고(mkdir용), false면 예외를 던집니다(읽기용). 도중에 파일을 폴더처럼 지나가려 하면 오류로 막습니다.

    🔧 도구:SOLID/DRYSRP

    private final DirNode root = new DirNode("/");
    
    private DirNode traverse(String[] parts, int upTo, boolean create) {
        DirNode cur = root;
        for (int i = 0; i < upTo; i++) {
            FsNode next = cur.children.get(parts[i]);
            if (next == null) {
                if (!create) throw new NoSuchElementException("No such dir: " + parts[i]);
                next = new DirNode(parts[i]);
                cur.children.put(parts[i], next);
            }
            if (!next.isDirectory()) throw new IllegalArgumentException(parts[i] + " is a file");
            cur = (DirNode) next;
        }
        return cur;
    }
    🗣 영어로 말해

    One traverse method walks the path, optionally creating missing directories.

    checking microphone…

  5. 5

    이제 mkdir과 ls를 traverse 위에 얹습니다. mkdir은 경로 끝까지 create=true로 내려가며 폴더를 다 만듭니다. ls는 경로가 폴더면 자식 이름 목록을, 파일이면 그 파일 이름 하나만 돌려줍니다.

    🔧 도구:SRP

    public void mkdir(String path) {
        String[] parts = split(path);
        traverse(parts, parts.length, true);
    }
    
    public List<String> ls(String path) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, Math.max(0, parts.length - 1), false);
        if (parts.length == 0) return new ArrayList<>(root.children.keySet());
        FsNode target = parent.children.get(parts[parts.length - 1]);
        if (target == null) throw new NoSuchElementException("Not found: " + path);
        if (!target.isDirectory()) return new ArrayList<>(List.of(target.name));
        return new ArrayList<>(((DirNode) target).children.keySet());
    }
    🗣 영어로 말해

    mkdir creates the full path; ls lists a directory's children or the single file.

    checking microphone…

  6. 6

    파일 쓰기와 읽기를 추가합니다. addContentToFile은 부모 폴더까지 만들고, 마지막 이름의 파일이 없으면 새로 만들고 있으면 내용을 이어 붙입니다. 같은 자리에 폴더가 있으면 오류입니다. readContentFromFile은 파일이 아니면 오류입니다. 여기서 instanceof 패턴 매칭으로 안전하게 캐스팅합니다.

    ⚖️ Trade-off: 타입을 instanceof로 검사하느냐, FsNode에 가상 메서드를 두느냐의 선택이 있습니다.

    ✅ Fix: 동작이 파일/폴더에서 의미가 다르므로(폴더는 read 불가), 상위에서 타입을 확인하고 Java 17 패턴 매칭으로 깔끔하게 분기합니다.

    🔧 도구:pattern-matchingfail-fast

    public void addContentToFile(String path, String content) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, parts.length - 1, true);
        String name = parts[parts.length - 1];
        FsNode node = parent.children.get(name);
        if (node == null) {
            FileNode f = new FileNode(name);
            f.append(content);
            parent.children.put(name, f);
        } else if (node instanceof FileNode f) {
            f.append(content);
        } else {
            throw new IllegalArgumentException("Is a directory: " + path);
        }
    }
    
    public String readContentFromFile(String path) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, parts.length - 1, false);
        FsNode node = parent.children.get(parts[parts.length - 1]);
        if (!(node instanceof FileNode f)) throw new NoSuchElementException("Not a file: " + path);
        return f.read();
    }
    🗣 영어로 말해

    Writing creates the file or appends; reading rejects anything that is not a file.

    checking microphone…

  7. 7

    엣지 케이스와 동시성을 짚습니다. 루트 경로 "/"는 split하면 빈 배열이 되어 따로 처리합니다. 파일 위에 폴더를 만들려는 경우는 traverse가 막습니다. 멀티스레드 환경이면 같은 폴더에 동시에 쓸 때 children 맵이 깨질 수 있습니다.

    ⚖️ Trade-off: 전체에 큰 락 하나를 거는 방법과, 노드(폴더)별로 잠그는 방법이 있습니다. 큰 락은 단순하지만 동시성이 낮고, 노드별 락은 병렬성이 좋지만 복잡합니다.

    ✅ Fix: 기본은 트리 전체를 보호하는 하나의 락(또는 ConcurrentHashMap을 children에)으로 단순하게 시작하고, 경합이 문제가 될 때 폴더별 락으로 세분화합니다.

    🔧 도구:concurrencylock-granularity

    // children = new ConcurrentHashMap<>();  // 동시 쓰기 안전(정렬은 포기)
    // 또는 트리 전체에 단일 락:
    private final Object lock = new Object();
    public void mkdirSafe(String path) {
        synchronized (lock) { mkdir(path); }
    }
    🗣 영어로 말해

    I start with a single coarse lock and split into per-directory locks only if needed.

    checking microphone…

  8. 8

    마지막으로 확장 지점을 둡니다. 지금은 동작이 4개지만 rm(삭제), mv(이동), 파일 메타데이터(크기/수정시간) 추가가 흔히 요청됩니다. FsNode가 공통 추상 타입이라 새 동작은 traverse를 재사용해 메서드만 추가하면 됩니다. 권한 검사 같은 정책은 나중에 Strategy로 분리할 수 있습니다.

    ⚖️ Trade-off: 지금 모든 확장(권한, 심볼릭 링크 등)을 미리 추상화하면 과한 설계가 됩니다.

    ✅ Fix: 현재는 핵심만 단순하게 두고, 권한·정책처럼 변할 부분이 실제로 생기면 그때 Strategy 인터페이스로 빼냅니다.

    🔧 도구:Strategyopen-closed-principle

    // 확장 예: 삭제는 traverse로 부모를 찾고 자식 맵에서 제거
    public void rm(String path) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, parts.length - 1, false);
        if (parent.children.remove(parts[parts.length - 1]) == null)
            throw new NoSuchElementException("Not found: " + path);
    }
    🗣 영어로 말해

    The abstract node makes new operations like remove or move easy to add later.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

Full Java (javac-verified)

import java.util.*;

public class Solution {
    enum NodeType { FILE, DIRECTORY }

    static abstract class FsNode {
        final String name;
        final NodeType type;
        FsNode(String name, NodeType type) { this.name = name; this.type = type; }
        boolean isDirectory() { return type == NodeType.DIRECTORY; }
    }

    static final class FileNode extends FsNode {
        private final StringBuilder content = new StringBuilder();
        FileNode(String name) { super(name, NodeType.FILE); }
        void append(String data) { content.append(data); }
        String read() { return content.toString(); }
    }

    static final class DirNode extends FsNode {
        final Map<String, FsNode> children = new TreeMap<>();
        DirNode(String name) { super(name, NodeType.DIRECTORY); }
    }

    private final DirNode root = new DirNode("/");

    private String[] split(String path) {
        if (path.equals("/")) return new String[0];
        return path.substring(1).split("/");
    }

    private DirNode traverse(String[] parts, int upTo, boolean create) {
        DirNode cur = root;
        for (int i = 0; i < upTo; i++) {
            FsNode next = cur.children.get(parts[i]);
            if (next == null) {
                if (!create) throw new NoSuchElementException("No such dir: " + parts[i]);
                next = new DirNode(parts[i]);
                cur.children.put(parts[i], next);
            }
            if (!next.isDirectory()) throw new IllegalArgumentException(parts[i] + " is a file");
            cur = (DirNode) next;
        }
        return cur;
    }

    public List<String> ls(String path) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, Math.max(0, parts.length - 1), false);
        if (parts.length == 0) return new ArrayList<>(root.children.keySet());
        FsNode target = parent.children.get(parts[parts.length - 1]);
        if (target == null) throw new NoSuchElementException("Not found: " + path);
        if (!target.isDirectory()) return new ArrayList<>(List.of(target.name));
        return new ArrayList<>(((DirNode) target).children.keySet());
    }

    public void mkdir(String path) {
        String[] parts = split(path);
        traverse(parts, parts.length, true);
    }

    public void addContentToFile(String path, String content) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, parts.length - 1, true);
        String name = parts[parts.length - 1];
        FsNode node = parent.children.get(name);
        if (node == null) {
            FileNode f = new FileNode(name);
            f.append(content);
            parent.children.put(name, f);
        } else if (node instanceof FileNode f) {
            f.append(content);
        } else {
            throw new IllegalArgumentException("Is a directory: " + path);
        }
    }

    public String readContentFromFile(String path) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, parts.length - 1, false);
        FsNode node = parent.children.get(parts[parts.length - 1]);
        if (!(node instanceof FileNode f)) throw new NoSuchElementException("Not a file: " + path);
        return f.read();
    }

    public void rm(String path) {
        String[] parts = split(path);
        DirNode parent = traverse(parts, parts.length - 1, false);
        if (parent.children.remove(parts[parts.length - 1]) == null)
            throw new NoSuchElementException("Not found: " + path);
    }

    public static void main(String[] args) {
        Solution fs = new Solution();
        System.out.println(fs.ls("/"));               // []
        fs.mkdir("/a/b/c");
        fs.addContentToFile("/a/b/c/d.txt", "hello");
        System.out.println(fs.ls("/"));               // [a]
        System.out.println(fs.readContentFromFile("/a/b/c/d.txt")); // hello
        fs.addContentToFile("/a/b/c/d.txt", " world");
        System.out.println(fs.readContentFromFile("/a/b/c/d.txt")); // hello world
        System.out.println(fs.ls("/a/b/c"));          // [d.txt]
        fs.rm("/a/b/c/d.txt");
        System.out.println(fs.ls("/a/b/c"));          // []
    }
}
🎙 이 카드 AI랑 음성 대화 · 기록 저장