Supabase RLS로 사용자는 자기 구독 등급만 읽고, 서버 전용 secret key를 쓰는 Next.js 라우트 핸들러가 포트원 웹훅을 검증·재조회해 entitlements 테이블을 멱등하게 갱신하는 구조를 코드로 정리했습니다. 토스페이먼츠·Stripe와의 차이도 비교합니다.
결제 완료 여부는 결제대행사 서버에 있고, 유료 기능 사용 가능 여부는 우리 DB에 있습니다. 이 글은 Supabase Auth와 Postgres RLS 위에서 웹훅으로 구독 등급(entitlement) 테이블을 갱신하는 구조를 Next.js App Router 라우트 핸들러 코드로 정리합니다. 예제는 포트원 V2 웹훅 기준이며 토스페이먼츠와 Stripe의 차이는 표로 비교합니다.
핵심 요약
- 테이블은 셋입니다. 결제 전에 서버가 만드는
orders, 웹훅 중복을 막는payment_events, 사용자가 읽는entitlements입니다. - 사용자는 RLS 정책
(select auth.uid()) = user_id로 자기 행만 읽습니다. 쓰기 정책은 없고, 쓰기는 서버의 secret key(sb_secret_...)만 합니다. secret key는service_role역할로 RLS를 우회합니다. - 웹훅은
await req.text()로 받아 서명을 검증하고, 결제 상태는 조회 API로 다시 읽어 주문 금액과 대조합니다. - 중복은
(provider, event_id)기본키로 막습니다. 포트원은webhook-id, 토스페이먼츠는tosspayments-webhook-transmission-id, Stripe는 event ID가 기준입니다. - 토스페이먼츠 자동결제는 완료 웹훅이 없으므로 정기결제 권한은 서버의 승인 API 응답에서 갱신합니다.
1. 데이터 흐름
- 사용자가 요금제를 고르면 서버가
orders에 주문 ID,user_id, 요금제, 금액을 기록합니다. 포트원paymentId와 토스페이먼츠orderId는 가맹점이 채번하므로 이 ID를 그대로 넘깁니다. - 브라우저가 결제창을 엽니다. 이 응답은 권한 판단에 쓰지 않습니다.
- 웹훅이 오면 서명 검증, 결제 재조회, 금액 대조 순으로 처리하고
entitlements를 갱신합니다.
포트원과 Stripe 문서 모두 도달 순서를 보장하지 않으므로 상태는 매번 조회 결과로 덮어씁니다.
2. 스키마와 RLS
create table public.orders (
id text primary key,
user_id uuid not null references auth.users(id),
plan text not null,
amount integer not null
);
create table public.payment_events (
provider text not null,
event_id text not null,
payment_id text not null,
received_at timestamptz not null default now(),
primary key (provider, event_id)
);
create table public.entitlements (
user_id uuid primary key references auth.users(id),
plan text not null,
status text not null check (status in ('active', 'canceled')),
current_period_end timestamptz,
source_payment_id text not null,
updated_at timestamptz not null default now()
);
alter table public.orders enable row level security;
alter table public.payment_events enable row level security;
alter table public.entitlements enable row level security;
create policy "own entitlement" on public.entitlements
for select to authenticated
using ((select auth.uid()) = user_id);
Supabase RLS 문서에 따르면 RLS를 켜고 정책이 없으면 publishable key로는 API에서 데이터에 접근할 수 없습니다. orders와 payment_events에 정책을 두지 않은 이유입니다. auth.uid()를 select로 감싸는 형태는 같은 문서가 성능 개선 방법으로 안내합니다. 쓰기 정책이 없으므로 브라우저의 등급 변경 요청은 거부됩니다.
3. 키 분리
| 용도 | 키와 환경변수 | 실행 위치 |
|---|---|---|
| 로그인, 본인 데이터 조회 | publishable sb_publishable_..., NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | 브라우저, 서버 |
| 웹훅에서 권한 쓰기 | secret sb_secret_..., SUPABASE_URL, SUPABASE_SECRET_KEY | 서버 전용 |
| 결제 조회, 서명 검증 | PORTONE_API_SECRET, PORTONE_WEBHOOK_SECRET | 서버 전용 |
Next.js는 NEXT_PUBLIC_ 변수만 브라우저 번들에 넣습니다. 레거시 anon·service_role 키는 2026년 말까지 폐기 예정입니다(Supabase API 키 문서).
// lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";
export const createAdminClient = () =>
createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, {
auth: { persistSession: false, autoRefreshToken: false },
});
server-only를 import한 모듈을 클라이언트에서 가져오면 Next.js가 빌드 오류를 냅니다.
4. 웹훅 라우트 핸들러
포트원 V2 웹훅은 Standard Webhooks 형식이며, 서버 SDK Webhook.verify(secret, payload, headers)가 webhook-id·webhook-timestamp·webhook-signature 헤더를 검증합니다. 클라이언트는 @portone/server-sdk 0.19.0 타입 정의대로 PortOneClient({ secret })로 만듭니다.
// app/api/webhooks/portone/route.ts
import * as PortOne from "@portone/server-sdk";
import { createAdminClient } from "@/lib/supabase/admin";
const portone = PortOne.PortOneClient({ secret: process.env.PORTONE_API_SECRET! });
const PERIOD_MS = 30 * 24 * 60 * 60 * 1000;
const res = (status: number) => new Response(null, { status });
export async function POST(req: Request) {
const body = await req.text();
let webhook;
try {
webhook = await PortOne.Webhook.verify(
process.env.PORTONE_WEBHOOK_SECRET!, body, Object.fromEntries(req.headers));
} catch (e) {
if (e instanceof PortOne.Webhook.WebhookVerificationError) return res(400);
throw e;
}
if (!("paymentId" in webhook.data)) return res(200);
const db = createAdminClient();
const key = { provider: "portone", event_id: req.headers.get("webhook-id")! };
const { paymentId } = webhook.data;
const { error } = await db.from("payment_events").insert({ ...key, payment_id: paymentId });
if (error?.code === "23505") return res(200);
if (error) return res(500);
try {
const payment = await portone.payment.getPayment({ paymentId });
const { data: order } = await db.from("orders")
.select("user_id, plan, amount").eq("id", paymentId).single();
if (!order) return res(200);
const now = new Date().toISOString();
if (payment.status === "PAID" && payment.amount.total === order.amount) {
await db.from("entitlements").upsert({
user_id: order.user_id, plan: order.plan, status: "active",
current_period_end: new Date(Date.parse(payment.paidAt) + PERIOD_MS).toISOString(),
source_payment_id: paymentId, updated_at: now,
}, { onConflict: "user_id" }).throwOnError();
} else if (payment.status === "CANCELLED") {
await db.from("entitlements").update({ status: "canceled", updated_at: now })
.eq("source_payment_id", paymentId).throwOnError();
}
return res(200);
} catch {
await db.from("payment_events").delete().match(key);
return res(500);
}
}
- 중복 판정: insert가 Postgres
23505(unique_violation)로 실패하면 처리한 이벤트이므로 200을 반환합니다. 처리 중 오류면 기록을 지우고 500을 반환해 재전송을 받습니다. - 금액 대조: 포트원 예제도 조회한
amount.total과 내부 주문 금액을 비교하고 불일치를 위변조 의심으로 분류합니다. - 상태 기준 갱신:
upsert는onConflict컬럼이 같은 행에 병합하고, 만료일을paidAt기준으로 계산하므로 같은 결제를 두 번 처리해도 결과가 같습니다. - 응답 시간: 포트원 웹훅 타임아웃은 연결·읽기 각 30초입니다.
라우트 핸들러의 POST는 캐시되지 않습니다(Next.js Route Handlers 문서). 같은 처리를 Supabase Edge Function으로 옮길 수도 있습니다. Edge Functions 기본값은 verify_jwt = true라서 외부 웹훅용 함수는 Supabase 문서 안내대로 verify_jwt = false로 두고, 키는 기본 제공되는 SUPABASE_SECRET_KEYS(JSON 사전)를 Deno.env.get으로 읽습니다.
5. 결제대행사별 차이
| 항목 | 포트원 V2 | 토스페이먼츠 | Stripe |
|---|---|---|---|
| 서명 검증 | Standard Webhooks, Webhook.verify | 일반 결제 웹훅은 서명 없음. paymentKey로 조회 API 재호출 | Stripe-Signature 헤더와 whsec_ 시크릿, 공식 라이브러리로 검증 |
| 중복 판정 키 | webhook-id | tosspayments-webhook-transmission-id | event ID |
| 재전송 | 최대 5회, 0·1·4·16·64·256분 간격과 지터 | 최대 7회(최초 전송 후 3일 19시간), 10초 안에 200 응답 | 라이브 모드 최대 3일, 지수 백오프 |
| 정기결제 | 예약 결제 승인도 Transaction.Paid | 자동결제·키인 결제는 완료 웹훅 없음 | 구독 생성 시 customer.subscription.created·invoice.paid 등이 순서 없이 도착 |
토스페이먼츠 핸들러는 verify 대신 조회 API 응답의 status가 DONE인지와 금액을 확인합니다. 토스페이먼츠의 서명 헤더 tosspayments-webhook-signature는 지급대행 payout.changed와 seller.changed에만 붙습니다. Stripe의 한국 판매자 가입 가능 여부는 Stripe 공식 지원국 목록에서 확인하시기 바랍니다.
6. 화면에서 권한 읽기
서버에서는 @supabase/ssr의 createServerClient로 클라이언트를 만들고 getClaims()로 사용자를 확인합니다. Supabase 가이드는 쿠키를 재검증 없이 읽는 getSession()을 서버에서 신뢰하지 말라고 안내합니다.
const supabase = await createClient(); // lib/supabase/server.ts
const { data } = await supabase.auth.getClaims();
if (!data?.claims) redirect("/login");
const { data: ent } = await supabase.from("entitlements")
.select("plan, status, current_period_end").maybeSingle();
const isPro = ent?.status === "active" && new Date(ent.current_period_end!) > new Date();
user_id 필터가 없어도 RLS가 본인 행만 돌려줍니다.
함께 읽을 글
- 바이브코딩 결제 연동 가이드: 국내 PG·해외 MoR 선택 기준
- 포트원 연동 가이드: V2 웹훅 서명 검증과 PG 계약 순서
- 토스페이먼츠 Next.js 연동: App Router 결제 승인·금액 검증·웹훅까지
- 결제 붙이기 전 법적 준비: 사업자등록·통신판매업 신고·환불 고지 체크리스트
- 서비스 구축 사업부 · 결제와 권한 구조 설계가 필요하면 문의하기
자주 묻는 질문
결제 성공 페이지에서 바로 등급을 올리면 안 되나요?
포트원 문서는 네트워크 끊김이나 새로고침으로 브라우저 응답이 누락될 수 있다고 설명합니다. 성공 페이지에서는 서버가 결제를 조회해 같은 갱신 함수를 호출하고 웹훅은 누락을 메우는 경로로 둡니다.
secret key로 쓰면 RLS 정책은 필요 없나요?
웹훅 쓰기에는 필요 없습니다. RLS는 publishable key로 접속하는 브라우저 요청에 적용됩니다. 조회 정책이 없으면 사용자가 자기 등급을 읽지 못하고, 쓰기 정책을 열면 사용자가 등급을 바꿀 수 있습니다.
같은 결제 웹훅이 두 번 오면 기간이 두 번 늘어나나요?
이 예제는 만료일을 paidAt 기준으로 덮어써서 값이 같습니다. 기존 만료일에 기간을 더하는 방식이면 중복 처리 시 기간이 늘어나므로 payment_events 중복 판정이 반드시 먼저 실행돼야 합니다.
출처
- Supabase 문서 RLS·API keys·Next.js Auth·Edge Functions: https://supabase.com/docs/guides/database/postgres/row-level-security , https://supabase.com/docs/guides/api/api-keys , https://supabase.com/docs/guides/auth/server-side/nextjs (2026-10-17 확인)
- Next.js with-supabase 예제: https://github.com/vercel/next.js/tree/canary/examples/with-supabase (2026-10-17 확인)
- Next.js 16 문서 Route Handlers·Data Security·Environment Variables, Supabase JS 레퍼런스 getClaims·upsert (2026-10-17 확인)
- 포트원 웹훅 연동하기(V2): https://developers.portone.io/opi/ko/integration/webhook/readme-v2 ,
@portone/server-sdk0.19.0: https://unpkg.com/@portone/server-sdk@0.19.0/ (2026-10-17 확인) - 토스페이먼츠 웹훅 연결하기·웹훅 이벤트: https://docs.tosspayments.com/guides/v2/webhook , https://docs.tosspayments.com/reference/using-api/webhook-events (2026-10-17 확인)
- Stripe 웹훅 문서: https://docs.stripe.com/webhooks (2026-10-17 확인)
- PostgreSQL Error Codes: https://www.postgresql.org/docs/current/errcodes-appendix.html (2026-10-17 확인)
코텍시스 창업자로 AX 컨설팅과 기업 AI 교육, AI 에이전트 개발을 직접 수행합니다.
코텍시스 AI 인사이트 최신 논문 리뷰
토큰당 KV 캐시 크기로 계산하는 온프레미스 동시 처리량
vLLM 논문이 보인 것처럼 LLM 서빙의 동시 처리량은 GPU 메모리에서 가중치를 빼고 남은 공간을 토큰당 KV 캐시 크기로 나눈 값이 정합니다. Qwen3-8B와 Qwen3-32B의 config.json으로 계산하면 80GiB GPU 1장에서 8,192토큰 세션 상한이 57개와 9개로, 가중치 크기 차이 4배보다 큰 6배 이상의 차이가 납니다.
초안 모델로 생성 속도를 높이는 추측 디코딩
Google Research의 추측 디코딩은 작은 초안 모델이 제안한 토큰을 큰 모델이 한 번에 검증하고 확률적 수락 규칙으로 출력 분포를 그대로 유지해, T5-XXL의 생성 속도를 재학습 없이 2배에서 3배 높였습니다. M3 Pro 맥북의 Gemma 4 12B 단일 사용자 실측에서는 초안을 켜면 같은 출력을 유지한 채 생성 속도가 15.3 tok/s에서 33.4 tok/s로 2.18배 올랐습니다.
AWQ 양자화: 활성값을 보고 가중치를 지키는 4비트 양자화
MIT 연구진의 AWQ는 활성값 분포를 기준으로 중요한 가중치 1%만 보호해도 저비트 양자화 오류가 크게 줄어든다는 관찰에서 출발해 재학습 없는 4비트 양자화를 구현했고 전용 커널 TinyChat으로 FP16 대비 3배 이상의 추론 속도를 달성했습니다. 온프레미스 sLLM을 소수의 GPU로 서빙해야 하는 기업에게 정확도 손실 없이 메모리와 비용을 동시에 줄이는 사실상의 표준 기법입니다.

