검증
architecture에서 잡은 FSD 슬라이스, API 어댑터, RLS 정책에 코드를 채워 넣은 다음, 그 코드가 사용자가 적은 시나리오대로 도는지, 그리고 service_role 키가 새지 않았는지 한 번 묻는 자리가 필요합니다. base는 그 질문에 정적과 동작 두 갈래로 답합니다.
| 도구 | 보는 자리 |
|---|---|
review-saas | 정적 6차원. 보안, 시크릿, a11y, SEO, 타입, 빌드 |
/test-saas | 동작 차원. docs/design/saas-spec.md §2 시나리오를 실제 함수 호출과 페이지 진입과 RLS 정책 검증으로 |
둘은 같은 코드를 다른 각도에서 봅니다. review가 “코드가 깨끗한가”를 본다면 test는 “시나리오대로 도는가”를 봅니다. 한쪽만 통과해서는 진짜 통과가 아닙니다.
흐름의 자리
섹션 제목: “흐름의 자리”implement-* (영역별 구현) ↓/test-saas (spec → Vitest + Playwright + supabase test db → 실행) ↓review-saas (정적 6차원) ↓build → deploy-saasimplement 직후에 test가 먼저 도는 이유는 단순합니다. 정적 검사는 동작 결과 없이도 통과할 수 있는데, 동작이 깨졌으면 review가 무엇을 통과시켜도 의미가 없습니다. 동작 신호를 먼저 받고 그 위에 정적 차원을 얹는 순서입니다.
review-saas 6차원
섹션 제목: “review-saas 6차원”루브릭은 AI_AUTOMATION.md 섹션 7에 SSOT가 있습니다. 통과 = 모든 차원 3점 이상.
| 차원 | 5점 자리 | 1점 자리 |
|---|---|---|
| 보안 | RLS 모든 테이블 + 보호 라우트 가드 + webhook 서명 검증 + CSP connect-src 명시 | service_role 클라이언트 노출 또는 RLS 비활성 테이블 |
| 시크릿 | 코드, .env, 번들 깨끗. VITE_*만 클라이언트. .env.example만 git. gitleaks 0건 | 실제 키가 git 히스토리에 |
| a11y | jsx-a11y 0 errors, axe-core 0 violations, 키보드 navigation 완전, focus indicator | semantic HTML 위반 또는 ARIA 오용 |
| SEO | meta(title/description/og) + sitemap.xml + robots.txt + JSON-LD + Lighthouse SEO 90 이상 | 빈 title, no meta, robots 누락 |
| 타입 | tsc --noEmit 0 errors. Supabase generated types 최신 | 타입 에러 1건 이상 |
| 빌드 | 빌드 성공 + 라우트별 200KB gzip 미만 (초기 chunk) + bundle analyzer 청정 + code splitting 동작 | 실패 |
iteration은 두 번까지입니다. 두 번 연속 changes_requested가 떨어지면 stage가 blocked으로 바뀌고 /recover-from-blocked가 트리거 자리에 들어갑니다. 그 흐름은 architecture의 복구 슬래시 명령 단락에 있습니다.
검증 명령:
pnpm typecheck # tsc --noEmitpnpm lint # FSD boundaries + jsx-a11ypnpm build # 프로덕션 빌드 + bundle size 보고npx gitleaks detect # 시크릿 grepnpx lhci autorun # Lighthouse CI (a11y + SEO + perf)supabase test db # RLS 정책 단위 테스트review-saas 스킬은 위 명령을 차례로 돌려 그 결과를 6차원 점수로 합성합니다. 결과는 .claude/state/review-{timestamp}.md에 한 장. 통과 또는 차원별 부족 사유와 같이 떨어집니다.
보안 기준선 S1~S8
섹션 제목: “보안 기준선 S1~S8”review-saas의 보안 차원이 보는 자리는 AI_AUTOMATION.md 섹션 4의 S1~S8입니다. 줄 하나씩.
- S1: 모든 Supabase 테이블 RLS 활성 + default deny. 보호 라우트는
<RequireAuth>누락 0건. 검증 자리는 migrations와 reviewer. - S2:
SUPABASE_SERVICE_ROLE_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET은 서버 전용. 클라이언트에는VITE_*prefix가 붙은 anon key, publishable key만..env*는.gitignore,.env.example만 git에. 검증 자리는env.tszod 검증, reviewer grep, gitleaks. - S3: 모든 사용자 입력은 zod schema로 검증 후 DB와 Stripe로 전달. URL 파라미터, 쿼리, 폼, webhook payload 전부. 검증 자리는
shared/lib/validation과 reviewer. - S4:
dangerouslySetInnerHTML사용 시 DOMPurify 필수. 사용자 입력 텍스트가 SQL이나 HTML로 직접 들어가는 자리 금지. 검증 자리는 reviewer. - S5:
rm -rf,git push --force,DROP TABLE, 원격supabase db reset --linked, productionstripe trigger는 자동 실행 금지. 사용자 명시 확인 필수. 검증 자리는 모든 작업의 진입 게이트. - S6: 명령은 프로젝트 디렉토리 안에서만 실행. Supabase production 프로젝트는 별도 환경변수로 격리. 검증 자리는 모든 작업.
- S7: JWT, session token, Stripe key, customer id, email은 앞 4자 +
***마스킹. production 로그에 평문 PII 금지. 검증 자리는 logging util과 reviewer. - S8: CSP
connect-src에 명시된 도메인만 fetch 허용. Supabase, Stripe, 측정 도구 한정. 임의 외부 URL fetch 금지. 검증 자리는index.html의 meta와 reviewer.
S1과 S2는 reviewer가 점수 1점으로 즉시 차단하는 자리입니다. 나머지는 점수 깎이지만 차단 단계까지 가지 않는 경우가 많습니다. 본인 도메인 entity에 따라 어떤 테이블에 RLS를 어떻게 잡을지가 달라지므로 구체 정책은 phase 4의 데이터 스키마 결정 자리에서 박힙니다. 이 자리는 fork 시 채워집니다.
SaaS 금지 패턴
섹션 제목: “SaaS 금지 패턴”AI_AUTOMATION.md 섹션 5가 reviewer가 차단하는 패턴 목록을 들고 있습니다. 정적 grep으로 잡히는 자리들이라 같은 코드를 두 번 작성할 일이 없습니다.
| 금지 | 사유 | 대체 |
|---|---|---|
SUPABASE_SERVICE_ROLE_KEY를 src/ 코드에서 import | 클라이언트 번들에 포함되면 RLS 우회 | Edge Function 내부에서만 Deno.env.get(...) |
| RLS 비활성 public 테이블 | anon key로 누구나 read/write | alter table ... enable row level security + 명시 policy |
localStorage에 JWT 직접 저장 | XSS 시 탈취 | supabase-js의 세션 관리 + 보호 라우트 가드 |
| Stripe webhook에서 서명 검증 누락 | 위조 webhook으로 임의 구독 활성화 | stripe.webhooks.constructEvent(body, sig, secret) 필수 |
dangerouslySetInnerHTML 무 sanitize | XSS | DOMPurify, 가능하면 컴포넌트 분해 |
| 보호 라우트에 가드 없음 | 비인증 상태에서 보호 UI 노출 | <RequireAuth> element wrapper |
console.log(session), console.log(token) | 로그, 스크린샷, 세션 리플레이로 유출 | mask(token) 헬퍼 또는 제거 |
select * from users 무 limit | 대규모 응답 + 사이드 채널 노출 | 명시 컬럼 + .limit() + RLS로 행 제한 |
import.meta.env.SUPABASE_URL (VITE_ prefix 누락) | Vite는 VITE_*만 클라이언트에 노출. 누락 시 undefined | import.meta.env.VITE_SUPABASE_URL + env.ts zod 검증 |
/test-saas의 spec-driven 자리
섹션 제목: “/test-saas의 spec-driven 자리”일반 boilerplate는 “Vitest 셋업”을 줍니다. 사용자가 직접 테스트 코드를 짜야 합니다. /test-saas는 같은 자리에 한 단계를 더 둡니다.
docs/design/saas-spec.md §2 시나리오 (사용자가 phase 3에서 작성) ↓/test-saas 가 시나리오 파싱과 매핑 ↓시나리오 단위 → Vitest 단위 + Playwright e2e + supabase test db RLS (AI 생성) ↓src/{slice}/__tests__/ 와 api/policies.test.sql 안에 떨어짐사용자는 시나리오 한 줄을 적습니다. “사용자가 Pro 플랜을 고른 다음 Stripe Checkout을 통과해 돌아오면 /app에 active subscription 상태로 들어간다.” 이 한 줄을 스킬이 검증 가능한 단위 셋으로 분해합니다.
분해는 FSD 슬라이스 또는 RLS 정책을 기준으로 합니다.
| 시나리오 조각 | 테스트 단위 | 자리 |
|---|---|---|
| Pro 플랜 카드 클릭 → Edge Function 호출 | features/checkout 액션 단위 | src/features/checkout/__tests__/ (Vitest) |
| Edge Function이 Stripe session URL 반환 | api/functions/checkout-session | api/functions/checkout-session/__tests__/ (Vitest, mocked Stripe) |
| Stripe 결제 완료 → /checkout/success 진입 | 라우트 진입 + UI 상태 | Playwright e2e |
webhook이 subscriptions 테이블 갱신 | webhook signature 검증 + DB upsert | api/functions/stripe-webhook/__tests__/ (Vitest) |
| 다른 user는 본인 subscription만 select | RLS 정책 단위 테스트 | api/policies.test.sql (supabase test db) |
테스트 패턴은 네 갈래가 박혀 있습니다.
- Vitest 단위:
src/{slice}/__tests__/에 co-located. FSD public API를 통과하지 않고 슬라이스 내부를 직접 import 가능합니다. 테스트가 슬라이스의 일부지 외부 소비자가 아니라는 신호입니다. - Edge Function 단위:
api/functions/<name>/__tests__/. Supabase client와 Stripe SDK를vi.mock으로 stub. 서명 검증의 경우 의도적으로 위조 signature를 던지는 negative 케이스가 같이 들어갑니다. - Playwright e2e: 라우트 진입과 인증 흐름과 결제 redirect를 실제로 밟습니다.
supabase start로 로컬 Postgres와 Edge runtime을 띄운 자리 위에서 돕니다. - RLS 단위:
api/policies.test.sql에 “이 user로 이 쿼리를 보냈을 때 거절돼야 한다” 형태로 적습니다.supabase test db가 로컬 DB에서 실행합니다.
테스트 파일이 어디로 떨어질지는 시나리오의 자리에 따라 다릅니다. 슬라이스에 닿는 자리는 슬라이스 안, RLS에 닿는 자리는 api/policies.test.sql. 베이스가 박은 자리는 위 네 갈래의 디렉토리 골격뿐입니다.
시나리오 언어로 결과 합성
섹션 제목: “시나리오 언어로 결과 합성”raw Vitest와 Playwright 출력은 사용자에게 그대로 가지 않습니다. 스킬이 결과를 시나리오 단위로 다시 합성합니다.
총 시나리오: 5개 — 4개 통과, 1개 실패
✓ Pro 플랜 결제 시작 — Edge Function checkout-session 정상 호출, Stripe URL 반환✓ Stripe webhook 서명 검증 — 위조 signature 거부, 정상 signature는 통과✓ 결제 완료 후 /app 진입 — active subscription 상태로 보호 라우트 통과✗ Customer Portal 진입 — Edge Function customer-portal 호출은 성공했으나 URL 반환 없음 파일: src/features/manage-subscription/__tests__/index.test.ts:24 고치는 곳: features/manage-subscription/model/openPortal.ts✓ 다른 user의 subscription select 거부 — RLS 정책 정상 동작raw를 그대로 보여주지 않는 자리는 의도된 것입니다. PM 또는 비개발자 입장에서 expected 'foo' to be 'bar' at line 32보다 “이 시나리오는 통과했고 이 시나리오는 실패했다”가 의사 결정에 직접 닿습니다. 동시에 raw 로그는 .claude/state/last-test.log에 통째로 남아 있어 깊이 파야 할 때 손이 닿습니다.
빠른 참조
섹션 제목: “빠른 참조”# 동작/test-saas # spec → 테스트 생성 → 실행 → 시나리오 언어 보고pnpm test # Vitest 직접pnpm test:e2e # Playwright 직접supabase test db # RLS 정책 단위 테스트
# 정적/review-saas # 6차원 합성pnpm typecheckpnpm lintpnpm buildnpx gitleaks detectnpx lhci autorun비개발자 호흡
섹션 제목: “비개발자 호흡”비개발자 입장에서 같은 자리를 한 줄 안내로 정리한 곳이 비개발자용 04장 real-backend 끝에 있습니다. 시나리오를 적는 사람은 spec 작성자고, 검증 코드는 AI가 만들고, 보고는 시나리오 언어로 돌아온다는 같은 원리입니다.
다음 섹션은 build-and-package입니다. Vite SPA 빌드가 dist/에 어떻게 떨어지는지, import.meta.env의 VITE_* 노출 경계, env.ts의 zod 검증이 빌드 차단 게이트 역할을 하는 자리, route별 chunk size 200KB gzip 목표, 그리고 Edge Functions가 별도 트랙으로 supabase functions deploy로 떨어지는 자리를 봅니다.