์น์์ ๋์ฉ๋ ํ์ผ์ ๋ค์ด๋ก๋ํ๋ ๋ค์ํ ๋ฐฉ๋ฒ
์ด๋ ๋ , ์คํก์์์ด ๋ค์ด๋ก๋ ๋์ง ์๋๋ค๋ 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๋ ํ์ตํ ๊ธฐํ๊ฐ ์์๋ค.