아키텍처
intro에서 guardrail 세 개를 짚었습니다. 여기서는 그 세 개가 코드에서 어떻게 생겼는지를 봅니다. FSD 6레이어 의존 방향, 라우트와 API 어댑터와 RLS 정책의 SSOT 자리, 그리고 Stripe 3단 분리. 모두 “AI가 임의로 가로지르지 못하게” 빌드 또는 마이그레이션이 막아주는 자리입니다.
src/(클라이언트 SPA)와 api/(서버) 두 트리가 import로 연결되지 않는 자리도 같이 짚습니다. 둘 사이에 흐를 수 있는 건 타입뿐이고, 그 타입의 자리는 src/shared/api/server/types.ts 한 곳입니다.
FSD 6레이어 의존 방향
섹션 제목: “FSD 6레이어 의존 방향”폴더는 위에서 아래로 의존이 흐릅니다. 화살표 반대로 import 하면 빌드가 깨집니다.
graph TB app[app — main.tsx, providers, router] pages[pages — landing, pricing, auth, app, checkout-result] widgets[widgets — site-header, pricing-grid, app-shell] features[features — sign-in, checkout, manage-subscription, require-auth] entities[entities — User, Subscription, Plan, Invoice] shared[shared — UI 키트, lib, api, config]
app --> pages pages --> widgets widgets --> features features --> entities entities --> shared각 레이어의 자리는 이렇게 갈립니다.
shared: 도메인 무관한 자리. Supabase client, API 어댑터, 라우트 상수, env 검증, UI 키트, 폼 헬퍼. 어디서나 가져다 쓸 수 있고, 자기 자신만 의존합니다.entities: 도메인 모델.User,Subscription,Plan,Invoice,Organization같은 자리. 베이스에는 골격만 박혀있고 본인 SaaS의 도메인 entity는 phase 5에서 채워집니다.features: 사용자 동작 한 단위.sign-in,sign-up,checkout,manage-subscription,require-auth같은 자리.widgets: feature 여러 개를 묶은 화면 블록.site-header,site-footer,pricing-grid,app-shell.pages: 라우트 단위 화면. 마케팅 페이지, 인증 페이지, 보호 영역의 대시보드, 결제 완료/취소 페이지.app: 진입점 글루.main.tsx의 createRoot,App.tsx의 RouterProvider, providers 트리.
eslint-plugin-boundaries가 위 화살표를 강제합니다. 역방향 import는 물론, 같은 레이어 안에서 슬라이스끼리 가로지르는 것도 막습니다. features/sign-in에서 features/checkout을 직접 import 하면 빌드 실패. 슬라이스 바깥에서 들어가는 유일한 통로는 그 슬라이스의 index.ts입니다.
같은 lint 단계에서 jsx-a11y가 접근성 위반을 같이 봅니다. <img alt> 누락, <label htmlFor> 누락, 클릭만 받는 <div>. 키보드 only navigation으로 안 닿는 인터랙티브 요소가 그 자리에서 잡힙니다. 빌드가 깨지므로 reviewer가 잡기 전에 모델이 줄을 섭니다.
검사 명령:
pnpm lint # FSD boundaries + jsx-a11y 한 번에npx eslint src --ext .ts,.tsx # 직접 호출AI에게 코드를 맡길 때 어느 레이어에 둘지를 모델이 직접 고르게 두면 십중팔구 features와 widgets 사이를 흐릿하게 섞습니다. 슬라이스 이름까지 사람이 지정해서 주는 편이 안전합니다. “features/manage-subscription에 Customer Portal 호출 액션 추가” 식으로.
의존 방향을 이 모양으로 잡은 근거는 docs/adr/0001-fsd-import-direction.md에 한 장 들어있습니다. 코드는 무엇을 했는지를, ADR은 왜 그랬는지를 들고 있습니다.
src/와 api/의 import 차단
섹션 제목: “src/와 api/의 import 차단”src/는 Vite SPA 번들로 들어갑니다. api/는 Deno (Edge Functions) 또는 Node (Hono 어댑터) 런타임으로 들어갑니다. 서로 다른 런타임이고, 서로의 코드를 import 하면 번들이 깨지거나 비밀이 새는 자리입니다.
src/ ← Vite SPA, 브라우저로 나감└── shared/api/server/types.ts ← 요청과 응답 인터페이스 정의 (양쪽 공유)
api/ ← Deno 또는 Node, 서버에 머무름├── functions/└── migrations/서버 코드가 클라이언트 번들에 섞이지 않게 강제하려면 둘이 import로 연결되지 않아야 합니다. 공유할 수 있는 건 타입뿐이고, 그 타입은 src/shared/api/server/types.ts에 둡니다. api/ 쪽에서 그 자리를 relative import 또는 path mapping으로 읽어옵니다.
src/에서 SUPABASE_SERVICE_ROLE_KEY를 import 하면 reviewer가 차단합니다. 그 키는 RLS를 우회하는 자격증이라 클라이언트 번들에 섞이면 모든 행 read/write가 풀립니다. service_role이 필요한 호출은 api/functions/<name>/index.ts 안에서 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')로만 닿습니다.
라우트 SSOT
섹션 제목: “라우트 SSOT”모든 라우트 path는 src/shared/lib/routing/routes.ts에 const로 정의합니다. 라우터 트리, <Link to=...>, navigate(...) 모두 이 상수를 import 합니다. 문자열 path를 코드에 흩뿌리면 한 자리를 바꿀 때 어디까지 따라가야 하는지 알 수 없는 자리가 만들어집니다.
export const ROUTES = { landing: '/', pricing: '/pricing', login: '/login', signup: '/signup', app: { root: '/app', settings: '/app/settings', billing: '/app/billing' }, checkout: { success: '/checkout/success', cancel: '/checkout/cancel' },} as const;라우트 모드를 본인 SaaS의 모양에 맞춰 고르는 자리가 phase 4(/4-design-saas)에 있습니다. 결정은 .claude/state/routing-config.json에 박힙니다. marketing-app(공개 + 보호 분리), app-only(로그인 게이트가 첫 화면), multi-tenant(/o/:slug/*), docs-heavy(콘텐츠 페이지 많음) 네 갈래. 그 결정 위에서 phase 5가 src/pages/ 트리와 ROUTES const를 같이 생성합니다.
위 const는 베이스의 기본 예시입니다. 본인 SaaS의 라우트는 phase 5 결과로 채워지므로, 베이스를 막 fork 한 시점에 본인 도메인 path가 박혀있지 않습니다. 이 자리는 fork 시 채워집니다.
API 어댑터 전략
섹션 제목: “API 어댑터 전략”src/shared/api/server/에 어댑터 인터페이스 한 개와 구현 두 개가 들어있습니다.
src/shared/api/server/├── index.ts 공개 진입점, createApiAdapter 팩토리├── types.ts ApiAdapter 인터페이스, 요청과 응답 타입├── edge-functions.ts supabase.functions.invoke 래퍼 (기본)└── hono.ts fetch('/api/...') 래퍼 (옵션)호출하는 쪽 (feature 또는 widget) 은 어떤 어댑터인지 모르고 api.invoke('checkout-session', payload) 만 부릅니다. 어댑터 결정은 import.meta.env.VITE_API_MODE(빌드 타임) 또는 .claude/state/api-adapter.json(phase 4 결정 자리)에서 결정됩니다. factory가 그 값을 읽어 인스턴스를 고릅니다.
이 어댑터의 본 목적은 백엔드 런타임이 정해지지 않은 단계에서 UI 작업을 시작할 수 있게 하는 것입니다. 첫 sprint에서는 Edge Functions로 가다가 Cloudflare로 옮기기로 결정되면 Hono 어댑터를 켜고, 호출 코드는 한 줄도 안 건드립니다.
src/shared/api/server는 entities를 import 하지 않습니다. entity ↔ API 요청 타입의 매핑은 features/<action>이 책임집니다. 어댑터는 transport 계층만 보고, 도메인은 features가 봅니다.
Supabase client SSOT
섹션 제목: “Supabase client SSOT”src/shared/api/supabase/client.ts에서 createClient<Database>(url, anonKey)를 단 한 번만 호출합니다. 다른 곳에서 createClient 중복 호출하면 세션 분기, realtime 채널 분기가 발생합니다. 한 사용자의 세션 두 개가 다른 인스턴스에 머무는 식의 사고가 거기서 시작합니다.
import { createClient } from '@supabase/supabase-js';import { env } from '@/shared/config/env';import type { Database } from './database.types';
export const supabase = createClient<Database>(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);Database 타입은 손으로 적지 않습니다. supabase gen types typescript --linked > src/shared/api/supabase/database.types.ts 명령이 현재 마이그레이션 상태에서 타입을 뽑아 박습니다. 마이그레이션을 새로 추가하면 같은 명령으로 다시 생성해야 합니다.
RLS 정책 SSOT
섹션 제목: “RLS 정책 SSOT”모든 RLS 정책은 api/migrations/<timestamp>_<name>.sql에 작성합니다. Supabase Studio의 UI에서 정책을 만들지 않습니다. UI에서 만든 정책은 git에 잡히지 않아 production과 staging 사이에 drift가 새는 자리가 됩니다.
예시는 본인 SaaS 도메인 entity에 따라 달라지지만, 형태는 비슷합니다. 본인 행만 select 하게 두고, write는 service_role만 허용합니다.
alter table public.subscriptions enable row level security;
create policy "subscriptions_select_own" on public.subscriptions for select to authenticated using (auth.uid() = user_id);베이스가 들고 있는 마이그레이션은 골격뿐입니다. 본인 SaaS의 테이블과 정책은 phase 5의 implement-data-layer 단계에서 채워집니다. 이 자리는 fork 시 채워집니다.
검증은 supabase test db가 합니다. api/policies.test.sql 안에 “이 user로 이 쿼리를 보냈을 때 거절돼야 한다” 같은 단위 테스트를 적어두면 마이그레이션이 잡고 있는 정책이 의도대로 동작하는지를 확인합니다.
Stripe 결제 3단 분리
섹션 제목: “Stripe 결제 3단 분리”결제는 시작/관리/수신 셋으로 갈립니다. 같은 흐름을 한 함수에 묶으려는 충동을 셋으로 끊어 두면 webhook 서명 검증 자리가 무엇과도 섞이지 않고 자기 모듈 안에 머뭅니다.
sequenceDiagram participant UI as features/checkout participant CSF as Edge Function checkout-session participant STRIPE as Stripe participant WH as api/functions/stripe-webhook participant DB as subscriptions 테이블
UI->>CSF: 사용자가 Pro 플랜 선택 CSF->>STRIPE: stripe.checkout.sessions.create STRIPE-->>CSF: session.url CSF-->>UI: { url } UI->>STRIPE: window.location.assign(url) STRIPE-->>UI: 결제 완료 후 /checkout/success 로 리다이렉트 STRIPE->>WH: webhook POST + signature WH->>WH: stripe.webhooks.constructEvent (서명 검증) WH->>DB: subscriptions upsert (status, plan, current_period_end)세 자리의 책임이 갈립니다.
- 시작은
features/checkout이 Edge Functioncheckout-session을 부르고, 받은 URL을window.location.assign(url)로 넘깁니다. Stripe SDK가 클라이언트에 노출되는 자리는 publishable key (pk_...) 뿐입니다. - 관리는
features/manage-subscription이 Edge Functioncustomer-portal을 부르고, 받은 URL로 사용자를 보냅니다. Customer Portal에서 플랜 변경, 결제 수단 변경, 구독 취소가 일어납니다. - 수신은
api/functions/stripe-webhook이 받습니다. 첫 줄에서stripe.webhooks.constructEvent(body, sig, secret)로 서명을 검증합니다. 검증 없이 받은 이벤트로 DB를 만지면 위조 webhook이 임의 구독을 활성화시킵니다. 서명 검증 누락은 reviewer가 무조건 차단하는 자리입니다.
서비스에 따라 subscriptions 외에 invoices, customers 등의 테이블이 동기화 대상이 될 수 있습니다. 어떤 테이블이 webhook에서 갱신되는지는 phase 4의 데이터 스키마 결정 자리에서 박힙니다. 이 자리는 fork 시 채워집니다.
환경변수 검증
섹션 제목: “환경변수 검증”src/shared/config/env.ts에서 zod로 환경변수를 한 번 검증한 다음 export 합니다. 누락 시 빌드가 즉시 실패합니다. 배포 후 런타임에 undefined로 터지는 자리를 빌드 시점으로 끌어옵니다.
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);VITE_ prefix가 핵심입니다. Vite는 VITE_*로 시작하는 환경변수만 클라이언트 번들에 노출합니다. SUPABASE_SERVICE_ROLE_KEY처럼 prefix 없는 변수는 클라이언트에서 undefined로 잡히고, 빌드 자리에서 잡힙니다. 노출 경계가 변수 이름에 박혀있는 자리입니다.
자동화 훅
섹션 제목: “자동화 훅”Claude Code 훅 세 개가 .claude/hooks/에 박혀 있고 .claude/settings.json의 hooks 필드가 연결합니다. 사용자 또는 AI가 명령을 의식적으로 따르지 않아도 컨텍스트가 무너지는 자리를 막습니다.
| 스크립트 | 이벤트 | 책임 |
|---|---|---|
session-start.sh | SessionStart | active.md, progress.md 마지막 30줄, feature_list.json, saas-spec.md 첫 50줄, MEMORY.md를 stdout으로 prepend |
stop-reminder.sh | Stop | active.md가 20자 미만이면 사용자에게 한 줄 메모 안내 |
pre-commit-check.sh | PreToolUse (Bash matcher) | tool_input.command에 git commit 매치되면 pnpm run typecheck && pnpm run lint, 실패 시 exit 2와 함께 로그 마지막 30줄을 stdout으로 흘려 AI에게 차단 사유 전달 |
PreToolUse의 Bash matcher는 stdin으로 들어온 JSON의 tool_input.command를 jq 또는 sed로 추출해 git commit 패턴만 typecheck 게이트로 보냅니다. 다른 Bash 명령은 zero exit으로 통과시켜 워크플로 마찰을 늘리지 않습니다.
session-start.sh가 prepend 하는 자리는 phase 1~6의 진행 상태를 그대로 들고 있습니다. active.md(다음 작업), feature_list.json의 passes 필드(어디까지 통과), saas-spec.md(컨셉과 시나리오와 데이터 모델). /clear 후 새 세션을 시작해도 이 다섯 자리만 prepend 되면 작업이 끊기지 않습니다.
훅을 새로 추가할 때는 .claude/hooks/<name>.sh를 작성하고 (#!/usr/bin/env bash 시작, chmod +x), settings.json의 적절한 이벤트 항목에 등록하고, .claude/hooks/README.md 표에 한 줄 더 적습니다. 모든 훅은 독립이라 한 개를 빼도 다른 두 개에 영향이 없습니다.
비개발자 호흡으로 같은 시스템을 정리한 자리는 비개발자용 07장에 있습니다.
복구 슬래시 명령
섹션 제목: “복구 슬래시 명령”자동화 훅이 사용자 의식 바깥에서 도는 안전망이라면, 사용자 명시 호출 쪽에는 슬래시 명령 두 개가 박혀 있습니다. 둘 다 .claude/skills/ 안 스킬이 wire 합니다.
| 명령 | 트리거 자리 | 핵심 동작 |
|---|---|---|
/recover-from-blocked | review-saas가 두 번 연속 changes_requested 끝에 blocked로 떨군 직후 | 최신 .claude/state/review-*.md에서 점수 < 3 차원 추출, 같은 영역의 builder-*.md 두 iteration을 비교해 same-mistake 감지. 사용자에게 평이한 보고 + 3옵션 (rollback / 사용자 도움 retry / manual) |
/resume | /clear 직후, 또는 며칠 만에 다시 진입한 자리 | state 파일과 git log를 종합해 stage 감지 (empty → paced → analyzed → designed → customized → implemented → built → deployed). session-start.sh 훅이 raw를 prepend 한 자리에 한 화면 합성 요약을 사용자에게 출력 |
/recover-from-blocked의 옵션 A (rollback)는 git reset --hard를 부르는 파괴적 자리라, AskUserQuestion 한 번 + yes 명시 입력 한 번. 두 번 차단이 의도적으로 들어 있습니다. dirty working tree면 그 변경도 사라진다는 안내가 한 줄 더 따라옵니다.
/resume은 active.md와 감지된 stage가 어긋나면 합성을 멈추고 사용자에게 어느 쪽이 맞는지 묻습니다. 다른 PC에서 작업이 진행됐거나 finish 후 active.md 갱신을 잊은 자리가 가끔 있어, 잘못 합성된 답보다 명시 확인이 쌉니다.
비개발자 호흡으로 같은 두 명령을 정리한 자리는 비개발자용 06장에 있습니다.
ADR 습관
섹션 제목: “ADR 습관”라우팅 모드, API 어댑터, 가격 플랜 monthly/annual 분기, 데이터 스키마의 multi-tenant 여부. 되돌리기 비싼 결정이 phase 4 안에서 한 번에 모입니다. 이 결정들이 docs/adr/에 한 장씩 쌓입니다.
ADR 한 장은 한 결정 한 장. 제목은 0001-routing-mode-marketing-app.md 식으로 일련번호와 결정 슬러그. 본문은 컨텍스트, 결정, 대안, 결과 네 단락. 형식은 docs/adr/README.md에 박혀 있고, phase 4에서 결정이 잡힐 때마다 그 안에서 자동으로 ADR 한 장이 떨어집니다.
.claude/agent-memory/<title>.md는 같은 결정의 AI용 캐시입니다. 사람이 보는 SSOT는 ADR이고, AI가 컨텍스트에 끼우는 자리는 agent-memory입니다. 둘이 어긋나면 ADR을 신뢰합니다.
다음 섹션은 verification입니다. saas-spec.md §2 시나리오를 /test-saas가 어떻게 Vitest, Playwright, supabase test db로 변환하는지, 그 결과를 raw 출력 대신 시나리오 언어로 다시 합성해 돌려주는 자리, 그리고 정적 측 review-saas의 6차원과 보안 기준선 S1~S8을 봅니다.