finsight · architecture

구글 로그인 · Supabase · Polar 결제는 이렇게 동작합니다

실제 코드(route handler · service · RPC) 기준으로 그린 흐름도입니다. 각 단계 옆 회색 칩은 그 일이 일어나는 파일/함수예요.

finsight (Next.js) Supabase Google Polar 사용자 브라우저

전체 구성 한눈에

브라우저는 항상 finsight(Next.js)하고만 직접 대화합니다. finsight가 가운데에서 Google·Supabase·Polar·Claude를 조율해요. 비밀키는 전부 서버(finsight)에만 있습니다.

🌐 사용자 브라우저
세션은 httpOnly 쿠키로만 보관 — 토큰이 JS에 노출되지 않음
finsight Next.js
middleware · route handler · server action. 모든 외부 키를 쥐고 조율하는 composition root
Supabase Auth+DB
구글 OAuth 중개 · 세션 발급 · Postgres + RLS · RPC
Google OAuth
실제 계정 인증 + 동의 화면
Polar 결제 MoR
체크아웃 호스팅 · 구독 상태를 웹훅으로 통지
1

구글 로그인 (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)

무슨 일이 일어나나

  1. 로그인 버튼 → 서버 액션. 페이지가 아니라 서버에서 OAuth URL을 만들어 비밀을 노출하지 않습니다. login/page.tsx · signInWithGoogle
  2. Supabase가 구글 OAuth URL을 발급. finsight는 그 URL로 사용자를 보내기만 합니다. services/supabase · createGoogleOAuthUrl
  3. 구글에서 실제 인증·동의. 비밀번호는 구글만 봅니다. 끝나면 코드가 Supabase 콜백으로 전달돼요.
  4. 앱 콜백에서 코드↔세션 교환. PKCE로 코드를 세션으로 바꾸고 httpOnly 쿠키를 심습니다. auth/callback/route.ts · exchangeAuthCodeForSession
  5. 원래 가려던 곳으로 복귀. 실패하면 /login?error=oauth로 우아하게 강등됩니다.

왜 안전한가

PKCE 코드 교환 httpOnly 세션 쿠키 비밀번호 미보관 키 없으면 우아한 강등

finsight는 구글 비밀번호를 절대 보지 않고, 토큰도 브라우저 JS에 노출하지 않습니다. 리다이렉트 경로(next)는 sanitizeRedirectPath로 오픈 리다이렉트를 막습니다.

2

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

무슨 일이 일어나나

  1. middleware가 길목을 지킴. 매칭되는 모든 경로에서 getUser()로 토큰을 검증합니다(쿠키만 믿지 않음). middleware.ts
  2. 보호 경로 가드. /dashboard는 미인증이면 /login으로. 공개 경로(/,/login)는 통과. resolveMiddlewareAuthDecision
  3. 서버에서 한 번 더 신원 확인. route/서버 컴포넌트는 getSession()이 아니라 getUser()를 씁니다. services/supabase · getCurrentUser
  4. 데이터는 RLS가 거름. 사용자 세션 키로 조회하면 Postgres가 auth.uid()=user_id 행만 반환 — 코드가 실수해도 남의 데이터가 안 샙니다.

핵심 규칙

전 테이블 RLS getUser() > getSession() service_role은 웹훅 전용

일반 요청은 publishable(anon) 키 + 사용자 쿠키로만 DB에 접근하므로 RLS가 항상 적용됩니다. RLS를 우회하는 service_role 키는 import "server-only"로 가드된 웹훅 모듈에서만 씁니다.

3

Polar 결제 · 웹훅 · Pro 게이팅

결제는 세 갈래로 나뉩니다 — ⓐ 체크아웃(사용자가 결제), ⓑ 웹훅(Polar가 구독 상태를 알려줌, 비동기), ⓒ 게이팅(분석할 때 DB 구독상태로 Pro 판정). 셋은 서로 분리돼 있어요.

🛒 쉽게 말하면

Polar는 "결제 대행 창구"입니다. finsight는 손님에게 직접 카드를 받지 않아요. 백화점에서 물건은 고르지만 계산은 입점한 별도 정산 카운터(Polar)에서 하는 것과 같습니다 — 카드번호·세금계산서·영수증은 전부 Polar가 처리하고, 앱은 카드번호를 아예 만지지 않습니다.

결제하러 가기
손님이 "Pro 업그레이드"를 누르면, 앱이 Polar에 "이 손님 결제페이지 만들어줘"라고 요청하고 받은 주소로 손님을 보냅니다.
Polar에서 결제
손님이 Polar 결제창에서 카드로 결제합니다. 이 단계에서 앱은 카드정보를 전혀 보지 않습니다.
결과를 앱에 통보
결제가 끝나면 Polar가 앱의 수신 전용 문(웹훅)으로 "이 사람 구독 완료!"를 알리고, 앱은 그제서야 등급을 Pro로 올립니다.

왜 직접 안 받고 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)

무슨 일이 일어나나

  1. 체크아웃은 uid를 서버가 강제. 결제 대상은 클라이언트 입력이 아니라 getUser().id로 고정합니다. runCheckoutRequest · /api/checkout
  2. Polar 결제 페이지로 이동. finsight는 checkout URL을 받아 redirect만 합니다. services/polar · createPolarCheckout
  3. 결제 결과는 웹훅으로 도착. 사용자 화면과 무관하게 Polar가 subscription.* 이벤트를 보냅니다. /api/webhook/polar
  4. 서명검증 → 구독 upsert → 멱등 마킹. raw body로 서명을 확인하고, service_role RPC로 구독을 갱신한 뒤 event_id를 선삽입해 중복을 막습니다. verifyPolarWebhook · upsert_subscription
  5. 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로 고정 — 남의 계정으로 결제/구독 매핑 불가.

runCheckoutRequest

🔒 서버측 Pro 게이팅

요청의 tier 헤더를 믿지 않고 DB의 status=active AND period_end>now()로만 판정. 미구독은 402가 아닌 200 + Free.

resolveTier

🔒 전 테이블 RLS

모든 테이블에 auth.uid()=user_id 정책. 사용자 세션 키로는 본인 행만 — 코드 버그가 있어도 데이터가 새지 않음.

0001_init.sql

🔒 웹훅 서명검증

Standard Webhooks 헤더(id·timestamp·signature)를 raw body로 검증. 시크릿 불일치 → 401. 위조 이벤트 차단.

verifyPolarWebhook

🔒 웹훅 멱등 + 순서

event_id 선삽입으로 중복 무시, event_ts 조건부 upsert로 stale 이벤트가 최신 상태를 덮어쓰지 못하게 함.

processed_webhook_events

🔒 RPC 권한 잠금 0003 적용

upsert_subscriptionservice_role 전용으로 잠금. 이전엔 anon/authenticated에 열려 있어 누구나 호출 → 무료 Pro 우회가 가능했음(봉쇄 완료).

0003_revoke_user_rpc_grants.sql