홈페이지 제작#Next.js#Supabase#멀티테넌시

Next.js 15 + Supabase로 만드는 모던 SMB 홈페이지 — 빈랩이 직접 쓴 방식

Next.js 15 App Router + Supabase로 1개 DB에서 4개 SMB 사이트를 운영한 빈랩의 실전 기록. RLS·ISR·Vercel 자동 배포까지 직접 겪은 함정 포함.

빈랩9분 읽기

빈랩의 멀티테넌시 아키텍처

빈랩이 사내 위키에 쓰던 회고를 외부용으로 다듬은 글입니다. 여러 업종 같은 SMB 사이트 4개와 운영 콘솔 1개를 단일 Supabase + 단일 모노레포로 굴리며 배운 것을 정리합니다.

왜 Next.js 15 + Supabase를 골랐나

처음에는 워드프레스 + 자체 PHP 백엔드를 잠깐 검토했습니다. 그런데 4개 사이트를 동시에 운영해야 하는 빈랩 입장에서 테마 4개·플러그인 4개·서버 4개를 따로 관리하는 그림이 도저히 그려지지 않았습니다.

결국 Next.js 15 App Router + Supabase + Vercel 조합으로 정착했고, 결정 근거는 세 가지였습니다.

  • App Router의 서버 컴포넌트가 SMB 사이트의 80%를 차지하는 "정적 콘텐츠 + 약간의 폼" 패턴에 자연스럽게 맞았습니다. 클라이언트 JS가 거의 안 나가니까 Core Web Vitals가 기본값으로 좋습니다.
  • Supabase는 Postgres + RLS + Storage + Auth를 한 곳에서 끝낼 수 있는 플랫폼입니다.
  • Vercel의 master push 배포 + Edge 캐시가 운영자(클라이언트)에게도 친숙합니다.

멀티테넌시 — 1개 DB로 4개 사이트 운영

가장 많이 받는 질문이 "사이트마다 DB 따로 만드는 게 안전하지 않나요?" 입니다. 직접 굴려보고 내린 결론은 "규모가 작을수록 단일 DB + site_id 격리가 압도적으로 운영하기 쉽다" 였습니다.

옵션장점단점
사이트별 DB 분리물리적 격리마이그레이션 N번, 콘솔이 N개 DB 조회
단일 DB + site_id 격리마이그레이션 1회, 콘솔 단순RLS·쿼리 규율 필수
스키마(namespace) 분리적당한 격리스키마 N개 마이그레이션
create table tb_sites ( id uuid primary key default gen_random_uuid(), slug text unique not null, domain text unique not null, config jsonb not null default '{}' ); create table tb_posts ( id uuid primary key default gen_random_uuid(), site_id uuid not null references tb_sites(id) on delete cascade, title text not null, status text not null default 'draft' );

모든 쿼리에 .eq("site_id", SITE_ID)가 빠지면 곧바로 타 테넌트 데이터가 노출됩니다. 6개월 차에 한 회사 사이트에서 인테리어 회사 게시글이 한 번 노출됐습니다(다행히 staging). 그 이후로는 RLS를 켜고 anon key의 권한 자체를 묶는 방식으로 바꿨습니다.

RLS로 anon key만 쓰면서 데이터 격리

alter table tb_posts enable row level security; create policy "anon read published" on tb_posts for select to anon using (status = 'published'); create policy "anon insert inquiry" on tb_inquiries for insert to anon with check ( site_id in (select id from tb_sites) and length(name) between 1 and 100 );

처음에는 INSERT 정책 없이 그냥 폼을 받았다가, 봇이 하루 수만 원 건씩 들어와서 with check + 길이 제한 조합으로 정리했습니다. service_role을 클라이언트 번들에 절대 두지 않는다는 규칙은 PR 체크리스트에 박아뒀습니다.

ISR + revalidate로 빠르고 신선한 페이지

export const revalidate = 60; export default async function NoticeListPage() { const supabase = createClient(); const { data: posts } = await supabase .from("tb_posts") .select("id, title, created_at") .eq("site_id", process.env.NEXT_PUBLIC_SITE_ID!) .eq("status", "published") .order("created_at", { ascending: false }) .limit(20); return <NoticeList items={posts ?? []} />; }

콘솔에서 게시글을 새로 발행하면 webhook으로 revalidatePath("/notice")를 호출해서 즉시 무효화합니다.

Vercel 자동 배포 + Edge 캐시

운영 흐름은 단순합니다. master 브랜치에 push → Vercel이 빌드 → Edge에 배포. 4개 테넌트 사이트는 각자 독립 GitHub repo + 독립 Vercel 프로젝트지만, 마이그레이션과 RLS 정책은 콘솔 repo에서 한 번에 굴립니다.

여기서 한 가지 함정이 있는데, Git author email입니다. macOS 기본값인 user@hostname.local로 커밋하면 Vercel이 author validation에서 막아 배포가 안 됩니다.

직접 운영하며 배운 함정 5가지

  1. .eq("site_id", ...) 누락 — 가장 무서운 버그. RLS + 환경변수 두 겹으로 묶음.
  2. .env.local\r\n 오염vercel env pull 직후 grep 확인 필수.
  3. Next.js 15에서 cookies()/headers()가 비동기 — 14에서 마이그레이션 시 await 빼먹어서 빌드 실패.
  4. ISR과 on-demand revalidate 동시 사용 안 하는 실수.
  5. Supabase Storage public bucket 그대로 두기 — 게시판 첨부는 signed URL로.

빈랩 추천

이 글에 쓴 패턴은 빈랩이 자체적으로 운영하고 있는 방식 그대로입니다. 여러 업종처럼 업종이 전혀 달라도, 콘텐츠 모델이 "공지/게시글/문의/이미지" 중심인 SMB라면 같은 구조를 그대로 이식해 드릴 수 있습니다. JSON-LD 구조화 데이터까지 같이 깔면 검색 노출 효과가 즉시 보입니다.

요약

  • Next.js 15 App Router + Supabase + Vercel은 SMB 다중 사이트 운영에 가장 잘 맞는 조합
  • 단일 DB + site_id 격리 + RLS 조합이 운영 비용 대비 안전성 최고
  • ISR(revalidate: 60)과 on-demand revalidate를 같이 써야 함
  • Git author/.env.local 오염 같은 인프라 함정이 코드 버그보다 더 자주 사고를 냄
  • 같은 패턴을 다른 업종 SMB에 그대로 이식 가능 — 빈랩이 4개 사이트로 검증

결과로 증명하는 IT 에이전시

대표님의 다음 홈페이지,
30분 무료 진단부터 시작하세요

빈랩이 만든 사이트라면 검색 노출·관리자 페이지·문의 알림이 제작 단계부터 포함됩니다.

30분 무료 진단으로 현재 사이트의 약점 리포트를 받아보실 수 있습니다 — 평균 24시간 이내 회신해 드립니다.

관련 아티클

같은 카테고리