원본 비공개 파일 시스템

파일 시스템 표준에서는 원본 비공개 파일 시스템 (OPFS)을 페이지 출처 전용 스토리지 엔드포인트로 도입합니다. 스토리지 엔드포인트는 성능을 위해 고도로 최적화된 특별한 종류의 파일에 대한 선택적 액세스를 제공합니다.

브라우저 지원

원본 비공개 파일 시스템은 최신 브라우저에서 지원되며 File System Living StandardWhatWG (Web Hypertext Application Technology Working Group)에 의해 표준화됩니다.

브라우저 지원

  • Chrome: 86 <ph type="x-smartling-placeholder">
  • Edge: 86. <ph type="x-smartling-placeholder">
  • Firefox: 111 <ph type="x-smartling-placeholder">
  • Safari 15.2. <ph type="x-smartling-placeholder">

소스

동기

컴퓨터에 있는 파일을 생각할 때는 파일 계층 구조, 즉 운영체제의 파일 탐색기로 탐색할 수 있는 폴더로 구성된 파일이라고 할 수 있습니다. 예를 들어 Windows에서 Tom이라는 사용자의 할 일 목록이 C:\Users\Tom\Documents\ToDo.txt에 있을 수 있습니다. 이 예에서 ToDo.txt은 파일 이름이고 Users, Tom, Documents는 폴더 이름입니다. Windows에서 `C:` 는 드라이브의 루트 디렉터리를 나타냅니다.

웹에서 파일로 작업하는 기존 방식

웹 애플리케이션에서 할 일 목록을 수정하려면 일반적인 흐름은 다음과 같습니다.

  1. 사용자가 <input type="file">를 사용하여 파일을 서버에 업로드하거나 클라이언트에서 엽니다.
  2. 사용자가 변경한 후, JavaScript를 통해 프로그래매틱 방식으로 click() 삽입된 <a download="ToDo.txt>으로 결과 파일을 다운로드합니다.
  3. 폴더를 열려면 <input type="file" webkitdirectory>의 특수 속성을 사용합니다. 이 속성은 독점적인 이름에도 불구하고 사실상 범용 브라우저 지원을 제공합니다.

웹에서 파일을 사용하는 현대적인 방법

이 흐름은 사용자가 파일 편집에 대해 생각하는 방식을 나타내지 않으며 사용자는 입력 파일의 다운로드된 사본을 갖게 된다는 것을 의미합니다. 따라서 File System Access API에는 이름에서 알 수 있는 것과 정확히 일치하는 세 가지 선택 도구 메서드(showOpenFilePicker(), showSaveFilePicker(), showDirectoryPicker())가 도입되었습니다. 다음과 같이 흐름을 사용 설정합니다.

  1. showOpenFilePicker()ToDo.txt를 열고 FileSystemFileHandle 객체를 가져옵니다.
  2. FileSystemFileHandle 객체에서 파일 핸들의 getFile() 메서드를 호출하여 File를 가져옵니다.
  3. 파일을 수정한 후 핸들에서 requestPermission({mode: 'readwrite'})를 호출합니다.
  4. 사용자가 권한 요청을 수락하면 변경사항을 원본 파일에 다시 저장합니다.
  5. 또는 showSaveFilePicker()를 호출하여 사용자가 새 파일을 선택하도록 합니다. (사용자가 이전에 연 파일을 선택하면 내용을 덮어씁니다.) 반복 저장 시 파일 핸들을 보관할 수 있으므로 파일 저장 대화상자를 다시 표시하지 않아도 됩니다.

웹에서 파일 작업 시 제한사항

이러한 메서드를 통해 액세스할 수 있는 파일과 폴더는 사용자가 볼 수 있는 파일 시스템에 상주합니다. 웹에서 저장된 파일과 특히 실행 파일은 웹 표시로 표시되므로 위험할 가능성이 있는 파일이 실행되기 전에 운영체제에 표시될 수 있는 추가 경고가 표시됩니다. 추가 보안 기능으로 웹에서 가져온 파일은 세이프 브라우징으로도 보호됩니다. 세이프 브라우징은 편의상 클라우드 기반 바이러스 검사라고 생각하면 됩니다. File System Access API를 사용하여 파일에 데이터를 쓰는 경우 쓰기는 제자리에 있지 않고 임시 파일을 사용합니다. 파일 자체는 이러한 보안 검사를 모두 통과하지 못하면 수정되지 않습니다. 상상할 수 있는 바와 같이, 이 작업(예: macOS)에서 가능한 경우 개선이 적용되었음에도 불구하고 파일 작업이 비교적 느려집니다. 여전히 모든 write() 호출은 독립적이며, 따라서 내부적으로 파일을 열고 지정된 오프셋을 탐색한 후 최종적으로 데이터를 씁니다.

처리 기반인 파일

동시에 파일은 데이터를 기록하는 훌륭한 방법입니다. 예를 들어 SQLite는 전체 데이터베이스를 단일 파일에 저장합니다. 또 다른 예로는 이미지 처리에 사용되는 밉맵이 있습니다. 밉맵은 미리 계산되고 최적화된 이미지 시퀀스로, 각각은 이전 이미지의 해상도를 점진적으로 낮춰 표현하므로 확대/축소와 같은 많은 작업이 더 빨라집니다. 그렇다면 웹 기반 파일 처리에 따른 성능 비용 없이 웹 애플리케이션이 파일의 이점을 얻으려면 어떻게 해야 할까요? 정답은 원본 비공개 파일 시스템입니다.

사용자에게 표시되는 비공개 파일 시스템과 원본 비공개 파일 시스템 비교

운영체제의 파일 탐색기를 사용하여 탐색하며 사용자가 볼 수 있는 파일 시스템과 달리 읽고, 쓰고, 이동하고, 이름을 변경할 수 있는 파일과 폴더가 있지만 원본 비공개 파일 시스템은 사용자에게 보이지 않습니다. 원본 비공개 파일 시스템의 파일과 폴더는 이름에서 알 수 있듯 비공개이며 더 확실하게 사이트의 원본에게만 공개됩니다. DevTools 콘솔에서 location.origin를 입력하여 페이지의 출처를 탐색합니다. 예를 들어 https://developer.chrome.com/articles/ 페이지의 출처가 https://developer.chrome.com입니다 (즉, /articles 부분은 출처의 일부가 아닙니다). 원본 이론에 대한 자세한 내용은 '동일 사이트'의 이해에서 확인할 수 있습니다. 및 'same-origin'으로 확인하세요. 동일한 출처를 공유하는 모든 페이지는 동일한 원본 비공개 파일 시스템 데이터를 볼 수 있으므로 https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/는 이전 예와 동일한 세부정보를 볼 수 있습니다. 각 출처에는 독립적인 출처 비공개 파일 시스템이 있습니다. 즉, https://developer.chrome.com의 원본 비공개 파일 시스템은 https://web.dev 중 하나와 완전히 구분됩니다. Windows에서 사용자에게 표시되는 파일 시스템의 루트 디렉터리는 C:\\입니다. 원본 비공개 파일 시스템에 상응하는 것은 비동기 메서드를 호출하여 액세스하는 출처별로 처음에는 비어 있는 루트 디렉터리입니다. navigator.storage.getDirectory() 사용자에게 표시되는 파일 시스템과 원본 비공개 파일 시스템을 비교하려면 다음 다이어그램을 참조하세요. 이 다이어그램은 루트 디렉터리를 제외하고 다른 모든 것이 개념적으로 동일하며 데이터 및 스토리지 요구사항에 따라 필요에 따라 구성하고 정렬할 파일과 폴더의 계층 구조가 있는 것을 보여줍니다.

사용자에게 표시되는 파일 시스템과 두 가지 예시 파일 계층 구조를 갖춘 원본 비공개 파일 시스템의 다이어그램 사용자에게 표시되는 파일 시스템의 진입점은 심볼릭 하드디스크이고, 원본 개인 파일 시스템의 진입점은 &#39;navigator.storage.getDirectory&#39; 메서드를 호출합니다.

원본 비공개 파일 시스템의 사양

브라우저의 다른 저장 메커니즘 (예: localStorage 또는 IndexedDB)과 마찬가지로 원본 비공개 파일 시스템에는 브라우저 할당량 제한이 적용됩니다. 사용자가 모든 인터넷 사용 기록 또는 모든 사이트 데이터를 삭제하면 원본 비공개 파일 시스템도 삭제됩니다. navigator.storage.estimate()를 호출하고 결과 응답 객체에서 usage 항목을 확인하여 앱에서 이미 소비하고 있는 스토리지를 확인합니다. 이 스토리지는 fileSystem 항목을 구체적으로 살펴볼 usageDetails 객체의 스토리지 메커니즘별로 분류됩니다. 원본 비공개 파일 시스템은 사용자에게 표시되지 않으므로 권한 메시지 및 세이프 브라우징 확인도 표시되지 않습니다.

루트 디렉터리에 대한 액세스 권한 얻기

루트 디렉터리에 액세스하려면 다음 명령어를 실행합니다. 빈 디렉터리 핸들, 더 구체적으로 FileSystemDirectoryHandle가 생성됩니다.

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

기본 스레드 또는 Web Worker

원본 비공개 파일 시스템은 기본 스레드 또는 웹 워커에서 사용할 수 있습니다. 웹 작업자는 기본 스레드를 차단할 수 없습니다. 즉, 이 컨텍스트에서 API는 동기식일 수 있으며 패턴은 일반적으로 기본 스레드에서 허용되지 않습니다. 동기 API는 프로미스를 처리할 필요가 없기 때문에 더 빠를 수 있으며, 파일 작업은 일반적으로 WebAssembly로 컴파일할 수 있는 C와 같은 언어에서 동기식입니다.

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

가장 빠른 파일 작업이 필요하거나 WebAssembly를 다루는 경우 Web Worker에서 원본 비공개 파일 시스템 사용으로 건너뛰세요. 그 외의 경우에는 다음 내용을 읽어도 됩니다.

기본 스레드에서 원본 비공개 파일 시스템 사용

새 파일 및 폴더 만들기

루트 폴더가 있으면 각각 getFileHandle() 메서드와 getDirectoryHandle() 메서드를 사용하여 파일과 폴더를 만듭니다. 파일 또는 폴더가 존재하지 않는 경우 {create: true}를 전달하면 생성됩니다. 새로 만든 디렉터리를 시작점으로 사용하여 이러한 함수를 호출하여 파일의 계층 구조를 만듭니다.

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

이전 코드 샘플의 결과 파일 계층 구조

기존 파일 및 폴더에 액세스

이름을 알고 있는 경우 getFileHandle() 또는 getDirectoryHandle() 메서드를 호출하고 파일 또는 폴더의 이름을 전달하여 이전에 만든 파일 및 폴더에 액세스합니다.

const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

읽기를 위해 파일 핸들과 연결된 파일 가져오기

FileSystemFileHandle는 파일 시스템의 파일을 나타냅니다. 연결된 File를 가져오려면 getFile() 메서드를 사용합니다. File 객체는 특정 종류의 Blob이며 Blob가 할 수 있는 모든 컨텍스트에서 사용할 수 있습니다. 특히 FileReader, URL.createObjectURL(), createImageBitmap(), XMLHttpRequest.send()BlobsFiles을 모두 허용합니다. 원한다면 FileSystemFileHandle에서 File를 'frees'로 따라서 사용자는 그 데이터에 액세스하고 사용자가 볼 수 있는 파일 시스템에서 사용할 수 있습니다.

const file = await fileHandle.getFile();
console.log(await file.text());

스트리밍을 통해 파일에 쓰기

createWritable()를 호출하여 파일로 데이터를 스트리밍한 다음 FileSystemWritableFileStream를 생성한 다음 콘텐츠를 write()합니다. 마지막에 스트림을 close()해야 합니다.

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

파일 및 폴더 삭제

파일 또는 디렉터리 핸들의 특정 remove() 메서드를 호출하여 파일과 폴더를 삭제합니다. 모든 하위 폴더를 포함한 폴더를 삭제하려면 {recursive: true} 옵션을 전달합니다.

await fileHandle.remove();
await directoryHandle.remove({recursive: true});

또는 디렉터리에서 삭제할 파일 또는 폴더의 이름을 알고 있는 경우 removeEntry() 메서드를 사용합니다.

directoryHandle.removeEntry('my first nested file');

파일 및 폴더 이동 및 이름 바꾸기

move() 메서드를 사용하여 파일 및 폴더의 이름을 바꾸고 이동합니다. 이동 및 이름 변경은 함께 수행할 수도 있고 개별적으로 수행할 수도 있습니다.

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

파일 또는 폴더의 경로 확인

참조 디렉터리와 관련하여 지정된 파일 또는 폴더의 위치를 알아보려면 resolve() 메서드를 사용하여 FileSystemHandle에 인수로 전달합니다. 원본 비공개 파일 시스템에 있는 파일 또는 폴더의 전체 경로를 가져오려면 navigator.storage.getDirectory()를 통해 얻은 참조 디렉터리로 루트 디렉터리를 사용합니다.

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.

두 개의 파일 또는 폴더 핸들이 동일한 파일 또는 폴더를 가리키는지 확인

핸들이 두 개 있는데 핸들이 같은 파일 또는 폴더를 가리키고 있는지 알 수 없는 경우도 있습니다. 이 경우에 해당하는지 확인하려면 isSameEntry() 메서드를 사용하세요.

fileHandle.isSameEntry(nestedFileHandle);
// Returns `false`.

폴더 내용 나열

FileSystemDirectoryHandlefor await…of 루프로 반복하는 비동기 반복자입니다. 비동기 반복자로서 필요한 정보에 따라 선택할 수 있는 entries(), values(), keys() 메서드도 지원합니다.

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

폴더와 모든 하위 폴더의 콘텐츠를 재귀적으로 나열

재귀와 쌍을 이루는 비동기 루프 및 함수를 처리하는 것은 실수하기 쉽습니다. 아래 함수는 모든 파일 및 크기를 포함하여 폴더 및 모든 하위 폴더의 콘텐츠를 나열하기 위한 시작점 역할을 할 수 있습니다. 파일 크기가 필요하지 않은 경우 directoryEntryPromises.push를 사용하여 handle.getFile() 프로미스를 푸시하지 않고 handle를 직접 푸시하는 함수를 단순화할 수 있습니다.

  const getDirectoryEntriesRecursive = async (
    directoryHandle,
    relativePath = '.',
  ) => {
    const fileHandles = [];
    const directoryHandles = [];
    const entries = {};
    // Get an iterator of the files and folders in the directory.
    const directoryIterator = directoryHandle.values();
    const directoryEntryPromises = [];
    for await (const handle of directoryIterator) {
      const nestedPath = `${relativePath}/${handle.name}`;
      if (handle.kind === 'file') {
        fileHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          handle.getFile().then((file) => {
            return {
              name: handle.name,
              kind: handle.kind,
              size: file.size,
              type: file.type,
              lastModified: file.lastModified,
              relativePath: nestedPath,
              handle
            };
          }),
        );
      } else if (handle.kind === 'directory') {
        directoryHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          (async () => {
            return {
              name: handle.name,
              kind: handle.kind,
              relativePath: nestedPath,
              entries:
                  await getDirectoryEntriesRecursive(handle, nestedPath),
              handle,
            };
          })(),
        );
      }
    }
    const directoryEntries = await Promise.all(directoryEntryPromises);
    directoryEntries.forEach((directoryEntry) => {
      entries[directoryEntry.name] = directoryEntry;
    });
    return entries;
  };

Web Worker에서 원본 비공개 파일 시스템 사용

앞서 설명한 것처럼 Web Workers는 기본 스레드를 차단할 수 없습니다. 이것이 바로 이 맥락에서 동기 메서드가 허용되는 이유입니다.

동기식 액세스 핸들 가져오기

가능한 가장 빠른 파일 작업의 진입점은 FileSystemSyncAccessHandle로, createSyncAccessHandle()를 호출하여 일반 FileSystemFileHandle에서 가져옵니다.

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

동기식 인플레이스 파일 메서드

동기식 액세스 핸들이 있으면 모두 동기식인 빠른 인플레이스 파일 메서드에 액세스할 수 있습니다.

  • getSize(): 파일의 크기를 바이트 단위로 반환합니다.
  • write(): 버퍼의 콘텐츠를 지정된 오프셋에 선택적으로 파일에 쓰고 작성된 바이트 수를 반환합니다. 반환된 쓰기 바이트 수를 확인하면 호출자가 오류와 부분 쓰기를 감지하고 처리할 수 있습니다.
  • read(): 지정된 오프셋에 따라 파일의 콘텐츠를 버퍼로 읽습니다.
  • truncate(): 파일 크기를 지정된 크기로 조정합니다.
  • flush(): 파일 콘텐츠에 write()를 통해 실행된 모든 수정사항이 포함되도록 합니다.
  • close(): 액세스 핸들을 닫습니다.

다음은 위에서 언급한 모든 메서드를 사용하는 예입니다.

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle('fast', {create: true});
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

원본 비공개 파일 시스템에서 사용자에게 표시되는 파일 시스템으로 파일을 복사합니다.

위에서 언급했듯이 파일을 원본 비공개 파일 시스템에서 사용자에게 표시되는 파일 시스템으로 이동하는 것은 불가능하지만 파일을 복사할 수는 있습니다. showSaveFilePicker()는 기본 스레드에서만 노출되고 작업자 스레드에서는 노출되지 않으므로 기본 스레드에서 코드를 실행해야 합니다.

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || ''
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

원본 비공개 파일 시스템 디버그

내장된 DevTools 지원이 추가될 때까지 (crbug/1284595 참고) OPFS Explorer Chrome 확장 프로그램을 사용하여 원본 비공개 파일 시스템을 디버그합니다. 위 새 파일 및 폴더 만들기 섹션의 스크린샷은 확장 프로그램에서 바로 가져온 것입니다.

Chrome 웹 스토어의 OPFS Explorer Chrome DevTools 확장 프로그램

확장 프로그램을 설치한 후 Chrome DevTools를 열고 OPFS 탐색기 탭을 선택하면 파일 계층 구조를 검사할 수 있습니다. 파일 이름을 클릭하여 원본 비공개 파일 시스템에서 사용자에게 표시되는 파일 시스템에 파일을 저장하고 휴지통 아이콘을 클릭하여 파일과 폴더를 삭제합니다.

데모

WebAssembly로 컴파일된 SQLite 데이터베이스의 백엔드로 사용하는 데모에서 원본 비공개 파일 시스템의 실제 작동 방식 (OPFS Explorer 확장 프로그램을 설치한 경우)을 확인하세요. Glitch의 소스 코드를 확인하세요. 아래의 삽입된 버전은 원본 비공개 파일 시스템 백엔드를 사용하지 않지만 (iframe이 크로스 출처이기 때문) 별도의 탭에서 데모를 열면 사용됩니다.

결론

WhatWG에서 지정한 원본 비공개 파일 시스템은 우리가 웹에서 파일을 사용하고 상호작용하는 방식에 영향을 주었습니다. 이로 인해 사용자가 볼 수 있는 파일 시스템으로는 달성할 수 없었던 새로운 사용 사례가 가능해졌습니다. Apple, Mozilla, Google 등 모든 주요 브라우저 공급업체가 참여하고 있으며 공동의 비전을 공유합니다. 원본 비공개 파일 시스템의 개발은 매우 많은 공동 작업을 통해 이루어지며, 발전을 위해서는 개발자와 사용자의 의견이 반드시 필요합니다. Google에서 표준을 계속 미세 조정하고 개선함에 따라 문제 또는 pull 요청의 형태로 whatwg/fs 저장소에 관한 의견을 보내주세요.

감사의 말씀

이 도움말은 Austin Sully, Etienne Noël, Rachel Andrew가 검토했습니다. 히어로 이미지: 크리스티나 럼프 제공: Unsplash