빈랩이 사내 위키에 쓰던 회고를 외부용으로 다듬은 글입니다. 여러 업종 같은 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가지
.eq("site_id", ...)누락 — 가장 무서운 버그. RLS + 환경변수 두 겹으로 묶음..env.local의\r\n오염 —vercel env pull직후 grep 확인 필수.- Next.js 15에서
cookies()/headers()가 비동기 — 14에서 마이그레이션 시await빼먹어서 빌드 실패. - ISR과 on-demand revalidate 동시 사용 안 하는 실수.
- 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시간 이내 회신해 드립니다.
관련 아티클
같은 카테고리학원 원장님이 직접 홈페이지 만들지 말아야 할 5가지 이유
학원 원장님이 직접 홈페이지를 만들면 왜 실패하는지, SEO·모바일·자동화·관리자 페이지·시간 비용 5가지 관점에서 풀어드립니다.
한 회사 포트폴리오 사이트 — 카페24·아임웹·외주 어디가 좋을까
한 회사가 시공 사진을 가장 잘 보여줄 수 있는 홈페이지 플랫폼 3가지를 비교했습니다. 카페24·아임웹 self-serve부터 외주·풀서비스 에이전시까지, 견적 시 반드시 물어볼 체크리스트와 회사 규모별 결정 매트릭스를 제공합니다.
5년 된 홈페이지, 리뉴얼할까 그대로 둘까? ROI 계산 4단계
5년차 홈페이지 리뉴얼 의사결정을 NPV와 Payback Period로 정량화. SMB 업종별 hidden cost와 expected return을 4단계로 계산하는 재무 프레임워크.