원본 비공개 파일 시스템

파일 시스템 표준은 출처 비공개 파일 시스템(OPFS)을 페이지 출처에 비공개인 스토리지 엔드포인트로 도입합니다. 이 엔드포인트는 사용자에게 표시되지 않으며 성능에 최적화된 특수한 종류의 파일에 대한 선택적 액세스를 제공합니다.

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

브라우저 지원

  • Chrome: 86
  • Edge: 86.
  • Firefox: 111
  • Safari: 15.2.

소스

동기

컴퓨터의 파일을 생각하면 운영체제의 파일 탐색기로 탐색할 수 있는 폴더로 구성된 파일인 파일 계층 구조가 떠오를 것입니다. 예를 들어 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-site'와 '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 항목을 확인하여 앱에서 이미 사용 중인 스토리지 양을 확인합니다. 스토리지 양은 usageDetails 객체의 스토리지 메커니즘별로 분류되며 여기서 fileSystem 항목을 구체적으로 확인해야 합니다. 출처 비공개 파일 시스템은 사용자에게 표시되지 않으므로 권한 메시지와 세이프 브라우징 검사가 표시되지 않습니다.

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

루트 디렉터리에 액세스하려면 다음 명령어를 실행합니다. 빈 디렉터리 핸들(특히 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를 가져와서 데이터를 '해제'하므로 데이터에 액세스하여 사용자에게 표시되는 파일 시스템에서 사용할 수 있습니다.

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;
  };

웹 작업자에서 출처 비공개 파일 시스템 사용

앞서 설명한 대로 웹 워커는 기본 스레드를 차단할 수 없으므로 이 컨텍스트에서는 동기 메서드가 허용됩니다.

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

최대한 빠른 파일 작업의 진입점은 createSyncAccessHandle()를 호출하여 일반 FileSystemFileHandle에서 가져온 FileSystemSyncAccessHandle입니다.

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 Explorer 탭을 선택하면 파일 계층 구조를 검사할 수 있습니다. 파일 이름을 클릭하여 원본 비공개 파일 시스템의 파일을 사용자에게 표시되는 파일 시스템에 저장하고 휴지통 아이콘을 클릭하여 파일과 폴더를 삭제합니다.

데모

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

결론

WhatWG에서 지정한 원본 비공개 파일 시스템은 우리가 웹에서 파일을 사용하고 상호작용하는 방식에 영향을 주었습니다. 이를 통해 사용자에게 표시되는 파일 시스템으로는 불가능했던 새로운 사용 사례를 구현할 수 있었습니다. Apple, Mozilla, Google 등 모든 주요 브라우저 공급업체가 참여하고 있으며 공동 비전을 공유하고 있습니다. 출처 비공개 파일 시스템의 개발은 많은 협력의 결과이며 개발자와 사용자의 의견은 개발에 매우 중요합니다. 표준을 계속해서 수정하고 개선하는 과정에서 문제 또는 풀 리퀘스트 형식으로 whatwg/fs 저장소에 관한 의견을 보내주시면 감사하겠습니다.

감사의 말씀

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