모바일청첩장 내가 만들래

히태하태·2026년 6월 1일

SidePrj

목록 보기
1/1
post-thumbnail

"모바일 청첩장에 '실시간 사진 콘테스트 + 방명록 + RSVP'를 Supabase로 붙이기"
서버 없이 정적 호스팅(Vercel)만으로 실시간 사진 투표, 방명록, 참석 여부, 관리자 페이지까지 만든 과정 정리"

들어가며

직접 만든 모바일 청첩장에 하객 참여형 기능을 붙였습니다. 핵심은 다음 네 가지입니다.

  1. 실시간 사진 자랑(콘테스트) — 하객이 본식 사진을 올리고, 하트로 투표하면 실시간으로 순위가 바뀜
  2. 방명록 — 축하 메시지 남기기
  3. 참석 여부(RSVP) — 참석/불참, 인원 집계
  4. 관리자 페이지 — 부적절 사진 숨김/삭제, 방명록 관리, RSVP 명단·합계 확인

별도 백엔드 서버 없이 정적 호스팅(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]
  • 일반 조회/입력은 RLS(행 보안) 정책으로 직접 허용
  • 좋아요 증가, 삭제, 숨김, 관리자 조회처럼 권한이 필요한 작업security definer 함수로만 가능하게 막음
  • 사진은 Storage에 업로드하고, 메타데이터(닉네임/URL/좋아요 수)는 테이블에 저장

1. 데이터베이스 설계

테이블 3개

-- 사진 콘테스트
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 정책 설계 사상

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 함수로만 드나들게 했습니다.

Storage 버킷

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을 얻습니다.

실시간(Realtime)

alter publication supabase_realtime add table public.photos;
alter publication supabase_realtime add table public.guestbook;

이 한 줄로 해당 테이블의 INSERT/UPDATE/DELETE 이벤트를 클라이언트가 구독할 수 있게 됩니다.


2. security definer 함수(RPC) — 핵심 보안 장치

security definer는 함수를 소유자(관리자) 권한으로 실행하게 합니다. 즉 RLS를 우회합니다. 그래서 "anon에게는 테이블 권한을 안 주되, 딱 이 동작만 함수로 열어준다"가 가능합니다.

좋아요 +1 (원자적 증가)

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하지 않습니다. 아래 관리자 함수들이 내부에서만 호출합니다(정의자 권한으로 실행되므로 호출 가능).

RSVP 저장 (신규/수정 겸용)

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만으로 만들 수 있습니다.


3. 프론트엔드 구현

Supabase 클라이언트

// 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_role key는 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위, 사진찍는 토끼!"를 호명하면, 자기 폰에 그 닉네임이 떠 있는 사람이 당첨자입니다. 중앙 명단 없이 자진 신고 방식으로 동작합니다.

좋아요 — 낙관적 업데이트 + 한 사진당 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 + 전체보기 그리드 + 라이트박스

본문에는 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]);  // 최신순 맨 위

RSVP — localStorage로 재방문 수정

명단은 비공개라 직접 조회가 안 됩니다. 그래서 제출한 값을 브라우저 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);

4. 보안 설계 정리

항목처리
anon key 노출공개돼도 안전(설계상). 진짜 방어선은 RLS
service_role key절대 사용/포함하지 않음
좋아요/삭제 등 쓰기RLS는 막아두고 security definer 함수로만 허용
RSVP 명단RLS 정책 비워 직접 접근 차단, 조회는 비번 검증 RPC로만
관리자 비밀번호DB 함수 안(서버측)에만 존재, 번들 비노출
사진 숨김 필터클라이언트 필터로 처리(마이그레이션 타이밍에 안 깨지게). 강한 차단이 필요하면 서버측 필터/삭제 병행

캐주얼한 결혼식 이벤트 기준의 절충입니다. delete_photo처럼 id만 받는 함수는 id를 아는 사람이 호출할 수 있는 한계가 있어, 더 엄격히 하려면 업로드 시 비밀 토큰을 함께 저장해 대조하는 방식으로 확장할 수 있습니다.


5. 배포 (Vercel)

정적 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 조합만으로

  • 실시간 투표/순위
  • 이미지 업로드(다운스케일)
  • 공개 방명록
  • 비공개 RSVP 집계
  • 비밀번호 기반 관리자 기능

을 모두 구현했습니다. 핵심 교훈은 "RLS로 공개 범위를 좁히고, 권한이 필요한 동작은 security definer 함수로 좁게 연다" 는 패턴입니다. 정적 호스팅만으로도 충분히 동적인 서비스를 만들 수 있었습니다.

profile
시작이 반이다. 일단 시작해보자.

0개의 댓글