๐Ÿ“ฅ
๊ฐœ๋ฐœโ€ขํ”„๋ก ํŠธ์—”๋“œ

์›น์—์„œ ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•˜๋Š” ๋‹ค์–‘ํ•œ ๋ฐฉ๋ฒ•

2024.10.15

์–ด๋А ๋‚ , ์Šคํ†ก์˜์ƒ์ด ๋‹ค์šด๋กœ๋“œ ๋˜์ง€ ์•Š๋Š”๋‹ค๋Š” cs๊ฐ€ ๋“ค์–ด์™”๋‹ค.

์œ ์ €๊ฐ€ ๋‹ค์šด๋กœ๋“œ ํ•œ ํŒŒ์ผ์„ ํ™•์ธํ•ด๋ณด๋‹ˆ 1๋ถ„์ด ๋„˜๋Š” ๊ธธ์ด์— 4k ํ™”์งˆ๋กœ ์šฉ๋Ÿ‰์ด ๊ฝค ํฐ ํŽธ์ด์˜€๋‹ค.

๊ธฐ์กด ๋‹ค์šด๋กœ๋“œ ๋ฐฉ์‹์€ ์•„๋ž˜์™€ ๊ฐ™์•˜๋‹ค.

const response = await fetch('file-url');
const reader = response.body.getReader();
let chunks = [];
let receivedLength = 0;

while(true) {
    const {done, value} = await reader.read();
    if (done) break;
    chunks.push(value); // Chunk๋“ค์„ ๋ฐฐ์—ด์— ๋ˆ„์ 
    receivedLength += value.length; // ๋ˆ„์ ๋œ ๊ธธ์ด ๊ณ„์‚ฐ
}

// ๋ˆ„์ ๋œ chunks๋ฅผ ํ•ฉ์ณ์„œ ํŒŒ์ผ๋กœ ์ฒ˜๋ฆฌ
let blob = new Blob(chunks);

์›์ธ์€ ๊ธฐ์กด ๋‹ค์šด๋กœ๋“œ๊ฐ€ Blob ํ˜•ํƒœ์˜€๋Š”๋ฐ, Blob์€ InMemory ๋ฐฉ์‹์ด๋ผ, ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ๋ฉ”๋ชจ๋ฆฌ(RAM)๋ณด๋‹ค ํฐ ๊ฒฝ์šฐ ์˜ˆ์™ธ ์ƒํ™ฉ์ด ๋ฐœ์ƒํ•˜๋Š” ๊ฒƒ์ด์˜€๋‹ค.

๋™๋ฃŒ๊ฐ€ ๋ฆฌ์„œ์น˜ํ•œ 3๊ฐ€์ง€ ์ •๋„ ๋ฐฉ์•ˆ์ด ์žˆ์—ˆ๋Š”๋ฐ,

  1. Stream ์ฒ˜๋ฆฌ
  2. multipart download > s3 using large file scenario docs
  3. window showSaveFilePicker

2๋ฒˆ์˜ ๊ฒฝ์šฐ object์˜ ์ •ํ™•ํ•œ ๋””๋ ‰ํ† ๋ฆฌ ์œ„์น˜๋ฅผ ์ „๋‹ฌํ•˜๋Š” ๋ฐฉ์‹์ด ํ•„์š”ํ–ˆ๋Š”๋ฐ, ํ˜„์žฌ ์‚ฌ์šฉํ•˜๋Š” url์ด ๋ณด์•ˆ์„ ์ถ”๊ตฌํ•˜๋Š” preSignedUrl์ด๋ผ ๋งž์ง€์•Š์•˜๊ณ 

3๋ฒˆ์˜ ๊ฒฝ์šฐ safari๋Š” ๋ฏธ์ง€์›ํ•˜๊ธฐ ๋•Œ๋ฌธ์— 1๋ฒˆ Stream ๋ฐฉ์‹์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ๋‹ค.

๋‹ค๋งŒ ๋ธŒ๋ผ์šฐ์ € native์˜ stream์€ ์ œ์•ฝ์ด ๋งŽ์€๋ฐ, ์šฐ์„ ์ˆœ์œ„๊ฐ€ ๋†’์€ ํ•ด๋‹น ์ด์Šˆ๋ฅผ ๊ฐœ์„ ํ•˜๊ธฐ์—” ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๊ฒŒ ํ•ฉ๋ฆฌ์ ์ด๋ผ ํŒ๋‹จ๋˜์–ด FileSaver๋ฅผ extendsํ•œ streamsaver๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค.

๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ๊ฐ€ 2-3๋…„ ์ „์ด๋ผ๋Š”๊ฒŒ ์บฅ๊ธฐ๊ธด ํ–ˆ์ง€๋งŒ star๋ฅผ ๋งŽ์ด ๋ฐ›์•˜๊ณ  ๊ด€๋ จ ๋‚ด์šฉ๋„ ๋งŽ์•„์„œ ํƒ€ํ˜‘ํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค

Streamsaver

ํŒจํ‚ค์ง€ ์„ค์น˜ ํ›„ ๋Œ€๋žต ์•„๋ž˜์™€ ๊ฐ™์ด ์ž‘์—…ํ•ด์„œ fetchํ•  url๊ณผ filename์„ ์ „๋‹ฌ๋ฐ›๋Š” hook์„ ๊ตฌํ˜„ํ•ด์„œ ๋‹ค์šด๋กœ๋“œ ํ•˜๋Š”๊ณณ์— ์—ฐ๊ฒฐํ•˜์˜€๋‹ค.

import {createWriteStream} from 'streamsaver'
// ...
const res = await fetch(url, { signal: abortController.current.signal });
const readableStream = res.body;
const fileStream = createWriteStream(filename);
// ...
if (window.WritableStream) {
    return readableStream
        .pipeThrough(progress, { signal: abortController.current?.signal })
        .pipeTo(fileStream, { signal: abortController.current?.signal })
        .then(() => {
            setStatus('done');
            onSuccess?.();
        })
        .catch((err) => {
            onError?.(err);
            setStatus('error');
        });
}

const writer = fileStream.getWriter();

const reader = res.body?.getReader();
const pump = () => {
    reader
        ?.read()
        .then((res) => (res.done ? writer.close() : writer.write(res.value).then(pump)))
        .catch((err) => {
            onError?.(err);
            setStatus('error');
        });
};

pump();

๋ฌธ์ œ๊ฐ€ ์—†์—ˆ๋‹ค. ์ •์ƒ ๋™์ž‘ํ–ˆ์—ˆ๋‹ค

๋ฌธ์ œ๊ฐ€ ์—†์—ˆ๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ๋Š”๋ฐ, ์‚ฌํŒŒ๋ฆฌ์—์„  ๋™์ž‘ํ•˜์ง€ ์•Š์•˜๋‹ค.

ํด๋ฆฌํ•„์ด ๋ฌธ์ œ์ผ๊นŒ ์ƒ๊ฐํ•ด์„œ ๊ตฌ๊ธ€๋งํ•œ๋Œ€๋กœ web-streams-polyfill๋„ ์„ค์น˜ํ•ด๋ดค๋‹ค.

๊ทธ๋Ÿฌ๋‚˜ ๋™์ž‘ํ•˜์ง€ ์•Š์•˜๋‹ค.

๊ฒฐ๊ตญ์—” ํŒจํ‚ค์ง€์— ๋ฌธ์ œ๊ฐ€ ์žˆ๋‹ค๊ณ  ํŒ๋‹จ (2๋…„๋„˜๊ฒŒ ์—…๋ฐ์ดํŠธ๊ฐ€ ๋˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ..?) ํ•˜์—ฌ ํŒจํ‚ค์ง€ ์—†์ด ์ง์ ‘ ๊ตฌํ˜„ํ•ด๋ณด๊ธฐ๋กœ ํ–ˆ๋‹ค.

Stream Api๋ฅผ ์ฐพ์•„๋ณด๊ณ  ์—ฌ๋Ÿฌ ์‹œํ–‰์ฐฉ์˜ค ๋์— reader์™€ writer / pipeThrough์™€ pipeTo์— ๋Œ€ํ•ด ์ ์  ์ดํ•ด๊ฐ€ ๋๋‹ค.

const response = await fetch('file-url');
const reader = response.body.getReader();
const stream = new WritableStream({
    write(chunk) {
        // ๊ฐ chunk๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ €์žฅํ•˜๊ฑฐ๋‚˜ ๋‹ค์šด๋กœ๋“œ ์ฒ˜๋ฆฌprocessChunk(chunk); // ์˜ˆ์‹œ ํ•จ์ˆ˜๋กœ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ฒ˜๋ฆฌ
    }
});

response.body.pipeTo(stream);

๊ทธ๋Ÿฌ๋‚˜ ์ด ๋˜ํ•œ write(chunk) ๋ฅผ ํ•˜๊ณ  ๋‚œ ๋‹ค์Œ ์–ด๋–ป๊ฒŒ ํ•ด์•ผํ•  ์ง€ ์•Œ์ˆ˜๊ฐ€ ์—†์—ˆ๋‹ค.

๊ตฌ๊ธ€๋ง๋„ gpt๋„ ํ•ด๋ณด๋‹ˆ ํŒจํ‚ค์ง€ ์—†์ด ํด๋ผ์ด์–ธํŠธ๋‹จ์—์„œ๋งŒ Stream ๋‹ค์šด๋กœ๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ๋Š” ์–ด๋ ต๊ณ  Service Worker๋‚˜ node(์„œ๋ฒ„)๋ฅผ ๊ฐ™์ด ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค๊ณ  ํ–ˆ๋‹ค.

์šฐ์„  next.js๋ฅผ ์‚ฌ์šฉํ•˜๋‹ˆ ์„œ๋ฒ„๋ฅผ ํ™œ์šฉํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค. app router์— ๋งž๊ฒŒ ์ˆ˜์ •์„ ํ–ˆ๋Š”๋ฐ, ์ด ๋ฐฉ๋ฒ•๋„ stream ํ˜•์‹์œผ๋กœ ๋‹ค์šด๋กœ๋“œ ํ•  ์ˆ˜๋Š” ์—†์—ˆ๋‹ค.

์ œ๋กœ์ดˆ๋‹˜์˜ ์ฐธ๊ณ ๋ฌธ์„œ

๊ฐœ๋ฐœ์ž ๋ธ”๋กœ๊ทธ ์ฐธ๊ณ ๋ฌธ์„œ

Stream Data ๋ฌธ์„œ

์ด๋Ÿฌํ•œ ์—ฌ๋Ÿฌ ์ฐธ๊ณ ์ž๋ฃŒ๋ฅผ ๋ณด๋‹ค๊ฐ€ stream api ๊ด€๋ จ ๋ณด๋‹ค๋Š” large file์„ ์–ด๋–ป๊ฒŒ ์›น์—์„œ ๋‹ค์šด๋กœ๋“œ ํ•˜๋Š”์ง€๋ฅผ ์ฐพ์•„๋ดค๋Š”๋ฐ,

upload-and-download-files-using-presigned-urls ์„ ๋ฐœ๊ฒฌํ–ˆ๋‹ค.

Presigned URLs

presigned URL์„ ์‚ฌ์šฉํ•ด์„œ ๋‹ค์šด๋กœ๋“œ ํ•˜๋Š” ๋ฐฉ๋ฒ•

s3 > presigned URL์„ ์‚ฌ์šฉํ•ด์„œ ๋‹ค์šด๋กœ๋“œ ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๋‚ด์šฉ์„ ์ฐพ๊ฒŒ๋˜์—ˆ๋‹ค.

presigned URL์ด๋ž€ ๋ง ๊ทธ๋Œ€๋กœ ๋ฏธ๋ฆฌ ์„œ๋ช…ํ•œ url์ด๋ž€ ๋œป์œผ๋กœ ๋ฏธ๋ฆฌ ์ผ์ •์‹œ๊ฐ„๋™์•ˆ ๊ถŒํ•œ์ด๋‚˜ ์˜ต์…˜์„ ๋ถ€์—ฌํ•œ url์ด๋‹ค

์šฐ๋ฆฌ๋Š” ๊ธฐ์กด์— presignedUrl์„ ์‚ฌ์šฉ์ค‘์ด์˜€๊ณ  window.open(presignedUrl,'_self') ๋ฅผ ์‹คํ–‰ํ•˜๋‹ˆ ์ƒ๊ฐํ•œ๋Œ€๋กœ์˜ ๋ถ„ํ•  ๋‹ค์šด๋กœ๋“œ๊ฐ€ ๋™์ž‘ํ–ˆ๋‹ค!!

๊ทธ๋Ÿฌ๋‚˜ mov ํŒŒ์ผ์€ ๋‹ค์šด๋กœ๋“œ๊ฐ€ ์ž˜ ๋๋Š”๋ฐ, mp4ํŒŒ์ผ์€ ์ƒˆํƒญ์œผ๋กœ ์—ด๋ฆฌ๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค.

๊ด€๋ จํ•ด์„œ ์ฐพ์•„๋ณด๋‹ˆ

๊ธฐ๋ณธ MIME ํƒ€์ž… ์ฒ˜๋ฆฌ ๋ฐฉ์‹ ๋•Œ๋ฌธ์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ธŒ๋ผ์šฐ์ €๋Š” ํ™•์žฅ์ž์— ๋”ฐ๋ผ ํŒŒ์ผ์„ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ์ง€ ๊ฒฐ์ •ํ•˜๋Š”๋ฐ, .mp4๋Š” ๋Œ€๋ถ€๋ถ„์˜ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์žฌ์ƒ ๊ฐ€๋Šฅํ•œ ํ˜•์‹์œผ๋กœ ๊ฐ„์ฃผ๋˜์–ด ์ƒˆ ํƒญ์—์„œ ์—ด๋ฆฌ์ง€๋งŒ, .mov ํŒŒ์ผ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋ฏธ๋””์–ด ํ”Œ๋ ˆ์ด์–ด๋กœ ์—ฐ๋™๋˜๊ฑฐ๋‚˜ ๋‹ค์šด๋กœ๋“œ๋ฉ๋‹ˆ๋‹ค.

์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ๋ฐฉ๋ฒ•์œผ๋กœ, ์„œ๋ฒ„์—์„œ ์ œ๊ณตํ•˜๋Š” ํŒŒ์ผ์˜ Content-Disposition ํ—ค๋”๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ผ๊ณ  ๋‚˜์™€์„œ nextjs ๋กœ ๊ตฌํ˜„๋œ ์„œ๋ฒ„๋ฅผ ๊นŒ๋ณด๋‹ˆ presigned URL์„ ์ƒ์„ฑํ•˜๋Š” ๋กœ์ง์ด ์žˆ์—ˆ๋Š”๋ฐ Content-Disposition ์ด ์„ค์ •๋˜์–ด ์žˆ์ง€ ์•Š์•˜๋‹ค.

async createGetPresignedUrl(
    // ...args
): Promise<string> {
    const command = new GetObjectCommand({
        Bucket: this.AWS_BUCKET,
        // ...
        ResponseContentDisposition:`attachment;filename="${filename}"`,
    })
    // ...
}

๋‹ค์Œ๊ณผ ๊ฐ™์ด ResponseContentDisposition๋ฅผ ์„ค์ •ํ•˜์—ฌ url์„ ์ƒ์„ฑํ•˜๋‹ˆ mp4ํŒŒ์ผ๋„ ์ •์ƒ์ ์œผ๋กœ ๋‹ค์šด๋กœ๋“œ๊ฐ€ ๋๋‹ค!!

๊ทธ๋Ÿฐ๋ฐ, ๊ฒ€์ฆ๊ณผ์ •์—์„œ ํ•œ๊ธ€์ด ํฌํ•จ๋œ filename์˜ ๊ฒฝ์šฐ ์ž…๋ ฅํ•œ ๊ฐ’์ด ์•„๋‹Œ ๋””์ฝ”๋”ฉ๋œ ์ด๋ฆ„์œผ๋กœ ๋‹ค์šด๋กœ๋“œ๊ฐ€ ๋˜๋Š” ์ด์Šˆ๋ฅผ ๋ฐœ๊ฒฌํ–ˆ๋‹ค.

๊ทธ๋ž˜์„œ encodeURI๋ฅผ ์ฒ˜๋ฆฌํ•˜์˜€๋‹ค. ํ”„๋ก ํŠธ๋‚˜ ๋ฐฑ์—์„œ ์ธ์ฝ”๋”ฉ์„ ์ฒ˜๋ฆฌํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

async createGetPresignedUrl(
    // ...args
): Promise<string> {
    
    const encodedFilename = encodeURI(filename);
    
    const command = new GetObjectCommand({
        Bucket: this.AWS_BUCKET,
        // ...
        ResponseContentDisposition:`attachment;filename="${encodedFilename}"`,
    })
    // ...
}

์šด์˜์ค‘์ธ ์Šคํ†ก ์„œ๋น„์Šค๋Š” ์‚ฌํŒŒ๋ฆฌ๋„ ์ง€์›ํ–ˆ๋Š”๋ฐ, Header value cannot be represented using ISO-8859-1 ๋ผ๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด์„œ ์ •์ƒ์ ์œผ๋กœ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ๊ฐ€ ๋˜์ง€์•Š์•˜๋‹ค. ๐Ÿคฌ

gpt์—๊ฒŒ ๋ฌผ์–ด๋ณด๋‹ˆ ์นœ์ ˆํ•˜๊ฒŒ ๋‹ต์„ ์•Œ๋ ค์ฃผ์—ˆ๋‹ค.

"Header value cannot be represented using ISO-8859-1"๋ผ๋Š” ์—๋Ÿฌ๋Š” HTTP ํ—ค๋”์—์„œ ์‚ฌ์šฉ๋œ ๊ฐ’์ด ISO-8859-1 ๋ฌธ์ž ์ธ์ฝ”๋”ฉ์œผ๋กœ ํ‘œํ˜„๋  ์ˆ˜ ์—†์„ ๋•Œ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ISO-8859-1์€ ๋Œ€๋ถ€๋ถ„์˜ ์„œ๊ตฌ ์–ธ์–ด์—์„œ ์‚ฌ์šฉ๋˜๋Š” 8๋น„ํŠธ ๋ฌธ์ž ์ธ์ฝ”๋”ฉ์ด์ง€๋งŒ, ํ•œ๊ธ€์ด๋‚˜ ๋‹ค๋ฅธ ๋น„์„œ๊ตฌ๊ถŒ ๋ฌธ์ž๋“ค(์˜ˆ: ์ผ๋ณธ์–ด, ์ค‘๊ตญ์–ด) ๋˜๋Š” ์ด๋ชจ์ง€์™€ ๊ฐ™์€ ํŠน์ˆ˜ ๋ฌธ์ž๋Š” ์ด ์ธ์ฝ”๋”ฉ์œผ๋กœ ํ‘œํ˜„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

๊ทธ๋ž˜์„œ ์ตœ์ข…์ ์œผ๋กœ presignedUrl๋ฅผ ๋ฐœ๊ธ‰๋ฐ›๋Š” ์ฝ”๋“œ๊ฐ€ ์™„์„ฑ๋๋‹ค.


async function getPresignedUrl(bucketName, objectKey) {
    // ํŒŒ์ผ๋ช…์„ URI ์ธ์ฝ”๋”ฉ (UTF-8๋กœ ์ธ์ฝ”๋”ฉ)
    const filename = 'DropshotStock_UP6WFW1U_์•ˆ๊ฒฝ์„ ๋ผ๊ณ  ํด๋ฆฝ๋ณด๋“œ์— ๋ฌด์–ธ๊ฐ€๋ฅผ ์ ๋Š” ๊ธด ๋จธ๋ฆฌ ์—ฌ์„ฑ_preview.mov';
    const encodedFilename = encodeURIComponent(filename);

    const command = new GetObjectCommand({
        Bucket: bucketName,
        Key: objectKey,
        ResponseContentDisposition: `attachment; filename*=UTF-8''${encodedFilename}`,  // filename*์„ ์‚ฌ์šฉํ•˜์—ฌ UTF-8 ์ธ์ฝ”๋”ฉ๋œ ํŒŒ์ผ๋ช… ์„ค์ •
    });

    try {
        const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
        return presignedUrl;
    } catch (err) {
        //...
    }
}

ํ›„๊ธฐ

์—ฌ๋Ÿฌ ์‹œํ–‰์ฐฉ์˜ค๊ฐ€ ์žˆ์—ˆ์ง€๋งŒ, ์‚ฝ์งˆํ•˜๋Š” ๊ณผ์ •์—์„œ stream api์— ๋Œ€ํ•ด์„œ๋„ ์•Œ๊ฒŒ๋˜์—ˆ๊ณ , s3์˜ presigned URL๋„ ํ•™์Šตํ•  ๊ธฐํšŒ๊ฐ€ ์žˆ์—ˆ๋‹ค.