전체 구성 한눈에
브라우저는 항상 finsight(Next.js)하고만 직접 대화합니다. finsight가 가운데에서 Google·Supabase·Polar·Claude를 조율해요. 비밀키는 전부 서버(finsight)에만 있습니다.
구글 로그인 (OAuth)
"Google로 계속하기"를 누르면 finsight → Google → Supabase → finsight 순으로 한 바퀴 돌고, 마지막에 세션 쿠키가 심어진 채 /dashboard로 돌아옵니다.
sequenceDiagram autonumber actor U as 사용자 participant A as finsight (Next.js) participant S as Supabase Auth participant G as Google U->>A: /login → "Google로 계속하기" Note over A: signInWithGoogle() (Server Action)
callback URL = /auth/callback?next=… A->>S: createGoogleOAuthUrl()
auth.signInWithOAuth(google) S-->>A: Google OAuth URL A-->>U: redirect → Google 동의 화면 U->>G: 구글 로그인 + 권한 동의 G-->>S: 인가 코드 → Supabase 콜백 S-->>U: redirect → /auth/callback?code=&next= U->>A: GET /auth/callback?code=… Note over A: resolveAuthCallbackRedirect() A->>S: exchangeCodeForSession(code) · PKCE S-->>A: 세션 쿠키 set (httpOnly) A-->>U: redirect → next (기본 /dashboard)
무슨 일이 일어나나
- 로그인 버튼 → 서버 액션. 페이지가 아니라 서버에서 OAuth URL을 만들어 비밀을 노출하지 않습니다. login/page.tsx · signInWithGoogle
- Supabase가 구글 OAuth URL을 발급. finsight는 그 URL로 사용자를 보내기만 합니다. services/supabase · createGoogleOAuthUrl
- 구글에서 실제 인증·동의. 비밀번호는 구글만 봅니다. 끝나면 코드가 Supabase 콜백으로 전달돼요.
- 앱 콜백에서 코드↔세션 교환. PKCE로 코드를 세션으로 바꾸고 httpOnly 쿠키를 심습니다. auth/callback/route.ts · exchangeAuthCodeForSession
- 원래 가려던 곳으로 복귀. 실패하면
/login?error=oauth로 우아하게 강등됩니다.
왜 안전한가
PKCE 코드 교환 httpOnly 세션 쿠키 비밀번호 미보관 키 없으면 우아한 강등finsight는 구글 비밀번호를 절대 보지 않고, 토큰도 브라우저 JS에 노출하지 않습니다. 리다이렉트 경로(next)는 sanitizeRedirectPath로 오픈 리다이렉트를 막습니다.
Supabase — 세션 검증 · 보호 경로 · RLS
로그인 뒤 모든 요청은 쿠키를 들고 옵니다. middleware가 매번 토큰을 검증하고, 데이터는 Postgres RLS가 "본인 행"만 돌려줍니다.
sequenceDiagram
autonumber
actor U as 사용자 (세션 쿠키)
participant M as middleware
participant R as Route / Server Component
participant DB as Supabase (Postgres + RLS)
U->>M: /dashboard 요청 (쿠키 동봉)
M->>DB: auth.getUser() — 토큰 검증
DB-->>M: user 또는 null
alt 미인증 + 보호 경로
M-->>U: redirect → /login?next=…
else 인증됨
M-->>R: 통과 (세션 쿠키 갱신)
R->>DB: getCurrentUser() → getUser()
R->>DB: select … (사용자 세션 키)
Note over DB: RLS 정책 auth.uid() = user_id
→ 본인 행만 반환
DB-->>R: 본인 데이터만
R-->>U: 대시보드 렌더
end
무슨 일이 일어나나
- middleware가 길목을 지킴. 매칭되는 모든 경로에서
getUser()로 토큰을 검증합니다(쿠키만 믿지 않음). middleware.ts - 보호 경로 가드.
/dashboard는 미인증이면/login으로. 공개 경로(/,/login)는 통과. resolveMiddlewareAuthDecision - 서버에서 한 번 더 신원 확인. route/서버 컴포넌트는
getSession()이 아니라getUser()를 씁니다. services/supabase · getCurrentUser - 데이터는 RLS가 거름. 사용자 세션 키로 조회하면 Postgres가
auth.uid()=user_id행만 반환 — 코드가 실수해도 남의 데이터가 안 샙니다.
핵심 규칙
전 테이블 RLS getUser() > getSession() service_role은 웹훅 전용일반 요청은 publishable(anon) 키 + 사용자 쿠키로만 DB에 접근하므로 RLS가 항상 적용됩니다. RLS를 우회하는 service_role 키는 import "server-only"로 가드된 웹훅 모듈에서만 씁니다.
Polar 결제 · 웹훅 · Pro 게이팅
결제는 세 갈래로 나뉩니다 — ⓐ 체크아웃(사용자가 결제), ⓑ 웹훅(Polar가 구독 상태를 알려줌, 비동기), ⓒ 게이팅(분석할 때 DB 구독상태로 Pro 판정). 셋은 서로 분리돼 있어요.
Polar는 "결제 대행 창구"입니다. finsight는 손님에게 직접 카드를 받지 않아요. 백화점에서 물건은 고르지만 계산은 입점한 별도 정산 카운터(Polar)에서 하는 것과 같습니다 — 카드번호·세금계산서·영수증은 전부 Polar가 처리하고, 앱은 카드번호를 아예 만지지 않습니다.
왜 직접 안 받고 Polar를 끼나요? Polar는 법적으로 판매 주체가 되어주는 Merchant of Record라, 해외 세금(VAT/GST)·카드 보안·환불 같은 골치 아픈 일을 대신 책임집니다. 작은 팀이 글로벌 결제를 굴리기에 적합해요. 아래 다이어그램은 같은 흐름을 코드(함수·RPC) 기준으로 자세히 본 것입니다.
sequenceDiagram
autonumber
actor U as 사용자
participant A as finsight (Next.js)
participant P as Polar
participant DB as Supabase
Note over U,DB: ⓐ 체크아웃
U->>A: POST /api/checkout ("Pro로 업그레이드")
Note over A: runCheckoutRequest · getCurrentUser()
customerExternalId = 서버 세션 uid (강제)
A->>P: checkouts.create(externalCustomerId = uid)
P-->>A: checkout URL
A-->>U: 303 redirect → Polar 결제 페이지
U->>P: 카드 결제 (샌드박스 4242…)
Note over U,DB: ⓑ 웹훅 (비동기)
P->>A: POST /api/webhook/polar (subscription.*, 서명헤더)
Note over A: verifyPolarWebhook() — raw body 서명검증
실패 → 401
A->>DB: upsert_subscription RPC (service_role)
event_ts 조건부 → stale 이벤트 무시
A->>DB: processed_webhook_events insert (event_id 멱등)
A-->>P: 200 received
Note over U,DB: ⓒ 게이팅 (분석할 때마다)
U->>A: POST /api/analyze
A->>DB: resolveTier(uid): status=active AND period_end > now()
DB-->>A: pro 또는 free
Note over A: free = Sonnet · pro = Opus
미구독도 402가 아니라 200 + Free 결과
A-->>U: 분석 결과 (+ pro.status)
무슨 일이 일어나나
- 체크아웃은 uid를 서버가 강제. 결제 대상은 클라이언트 입력이 아니라
getUser().id로 고정합니다. runCheckoutRequest · /api/checkout - Polar 결제 페이지로 이동. finsight는 checkout URL을 받아 redirect만 합니다. services/polar · createPolarCheckout
- 결제 결과는 웹훅으로 도착. 사용자 화면과 무관하게 Polar가
subscription.*이벤트를 보냅니다. /api/webhook/polar - 서명검증 → 구독 upsert → 멱등 마킹. raw body로 서명을 확인하고, service_role RPC로 구독을 갱신한 뒤 event_id를 선삽입해 중복을 막습니다. verifyPolarWebhook · upsert_subscription
- Pro 여부는 항상 DB로 판정. 분석 때
resolveTier가 활성 구독만 Pro로 인정 → Opus, 아니면 Sonnet. createSubscriptionGateway · resolveTier
설계 포인트
uid 서버 강제 raw body 서명검증 event_id 멱등 서버측 게이팅웹훅은 순서 보장이 없어 event_ts 조건부 upsert로 "취소→활성" 역전을 막습니다. upsert를 먼저 하고 그 성공 뒤에 event_id를 마킹해, 중간 실패 시 Polar 재전송이 실제로 재처리되어 결제가 유실되지 않습니다.
전 구간을 지키는 보안 포인트
세 흐름을 관통하는 6가지 방어선입니다. 가장 아래(RPC 권한)는 방금 닫은 결제 우회 구멍이에요.
🔒 결제 대상 uid 강제
customerExternalId를 요청 본문이 아니라 서버 세션 getUser().id로 고정 — 남의 계정으로 결제/구독 매핑 불가.
🔒 서버측 Pro 게이팅
요청의 tier 헤더를 믿지 않고 DB의 status=active AND period_end>now()로만 판정. 미구독은 402가 아닌 200 + Free.
🔒 전 테이블 RLS
모든 테이블에 auth.uid()=user_id 정책. 사용자 세션 키로는 본인 행만 — 코드 버그가 있어도 데이터가 새지 않음.
🔒 웹훅 서명검증
Standard Webhooks 헤더(id·timestamp·signature)를 raw body로 검증. 시크릿 불일치 → 401. 위조 이벤트 차단.
verifyPolarWebhook🔒 웹훅 멱등 + 순서
event_id 선삽입으로 중복 무시, event_ts 조건부 upsert로 stale 이벤트가 최신 상태를 덮어쓰지 못하게 함.
🔒 RPC 권한 잠금 0003 적용
upsert_subscription을 service_role 전용으로 잠금. 이전엔 anon/authenticated에 열려 있어 누구나 호출 → 무료 Pro 우회가 가능했음(봉쇄 완료).