"모바일 청첩장에 '실시간 사진 콘테스트 + 방명록 + RSVP'를 Supabase로 붙이기"
서버 없이 정적 호스팅(Vercel)만으로 실시간 사진 투표, 방명록, 참석 여부, 관리자 페이지까지 만든 과정 정리"
직접 만든 모바일 청첩장에 하객 참여형 기능을 붙였습니다. 핵심은 다음 네 가지입니다.
별도 백엔드 서버 없이 정적 호스팅(Vercel) + Supabase(Postgres / Storage / Realtime) 만으로 구현했습니다. 이 글은 최종 결과물 기준으로 설계·구현·쿼리를 정리한 기록입니다.
| 영역 | 사용 |
|---|---|
| 프론트엔드 | Vite + React 18 (SPA) |
| 호스팅 | Vercel (무료 플랜, 정적 빌드) |
| 데이터베이스 | Supabase Postgres |
| 파일 저장 | Supabase Storage (public 버킷) |
| 실시간 | Supabase Realtime (postgres_changes) |
| 인증 | 사용 안 함 (anon key만 사용) |
핵심 아이디어는 "백엔드 코드를 한 줄도 작성하지 않는다" 입니다. 클라이언트(브라우저)가 Supabase의 anon key로 직접 DB/Storage에 접근하고, 민감한 쓰기 작업만 DB 안의 함수(RPC)로 처리합니다.
[브라우저(React)]
│ anon key
├─ SELECT/INSERT ─────────▶ [Supabase Postgres] (RLS로 보호)
├─ rpc(...) ──────────────▶ [security definer 함수] (권한 우회 작업)
├─ Storage upload/getUrl ─▶ [Supabase Storage: guest-photos]
└─ realtime subscribe ────▶ [Realtime: postgres_changes]
security definer 함수로만 가능하게 막음-- 사진 콘테스트
create table if not exists public.photos (
id uuid primary key default gen_random_uuid(),
name text not null, -- 자동 배정 닉네임
image_url text not null,
likes integer not null default 0,
hidden boolean not null default false, -- 관리자가 숨기면 공개 목록 제외
created_at timestamptz not null default now()
);
-- 방명록 (공개)
create table if not exists public.guestbook (
id uuid primary key default gen_random_uuid(),
name text not null,
message text not null,
created_at timestamptz not null default now()
);
-- 참석 여부 (비공개 — RPC로만 접근)
create table if not exists public.rsvp (
id uuid primary key default gen_random_uuid(),
name text not null,
side text not null, -- 'groom' | 'bride'
attending boolean not null,
headcount integer not null default 1, -- 본인 포함 인원
created_at timestamptz not null default now()
);
RLS(Row Level Security)는 "누가 어떤 행을 읽고 쓸 수 있는가"를 DB 레벨에서 강제합니다. 인증을 안 쓰므로 모든 접근자는 anon 역할입니다. 그래서 "공개해도 되는 것만 열고, 나머지는 함수로만" 원칙을 세웠습니다.
-- photos: 누구나 조회/업로드, 좋아요·삭제·숨김은 함수로만
alter table public.photos enable row level security;
create policy "photos_select_all" on public.photos for select using (true);
create policy "photos_insert_all" on public.photos for insert with check (true);
-- UPDATE / DELETE 정책은 의도적으로 만들지 않음 → 직접 수정/삭제 불가
-- guestbook: 공개로 보여줄 거라 조회/입력 허용
alter table public.guestbook enable row level security;
create policy "guestbook_select_all" on public.guestbook for select using (true);
create policy "guestbook_insert_all" on public.guestbook for insert with check (true);
-- rsvp: 명단은 비공개 → 정책 자체를 안 만들어 직접 접근 전면 차단
alter table public.rsvp enable row level security;
-- (정책 없음 = anon 직접 SELECT/INSERT 모두 불가, 오직 RPC로만)
여기서 중요한 트릭: RLS가 켜져 있고 정책이 없으면 anon은 그 테이블에 직접 접근할 수 없습니다. RSVP 명단처럼 아무나 읽으면 안 되는 데이터는 정책을 비워두고, 아래의 security definer 함수로만 드나들게 했습니다.
insert into storage.buckets (id, name, public)
values ('guest-photos', 'guest-photos', true)
on conflict (id) do update set public = true;
create policy "guest_photos_insert" on storage.objects
for insert with check (bucket_id = 'guest-photos');
create policy "guest_photos_select" on storage.objects
for select using (bucket_id = 'guest-photos');
public 버킷이라 업로드한 사진은 getPublicUrl()로 바로 접근 가능한 URL을 얻습니다.
alter publication supabase_realtime add table public.photos;
alter publication supabase_realtime add table public.guestbook;
이 한 줄로 해당 테이블의 INSERT/UPDATE/DELETE 이벤트를 클라이언트가 구독할 수 있게 됩니다.
security definer는 함수를 소유자(관리자) 권한으로 실행하게 합니다. 즉 RLS를 우회합니다. 그래서 "anon에게는 테이블 권한을 안 주되, 딱 이 동작만 함수로 열어준다"가 가능합니다.
create or replace function public.increment_likes(photo_id uuid)
returns void language sql security definer set search_path = public
as $$ update public.photos set likes = likes + 1 where id = photo_id; $$;
grant execute on function public.increment_likes(uuid) to anon, authenticated;
동시에 여러 명이 눌러도 likes = likes + 1이 DB에서 원자적으로 처리되어 카운트가 꼬이지 않습니다.
create or replace function public.delete_photo(photo_id uuid)
returns void language sql security definer set search_path = public
as $$ delete from public.photos where id = photo_id; $$;
grant execute on function public.delete_photo(uuid) to anon, authenticated;
비밀번호를 함수 안(서버측) 에 둡니다. 클라이언트 코드나 번들에 노출되지 않습니다.
create or replace function public.admin_ok(p_pass text)
returns boolean language sql security definer set search_path = public
as $$ select p_pass = 'YOUR_ADMIN_PASSWORD'; $$; -- 원하는 비밀번호로 변경
이 함수는 anon에
grant하지 않습니다. 아래 관리자 함수들이 내부에서만 호출합니다(정의자 권한으로 실행되므로 호출 가능).
create or replace function public.save_rsvp(
p_id uuid, p_name text, p_side text, p_attending boolean, p_headcount integer
) returns uuid
language plpgsql security definer set search_path = public as $$
declare v_id uuid;
begin
if p_id is not null and exists (select 1 from rsvp where id = p_id) then
update rsvp set name=p_name, side=p_side,
attending=p_attending, headcount=p_headcount
where id = p_id;
return p_id;
else
insert into rsvp(name, side, attending, headcount)
values (p_name, p_side, p_attending, p_headcount)
returning id into v_id;
return v_id;
end if;
end; $$;
grant execute on function public.save_rsvp(uuid,text,text,boolean,integer) to anon, authenticated;
-- 사진 숨김/공개
create or replace function public.admin_set_hidden(p_id uuid, p_hidden boolean, p_pass text)
returns void language plpgsql security definer set search_path = public as $$
begin
if not admin_ok(p_pass) then raise exception 'unauthorized'; end if;
update photos set hidden = p_hidden where id = p_id;
end; $$;
-- 사진 삭제 / 방명록 삭제 (구조 동일)
create or replace function public.admin_delete_photo(p_id uuid, p_pass text) ...
create or replace function public.admin_delete_guestbook(p_id uuid, p_pass text) ...
-- RSVP 명단 조회 (비번 맞을 때만 행 반환)
create or replace function public.admin_list_rsvp(p_pass text)
returns setof rsvp language plpgsql security definer set search_path = public as $$
begin
if not admin_ok(p_pass) then raise exception 'unauthorized'; end if;
return query select * from rsvp order by created_at desc;
end; $$;
grant execute on function public.admin_list_rsvp(text) to anon, authenticated;
이 패턴 덕분에 "비밀번호가 맞을 때만 동작하는 API" 를 별도 서버 없이 DB만으로 만들 수 있습니다.
// src/lib/supabase.js
import { createClient } from "@supabase/supabase-js";
const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabaseEnabled = Boolean(url && anonKey);
export const supabase = supabaseEnabled ? createClient(url, anonKey) : null;
export const PHOTO_BUCKET = "guest-photos";
anon key는 공개돼도 되는 키입니다(RLS가 진짜 방어선). 반면service_rolekey는 RLS를 전부 무시하므로 절대 프론트엔드/번들에 넣지 않습니다.
휴대폰 원본 사진은 수 MB라 그대로 올리면 느리고 무료 용량을 금방 잡아먹습니다. 그래서 canvas로 긴 변 1280px, JPEG 품질 0.8 로 줄여서 올립니다.
async function downscale(file, maxSize = 1280, quality = 0.8) {
const bitmap = await createImageBitmap(file);
let { width, height } = bitmap;
if (width > maxSize || height > maxSize) {
const ratio = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement("canvas");
canvas.width = width; canvas.height = height;
canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height);
bitmap.close?.();
return await new Promise((res) => canvas.toBlob(res, "image/jpeg", quality));
}
async function uploadImage(file) {
const blob = await downscale(file);
const path = `${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`;
const { error } = await supabase.storage
.from(PHOTO_BUCKET)
.upload(path, blob, { contentType: "image/jpeg" });
if (error) throw error;
return supabase.storage.from(PHOTO_BUCKET).getPublicUrl(path).data.publicUrl;
}
인증을 안 쓰기 때문에 하객에게 이름을 강제로 입력받지 않습니다. 대신 업로드 시 형용사(의인화된 행동) + 명사(동물·채소·과일) 조합의 귀여운 닉네임을 자동 배정합니다. 예: 사진찍는 토끼, 운동하는 딸기.
// src/lib/nickname.js
const ADJECTIVES = ["사진찍는","춤추는","노래하는","운동하는", /* ...40개 */];
const NOUNS = ["토끼","고양이","강아지","딸기","당근","복숭아", /* ...40개 */];
export function generateNickname(taken = []) {
const used = new Set(taken);
for (let i = 0; i < 200; i++) {
const c = `${pick(ADJECTIVES)} ${pick(NOUNS)}`;
if (!used.has(c)) return c; // 기존 닉네임과 안 겹치게
}
// (40×40=1600 조합이 꽉 차는 극단적 경우엔 숫자 접미사)
}
업로드 직전 현재 닉네임 목록을 읽어와 중복되지 않는 조합을 뽑습니다.
const { data: existing } = await supabase.from("photos").select("name");
const nickname = generateNickname((existing || []).map((r) => r.name));
const url = await uploadImage(file);
const { data: inserted } = await supabase
.from("photos")
.insert({ name: nickname, image_url: url })
.select("*")
.single();
당첨자 확인 방식: 본인 폰에만 "내가 올린 사진: 사진찍는 토끼"가 표시됩니다(localStorage 저장). 진행자가 큰 화면에서 "1위, 사진찍는 토끼!"를 호명하면, 자기 폰에 그 닉네임이 떠 있는 사람이 당첨자입니다. 중앙 명단 없이 자진 신고 방식으로 동작합니다.
const like = async (id) => {
if (liked.has(id)) return; // 이 브라우저에서 한 사진당 한 번
const next = new Set(liked); next.add(id);
setLiked(next);
localStorage.setItem(LIKED_KEY, JSON.stringify([...next]));
setPhotos((prev) => prev.map((p) => // 먼저 화면에 반영(낙관적)
p.id === id ? { ...p, likes: p.likes + 1 } : p));
await supabase.rpc("increment_likes", { photo_id: id }); // 서버 반영
};
좋아요 누른 사진 id는 localStorage에 저장해 다른 사진은 각각 누를 수 있지만, 같은 사진은 한 번만 누르게 했습니다.
여러 명이 한꺼번에 올리거나 좋아요를 누를 때 매 이벤트마다 재조회하면 부하가 큽니다. 그래서 400ms 디바운스로 묶어 한 번만 다시 불러옵니다.
useEffect(() => {
fetchPhotos();
const debounced = () => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(fetchPhotos, 400);
};
const channel = supabase
.channel("photos-realtime")
.on("postgres_changes",
{ event: "*", schema: "public", table: "photos" },
debounced)
.subscribe();
return () => { clearTimeout(debounceRef.current); supabase.removeChannel(channel); };
}, []);
const { data } = await supabase
.from("photos").select("*")
.order("likes", { ascending: false }) // 좋아요 많은 순
.order("created_at", { ascending: true }); // 동률이면 먼저 올린 순
const list = (data || []).filter((p) => !p.hidden); // 숨긴 사진 제외
본문에는 TOP 3만 노출하고, 4장 이상이면 "전체보기" 버튼으로 그리드 모달을 엽니다. 그리드의 사진을 탭하면 라이트박스로 크게 보면서 좋아요를 누를 수 있습니다.
const TOP_N = 3;
...
<ol className="contest__list">
{photos.slice(0, TOP_N).map((p, i) => <RankCard key={p.id} p={p} rank={i+1} />)}
</ol>
{photos.length > TOP_N && (
<button onClick={() => setShowAll(true)}>전체보기 ({photos.length}장)</button>
)}
사진과 동일한 패턴(테이블 + 실시간 구독)이라 빠르게 붙였습니다.
const { data: inserted } = await supabase
.from("guestbook")
.insert({ name: n, message: m })
.select("*").single();
setEntries((prev) => [inserted, ...prev]); // 최신순 맨 위
명단은 비공개라 직접 조회가 안 됩니다. 그래서 제출한 값을 브라우저 localStorage에 저장해두고, 재방문 시 그 값으로 폼을 채워 수정할 수 있게 했습니다. 저장/수정은 save_rsvp RPC가 id 유무로 분기합니다.
const { data: id } = await supabase.rpc("save_rsvp", {
p_id: saved?.id ?? null, // 있으면 수정, 없으면 신규
p_name: n, p_side: side, p_attending: attending, p_headcount: count,
});
localStorage.setItem(RSVP_KEY, JSON.stringify({ id, name: n, side, attending, headcount: count }));
라우터 없이 ?admin 쿼리 파라미터로 진입합니다.
// App.jsx
if (new URLSearchParams(window.location.search).has("admin")) {
return <Admin />;
}
로그인은 별도 인증 없이 비밀번호로 RPC를 한 번 호출해보고 성공하면 통과시키는 방식입니다.
const login = async () => {
const { error } = await supabase.rpc("admin_list_rsvp", { p_pass: pass });
if (error) { setError("비밀번호가 올바르지 않아요."); return; } // unauthorized 예외
setAuthed(true);
loadAll(pass);
};
이후 모든 관리자 동작은 비밀번호를 함께 넘겨 서버측에서 검증합니다.
await supabase.rpc("admin_set_hidden", { p_id: id, p_hidden: true, p_pass: pass });
await supabase.rpc("admin_delete_photo", { p_id: id, p_pass: pass });
await supabase.rpc("admin_delete_guestbook", { p_id: id, p_pass: pass });
RSVP 합계는 받아온 명단으로 클라이언트에서 계산합니다.
const attendCount = rsvp.filter(r => r.attending)
.reduce((s, r) => s + (r.headcount || 0), 0);
| 항목 | 처리 |
|---|---|
| anon key 노출 | 공개돼도 안전(설계상). 진짜 방어선은 RLS |
| service_role key | 절대 사용/포함하지 않음 |
| 좋아요/삭제 등 쓰기 | RLS는 막아두고 security definer 함수로만 허용 |
| RSVP 명단 | RLS 정책 비워 직접 접근 차단, 조회는 비번 검증 RPC로만 |
| 관리자 비밀번호 | DB 함수 안(서버측)에만 존재, 번들 비노출 |
| 사진 숨김 필터 | 클라이언트 필터로 처리(마이그레이션 타이밍에 안 깨지게). 강한 차단이 필요하면 서버측 필터/삭제 병행 |
캐주얼한 결혼식 이벤트 기준의 절충입니다.
delete_photo처럼 id만 받는 함수는 id를 아는 사람이 호출할 수 있는 한계가 있어, 더 엄격히 하려면 업로드 시 비밀 토큰을 함께 저장해 대조하는 방식으로 확장할 수 있습니다.
정적 SPA라 빌드만 올리면 됩니다. 환경변수는 빌드 시 주입됩니다.
# 로컬: .env.local
VITE_SUPABASE_URL=https://xxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...
# Vercel: 프로젝트 Settings > Environment Variables 에 동일하게 등록
vercel --prod
VITE_ 접두사가 붙은 변수는 빌드 시 번들에 인라인됩니다(그래서 anon key처럼 공개 가능한 값만 넣습니다).
별도 백엔드 없이 Supabase의 Postgres + Storage + Realtime + RPC 조합만으로
을 모두 구현했습니다. 핵심 교훈은 "RLS로 공개 범위를 좁히고, 권한이 필요한 동작은 security definer 함수로 좁게 연다" 는 패턴입니다. 정적 호스팅만으로도 충분히 동적인 서비스를 만들 수 있었습니다.