리액트 + 비트(Vite)로 모바일 청첩장 만들기 — 2편
모바일 청첩장 시리즈
2편 · R2 갤러리 & Firebase 방명록
클라우드플레어 R2로 갤러리 저장하고, Firebase로 방명록 달기
안녕하세요, 병민입니다 🙌 1편에서 전체 흐름을 잡았고, 이번엔 사진 업로드/보관과 방명록을 연결합니다. 서버는 따로 없고 Cloudflare Pages를 쓰고 있으니, Pages Functions(= 워커)로 R2에 사전서명 URL을 만들어주고, 프론트에서 그 URL로 바로 업로드하는 구조예요. 방명록은 Firebase DB로 간단·안전하게!
프론트(React) → /api/r2/upload로 업로드용 URL 요청 → R2에 파일 PUT
프론트(React) → /api/r2/list로 목록 요청 → 갤러리 렌더
프론트(React) → Firebase SDK로 방명록 작성/조회
1) R2 버킷 & Pages Functions 준비
- Cloudflare 대시보드 > R2 > Create bucket (예:
wedding-gallery) - 버킷 > Settings > CORS에서 사이트 도메인 허용(예:
https://*.pages.dev, 커스텀 도메인) - Pages 프로젝트 > Settings > Functions에서 R2 바인딩 추가:
- Binding name:
GALLERY_BUCKET - Bucket:
wedding-gallery
- Binding name:
* “자격증명 키”는 워커/함수에만 바인딩(백엔드). 프론트에 노출하지 않습니다.
2) Pages Functions(워커) — 업로드 URL 발급 & 목록 조회
리포지토리 루트에 /functions 폴더를 만들면 Pages가 자동으로 빌드해줍니다.
/functions/api/r2/upload.ts
export const onRequestPost: PagesFunction<{ GALLERY_BUCKET: R2Bucket }> = async (ctx) => {
const url = new URL(ctx.request.url);
const filename = url.searchParams.get("filename");
const type = url.searchParams.get("type") || "image/jpeg";
if (!filename) {
return new Response(JSON.stringify({ error: "filename is required" }), { status: 400 });
}
// 업로드 키(폴더/파일명 규칙)
const objectKey = `uploads/${Date.now()}-${filename}`;
// 1) 업로드용 사전서명 URL 생성(일정 시간만 유효)
const putUrl = await ctx.env.GALLERY_BUCKET.createPresignedUrl({
method: "PUT",
key: objectKey,
expiration: 10 * 60, // 10분
// R2는 조건별 옵션이 달라질 수 있음
// 일부 플랜/지역에선 워커로 직접 put/get 수행 권장
});
// 2) 공개 접근 URL(리스트용) — 퍼블릭 에셋 경로(도메인+key) 형태 사용
// * R2 퍼블릭 접근을 쓰지 않는다면, 별도 게이트웨이/워커 경유로 서빙
const publicUrl = `https://<YOUR_PUBLIC_R2_DOMAIN_OR_ROUTE>/${objectKey}`;
return new Response(JSON.stringify({ putUrl, publicUrl, objectKey, type }), {
headers: { "content-type": "application/json" }
});
};
/functions/api/r2/list.ts
export const onRequestGet: PagesFunction<{ GALLERY_BUCKET: R2Bucket }> = async (ctx) => {
const objects = await ctx.env.GALLERY_BUCKET.list({ prefix: "uploads/" });
// 퍼블릭 경로 규칙에 맞춰 URL 생성
const items = objects.objects.map(o => ({
key: o.key,
size: o.size,
uploaded: o.uploaded,
url: `https://<YOUR_PUBLIC_R2_DOMAIN_OR_ROUTE>/${o.key}`
}));
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" }
});
};
주의
- R2의 퍼블릭 서빙 방식(커스텀 도메인/라우트) 구성은 프로젝트마다 다릅니다. 위
- 보안상 업로드 권한은 항상 서버(Functions)에서 사전서명 URL을 생성해 클라이언트에 전달하는 구조로 잡습니다.
- R2의 퍼블릭 서빙 방식(커스텀 도메인/라우트) 구성은 프로젝트마다 다릅니다. 위
<YOUR_PUBLIC_R2_DOMAIN_OR_ROUTE>는 본인 환경에 맞춰 교체하세요.- 보안상 업로드 권한은 항상 서버(Functions)에서 사전서명 URL을 생성해 클라이언트에 전달하는 구조로 잡습니다.
3) 프론트(React) — 사진 업로드 & 렌더
아래는 GuestGallery 내부에서 사용하는 형태의 업로드 흐름입니다. 이미 1편 코드처럼 GuestGallery 컴포넌트가 붙어 있으니, 로직만 참고해서 맞춰 넣으면 됩니다.
업로드: 사전서명 URL 받아서 PUT
async function uploadToR2(file) {
// 1) 업로드용 URL 발급
const q = new URLSearchParams({ filename: file.name, type: file.type });
const res = await fetch(`/api/r2/upload?${q.toString()}`, { method: "POST" });
const { putUrl, publicUrl, objectKey } = await res.json();
// 2) 해당 URL로 파일 PUT
const put = await fetch(putUrl, {
method: "PUT",
headers: { "content-type": file.type },
body: file
});
if (!put.ok) throw new Error("R2 업로드 실패");
// 3) 업로드 성공시 공개 URL 저장(갤러리 목록/DB 등에 기록할 수도 있음)
return { publicUrl, objectKey };
}
목록 읽기: /api/r2/list
async function fetchGallery() {
const res = await fetch("/api/r2/list");
const data = await res.json();
return data.items; // [ { key, url, ... } ]
}
* 썸네일이 필요하면 캔버스로 리사이즈 후 함께 업로드하거나, 워커에서 변환용 라우트를 두는 방법도 있어요.
4) Firebase — 방명록 DB 연결
방명록은 Firebase를 붙여 깔끔하게 처리했어요. Firestore든 Realtime DB든 가능하지만, 저는 문서형으로 다루기 쉬운 Firestore를 기준으로 설명할게요.
- Firebase 프로젝트 생성 > 웹 앱 추가 > SDK 설정값(키) 복사
- Firestore 사용 설정 & 보안 규칙(읽기/쓰기 조건) 점검
- 프론트에서 SDK로 초기화 →
Guestbook컴포넌트에서 읽고/쓰기
src/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "{{YOUR_KEY}}",
authDomain: "{{YOUR_APP}}.firebaseapp.com",
projectId: "{{YOUR_PROJECT_ID}}",
storageBucket: "{{YOUR_APP}}.appspot.com",
messagingSenderId: "{{SENDER_ID}}",
appId: "{{APP_ID}}"
};
export const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
Guestbook 사용 예 (간단 형태)
import { collection, addDoc, serverTimestamp, query, orderBy, onSnapshot } from "firebase/firestore";
import { db } from "./firebase";
export function initGuestbook(setItems) {
const qRef = query(collection(db, "guestbook"), orderBy("createdAt", "desc"));
return onSnapshot(qRef, (snap) => {
const arr = [];
snap.forEach((doc) => arr.push({ id: doc.id, ...doc.data() }));
setItems(arr);
});
}
export async function writeGuestbook(name, message) {
await addDoc(collection(db, "guestbook"), {
name, message, createdAt: serverTimestamp()
});
}
보안 규칙 예시(Firestore)
- 공개 방명록이면 읽기는 허용, 쓰기는 간단한 rate-limit & 필드 검증 필요
- 실제 규칙은 서비스 성격에 맞게 조정하세요(스팸 방지 위해 reCAPTCHA/토큰도 고려)
- 공개 방명록이면 읽기는 허용, 쓰기는 간단한 rate-limit & 필드 검증 필요
- 실제 규칙은 서비스 성격에 맞게 조정하세요(스팸 방지 위해 reCAPTCHA/토큰도 고려)
5) 우리 코드에선 이렇게 붙였다
- 갤러리:
<GuestGallery />내부에서- 목록: GET /api/r2/list → 썸네일 그리드 렌더
- 업로드: 파일 선택 → POST /api/r2/upload → 반환된
putUrl로 바로 업로드 - 모달/더보기: 이미 1편 코드처럼 상태 관리(
showAllGallery,selectedImageIndex)
- 방명록:
<Guestbook />내부에서- 초기화 시 Firestore 구독(
onSnapshot) - 작성은
addDoc한 줄 + 타임스탬프
- 초기화 시 Firestore 구독(
실무 팁
- 파일명은
- 이미지 용량이 큰 경우, 프론트에서 리사이즈(캔버스) 후 업로드하면 데이터 절약 ⛳
- 리스트는 캐싱해도 좋지만, 새 업로드 직후엔 즉시 갱신하도록 분기 처리
- 파일명은
uploads/<timestamp>-<random>-원본명처럼 충돌 없이 저장- 이미지 용량이 큰 경우, 프론트에서 리사이즈(캔버스) 후 업로드하면 데이터 절약 ⛳
- 리스트는 캐싱해도 좋지만, 새 업로드 직후엔 즉시 갱신하도록 분기 처리
6) 트러블슈팅 메모
- CORS 에러: R2 버킷 CORS에 정확한 오리진을 넣고,
PUT메서드/헤더 허용 확인 - 퍼블릭 URL 403: 퍼블릭 라우트 설정(도메인 매핑) 점검. 퍼블릭 모드 아니라면 워커 경유로
GET서빙 - Firebase 권한: 규칙 때문에 쓰기 막히면 콘솔에서 규칙/인덱스 확인
댓글
댓글 쓰기