์น์์ ๋์ฉ๋ ํ์ผ์ ๋ค์ด๋ก๋ํ๋ ๋ค์ํ ๋ฐฉ๋ฒ
์ด๋ ๋ , ์คํก์์์ด ๋ค์ด๋ก๋ ๋์ง ์๋๋ค๋ 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๊ฐ์ง ์ ๋ ๋ฐฉ์์ด ์์๋๋ฐ,
- Stream ์ฒ๋ฆฌ
- multipart download > s3 using large file scenario docs
- 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 api ๊ด๋ จ ๋ณด๋ค๋ large file์ ์ด๋ป๊ฒ ์น์์ ๋ค์ด๋ก๋ ํ๋์ง๋ฅผ ์ฐพ์๋ดค๋๋ฐ,
upload-and-download-files-using-presigned-urls ์ ๋ฐ๊ฒฌํ๋ค.
Presigned URLs

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๋ ํ์ตํ ๊ธฐํ๊ฐ ์์๋ค.