빌드와 패키징
src/의 React와 TypeScript 코드가 어떻게 dist/로 만들어지는지, import.meta.env의 VITE_* 노출 경계가 어디서 강제되는지, src/shared/config/env.ts의 zod 검증이 빌드 차단 게이트 역할을 하는 방식, 그리고 api/functions/가 별도 경로로 어떻게 배포되는지를 봅니다. architecture에서 정한 FSD 트리가 여기서 번들과 만나 정적 호스팅이 가능한 산출물이 됩니다.
두 갈래 경로
섹션 제목: “두 갈래 경로”graph LR src[src/ : Vite SPA] vite[vite build] dist[dist/] hosting[정적 호스팅]
api[api/functions/] cli[supabase functions deploy] edge[Supabase Edge runtime]
src --> vite --> dist --> hosting api --> cli --> edgesrc/는 Vite가 번들합니다. pnpm build가 dist/를 만듭니다. 그 산출물을 Vercel, Netlify, Cloudflare Pages, self-host 중 한 곳에 정적 호스팅으로 올립니다.
api/functions/는 Vite와 관계가 없습니다. supabase functions deploy <name>이 Deno 함수를 한 개씩 Supabase 프로젝트에 올립니다. 빌드 산출물이 따로 만들어지지 않고 Supabase 쪽 인프라가 들고 있습니다.
두 경로가 분리된 이유는 런타임이 다르기 때문입니다. SPA는 브라우저에서, Edge Function은 Deno에서 동작합니다. 같은 빌드 파이프라인에 묶으면 service_role 키가 SPA 번들로 노출되거나, 브라우저 API가 Deno 함수에 들어가는 사고가 생깁니다.
Vite SPA 빌드
섹션 제목: “Vite SPA 빌드”pnpm build # dist/ 에 운영 환경 빌드를 만듭니다pnpm preview # dist/ 를 로컬에서 미리 봅니다pnpm build가 만드는 dist/는 정적 파일 묶음입니다. index.html 하나와 vendor chunk, 라우트별 chunk, CSS 번들, 정적 자산이 들어 있습니다. SPA라 진입점이 하나여서 빌드 산출물도 단일 entry chunk와 vendor chunk, 라우트별 lazy chunk 모양이 됩니다.
이 상태로 nginx, S3 + CloudFront, Vercel, Netlify 등 정적 호스팅이 되는 곳이라면 어디에든 올릴 수 있습니다.
import.meta.env와 VITE_ 노출 경계
섹션 제목: “import.meta.env와 VITE_ 노출 경계”Vite의 환경변수 노출은 접두사가 결정합니다. VITE_로 시작하는 변수만 클라이언트 번들에 노출됩니다.
// 클라이언트에서 읽을 수 있는 값입니다import.meta.env.VITE_SUPABASE_URL // okimport.meta.env.VITE_SUPABASE_ANON_KEY // okimport.meta.env.VITE_STRIPE_PUBLISHABLE_KEY // ok
// 클라이언트에서 undefined 로 처리되는 값입니다import.meta.env.SUPABASE_SERVICE_ROLE_KEY // undefinedimport.meta.env.STRIPE_SECRET_KEY // undefinedimport.meta.env.STRIPE_WEBHOOK_SECRET // undefinedSUPABASE_SERVICE_ROLE_KEY 같은 서버 전용 시크릿이 실수로 클라이언트 코드에 들어가도 import.meta.env에서 undefined로 처리됩니다. 변수 이름의 접두사 자체가 노출 경계입니다.
대신 그 변수들이 정말로 필요한 곳(예를 들어 Edge Function 안)에서는 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')로 읽습니다. Supabase가 Edge Function을 배포할 때 자기 인프라에서 그 환경변수를 자동으로 주입합니다.
env.ts zod 검증
섹션 제목: “env.ts zod 검증”src/shared/config/env.ts가 빌드가 시작되는 시점에 zod로 환경변수를 검증합니다. 누락되면 빌드가 즉시 실패합니다.
import { z } from 'zod';
const Env = z.object({ VITE_SUPABASE_URL: z.string().url(), VITE_SUPABASE_ANON_KEY: z.string().min(20), VITE_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'), VITE_API_MODE: z.enum(['edge-functions', 'hono']).default('edge-functions'),});
export const env = Env.parse(import.meta.env);Env.parse(...)가 실행되는 시점은 번들 어디에서든 env를 처음 import 하는 시점입니다. 빌드 시점에 정적 평가가 닿지 않으면 런타임 첫 진입에서 실패합니다. 그것도 사용자 화면이 뜨기 전이라 운영 사고가 아니라 배포 실패로 잡힙니다.
startsWith('pk_')처럼 키의 접두사를 강제하는 검사가 들어가 있습니다. pk_test_ 또는 pk_live_로 시작하지 않는 값이 들어오면(예를 들어 실수로 secret key를 넣었을 때) zod가 잡습니다. 같은 변수 이름에 잘못된 키가 들어가는 것을 막아 주는 안전망입니다.
라우트별 chunk size 목표
섹션 제목: “라우트별 chunk size 목표”라우트별 chunk size는 200KB gzip 미만이 review-saas 빌드 관점의 5점 기준입니다. 초기 chunk가 500KB를 넘으면 3점으로 내려갑니다.
pnpm build가 콘솔에 chunk별 크기를 출력합니다. 지나치게 큰 chunk가 있으면 bundle analyzer로 어떤 의존성이 들어가 있는지 봅니다.
pnpm build# 또는pnpm dlx vite-bundle-visualizerVite는 기본적으로 라우트별 code splitting을 자동으로 처리합니다. React.lazy로 페이지 컴포넌트를 import 하면 라우트에 진입하는 시점에 chunk를 내려받습니다. 라우트 트리는 phase 5에서 자동으로 만들어지며, lazy import 패턴이 거기에 들어 있습니다.
지나치게 큰 의존성이 자주 발견되는 경우는 셋입니다.
- 차트 라이브러리 (recharts, chart.js)를 marketing 페이지에 import 한 경우입니다. 대시보드 chunk에만 둡니다.
- DOMPurify, marked 같은 sanitize 또는 parser 라이브러리를 entry chunk에 import 한 경우입니다. lazy load 로 바꿉니다.
- Stripe SDK의 elements 패키지를 결제 페이지가 아닌 곳에 import 한 경우입니다.
features/checkout내부에서만 씁니다.
Edge Functions 배포
섹션 제목: “Edge Functions 배포”supabase functions deploy checkout-sessionsupabase functions deploy customer-portalsupabase functions deploy stripe-webhook각 함수가 api/functions/<name>/index.ts 한 파일을 진입점으로 삼습니다. supabase functions deploy <name>이 그 디렉터리를 Deno bundle로 묶어 Supabase Edge runtime에 올립니다.
환경변수는 Supabase 대시보드 또는 CLI로 주입합니다.
supabase secrets set STRIPE_SECRET_KEY=sk_live_...supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...supabase secrets listsupabase secrets set은 Supabase 프로젝트에 비밀 값을 넣는 명령이고, 함수 안에서 Deno.env.get('STRIPE_SECRET_KEY')로 읽습니다. .env* 파일과는 관계가 없습니다.
로컬에서 Edge Function을 띄울 때는 다릅니다.
supabase functions serve --env-file ./api/functions/.env.local로컬 .env.local이 .gitignore에 있어야 합니다. .env.example만 git에 올라갑니다. 보안 기준선 S2에 해당합니다.
환경변수 체크리스트
섹션 제목: “환경변수 체크리스트”배포 직전에 확인할 항목입니다.
| 변수 | 어디로 | 어디서 |
|---|---|---|
VITE_SUPABASE_URL | 클라이언트 번들로 갑니다 | 호스팅(Vercel/Netlify/CF Pages) env 에 넣습니다 |
VITE_SUPABASE_ANON_KEY | 클라이언트 번들로 갑니다 | 호스팅 env 에 넣습니다 |
VITE_STRIPE_PUBLISHABLE_KEY | 클라이언트 번들로 갑니다 | 호스팅 env 에 넣습니다 |
VITE_API_MODE | 클라이언트 번들로 가며 어댑터를 선택합니다 | 호스팅 env 에 넣습니다 |
SUPABASE_SERVICE_ROLE_KEY | Edge Function 에서만 씁니다 | supabase secrets set 으로 넣습니다 |
STRIPE_SECRET_KEY | Edge Function 에서만 씁니다 | supabase secrets set 으로 넣습니다 |
STRIPE_WEBHOOK_SECRET | Edge Function 에서만 쓰며 webhook 함수가 읽습니다 | supabase secrets set 으로 넣습니다 |
이 표가 distribution 섹션의 네 갈래 호스팅 안내로 이어집니다. 각 호스팅이 env를 어떻게 주입하는지가 거기서 갈립니다.
.env.example이 base에 들어 있고, 본인 SaaS의 도메인별 추가 변수(예를 들어 서드파티 API 키)는 phase 5의 /5-customize-saas 결과로 같은 파일에 덧붙습니다. 이 항목은 fork 할 때 채워집니다.
자동화가 멈추는 지점
섹션 제목: “자동화가 멈추는 지점”pnpm build가 dist/를 만들고, supabase functions deploy가 함수를 올립니다. 거기까지가 base 자동화의 끝입니다. 그다음에 어느 호스팅에 올릴지, custom domain을 어떻게 연결할지, Stripe 운영 환경 webhook endpoint를 어디로 정할지는 도구가 손을 떼는 영역입니다.
deploy-saas 스킬이 dist/ 옆에 같이 만드는 docs/deployment/DEPLOYMENT.md도 결정을 정리한 메모일 뿐, 자동으로 누군가에게 보내거나 어딘가로 올리지 않습니다.
이 경계는 의도한 설계입니다. 호스팅 콘솔과 Stripe 운영 환경 키, DNS는 사용자 자격으로 행동해야 접근할 수 있는 자산이고, 자동화가 그 선을 넘으면 비밀 값이 노출되거나 운영 환경에 잘못된 webhook이 등록됩니다. 다음 절에서 네 갈래 호스팅과 Stripe 운영 환경 webhook 등록 절차를 봅니다.
다음 섹션은 distribution입니다. Vercel, Netlify, Cloudflare Pages, self-host 네 갈래의 장단점, 각 호스팅에서 env를 주입하는 명령, custom domain, CSP connect-src, 그리고 Stripe 운영 환경 webhook과 Supabase 운영 환경 프로젝트 분리를 봅니다.