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_subscription을 service_role 전용으로 잠금. 이전엔 anon/authenticated에 열려 있어 누구나 호출 → 무료 Pro 우회가 가능했음(봉쇄 완료).

0003_revoke_user_rpc_grants.sql