2026. 2. 22. 01:03ใProjects/WooniePangee

cloudflare๋ก ์ด๋ฏธ์ง ๋ค์ด๋ฐ๊ธฐ
๋ชฉ์ฐจ
1. cloudflare ๊ณ์ ์์ฑ ๋ฐ ๋ฒํท ์ถ๊ฐ
1. cloudflare ๊ณ์ ์์ฑ ๋ฐ ๋ฒํท ์ถ๊ฐ
์ด๋ฏธ์ง ๊ณต์ ๋งํฌ๋ฅผ ๋ง๋ค๊ธฐ์ cloudflare๊ฐ ๋ฌด๋ฃ๋ก ๋ด๊ฐ ํ์ํ ๋ฒ์๋ฅผ ์ ๊ณตํ๊ธฐ์ ๊ฐ์ฅ ์ ์ ํ๋ค๊ณ ์๊ฐํด์ cloudflare๋ก ์ ํํ๋ค.
๋ด๊ฐ ํ์ํ ๊ฑด ํด๋ฆญ์ ํตํด์ ์ด๋ฏธ์ง ํ์ผ์ ๋ค์ด๋ฐ๋ ๊ฒ์ด ์ ๋ถ๊ณ , ๋ค์ด๋ก๋์์ด ์ ๋ฃํ๋๋งํผ ๋ง์์ง์ง ์์ ๊ฑฐ๋ผ ์๊ฐํ๋ค.
https://www.cloudflare.com/ko-kr/
๋ชจ๋ ๊ณณ์์ ์ฐ๊ฒฐํ๊ณ , ๋ณดํธํ๊ณ , ๊ตฌ์ถํ๊ธฐ
๋ณต์ก์ฑ๊ณผ ๋น์ฉ์ ์ค์ด๋ฉด์ ์ง์, ์ ํ๋ฆฌ์ผ์ด์ , ๋คํธ์ํฌ๋ฅผ ์ด๋์์๋ ๋ ๋น ๋ฅด๊ณ ์์ ํ๊ฒ ๋ง๋ญ๋๋ค.
www.cloudflare.com
1. domain ์ถ๊ฐ

์์ง ๋๋ฉ์ธ ๊ตฌ๋งค๋ฅผ ์ํด์ ์ผ๋จ ์์๋ก ๋ฒ์ ๋ฐฐํฌ๋งํฌ๋ฅผ ์ถ๊ฐํ๋ค.
2. ๋ฒํท ์ถ๊ฐ

create bucket์ผ๋ก ๋ฒํท์ ์์ฑํ๋ค.

๋ฒํท์ ํ์ผ์ ๋์ด๋ค ์ถ๊ฐํ๊ธฐ๋ง ํ๋ฉด ํด๋น ํ์ผ์ url์ด ์์ฑ๋๋ค.
์ฌ๊ธฐ์ ์ค์ํ ๊ฑด Settings์์ ๊ผญ ์๋ Public Development URL ํญ๋ชฉ์ able์ฒ๋ฆฌ ํด์ค์ผ ํ๋ ๊ฒ์ด๋ค.

2. data ์ถ๊ฐ
์ผ๋จ ๋ฐฑ์๋๊ฐ ๋น์ฅ ํ์ํ์ง ์๊ธฐ ๋๋ฌธ์ src/data/cards.ts ์์น๋ก ์นด๋ ๋ฐ์ดํฐ๋ค์ ์์ฑํด์ ๋ฃ์๋ค.
export const CardDatas = [
{
id: 1,
title: '๋งํฌ',
tags: ['์ฐ๋'],
tmi: '๊ด์ด๋ํ๊ต ๋งํฌ์ ํฌํจ๋์ด์๋ ๋ง ์์ด์ฝ์ด๋ค.',
fileUrls: [
'https://pub-c055a1822d244b0aaba0dc63c00dcba2.r2.dev/mark.ai',
'https://pub-c055a1822d244b0aaba0dc63c00dcba2.r2.dev/mark.pdf',
'https://pub-c055a1822d244b0aaba0dc63c00dcba2.r2.dev/mark.png',
],
thumbnail: 'https://pub-c055a1822d244b0aaba0dc63c00dcba2.r2.dev/mark.png',
createdAt: '2022-06-29',
},
...
]
์ด๋ฐ์์ด๋ค.
์ url๋ค์ ์์์ ์์ฑ๋ ๋งํฌ๋ค์ด๋ค.
3. ๋ค์ด๋ก๋ ๋งํฌ
์ฒ์์๋ ์์ ๋งํฌ๋ฅผ <a href="https://r2.xxx.com/file.pdf" download>์ ๊ฐ์ด ๋ฃ์์ผ๋, CORS ์ ์ฑ ์ผ๋ก ์ธํด ์ธ๋ถ ๋๋ฉ์ธ์ ํ์ผ์ download ์์ฑ์ด ๋ฌด์ํ๊ณ , ์ ํญ์์ ์ด์ด๋ฒ๋ฆฐ๋ค๊ณ ํ๋ค.
๋ฐ๋ผ์ Next.js API Route๋ฅผ ์ฌ์ฉํ๋ค.
- SSRF ๋ฐฉ์ง: ํ์ฉ๋ R2 ํธ์คํธ๋ง ์์ฒญ์ด ๊ฐ๋ฅํ๊ฒ ํ์ฌ ๊ณต๊ฒฉ์ ๋ฐฉ์งํ๋ค.
- ํ์ผ ๊ฐ์ ธ์ค๊ธฐ: ์๋ฒ์์ R2๋ก fetchํด์ ํ์ผ์ ๋ฐ์์จ๋ค.
- ์คํธ๋ฆฌ๋ฐ ์ ๋ฌ: ํ์ผ์ ๋ค์ด๋ฐ๋ ์ฆ์ ์ฌ์ฉ์์๊ฒ ๋ณด๋ด์ ์๋ฒ ๋ฉ๋ชจ๋ฆฌ๋ฅผ ์ ๊ฒ ์ฌ์ฉํ๋ค.
API Route๋ฅผ ๊ฑฐ์ณ์ download.ts ์ ํธ ํจ์๋ฅผ ์ฌ์ฉํ์ฌ ๋ค์ด์ ๋ฐ๋๋ค.
1. Next.js API Route
import { NextRequest, NextResponse } from 'next/server';
// ๋ณด์์ ์ํด ํ์ฉ๋ ํธ์คํธ ํ์ดํธ๋ฆฌ์คํธ
const ALLOWED_R2_HOST = process.env.NEXT_PUBLIC_R2_DOMAIN;
R2๋ ์ด์ฐจํผ ๋ค์ด๋ก๋ ๋งํฌ์ ํฌํจ๋์ด ์์ด์ ๋ ธ์ถ๋์ด๋ ์๊ด์๋ ์ฃผ์์ด์ง๋ง ์ผ๋จ ํต์ผ๊ฐ์๊ฒ ๊ด๋ฆฌํ๋ ๋๋์ผ๋ก env์ ๋ฃ์๋ค.
export async function GET(request: NextRequest) {
if (!ALLOWED_R2_HOST) {
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
try {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const filename = searchParams.get('filename') || 'download';
if (!url) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
ํธ์คํธ ๋งํฌ์ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ๊ฒฝ์ฐ์ ๋๋นํ ์ฝ๋๋ฅผ ๋ฃ์๋ค.
// SSRF ๋ฐฉ์ง: ํ์ฉ๋ ํธ์คํธ์ธ์ง ํ์ธ
let targetUrl: URL;
try {
targetUrl = new URL(url);
if (targetUrl.host !== ALLOWED_R2_HOST) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} catch {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
}
์์ฒญ์ ํ์ ๋ ํ์ฉ๋ ํธ์คํธ์ธ์ง ํ์ธํ๋ค.
// Cloudflare์์ ํ์ผ ์คํธ๋ฆฌ๋ฐ
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); // 15์ด ํ์์์
if (!response.ok) throw new Error('Failed to fetch file');
// ์คํธ๋ฆฌ๋ฐ ์ฒ๋ฆฌ๋ฅผ ์ํด body๊ฐ ์์ ๊ฒฝ์ฐ ์์ธ ์ฒ๋ฆฌ
if (!response.body) {
throw new Error('No content body found');
}
// body ์คํธ๋ฆผ์ ์ง์ ์ฌ์ฉํ์ฌ ๋ฉ๋ชจ๋ฆฌ ์ ์ฝ
const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
const safeFilename = filename.replace(/[\r\n"]/g, '').trim();
// ASCII fallback: ๋น ASCII ๋ฌธ์๋ฅผ ์ ๊ฑฐ
const asciiFilename = safeFilename.replace(/[^\x20-\x7E]/g, '_');
const encodedFilename = encodeURIComponent(safeFilename)
.replace(/['()]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/\*/g, '%2A');
GET ์์ฒญ์ ๋ฐ์ ๋งํฌ(cloudflare)์์ ํ์ผ ์คํธ๋ฆฌ๋ฐ์ ํ๋ค.
ํ์ผ์ ์ ๋ก๋ํ ๋ ํ์ผ๋ช ์ ํ๊ธ์ ํฌํจ์ํค์ง ์๋๋ก ํ์ผ๋, ํน์๋ ํ๋ ์ํฉ์ ๋๋นํด ์์คํค์ฝ๋ ์ฒ๋ฆฌ ์ฝ๋๋ฅผ ๋ฃ์ด๋๋ค.
return new NextResponse(response.body, {
headers: {
'Content-Type': contentType,
// attachment ์ค์ ์ผ๋ก ๋ธ๋ผ์ฐ์ ์์ ํ์ผ์ ์ด์ง ์๊ณ ๋ฐ๋ก ๋ค์ด๋ก๋
'Content-Disposition': `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch (error) {
console.error('Download error:', error);
return NextResponse.json({ error: 'Download failed' }, { status: 500 });
}
}
2. download.ts
export function download(url: string, title: string, extension: string) {
if (typeof window === 'undefined') return; // ์๋ฒ ์ปดํฌ๋ํธ ์ํฌํธ ๋ฐฉ์ง
const filename = `${title}.${extension.toLowerCase()}`;
const downloadUrl = `/api/download?url=${encodeURIComponent(url)}&filename=${encodeURIComponent(filename)}`;
const link = document.createElement('a');
link.href = downloadUrl;
// ๋ค์ด๋ก๋ ์์ฑ ๋ช
์
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
// ๊ฐ๋น์ง ์ปฌ๋ ์
์ ๋
setTimeout(() => {
document.body.removeChild(link);
}, 100);
}
url: ๋ค์ด๋ก๋ ๋งํฌ
title: ๋ค์ด๋ก๋ ํ์ผ๋ช
extension: ๋ค์ดํ์ (.ai, .png, .pdf ๋ฑ๋ฑ)
3. ๋ค์ด๋ฒํผ
<button
key={`${id}-file-${index}`}
role="menuitem"
onClick={() => {
download(url, title, extension);
close();
}}
className="text-gray700 hover:bg-gray50 block w-full px-4 py-2 text-left text-sm transition-colors duration-150"
>
{extension} ๋ค์ด๋ก๋
</button>
onClick์ผ๋ก download ํจ์๋ฅผ ์ ๋ฌํด์ ์ฌ์ฉํ๋ฉด ํ์ผ๋ค์ด์ด ๋๋ค.
์ฐธ๊ณ
'Projects > WooniePangee' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| Three.js๋ก ์นํ์ด์ง์ 3D ๊ทธ๋ํฝ ๋ฃ๊ธฐ (feat.blender) (0) | 2026.02.02 |
|---|---|
| ESLint + Prettier์์ Delete cr ์๋ฌ๊ฐ ๋๋ ์ด์ (0) | 2026.01.28 |
| Github Acitons CI ์ธํ ํ๊ธฐ (0) | 2026.01.22 |
| ํ๋ก์ ํธ ์์ : ๋ง์ค์ฝํธ ์๊ฐ ์นํ์ด์ง MVP (1) | 2026.01.22 |
GitHub