콘텐츠로 이동

빌드와 패키징

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 --> edge

src/는 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 함수에 들어가는 사고가 생깁니다.

Terminal window
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 등 정적 호스팅이 되는 곳이라면 어디에든 올릴 수 있습니다.

Vite의 환경변수 노출은 접두사가 결정합니다. VITE_로 시작하는 변수만 클라이언트 번들에 노출됩니다.

// 클라이언트에서 읽을 수 있는 값입니다
import.meta.env.VITE_SUPABASE_URL // ok
import.meta.env.VITE_SUPABASE_ANON_KEY // ok
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY // ok
// 클라이언트에서 undefined 로 처리되는 값입니다
import.meta.env.SUPABASE_SERVICE_ROLE_KEY // undefined
import.meta.env.STRIPE_SECRET_KEY // undefined
import.meta.env.STRIPE_WEBHOOK_SECRET // undefined

SUPABASE_SERVICE_ROLE_KEY 같은 서버 전용 시크릿이 실수로 클라이언트 코드에 들어가도 import.meta.env에서 undefined로 처리됩니다. 변수 이름의 접두사 자체가 노출 경계입니다.

대신 그 변수들이 정말로 필요한 곳(예를 들어 Edge Function 안)에서는 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')로 읽습니다. Supabase가 Edge Function을 배포할 때 자기 인프라에서 그 환경변수를 자동으로 주입합니다.

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는 200KB gzip 미만이 review-saas 빌드 관점의 5점 기준입니다. 초기 chunk가 500KB를 넘으면 3점으로 내려갑니다.

pnpm build가 콘솔에 chunk별 크기를 출력합니다. 지나치게 큰 chunk가 있으면 bundle analyzer로 어떤 의존성이 들어가 있는지 봅니다.

Terminal window
pnpm build
# 또는
pnpm dlx vite-bundle-visualizer

Vite는 기본적으로 라우트별 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 내부에서만 씁니다.
Terminal window
supabase functions deploy checkout-session
supabase functions deploy customer-portal
supabase functions deploy stripe-webhook

각 함수가 api/functions/<name>/index.ts 한 파일을 진입점으로 삼습니다. supabase functions deploy <name>이 그 디렉터리를 Deno bundle로 묶어 Supabase Edge runtime에 올립니다.

환경변수는 Supabase 대시보드 또는 CLI로 주입합니다.

Terminal window
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
supabase secrets list

supabase secrets set은 Supabase 프로젝트에 비밀 값을 넣는 명령이고, 함수 안에서 Deno.env.get('STRIPE_SECRET_KEY')로 읽습니다. .env* 파일과는 관계가 없습니다.

로컬에서 Edge Function을 띄울 때는 다릅니다.

Terminal window
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_KEYEdge Function 에서만 씁니다supabase secrets set 으로 넣습니다
STRIPE_SECRET_KEYEdge Function 에서만 씁니다supabase secrets set 으로 넣습니다
STRIPE_WEBHOOK_SECRETEdge 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 운영 환경 프로젝트 분리를 봅니다.