브라우저-fs-access 라이브러리로 파일과 디렉터리 읽기 및 쓰기

브라우저는 오랫동안 파일과 디렉터리를 처리할 수 있었습니다. File API는 웹 애플리케이션에서 파일 객체를 나타내는 기능과 프로그래매틱 방식으로 파일 객체를 선택하고 데이터에 액세스하는 기능을 제공합니다. 하지만 자세히 살펴보면 금이 아닌 것도 있습니다.

파일을 처리하는 기존 방식

파일 열기

개발자는 <input type="file"> 요소를 통해 파일을 열고 읽을 수 있습니다. 가장 간단한 형태에서 파일을 여는 것은 아래 코드 샘플과 비슷합니다. input 객체는 FileList를 제공하며, 아래의 경우 하나의 File로만 구성됩니다. File은 특정 종류의 Blob이며 Blob이 사용될 수 있는 모든 컨텍스트에서 사용될 수 있습니다.

const openFile = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

디렉터리 열기

폴더 (또는 디렉터리)를 여는 경우 <input webkitdirectory> 속성을 설정할 수 있습니다. 그 외에는 위와 동일하게 작동합니다. 공급업체 접두사가 붙은 이름과 달리 webkitdirectory는 Chromium 및 WebKit 브라우저뿐만 아니라 기존 EdgeHTML 기반 Edge와 Firefox에서도 사용할 수 있습니다.

파일 저장 (다운로드)

파일 저장의 경우 기존에는 다운로드만 가능했으며 이는 <a download> 속성 덕분에 가능했습니다. Blob이 주어지면 URL.createObjectURL() 메서드에서 가져올 수 있는 blob: URL로 앵커의 href 속성을 설정할 수 있습니다.

const saveFile = async (blob) => {
  const a = document.createElement('a');
  a.download = 'my-file.txt';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

문제

다운로드 접근 방식의 가장 큰 단점은 기존의 열기→수정→저장 흐름을 실행할 방법이 없다는 것입니다. 즉, 원본 파일을 덮어쓸 방법이 없습니다. 대신 '저장'할 때마다 운영체제의 기본 다운로드 폴더에 원본 파일의 새 사본이 생성됩니다.

File System Access API

File System Access API를 사용하면 열기와 저장 작업이 훨씬 간단해집니다. 또한 실제 저장이 가능합니다. 즉, 파일을 저장할 위치를 선택할 수 있을 뿐만 아니라 기존 파일을 덮어쓸 수도 있습니다.

파일 열기

파일 시스템 액세스 API를 사용하면 window.showOpenFilePicker() 메서드를 한 번 호출하여 파일을 열 수 있습니다. 이 호출은 파일 핸들을 반환하며, 이 핸들에서 getFile() 메서드를 통해 실제 File를 가져올 수 있습니다.

const openFile = async () => {
  try {
    // Always returns an array.
    const [handle] = await window.showOpenFilePicker();
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

디렉터리 열기

파일 대화상자에서 디렉터리를 선택할 수 있도록 하는 window.showDirectoryPicker()를 호출하여 디렉터리를 엽니다.

파일 저장

파일 저장도 마찬가지로 간단합니다. 파일 핸들에서 createWritable()를 통해 쓰기 가능한 스트림을 만든 다음 스트림의 write() 메서드를 호출하여 Blob 데이터를 쓰고 마지막으로 close() 메서드를 호출하여 스트림을 닫습니다.

const saveFile = async (blob) => {
  try {
    const handle = await window.showSaveFilePicker({
      types: [{
        accept: {
          // Omitted
        },
      }],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return handle;
  } catch (err) {
    console.error(err.name, err.message);
  }
};

browser-fs-access 소개

File System Access API는 완벽하지만 아직 널리 제공되지는 않습니다.

File System Access API의 브라우저 지원 표 모든 브라우저가 &#39;지원 안 함&#39; 또는 &#39;플래그 뒤에 있음&#39;으로 표시됩니다.
File System Access API의 브라우저 지원 표입니다. (출처)

이러한 이유로 파일 시스템 액세스 API를 점진적 개선으로 간주합니다. 따라서 브라우저가 지원하는 경우 이를 사용하고 지원하지 않는 경우 기존 방식을 사용하고 싶습니다. 지원되지 않는 JavaScript 코드를 불필요하게 다운로드하여 사용자에게 불이익을 주지 않으면서 말입니다. browser-fs-access 라이브러리는 이 문제에 대한 제 대답입니다.

설계 철학

File System Access API는 향후 변경될 가능성이 있으므로 browser-fs-access API는 이를 따르지 않습니다. 즉, 라이브러리는 polyfill이 아니라 ponyfill입니다. 앱을 최대한 작게 유지하는 데 필요한 기능을 독점적으로 (정적으로 또는 동적으로) 가져올 수 있습니다. 사용 가능한 메서드는 적절한 이름의 fileOpen(), directoryOpen(), fileSave()입니다. 내부적으로 라이브러리는 파일 시스템 액세스 API가 지원되는지 기능 감지한 다음 해당 코드 경로를 가져옵니다.

browser-fs-access 라이브러리 사용

세 가지 방법은 사용하기에 직관적입니다. 앱에서 허용되는 mimeTypes 또는 파일 extensions을 지정하고 multiple 플래그를 설정하여 여러 파일 또는 디렉터리의 선택을 허용하거나 허용하지 않을 수 있습니다. 자세한 내용은 browser-fs-access API 문서를 참고하세요. 아래 코드 샘플은 이미지 파일을 열고 저장하는 방법을 보여줍니다.

// The imported methods will use the File
// System Access API or a fallback implementation.
import {
  fileOpen,
  directoryOpen,
  fileSave,
} from 'https://unpkg.com/browser-fs-access';

(async () => {
  // Open an image file.
  const blob = await fileOpen({
    mimeTypes: ['image/*'],
  });

  // Open multiple image files.
  const blobs = await fileOpen({
    mimeTypes: ['image/*'],
    multiple: true,
  });

  // Open all files in a directory,
  // recursively including subdirectories.
  const blobsInDirectory = await directoryOpen({
    recursive: true
  });

  // Save a file.
  await fileSave(blob, {
    fileName: 'Untitled.png',
  });
})();

데모

GitHub의 데모에서 위의 코드가 작동하는 것을 확인할 수 있습니다. 소스 코드도 마찬가지로 제공됩니다. 보안상의 이유로 교차 출처 하위 프레임은 파일 선택기를 표시할 수 없으므로 데모를 이 도움말에 삽입할 수 없습니다.

실제 사용 중인 browser-fs-access 라이브러리

여가 시간에는 Excalidraw라는 설치 가능한 PWA에 조금이나마 기여하고 있습니다. 이 도구는 손으로 그린 듯한 느낌으로 다이어그램을 쉽게 스케치할 수 있는 화이트보드 도구입니다. 완전한 반응형이며 작은 휴대전화부터 화면이 큰 컴퓨터에 이르기까지 다양한 기기에서 잘 작동합니다. 즉, 파일 시스템 액세스 API를 지원하는지 여부와 관계없이 다양한 플랫폼에서 파일을 처리해야 합니다. 따라서 browser-fs-access 라이브러리에 적합합니다.

예를 들어 iPhone에서 그림을 시작하고 iPhone 다운로드 폴더에 저장(기술적으로는 다운로드, Safari는 File System Access API를 지원하지 않음)한 후 휴대전화에서 전송한 파일을 데스크톱에서 열고 파일을 수정하고 변경사항으로 덮어쓰거나 새 파일로 저장할 수 있습니다.

iPhone의 Excalidraw 그림
파일 시스템 액세스 API가 지원되지 않지만 파일을 다운로드 폴더에 저장 (다운로드)할 수 있는 iPhone에서 Excalidraw 그림을 시작합니다.
데스크톱의 Chrome에서 수정된 Excalidraw 그림
File System Access API가 지원되어 API를 통해 파일에 액세스할 수 있는 데스크톱에서 Excalidraw 그림을 열고 수정합니다.
수정사항으로 원본 파일을 덮어씁니다.
원본 Excalidraw 그림 파일을 수정하여 원본 파일을 덮어씁니다. 브라우저에 괜찮은지 묻는 대화상자가 표시됩니다.
수정사항을 새 Excalidraw 그림 파일에 저장합니다.
수정사항을 새 Excalidraw 파일에 저장합니다. 원본 파일은 변경되지 않습니다.

실제 코드 샘플

아래에서 Excalidraw에서 사용되는 browser-fs-access의 실제 예를 확인할 수 있습니다. 이 발췌문은 /src/data/json.ts에서 가져왔습니다. 특히 saveAsJSON() 메서드가 파일 핸들이나 null를 browser-fs-access의 fileSave() 메서드에 전달하는 방식이 흥미롭습니다. 이렇게 하면 핸들이 제공된 경우 덮어쓰고 그렇지 않은 경우 새 파일에 저장됩니다.

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
  fileHandle: any,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: "application/json",
  });
  const name = `${appState.name}.excalidraw`;
  (window as any).handle = await fileSave(
    blob,
    {
      fileName: name,
      description: "Excalidraw file",
      extensions: ["excalidraw"],
    },
    fileHandle || null,
  );
};

export const loadFromJSON = async () => {
  const blob = await fileOpen({
    description: "Excalidraw files",
    extensions: ["json", "excalidraw"],
    mimeTypes: ["application/json"],
  });
  return loadFromBlob(blob);
};

UI 고려사항

Excalidraw든 앱이든 UI는 브라우저의 지원 상황에 맞게 조정되어야 합니다. File System Access API가 지원되는 경우 (if ('showOpenFilePicker' in window) {}) 저장 버튼 외에 다른 이름으로 저장 버튼을 표시할 수 있습니다. 아래 스크린샷은 iPhone과 Chrome 데스크톱에서 Excalidraw의 반응형 기본 앱 툴바의 차이를 보여줍니다. iPhone에서는 다른 이름으로 저장 버튼이 누락되어 있습니다.

&#39;저장&#39; 버튼만 있는 iPhone의 Excalidraw 앱 툴바
저장 버튼만 있는 iPhone의 Excalidraw 앱 툴바
&#39;저장&#39; 및 &#39;다른 이름으로 저장&#39; 버튼이 있는 Chrome 데스크톱의 Excalidraw 앱 툴바
저장 버튼과 포커스가 맞춰진 다른 이름으로 저장 버튼이 있는 Chrome의 Excalidraw 앱 툴바

결론

시스템 파일 작업은 기술적으로 모든 최신 브라우저에서 작동합니다. File System Access API를 지원하는 브라우저에서는 파일의 실제 저장 및 덮어쓰기 (다운로드뿐만 아님)를 허용하고 사용자가 원하는 곳에 새 파일을 만들 수 있도록 하여 환경을 개선할 수 있습니다. File System Access API를 지원하지 않는 브라우저에서도 기능은 유지됩니다. browser-fs-access는 점진적 개선의 미묘한 부분을 처리하고 코드를 최대한 간단하게 만들어 개발자의 작업을 더 쉽게 해줍니다.

감사의 말씀

이 도움말은 조 메들리케이시 바스크가 검토했습니다. 프로젝트에 참여하고 내 풀 요청을 검토해 준 Excalidraw 기여자에게 감사드립니다. Unsplash의 Ilya Pavlov가 촬영한 히어로 이미지