검증
architecture에서 정한 FSD 슬라이스와 API 어댑터, 행 수준 접근 정책(RLS)에 코드를 채워 넣은 다음에는, 그 코드가 사용자가 적은 시나리오대로 동작하는지 그리고 service_role 키가 노출되지 않았는지 한 번 확인하는 단계가 필요합니다. base는 그 질문에 정적 검사와 동작 검사 두 갈래로 답합니다.
| 도구 | 무엇을 보나 |
|---|---|
review-saas | 정적 검사 여섯 관점을 봅니다. 보안, 시크릿, 접근성, 검색 대응, 타입, 빌드입니다 |
/test-saas | 동작을 봅니다. docs/design/saas-spec.md §2 시나리오를 실제 함수 호출과 페이지 진입, 행 수준 접근 정책 검증으로 옮깁니다 |
둘은 같은 코드를 다른 각도에서 봅니다. review가 “코드가 깨끗한가”를 본다면 test는 “시나리오대로 동작하는가”를 봅니다. 한쪽만 통과해서는 진짜 통과가 아닙니다.
검증이 흐름에 들어가는 위치
섹션 제목: “검증이 흐름에 들어가는 위치”implement-* (영역별 구현) ↓/test-saas (spec 을 Vitest 와 Playwright, supabase test db 로 변환해 실행) ↓review-saas (정적 검사 여섯 관점) ↓build → deploy-saasimplement 직후에 test가 먼저 도는 이유는 단순합니다. 정적 검사는 동작 결과 없이도 통과할 수 있는데, 동작이 깨졌으면 review가 무엇을 통과시켜도 의미가 없습니다. 동작 신호를 먼저 받고 그 위에 정적 관점을 얹는 순서입니다.
review-saas 여섯 관점
섹션 제목: “review-saas 여섯 관점”채점 기준은 AI_AUTOMATION.md 섹션 7에 기준 문서로 정리돼 있습니다. 모든 관점이 3점 이상이면 통과입니다.
| 관점 | 5점 기준 | 1점 기준 |
|---|---|---|
| 보안 | 모든 테이블에 행 수준 접근 정책 적용 + 보호 라우트 가드 + webhook 서명 검증 + CSP connect-src 명시 | service_role 이 클라이언트에 노출되거나 행 수준 접근 정책이 꺼진 테이블이 있음 |
| 시크릿 | 코드와 .env, 번들이 모두 깨끗함. VITE_*만 클라이언트로. .env.example만 git 에. gitleaks 0건 | 실제 키가 git 이력에 남아 있음 |
| 접근성 | jsx-a11y 0 errors, axe-core 0 violations, 키보드 navigation 완전, focus indicator 있음 | semantic HTML 위반 또는 ARIA 오용 |
| 검색 대응 | meta(title/description/og) + sitemap.xml + robots.txt + JSON-LD + Lighthouse SEO 90 이상 | 빈 title, 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 --noEmit 을 실행합니다pnpm lint # FSD boundaries 와 jsx-a11y 를 검사합니다pnpm build # 운영 환경 빌드와 bundle size 보고를 실행합니다npx gitleaks detect # 시크릿을 검색합니다npx lhci autorun # Lighthouse CI 로 접근성과 검색 대응, 성능을 봅니다supabase test db # 행 수준 접근 정책 단위 테스트를 실행합니다review-saas 스킬은 위 명령을 차례로 실행해 그 결과를 여섯 관점 점수로 정리합니다. 결과는 .claude/state/review-{timestamp}.md에 한 장으로 남습니다. 통과 여부와 관점별 부족 사유가 같이 적힙니다.
보안 기준선 S1부터 S8까지
섹션 제목: “보안 기준선 S1부터 S8까지”review-saas의 보안 관점이 보는 것은 AI_AUTOMATION.md 섹션 4의 S1부터 S8까지입니다. 한 줄씩 정리하면 다음과 같습니다.
- S1: 모든 Supabase 테이블에 행 수준 접근 정책을 켜고 default deny 로 둡니다. 보호 라우트는
<RequireAuth>누락이 0건이어야 합니다. 검증하는 곳은 migrations와 reviewer입니다. - S2:
SUPABASE_SERVICE_ROLE_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET은 서버 전용입니다. 클라이언트에는VITE_*접두사가 붙은 anon key와 publishable key만 둡니다..env*는.gitignore에 넣고.env.example만 git에 올립니다. 검증하는 곳은env.tszod 검증과 reviewer 검색, 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, 운영 환경stripe trigger는 자동 실행을 금지합니다. 사용자의 명시적 확인이 필요합니다. 검증하는 곳은 모든 작업의 진입 게이트입니다. - S6: 명령은 프로젝트 디렉터리 안에서만 실행합니다. Supabase 운영 환경 프로젝트는 별도 환경변수로 격리합니다. 검증하는 곳은 모든 작업입니다.
- S7: JWT와 session token, Stripe key, customer id, email은 앞 4자만 남기고
***로 가립니다. 운영 환경 로그에 평문 개인정보를 남기지 않습니다. 검증하는 곳은 logging util과 reviewer입니다. - S8: CSP
connect-src에 명시된 도메인만 fetch를 허용합니다. Supabase와 Stripe, 측정 도구로 한정합니다. 임의의 외부 URL fetch를 금지합니다. 검증하는 곳은index.html의 meta와 reviewer입니다.
S1과 S2는 reviewer가 1점을 주고 즉시 차단합니다. 나머지는 점수가 깎이지만 차단 단계까지 가지 않는 경우가 많습니다. 본인 도메인 entity에 따라 어떤 테이블에 어떤 정책을 적용할지가 달라지므로 구체적인 정책은 phase 4의 데이터 스키마 결정에서 정해집니다. 이 항목은 fork 할 때 채워집니다.
SaaS 금지 패턴
섹션 제목: “SaaS 금지 패턴”AI_AUTOMATION.md 섹션 5가 reviewer가 차단하는 패턴 목록을 담고 있습니다. 정적 검색으로 잡히는 것들이라 같은 코드를 두 번 작성할 일이 없습니다.
| 금지 | 사유 | 대체 |
|---|---|---|
SUPABASE_SERVICE_ROLE_KEY를 src/ 코드에서 import | 클라이언트 번들에 포함되면 행 수준 접근 정책을 우회 | Edge Function 내부에서만 Deno.env.get(...) |
| 행 수준 접근 정책이 꺼진 public 테이블 | anon key로 누구나 읽고 쓸 수 있음 | 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(), 행 수준 접근 정책으로 행 제한 |
import.meta.env.SUPABASE_URL (VITE_ 접두사 누락) | Vite는 VITE_*만 클라이언트에 노출하므로 누락 시 undefined | import.meta.env.VITE_SUPABASE_URL 와 env.ts zod 검증 |
/test-saas의 spec 기반 방식
섹션 제목: “/test-saas의 spec 기반 방식”일반 boilerplate는 “Vitest 셋업”까지만 줍니다. 사용자가 직접 테스트 코드를 짜야 합니다. /test-saas는 거기에 한 단계를 더 둡니다.
docs/design/saas-spec.md §2 시나리오 (사용자가 phase 3에서 작성합니다) ↓/test-saas 가 시나리오를 파싱하고 매핑합니다 ↓시나리오 단위를 Vitest 단위와 Playwright e2e, supabase test db 정책 테스트로 (AI 생성) ↓src/{slice}/__tests__/ 와 api/policies.test.sql 안에 생성됩니다사용자는 시나리오 한 줄을 적습니다. “사용자가 Pro 플랜을 고른 다음 Stripe Checkout을 통과해 돌아오면 /app에 active subscription 상태로 들어간다.” 이 한 줄을 스킬이 검증 가능한 단위 셋으로 분해합니다.
분해는 FSD 슬라이스 또는 행 수준 접근 정책을 기준으로 합니다.
| 시나리오 조각 | 테스트 단위 | 파일 경로 |
|---|---|---|
| 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, Stripe 를 mock 처리) |
| Stripe 결제를 완료하고 /checkout/success 로 진입합니다 | 라우트 진입과 UI 상태 | Playwright e2e |
webhook이 subscriptions 테이블을 갱신합니다 | webhook 서명 검증과 DB upsert | api/functions/stripe-webhook/__tests__/ (Vitest) |
| 다른 user는 본인 subscription만 select 합니다 | 행 수준 접근 정책 단위 테스트 | api/policies.test.sql (supabase test db) |
테스트 패턴은 네 갈래로 정해져 있습니다.
- Vitest 단위:
src/{slice}/__tests__/에 같은 위치로 둡니다. FSD public API를 거치지 않고 슬라이스 내부를 직접 import 할 수 있습니다. 테스트가 슬라이스의 일부이지 외부 소비자가 아니라는 신호입니다. - Edge Function 단위:
api/functions/<name>/__tests__/에 둡니다. Supabase client와 Stripe SDK를vi.mock으로 대체합니다. 서명 검증의 경우 의도적으로 위조 signature를 던지는 실패 케이스가 같이 들어갑니다. - Playwright e2e: 라우트 진입과 인증 흐름, 결제 redirect를 실제로 거칩니다.
supabase start로 로컬 Postgres와 Edge runtime을 띄운 위에서 동작합니다. - 행 수준 접근 정책 단위:
api/policies.test.sql에 “이 user로 이 쿼리를 보냈을 때 거절돼야 한다” 형태로 적습니다.supabase test db가 로컬 DB에서 실행합니다.
테스트 파일이 어디에 만들어질지는 시나리오가 무엇을 다루는지에 따라 다릅니다. 슬라이스를 다루면 슬라이스 안에, 행 수준 접근 정책을 다루면 api/policies.test.sql에 만들어집니다. base가 만들어 둔 것은 위 네 갈래의 디렉터리 골격뿐입니다.
시나리오 언어로 결과 정리
섹션 제목: “시나리오 언어로 결과 정리”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 거부: 행 수준 접근 정책 정상 동작원본 출력을 그대로 보여주지 않는 것은 의도한 설계입니다. PM 또는 비개발자 입장에서는 expected 'foo' to be 'bar' at line 32 보다 “이 시나리오는 통과했고 이 시나리오는 실패했다” 는 정보가 의사 결정에 직접 닿습니다. 동시에 원본 로그는 .claude/state/last-test.log에 통째로 남아 있어서 깊이 파야 할 때 볼 수 있습니다.
빠른 참조
섹션 제목: “빠른 참조”# 동작 검사/test-saas # spec 으로 테스트를 생성하고 실행한 다음 시나리오 언어로 보고합니다pnpm test # Vitest 를 직접 실행합니다pnpm test:e2e # Playwright 를 직접 실행합니다supabase test db # 행 수준 접근 정책 단위 테스트를 실행합니다
# 정적 검사/review-saas # 여섯 관점으로 채점합니다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 검증이 빌드 차단 게이트 역할을 하는 방식, 라우트별 chunk size 200KB gzip 목표, 그리고 Edge Functions가 별도 경로로 supabase functions deploy를 통해 배포되는 흐름을 봅니다.