콘텐츠로 이동

아키텍처

intro에서 guardrail 세 개를 짚었습니다. 여기서는 그 세 개가 코드에서 어떻게 생겼는지 봅니다. FSD 6레이어 의존 방향, 라우트와 API 어댑터와 행 수준 접근 정책(RLS)의 기준 문서, 그리고 Stripe 3단 분리를 차례로 다룹니다. 모두 “AI가 임의로 경계를 넘지 못하게” 빌드 또는 마이그레이션이 막아 줍니다.

src/(클라이언트 SPA)와 api/(서버) 두 트리가 import로 연결되지 않는다는 점도 같이 짚습니다. 둘 사이에 오갈 수 있는 것은 타입뿐이고, 그 타입은 src/shared/api/server/types.ts 한 파일에 둡니다.

폴더는 위에서 아래로 의존이 흐릅니다. 화살표 반대 방향으로 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 같은 것들입니다. base에는 골격만 들어 있고 본인 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>가 대상입니다. 키보드만으로는 닿지 않는 인터랙티브 요소가 여기서 걸러집니다. 빌드가 실패하기 때문에 reviewer가 지적하기 전에 모델이 먼저 규칙을 따르게 됩니다.

검사 명령은 다음과 같습니다.

Terminal window
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/는 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가 차단합니다. 그 키는 행 수준 접근 정책을 우회하는 자격 증명이라 클라이언트 번들에 섞이면 모든 행의 읽기와 쓰기가 열립니다. service_role이 필요한 호출은 api/functions/<name>/index.ts 안에서 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')로만 접근합니다.

모든 라우트 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는 base의 기본 예시입니다. 본인 SaaS의 라우트는 phase 5 결과로 채워지므로, base를 막 fork 한 시점에는 본인 도메인 path가 들어 있지 않습니다. 이 항목은 fork 할 때 채워집니다.

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>이 책임집니다. 어댑터는 전송 계층만 보고, 도메인은 features가 봅니다.

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 명령이 현재 마이그레이션 상태에서 타입을 뽑아 기록합니다. 마이그레이션을 새로 추가하면 같은 명령으로 다시 생성해야 합니다.

모든 행 수준 접근 정책은 api/migrations/<timestamp>_<name>.sql에 작성합니다. Supabase Studio 화면에서 정책을 만들지 않습니다. 화면에서 만든 정책은 git에 남지 않아 운영 환경과 staging 사이에 차이가 생깁니다.

예시는 본인 SaaS 도메인 entity에 따라 달라지지만, 형태는 비슷합니다. 본인 행만 select 하게 두고, 쓰기는 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);

base가 들고 있는 마이그레이션은 골격뿐입니다. 본인 SaaS의 테이블과 정책은 phase 5의 implement-data-layer 단계에서 채워집니다. 이 항목은 fork 할 때 채워집니다.

검증은 supabase test db가 합니다. api/policies.test.sql 안에 “이 user로 이 쿼리를 보냈을 때 거절돼야 한다” 같은 단위 테스트를 적어 두면 마이그레이션이 담고 있는 정책이 의도대로 동작하는지 확인합니다.

결제는 시작과 관리, 수신 셋으로 나뉩니다. 같은 흐름을 한 함수에 묶으려는 충동을 셋으로 끊어 두면 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 Function checkout-session을 호출하고, 받은 URL을 window.location.assign(url)로 넘깁니다. Stripe SDK에서 클라이언트에 노출되는 것은 publishable key (pk_...) 뿐입니다.
  • 관리는 features/manage-subscription이 Edge Function customer-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_ 접두사가 핵심입니다. Vite는 VITE_*로 시작하는 환경변수만 클라이언트 번들에 노출합니다. SUPABASE_SERVICE_ROLE_KEY처럼 접두사가 없는 변수는 클라이언트에서 undefined로 처리되고, 빌드 시점에 걸립니다. 노출 경계가 변수 이름에 드러나 있는 구조입니다.

Claude Code 훅 세 개가 .claude/hooks/에 들어 있고 .claude/settings.json의 hooks 필드가 이를 연결합니다. 사용자 또는 AI가 명령을 의식적으로 따르지 않아도 컨텍스트가 무너지는 것을 막습니다.

스크립트이벤트책임
session-start.shSessionStartactive.md, progress.md 마지막 30줄, feature_list.json, saas-spec.md 첫 50줄, MEMORY.md를 stdout으로 앞에 붙입니다
stop-reminder.shStopactive.md가 20자 미만이면 사용자에게 한 줄 메모를 안내합니다
pre-commit-check.shPreToolUse (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가 앞에 붙이는 파일들은 phase 1부터 6까지의 진행 상태를 그대로 담고 있습니다. active.md는 다음 작업을, feature_list.json의 passes 필드는 어디까지 통과했는지를, saas-spec.md는 컨셉과 시나리오와 데이터 모델을 담습니다. /clear 후 새 세션을 시작해도 이 다섯 개만 앞에 붙으면 작업이 끊기지 않습니다.

훅을 새로 추가할 때는 .claude/hooks/<name>.sh를 작성하고 (#!/usr/bin/env bash 로 시작하고 chmod +x 를 적용합니다), settings.json의 해당 이벤트 항목에 등록하고, .claude/hooks/README.md 표에 한 줄 더 적습니다. 모든 훅은 독립적으로 동작하므로 한 개를 빼도 다른 두 개에 영향이 없습니다.

비개발자용 문체로 같은 시스템을 정리한 문서는 비개발자용 07장에 있습니다.

자동화 훅이 사용자 의식 바깥에서 도는 안전망이라면, 사용자가 직접 호출하는 쪽에는 슬래시 명령 두 개가 준비돼 있습니다. 둘 다 .claude/skills/ 안의 스킬이 연결합니다.

명령언제 실행되나핵심 동작
/recover-from-blockedreview-saas가 두 번 연속 changes_requested 끝에 blocked로 기록한 직후에 실행합니다최신 .claude/state/review-*.md에서 점수가 3 미만인 관점을 추출하고, 같은 영역의 builder-*.md 두 iteration을 비교해 같은 실수를 감지합니다. 사용자에게 쉬운 보고와 세 가지 옵션 (rollback, 사용자 도움을 받아 재시도, 직접 수정) 을 제시합니다
/resume/clear 직후, 또는 며칠 만에 다시 들어왔을 때 실행합니다state 파일과 git log를 종합해 stage를 감지합니다 (empty → paced → analyzed → designed → customized → implemented → built → deployed). session-start.sh 훅이 원본을 앞에 붙인 다음, 한 화면 요약을 사용자에게 출력합니다

/recover-from-blocked의 옵션 A (rollback)는 git reset --hard를 호출하는 파괴적 동작이라, AskUserQuestion 한 번과 yes 명시 입력 한 번으로 두 번 막도록 의도적으로 설계했습니다. working tree가 dirty 하면 그 변경도 사라진다는 안내가 한 줄 더 따라옵니다.

/resume은 active.md와 감지된 stage가 어긋나면 요약을 멈추고 사용자에게 어느 쪽이 맞는지 묻습니다. 다른 PC에서 작업이 진행됐거나 작업을 끝낸 뒤 active.md 갱신을 잊은 경우가 가끔 있어서, 잘못 요약한 답보다 직접 확인받는 비용이 더 적습니다.

비개발자용 문체로 같은 두 명령을 정리한 문서는 비개발자용 06장에 있습니다.

라우팅 모드, 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용으로 캐시한 파일입니다. 사람이 보는 기준 문서는 ADR이고, AI가 컨텍스트에 넣는 것은 agent-memory입니다. 둘이 어긋나면 ADR을 신뢰합니다.

다음 섹션은 verification입니다. saas-spec.md §2 시나리오를 /test-saas가 어떻게 Vitest와 Playwright, supabase test db로 변환하는지, 그 결과를 원본 출력 대신 시나리오 언어로 다시 정리해 돌려주는 흐름, 그리고 정적 검사를 맡는 review-saas의 여섯 관점과 보안 기준선 S1부터 S8까지를 봅니다.