티스토리 뷰

Front

Browser 대용량 파일 다운로드 구현기

콜라먹는 펭귄이 2023. 3. 20. 23:05

서론

최근 위성영상을 로컬에 다운로드 받는 기능을 구현해야하는 일이 있었다. 평소 다운로드 기능을 구현하던대로 fetch api로 서버에 요청을 보내 a tag 태그를 생성하여 다운로드 받도록 구현하였다. 하지만 파일 다운로드 하는데 너무 오래걸렸다. 확인해보니 파일을 다 받아올때까지 로컬에 다운로드 하지 않고 파일을 다 받아왔을때 브라우저에서 로컬에 다운을 시작해 시간이 두배로 걸리는 일이 생겼다. 그냥 a tag에 바로 파일의 url을 넣어줘도 되지만 권한이 필요할땐 토큰을 넣어 줄 수 없었다. 그래서 다운로드 받으면서 바로바로 로컬에 다운로드를 할 수 있는 방법을 찾아보았다.

구현

한참 이것저것 찾다보니 streamsaver라는 라이브러리를 쓰면 된다고 하는 stackoverflow의 글을 보았다. 그래서 그 글을 참고해 다음과 같은 코드를 짜 보았다.

import { createWriteStream } from "streamsaver"

export const downloadWithToken = async (url: string, name: string, options?: RequestInit) => {
  const token = localStorage.getItem("token")
  const authorization = "Bearer " + token
  const headers = new Headers({ authorization })
  const _options = { headers, ...options }

  const response = await fetch(url, _options)

  const filestream = createWriteStream(name)
  const writer = filestream.getWriter()

  if (response.body?.pipeTo) {
    writer.releaseLock()
    return response.body.pipeTo(filestream)
  }

  const reader = response.body?.getReader()

  const pump: () => void = () =>
    reader?.read().then(({ value, done }) => (done ? writer.close() : writer.write(value).then(pump)))

  pump()
}

 

코드를 짜고 나니 생각했던대로 파일을 받는대로 바로바로 다운로드가 되었다. 서버에서 파일을 가져오는 시간은 비슷했지만 파일 데이터를 다 받아오기를 기다리고 로컬에 저장하는 저장하는 과정이 사라져 시간이 조금 단축되었다.

약 5GB기준 20초가량을 단축할 수 있었다.

구현한 코드를 한글로 해석하자면 다음과 같다.

 

1. fetch api를 통해 파일을 요청한다.

2. StreamSaver를 통해 WritableStream을 생성한다.

3. WritableStream 인스턴스의 getWriter 메소드를 호출하여 WritableStreamDefaultWriter 인스턴스를 반환 받고 스트림에 락을 건다. 락이 걸린 동안에는 해제하기 전까지 다른 writer를 요청할 수 없다.

4. fetch api요청의 응답 body에 pipeTo 메소드가 있으면 스트림의 락을 해제하고 pipeTo 메소드에 WritableStream을 인자로 넣어 호출하고 return한다. pipeTo는 ReadableStream을 WritableStream에 파이프한다. 이해한 바로는 WritableStream에 정의된 start, write, abort등의 UnderlyingSink메소드들을 호출하는것 같다.

5. pipeTo 메소드가 없다면 응답 바디에서 getReader 메소드를 통해 ReadableStream을 가져온다.

6. 가져온 ReadableStream을 통해 파일을 읽는대로 WritableStreamDefaultWriter의 write를 호출하는 재귀함수를 만들어 호출한다.

 

WritableStream과 ReadableStream은 MDN에 잘 설명되어 있었다.

 

WritableStream

https://developer.mozilla.org/ko/docs/Web/API/WritableStream

 

WritableStream - Web API | MDN

Streams API의 WritableStream 는 지정된 곳에 스트림 데이터를 writing하기 위한 싱크 추상 인터페이스입니다. 이 객체는 내장 백프레셔와 큐잉으로 구성되어 있다.

developer.mozilla.org

ReadableStream

https://developer.mozilla.org/ko/docs/Web/API/ReadableStream

 

ReadableStream - Web API | MDN

Streams API의 ReadableStream 인터페이스는 바이트 데이터를 읽을수 있는 스트림을 제공합니다. Fetch API는 Response 객체의 body 속성을 통하여 ReadableStream의 구체적인 인스턴스를 제공합니다.

developer.mozilla.org

StreamSaver

StreamSaver의 내부가 어떻게 구현되어 있는지 궁금해져서 해당 라이브러리에 대해 더 알아보았다. 

https://github.dev/jimmywarting/StreamSaver.js/blob/master/StreamSaver.js

 

https://github.dev/jimmywarting/StreamSaver.js/blob/master/StreamSaver.js

Setting up your web editor eyJzZXJ2ZXJDb3JyZWxhdGlvbklkIjoiZGMwM2YzZDItODE0Yi00ZjY4LWEzMDYtYzBkNWEwN2I4YjFmIiwid29ya2JlbmNoVHlwZSI6ImVkaXRvciIsIndvcmtiZW5jaENvbmZpZyI6eyJ2c2NvZGVWZXJzaW9uSW5mbyI6eyJzdGFibGUiOnsiY29tbWl0IjoiZWUyYjE4MGQ1ODJhN2Y2MDFmYTZlY2ZkY

github.dev

 

먼저 repository에 있는 라이브러리 설명글을 보았다. 요약하자면 다음과 같다. "StreamSaver는 response header와 service worker를 사용해 메모리제약과 blob 사이즈 제약 없이 파일을 다운로드 할 수 있도록 서버가 일부 response header + service worker를 사용하여 파일을 저장하도록 브라우저에 지시하는 방법을 에뮬레이션하여 수행됩니다." 이 글을 읽고 무슨소리인지 잘 이해가 가지 않았다. (영어를 잘 못해 번역체였던 탓도 있는 것 같다. ㅠ) 그래서 코드를 들여다 보았다.

 

createStream이 호출되었을 때 내가 이해한 코드의 중요 흐름은 다음과 같았다.

1. MessageChannel API를 통해 메세지 채널을 만든다.

2. 정의된 mitm파일을 Iframe을 통해 띄우고 contentWindow.postMessage 메소드를 통해 MessageChannel로 만든 port와 정의한 response값을 넘긴다.

const response = {
  transferringReadable: supportsTransferable,
  pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename,
  headers: {
    'Content-Type': 'application/octet-stream; charset=utf-8',
    'Content-Disposition': "attachment; filename*=UTF-8''" + filename
  }
}

if (opts.size) {
  response.headers['Content-Length'] = opts.size
}

response 값을 정의하는 코드

3. TransformStream을 생성하고 port1을 통해 port2에게 TransfromStream으로 생성된 ReadableStream을 보낸다.

4. 생성된 mitm은 sw.js라는 이름으로 정의된 service worker를 생성하고 message로 전달받은 데이터를 service worker에게 보낸다.

5. 데이터를 전달받은 service worker는 데이터(ReadableStream, port2)를 저장하고 전달받은 port2로 downloadURL을 담아 메시지를 보낸다.

const downloadURL = data.url || self.registration.scope + Math.random() + "/" + (typeof data === "string" ? data : data.filename)

downloadURL

6. downloadURL을 전달받은 port1은 src속성에 downloadURL을 넣은 Iframe을 생성한다.

7. 생성해뒀던 service worker가 iframe의 요청을 하이재킹해서 저장해 뒀던 데이터들을 통해 response 헤더를 만들고 저장해뒀던ReadableStream을 담아 응답을 보낸다. 여기에서 중요한 부분은 response헤더에 들어간 contetn-type과 content-disposition, content-length이다. 자세한 내용은 이 문서를 참고하면 좋을 것이다.

8. 이후에는 donwloadWithToken에서 정의한 코드가 실행된다.글로 서술하기가 힘들어 설명이 조금 부족한데 코드와 함께보면 조금 더 이해가 수월할 수 있다.

 

코드를 보니 위에 이해가 어려웠던 문장이 조금 감이 잡힌 것 같다. service worker를 통해 요청을 하이재킹하고 response의 헤더값을 바꿔서 readable stream과 함께 응답을 보낸다는 것을 설명한 문장이었던 것 같다.

 

마무리

아직 명쾌하게 이해했다는 느낌이 없어 백엔드 프로그램에서 위에서 사용했던 헤더를 직접 보내주거나 하는등의 실험들을 해보고 TransformStream, ReadableStream, WriatableStream 문서를 정독해봐야겠다. 일단은 이해한것까지 정리해 본다.

'Front' 카테고리의 다른 글

리팩토링기  (0) 2023.01.19
해시뱅  (0) 2019.03.07
DOM 문서 객체 모델  (0) 2019.03.05
SEO 검색 엔진 최적화  (0) 2019.03.04
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함