์ด๋ฏธ์ง€ ๋‹ค์šด ๋งํฌ ๋ฐ์ดํ„ฐ ๋„ฃ๊ธฐ (feat. cloudflare)

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

๋ฐ˜์‘ํ˜•

 

cloudflare๋กœ ์ด๋ฏธ์ง€ ๋‹ค์šด๋ฐ›๊ธฐ

๋ชฉ์ฐจ

1. cloudflare ๊ณ„์ • ์ƒ์„ฑ ๋ฐ ๋ฒ„ํ‚ท ์ถ”๊ฐ€

2. data ์ถ”๊ฐ€

3. ๋‹ค์šด๋กœ๋“œ ๋งํฌ

 


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 ํ•จ์ˆ˜๋ฅผ ์ „๋‹ฌํ•ด์„œ ์‚ฌ์šฉํ•˜๋ฉด ํŒŒ์ผ๋‹ค์šด์ด ๋œ๋‹ค.

 

 

 

 

 

์ฐธ๊ณ 

๋ฐ˜์‘ํ˜•