Biz Setup Sepay Payment — Next.js용 VietQR 결제 인프라 종합 설정
이 스킬은 Sepay(베트남 VietQR 결제 게이트웨이)를 사용하여 Next.js 랜딩페이지를 위한 완전한 결제 인프라를 구축합니다. 완료 후: 고객이 양식을 작성 → 주문 생성 → QR 스캔 → 계좌 이체 → Sepay 웹훅 수신 → 확인 + 리드 조회 + 이메일/텔레그램 트리거. 모든 과정이 자동화되며 관리자의 수동 확인이 필요하지 않습니다.
철학: Sepay는 베트남에서 가장 쉬운 경로입니다 — VietQR + 은행 네이티브 API, 고객이 별도로 가입할 필요 없이 기존 은행 앱으로 QR을 스캔하면 됩니다. 수수료 1.5-2%로 Stripe(4-5%)보다 저렴하고, T+1 정산되며, 월 100건 미만 랜딩페이지에 적합한 무료 등급이 있습니다.
리드 저장소가 필요한 이유: Sepay 웹훅은
content(고객이 이체 시 입력한 텍스트)와 금액만 보냅니다. Sepay는 고객이 누구인지 알 수 없습니다.content를 리드{name, phone, email, product, amount}에 매핑하려면 로컬 리드 저장소가 필요합니다.
2가지 리드 저장소 옵션 — 사용자가 0단계에서 선택:
- Vercel KV (Upstash Redis) — 2분 설정, 네이티브 TTL, 월 30,000 명령어 무료 → 월 500건 미만
- Supabase Free (Postgres) — 5분 설정, SQL 쿼리 + Studio UI, 무료 등급 약 50배 더 넓음 → 월 2000건 미만. 권장.
API 라우트(/api/checkout, /api/sepay-webhook, /api/admin/leads)는 lib/leads-store.ts 씬 re-export을 통해 공급자에 독립적입니다. 나중에 공급자를 변경하려면 1줄만 수정하면 됩니다.
스킬은 사용자를 대신해 Sepay 계정을 등록하지 않습니다 (KYC 비즈니스 + 은행 필요). 사용자를 단계별로 안내한 후 전체 코드를 연결합니다.
이 스킬을 사용해야 하는 경우
- 사용자가 디지털 제품/코스/코칭을 출시하고 온라인 결제를 받아야 하는 경우.
- 사용자가 이미 Next.js 랜딩페이지(
ui-ux-pro-max또는 유사)를 보유하고 결제 흐름을 추가하려는 경우. - 사용자가
biz-email-setup또는biz-telegram-payment-notify를 연결할 준비가 되었지만 결제 인프라가 없는 경우 → 이 스킬이 전제 조건입니다.
다음 경우에는 이 스킬을 사용하지 마십시오:
- 사용자가 Stripe/PayPal(국제 카드 결제)을 사용하는 경우 → 다른 사용 사례.
- 사용자가 수동 은행 송금 + 자체 확인(소규모, 권장하지 않음)을 수락하는 경우.
- 사용자가 이미 결제 인프라를 보유하고 알림만 필요한 경우 →
/biz-email-setup또는/biz-telegram-payment-notify로 바로 이동.
전체 워크플로 (7단계)
Phase 0: Next.js 스택 감지 + 공급자(KV/Supabase) 질문 — GATE
↓
Phase 1: 사용자 Sepay 계정 설정 안내 — 수동 GATE 사용자 완료 대기
↓
Phase 2A (KV 선택 시): Vercel KV 설정 (패키지 설치 + env vars + KV 네임스페이스)
Phase 2B (Supabase 선택 시): Supabase 설정 (프로젝트 생성 + SQL 마이그레이션 + service_role)
↓
Phase 3: lib/leads-store.ts (re-export 래퍼) + lib/leads-{kv|supabase}.ts (구현) 스캐폴딩
↓
Phase 4: /api/checkout 라우트 스캐폴딩 (양식 → 주문 → 저장)
↓
Phase 5: /api/sepay-webhook 라우트 스캐폴딩 (확인 + 조회 + 사이드 이펙트 플레이스홀더)
↓
Phase 6: VietQR을 랜딩페이지에 삽입 (3가지 UX 패턴 중 선택)
↓
Phase 7: 5단계 테스트 계획 (저장소 → 체크아웃 → 웹훅 → 프로덕션 → (Supabase 전용) cron 핑)
Phase 0 + 1에는 사용자 대기 게이트가 있습니다. Phase 2/3/4/5/6은 스킬이 능동적으로 수행합니다.
Phase 0 — Next.js 스택 감지 + 공급자 선택
0.1 스택 감지
test -f next.config.js -o -f next.config.mjs -o -f next.config.ts && echo "Next.js: ✓"
test -d app && echo "Router: App"
test -d pages && echo "Router: Pages"
test -f tsconfig.json && echo "TS: ✓"
test -f pnpm-lock.yaml && echo "pkg: pnpm" || test -f yarn.lock && echo "pkg: yarn" || echo "pkg: npm"
Next.js가 아닌 경우: 중단. 알림: "이 스킬은 현재 Next.js만 지원합니다. 현재 스택은 [X]입니다. Vercel Function을 사용하여 Vite/정적 사이트로 로직을 포팅할까요?" — 사용자 대기.
app/와 pages/가 모두 있는 경우: 사용자에게 어떤 것을 사용할지 물어봅니다.
0.2 기존 인프라 확인
ls app/api/ pages/api/ lib/ 2>/dev/null
grep -E '"@vercel/kv"|"@supabase/supabase-js"|"@upstash/redis"' package.json
grep -E "SEPAY|KV_REST|SUPABASE_" .env.local 2>/dev/null
0.3 사용자에게 공급자 선택 질문
Next.js [App/Pages] Router + TypeScript + [pnpm/yarn/npm]을 사용 중이신 것으로 확인했습니다.
리드 저장소를 어디에 저장하시겠습니까?
1. Vercel KV (Upstash Redis)
✓ 2분 빠른 설정 (Vercel 대시보드에서 1클릭)
✓ 네이티브 TTL — 프로젝트 일시 중지 걱정 없음
✗ 무료 월 30,000 명령어만 → 월 ~500건
✗ SQL 없음 — 관리자 필터가 복잡하고 어려움
2. Supabase Free (Postgres) — 권장
✓ 가장 넓은 무료 등급 (500MB DB + 5GB 대역폭, 무제한 쿼리)
✓ Postgres = 강력한 SQL 쿼리, Supabase Studio UI로 데이터 보기에 좋음
✓ TypeScript 타입 안전 자동 생성
✓ 5분 설정 (콘솔 + 2개의 env vars 붙여넣기)
⚠️ 7일간 사용 없으면 프로젝트 자동 일시 중지 → 6일마다 Vercel Cron 핑 `/api/health`를 연결하여 해결
⚠️ TTL은 pg_cron을 통해 수동으로 관리 (SQL 마이그레이션에 이미 연결됨)
→ 월 2000건 미만에 적합, 관리자 기능 확장 용이
1 또는 2를 선택해주세요.
GATE: 사용자가 선택할 때까지 Phase 1로 이동하지 않습니다. 기본적으로 Supabase를 제안하지만 명시적으로 선택하도록 합니다.
사용자가 선택한 후, 계획 요약:
**[Vercel KV / Supabase]**를 선택하셨습니다. 다음을 수행하겠습니다:
- Sepay 계정 설정 안내 (5-10분)
- 선택한 공급자에 따라 리드 저장소 설정
- 코드 연결: lib/leads-store.ts + /api/checkout + /api/sepay-webhook + VietQR 삽입
- 테스트
Phase 1을 시작할 준비가 되셨습니까?
Phase 1 — 사용자 Sepay 계정 설정 안내
전체 가이드는 references/sepay-account-setup.md를 참조하세요. 사용자에게 5단계 요약:
1단계 — my.sepay.vn에 가입
- https://my.sepay.vn 방문 → 가입
- 전화번호, 이메일, 비밀번호 입력 → OTP 인증
- 프로필 완료: 이름 + CCCD/CMND (개인) 또는 세금 코드 (회사)
2단계 — 은행 계좌 연결
대시보드 → 은행 계좌 → 계좌 추가:
- 은행 선택 (Vietcombank, Techcombank, MB, ACB, VPBank, BIDV, VietinBank, TPBank, ...)
- 계좌 번호 + 예금주 이름 입력 (은행 앱과 일치해야 함)
- 자동 인증 (은행 API를 통해) 또는 수동 인증 (Sepay가 제공하는 확인 코드로 1,000-10,000 VND 이체)
3단계 — API 키 발급
대시보드 → 설정 → API 및 웹훅 → 새 API 키 생성:
- 이름:
landing-page-prod - 권한:
webhook:read - 키 복사 (한 번만 표시됨)
4단계 — 웹훅 URL 설정
같은 페이지 → 웹훅 → 웹훅 추가:
- URL:
https://yourdomain.vn/api/sepay-webhook(배포 후 활성화) - 인증: API Key → 방금 생성한 키 붙여넣기
- 이벤트:
transaction:incoming - 저장
⚠️ 로컬 개발: Sepay 테스트를 위해 ngrok을 사용하여
localhost:3000을 노출하세요.
5단계 — 스킬에 붙여넣기
SEPAY_WEBHOOK_API_KEY=sk_xxxxxxxxxxxxxxxxxxxx
SEPAY_BANK_ACCOUNT_NUMBER=1023456789
SEPAY_BANK_NAME=Vietcombank
SEPAY_ACCOUNT_NAME=NGUYEN VAN A
GATE: 사용자가 모두 붙여넣을 때까지 Phase 2로 이동하지 않습니다.
Phase 2A — Vercel KV 설정 (Phase 0에서 사용자가 KV를 선택한 경우에만)
Vercel KV = 관리형 Upstash Redis. 월 30,000 명령어 무료.
2A.1 KV 네임스페이스 생성
Vercel 대시보드 → 프로젝트 → Storage → Create Database → KV (Redis) → 이름 sepay-leads → 생성.
4개의 env vars 자동 생성:
KV_URL=...
KV_REST_API_URL=...
KV_REST_API_TOKEN=...
KV_REST_API_READ_ONLY_TOKEN=...
Connect to Project 클릭 → Vercel이 env 주입 (Production + Preview + Development).
2A.2 로컬로 env 가져오기
npm i -g vercel # 아직 없는 경우
vercel link
vercel env pull .env.local
2A.3 패키지 설치
pnpm add @vercel/kv # 또는 yarn / npm install
2A.4 확인
node -e "
const { kv } = require('@vercel/kv');
kv.set('test', 'hello').then(() => kv.get('test')).then(v => {
console.log('KV OK:', v);
return kv.del('test');
});
"
KV OK: hello 출력 예상. Phase 3으로 이동.
Phase 2B — Supabase Free 설정 (Phase 0에서 사용자가 Supabase를 선택한 경우에만)
전체 6단계 상세 가이드는 references/supabase-setup.md를 참조하세요. 요약:
2B.1 Supabase 계정 + 프로젝트 생성
- https://supabase.com → Start your project → GitHub 로그인
- New project:
- 이름:
sepay-leads-<브랜드명> - 데이터베이스 비밀번호: Generate 클릭 → 저장
- 리전: ⚠️ Southeast Asia (Singapore) (기본 us-east 지연 시간 250ms+)
- 가격: Free
- 이름:
- ~2분 프로비저닝 대기
2B.2 SQL 마이그레이션 실행
사이드바 → SQL Editor → New query → templates/supabase-migration.sql 내용 붙여넣기 → Run.
4개 테이블 + 인덱스 + next_order_id() 함수 + RLS 활성화 + pg_cron 일일 정리 작업 생성.
출력 확인: 4개 테이블 (leads/phone_index/order_counter/webhook_dedup) + 1 pg_cron 작업 + next_order_id()가 "DH000001" 반환.
카운터 초기화 (확인으로 인해 카운터가 증가했으므로):
UPDATE order_counter SET current_value = 0 WHERE id = 1;
DELETE FROM leads WHERE order_id = 'DH000001';
2B.3 API 자격 증명 가져오기
Project Settings → API:
- Project URL 복사 (
https://xxxxx.supabase.co) service_role시크릿 복사 (anon키 아님 — service_role은 RLS를 우회하며 서버에서 사용)
2B.4 env vars 붙여넣기
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJI...
스킬 자동 확인:
- URL이
^https://[a-z0-9]+\.supabase\.co$와 일치하는지 확인 - 키가 JWT를 디코딩하고
role: "service_role"을 확인 (anon아님)
오류 시 → 오류 보고, 다시 붙여넣기 요청.
2B.5 패키지 설치
pnpm add @supabase/supabase-js
2B.6 Vercel Cron 핑 연결 (일시 중지 방지)
Supabase Free는 7일간 사용 없으면 자동으로 일시 중지됩니다. 해결 방법: 6일마다 /api/health 핑.
스킬이 app/api/health/route.ts (templates/api-health-route.ts에서) 스캐폴딩하고 vercel.json에 다음을 추가합니다:
{
"crons": [
{ "path": "/api/health", "schedule": "0 3 */6 * *" }
]
}
⚠️ Vercel Cron Hobby는 월 2회 cron으로 제한됩니다. */6 days 패턴 = 월 ~5회 → 할당량 초과. 옵션:
- Vercel Pro ($20/월) — 무제한 cron
- GitHub Actions 워크플로
.github/workflows/keep-alive.yml(무료) — 리포지토리가 GitHub인 경우 스킬이 스캐폴딩 - cron-job.org (무료) — 수동 설정, URL
/api/health붙여넣기
스킬은 기본적으로 GitHub 원격이 있는 .git/config가 있는 리포지토리의 경우 옵션 2(GitHub Actions)를 선택합니다. 그렇지 않으면 옵션 3 설명을 출력합니다.
2B.7 확인
node -e "
const { createClient } = require('@supabase/supabase-js');
const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
sb.from('order_counter').select('current_value').eq('id', 1).single().then(r => {
if (r.error) { console.error('FAIL:', r.error.message); process.exit(1); }
console.log('Supabase OK. current_value =', r.data.current_value);
});
"
Supabase OK. current_value = 0 출력 예상. Phase 3으로 이동.
Phase 3 — lib/leads-store.ts + 구현 스캐폴딩
공급자 추상화: API 라우트는 lib/leads-store.ts에서 가져오며, 내부가 KV인지 Postgres인지 알지 못합니다.
KV 선택 시 (Phase 0)
2개 파일 스캐폴딩:
lib/leads-store.ts←templates/lib-leads-store-kv.ts복사 (1줄export * from './leads-kv')lib/leads-kv.ts←templates/lib-leads-kv.ts복사 (KV 구현, ~160줄)
Supabase 선택 시
3개 파일 스캐폴딩:
lib/leads-store.ts←templates/lib-leads-store-supabase.ts복사 (Supabase re-export)lib/leads-supabase.ts←templates/lib-leads-supabase.ts복사 (Postgres 구현 + 관리자용 listLeads)lib/supabase-admin.ts←templates/lib-supabase-admin.ts복사 (service_role을 사용하는 캐시된 싱글톤 관리자 클라이언트)
공통 API 표면 (두 구현 모두):
createLead(input) → { orderId, lead }— 원자적 ID + 삽입 + 보조 인덱스getLeadByOrderId(orderId) → Lead | nullgetLeadByPhone(phone) → Lead | null— 폴백 조회findPendingLeadByAmountAndTime(amount, start, end) → Lead[]— 전략 3 웹훅 매칭markLeadPaid(orderId, payment) → Lead | nullisTransactionProcessed(sepayId) → boolean— 중복 제거markTransactionProcessed(sepayId) → void
TTL: 7일 보류 / 90일 완료. KV는 ex: 매개변수 사용. Supabase는 expire_at 필드 + pg_cron 일일 삭제 사용.
나중에 공급자 변경 = leads-store.ts 1줄 수정 (API 코드 건드리지 않음).
자세한 근거는 references/leads-kv-schema.md (KV) 또는 references/supabase-leads-schema.md (Supabase)를 참조하세요.
Phase 4 — /api/checkout 라우트 스캐폴딩
templates/api-checkout-app-router.ts (App Router) 또는 templates/api-checkout-pages-router.ts (Pages Router)를 읽고 해당 경로에 복사합니다.
두 템플릿 모두 import { createLead } from '@/lib/leads-store' — 공급자 독립적.
흐름:
클라이언트 POST /api/checkout { name, phone, email, productName, amount }
↓
서버:
1. 유효성 검사 (베트남 전화번호 형식, 이메일 정규식, amount > 0)
2. createLead() → { orderId: "DH000123" }
3. Sepay QR URL 생성: https://qr.sepay.vn/img?acc=X&bank=Y&amount=Z&des=DH000123&template=compact
4. { orderId, amount, bankInfo, content, qrUrl } 반환
↓
클라이언트: QR 모달 표시 또는 /checkout/DH000123으로 리디렉션
Phase 5 — /api/sepay-webhook 라우트 스캐폴딩
templates/api-sepay-webhook-app-router.ts를 읽고 app/api/sepay-webhook/route.ts에 복사합니다.
템플릿은 import ... from '@/lib/leads-store' — 공급자 독립적.
흐름:
Sepay POST /api/sepay-webhook
Authorization: Apikey {SEPAY_WEBHOOK_API_KEY}
본문: SepayWebhookPayload
↓
서버:
1. timingSafeEqual을 통한 인증 확인 — 타이밍 공격 방지
2. 초기 중복 제거: isTransactionProcessed(payload.id) → 200 반환
3. 중복 제거 먼저 기록 — 처리 실패에도 멱등성 보장
4. transferType === 'in' 필터링 (나가는 거래 제외)
5. 다중 전략 매칭:
a. content → order_id (정규식 /DH\d+/)
b. content → phone (정규식 /0\d{9}/) → getLeadByPhone
c. amount + timestamp ±30분 → findPendingLeadByAmountAndTime (길이가 1인 경우만 허용)
6. 부족 결제 거부, 초과 결제 수락
7. markLeadPaid(orderId, payment) → 저장소 업데이트
8. try/catch 루프를 통한 사이드 이펙트 팬아웃 (Promise.all 아님)
9. 항상 200 반환 (내부 오류 포함) — Sepay는 200이 아닌 경우 Fibonacci 방식으로 최대 7회 재시도
biz-email-setup + biz-telegram-payment-notify를 위한 사이드 이펙트 플레이스홀더는 나중에 연결됩니다.
중복 제거 근거 및 재시도 동작은 references/sepay-webhook-flow.md를 참조하세요.
Phase 6 — VietQR을 랜딩페이지에 삽입
3가지 UX 패턴, 사용자에게 1개 선택 요청:
| 패턴 | 사용 시기 | UX |
|---|---|---|
| A. 모달 팝업 | 단일 제품 / 충동 구매 | 양식 제출 → 원래 페이지에서 QR 모달 팝업 |
| B. /checkout/[orderId] 페이지 | 다중 제품 / URL 공유 필요 | 양식 제출 → /checkout/DH000123으로 이동하여 QR + 상태 표시 |
| C. QR 인라인 + 복사 내용 | 최소 설정 / 기술 사용자 | 양식 바로 아래 QR + 내용 복사 버튼, 자동 새로고침 없음 |
스킬이 선택한 패턴에 따라 새 파일을 스캐폴딩하거나 기존 파일을 수정합니다. 참조 템플릿 참조: templates/qr-modal.tsx (A), templates/checkout-page.tsx (B), templates/qr-inline.tsx (C).
Phase 7 — 5단계 테스트 계획
레벨 1: 저장소 테스트
node -e "
const { createLead, getLeadByOrderId } = require('./lib/leads-store');
createLead({ name: 'Test', phone: '0987654321', email: '[email protected]', productName: 'Test', amount: 100000 })
.then(r => console.log('생성됨:', r.orderId))
.then(() => getLeadByOrderId('DH000001'))
.then(l => console.log('조회됨:', l?.name))
.catch(e => console.error('실패:', e.message));
"
예상: 생성됨: DH000001 + 조회됨: Test.
레벨 2: 체크아웃 API 테스트
curl -X POST http://localhost:3000/api/checkout \
-H 'Content-Type: application/json' \
-d '{"name":"Test UX","phone":"0909123456","email":"[email protected]","productName":"UX Pro Max","amount":299000}'
예상: {"orderId":"DH000002","amount":299000,"bankInfo":{...},"content":"DH000002","qrUrl":"https://qr.sepay.vn/img?..."}.
레벨 3: 웹훅 시뮬레이션
Sepay 대시보드 → Webhook Logs → Send test 또는 curl로 시뮬레이션:
curl -X POST http://localhost:3000/api/sepay-webhook \
-H 'Content-Type: application/json' \
-H 'Authorization: Apikey sk_test_xxx' \
-d '{"id":"test_001","transferType":"in","accountNumber":"1023456789","amount":299000,"content":"DH000002","transactionDate":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}'
확인: getLeadByOrderId("DH000002") → payment.paid = true, payment.sepayId = "test_001".
중복 제거 확인: 동일한 페이로드로 다시 보내기 → success: true이지만 중복 플래그.
레벨 4: 프로덕션 체크리스트
-
NODE_ENV=production환경에 배포됨 -
SEPAY_WEBHOOK_API_KEY가 프로덕션 env vars에 설정됨 - Sepay 대시보드의 웹훅 URL이 프로덕션 URL을 가리킴
- (Supabase 전용)
KEEP_ALIVE_CRON활성화됨 (GitHub Actions 또는 cron-job.org) - SSL/HTTPS 활성화됨 (Vercel 기본값)
-
/api/sepay-webhook및/api/health가 200 반환
레벨 5: (Supabase 전용) Cron 핑 확인
curl -s -o /dev/null -w "%{http_code}" https://yourdomain.com/api/health
예상: 200.
GitHub Actions 워크플로 로그 확인: 성공적인 실행 확인.
체크리스트 (완료 시 사용자에게 제공)
- Sepay 계정 등록 + 은행 연결됨
- 환경 변수 설정됨 (SEPAY_*, 공급자 *)
-
lib/leads-store.ts+ 구현 스캐폴딩됨 -
/api/checkout활성화됨 -
/api/sepay-webhook활성화됨 - VietQR이 랜딩페이지에 삽입됨
- 저장소 테스트 통과
- 체크아웃 API 테스트 통과
- 웹훅 시뮬레이션 통과
- 프로덕션 체크리스트 완료
- (선택 사항)
biz-email-setup연결 - (선택 사항)
biz-telegram-payment-notify연결 - (선택 사항)
/api/admin/leads보안 + UI 추가 (프로젝트에 따라 다름)
참고 자료
| 파일 | 목적 |
|---|---|
references/sepay-account-setup.md | Sepay 계정 설정 전체 가이드 |
references/supabase-setup.md | Supabase 프로젝트 설정 가이드 |
references/leads-kv-schema.md | KV 데이터 구조 및 TTL 근거 |
references/supabase-leads-schema.md | Supabase 테이블 구조 및 인덱스 |
references/sepay-webhook-flow.md | 웹훅 처리 파이프라인 및 중복 제거 |
templates/supabase-migration.sql | Supabase 4개 테이블 + pg_cron SQL |
templates/api-health-route.ts | Supabase 일시 중지 방지 핑 |
templates/api-checkout-app-router.ts | App Router 체크아웃 API |
templates/api-checkout-pages-router.ts | Pages Router 체크아웃 API |
templates/api-sepay-webhook-app-router.ts | App Router 웹훅 API |
templates/lib-leads-store-kv.ts | KV 스토어 진입점 |
templates/lib-leads-store-supabase.ts | Supabase 스토어 진입점 |
templates/lib-leads-kv.ts | KV 구현 |
templates/lib-leads-supabase.ts | Supabase 구현 |
templates/lib-supabase-admin.ts | Supabase 관리자 클라이언트 |
templates/qr-modal.tsx | 패턴 A: 모달 팝업 |
templates/checkout-page.tsx | 패턴 B: 체크아웃 페이지 |
templates/qr-inline.tsx | 패턴 C: 인라인 QR |