무료 플랜 어뷰징이 무서워서 방어 코드부터 짰다
2026년 4월 · 귀찮은개발자 EP.07
2026년 4월 · 귀찮은개발자 EP.07
SaaS를 만들면 두 가지 공포가 있다. 아무도 안 쓰는 거. 그리고 무료로만 쓰는 거. 첫 번째는 마케팅 문제고, 두 번째는 설계 문제다. FeedMission을 만들면서 두 번째를 먼저 해결하기로 했다. 무료 유저가 몰려서 AI API 비용만 나가는 상황이 오면, 서비스를 유지할 수가 없다.
결제 시스템을 넣고, 플랜을 설계하고, 한도를 관리하는 코드를 짰다. 그리고 가격을 4시간 동안 3번 바꿨다. 이 글은 그 과정의 기록이다.
– FREE (50건/월, AI 없음) / STARTER $9 (200건, AI 포함) / PRO $19 (무제한)
– LemonSqueezy 결제 연동 — HMAC-SHA256 웹훅 검증, 4가지 구독 이벤트 처리
– 월간 피드백 한도 초과 시 위젯이 자동으로 숨겨짐 (API 사전 체크)
– FREE→유료: 체크아웃 URL 리다이렉트 / 유료→유료: LemonSqueezy API로 variant 변경
– 가격을 원화 → 달러로 피벗 (글로벌 서비스에는 달러가 맞았다)
– 취소/만료 시 FREE로 자동 다운그레이드 — 기존 데이터는 유지, 새 분석만 차단
플랜을 어떻게 나눌 것인가
Canny, Nolt, Fider의 플랜을 비교하면서 정했다. 핵심은 “무료에서도 쓸 만해야 하고, 유료에서는 확실히 다른 가치가 있어야 한다”는 거다. 무료 플랜이 너무 제한적이면 아무도 안 쓰고, 너무 넉넉하면 유료 전환이 안 일어난다.
투표, 로드맵, 체인지로그는 무료에서도 쓸 수 있다. 이게 핵심 기능이니까. AI 관련 기능이 유료 전환 포인트다. 피드백이 쌓이면서 “수동으로 분류하기 귀찮다”는 순간이 오면, 그때 STARTER로 올리면 된다.
FREE: { maxProjects: 1, maxFeedbackPerMonth: 50,
aiClustering: false, emailNotifications: false }
STARTER: { maxProjects: 3, maxFeedbackPerMonth: 200,
aiClustering: true, emailNotifications: true }
PRO: { maxProjects: Infinity, maxFeedbackPerMonth: Infinity,
aiClustering: true, insights: true, dailyDigest: true }
가격을 4시간에 3번 바꿨다
처음에 원화로 시작했다. STARTER 9,000원, PRO 19,000원. 근데 FeedMission은 글로벌 서비스다. 위젯이 6개 플랫폼을 지원하고, 다국어(ko/en)가 들어가 있다. 원화 가격은 맞지 않았다.
4분 만에 달러로 피벗했다. STARTER $9, PRO $19. 그리고 30분 뒤에 플랜별 기능 매트릭스를 다시 정리했다. 어떤 기능을 어느 플랜에서 열지. 이 결정이 코드에 직접 반영되니까, 한번 정하면 여러 파일을 고쳐야 한다. 그래서 plan-limits.ts 한 파일에 모든 플랜 정의를 넣고, 나머지 코드는 이 파일만 참조하게 만들었다.
왜 Stripe가 아니라 LemonSqueezy인가
결제를 넣으려면 Stripe가 먼저 떠오른다. 근데 한국 사업자는 Stripe를 직접 쓸 수 없다. 2026년 4월 기준, Stripe는 46개국에서 사업자 등록을 지원하는데 한국은 목록에 없다. 한국 금융 규제상 PG(결제대행) 사업을 하려면 금융당국 등록이 필요하고, Stripe가 한국 지사를 만들거나 규제를 통과한 적이 없다. 2025년 10월에 “Stripe가 한국 결제를 지원한다”는 소식이 돌았는데, 이건 해외 사업자가 한국 고객의 원화 결제를 받을 수 있게 된 거지, 한국 사업자가 Stripe 계정을 만들 수 있게 된 건 아니다.
우회 방법은 있다. Stripe Atlas로 미국 법인을 만들거나 싱가포르 법인을 통해 계정을 개설하는 거다. 근데 인디 개발자가 법인 설립까지 하고 싶지는 않았다.
LemonSqueezy는 MoR(Merchant of Record) 모델이다. 쉽게 말하면 LemonSqueezy가 “법적 판매자” 역할을 대신한다. 고객이 결제하면 LemonSqueezy가 돈을 받고, 100개국 이상의 부가세(VAT)·판매세를 대신 처리하고, 수수료를 떼고 나한테 송금해준다. 한국 사업자도 가입할 수 있고, 세금 신고를 별도로 안 해도 된다(매출세 한정 — 소득세는 직접 신고해야 한다). 2024년에 Stripe가 LemonSqueezy를 인수했는데, 서비스는 그대로 운영되고 있다.
수수료는 거래당 5% + $0.50이다. Stripe(2.9% + $0.30)보다 비싸다. 근데 해외 법인 설립 비용이나 세금 처리 리소스를 따지면, 매출이 연 1.5억 원 이하일 때는 MoR이 더 합리적이라는 계산이 나온다. FeedMission은 아직 그 규모가 아니니까 LemonSqueezy가 맞았다.
Stripe — 한국 사업자 직접 가입 불가. 해외 법인(Atlas 등) 필요.
LemonSqueezy — MoR 모델. 한국 사업자 가입 가능. 세금 대행. 5% + $0.50.
Paddle — MoR 모델. LemonSqueezy와 비슷. 수수료 구조 단순.
매출 연 1.5억 이하면 MoR이 합리적. 그 이상이면 해외 법인 + Stripe 전환점.
LemonSqueezy 웹훅 — 결제 이벤트를 받는 방법
사용자가 결제를 완료하면 LemonSqueezy가 내 서버로 웹훅을 보낸다. “이 사용자가 STARTER 플랜을 구독했어”라는 이벤트다. 이걸 받아서 DB에 반영한다.
subscription_created → 구독 생성 (upsert)
subscription_updated → 플랜 변경 반영
subscription_cancelled → 취소 → FREE로 다운그레이드
subscription_expired → 만료 → FREE로 다운그레이드
구독 생성과 업데이트는 Prisma의 upsert로 처리한다. 없으면 만들고, 있으면 업데이트한다. 취소와 만료 시에는 사용자 플랜을 FREE로 바꾼다. 기존 데이터는 그대로 둔다. 피드백, 클러스터, 로드맵 전부 남아 있다. 새로운 AI 분석만 차단된다.
웹훅에서 중요한 게 멱등성이다. 같은 이벤트가 여러 번 올 수 있다. LemonSqueezy가 내 서버의 응답을 못 받으면 재전송하니까. 기존 lsSubscriptionId와 이벤트의 ID를 비교해서 이미 처리된 이벤트는 무시한다.
어떤 플랜을 구독했는지는 variant ID로 판단한다. LemonSqueezy에서 상품을 만들 때 각 플랜이 variant가 된다. PRO variant ID와 일치하면 PRO, 아니면 STARTER. 간단한 로직이지만 환경변수로 관리해서 하드코딩은 피했다.
한도 초과하면 위젯이 사라진다
FREE 플랜은 월 50건이다. 50건을 넘으면 어떻게 될까? 두 가지를 동시에 막아야 한다. API에서 429를 반환하는 것, 그리고 위젯 자체를 숨기는 것.
위젯이 화면에 보이는데 제출하면 에러가 나면, 사용자 경험이 나쁘다. 위젯이 표시되기 전에 한도를 확인해서, 이미 초과했으면 아예 안 보여주는 게 맞다.
const monthStart = new Date()
monthStart.setDate(1) // 매월 1일 기준
monthStart.setHours(0, 0, 0, 0)
const count = await prisma.feedback.count({
where: { projectId, createdAt: { gte: monthStart } }
})
return { available: count < limit }
위젯이 로드될 때 /api/feedback/status를 호출한다. available: false가 오면 위젯을 숨긴다. 사용자는 왜 위젯이 안 보이는지 모르는데, 프로젝트 소유자한테는 대시보드에서 “이번 달 한도를 초과했습니다” 안내가 나온다. iOS SDK에서도 같은 체크를 넣었다. 한도 초과 시 SwiftUI에서 위젯을 숨긴다.
플랜 업그레이드와 다운그레이드
두 가지 경로가 있다. FREE에서 유료로 가는 것과, 유료에서 다른 유료로 바꾸는 것.
return { action: ‘checkout’, url: checkoutUrl }
// 유료 → 유료: LemonSqueezy API로 variant 변경
await fetch(`https://api.lemonsqueezy.com/v1/subscriptions/${id}`, {
method: ‘PATCH’,
body: { data: { attributes: { variant_id: newVariant } } }
})
FREE에서 유료로 갈 때는 LemonSqueezy 체크아웃 페이지로 리다이렉트한다. EP.06에서 만든 서버사이드 리다이렉트를 쓴다. 유료에서 유료로 바꿀 때는 LemonSqueezy API를 직접 호출해서 variant를 변경한다. STARTER에서 PRO로 올리면 프로레이팅(일할 계산)이 자동으로 적용된다.
API 호출이 성공하면 즉시 DB를 업데이트한다. 웹훅이 도착하기 전에 사용자가 바로 새 플랜의 기능을 쓸 수 있게. 웹훅은 이후에 도착해서 상태를 한 번 더 동기화한다.
CORS 에러 응답에도 헤더가 필요하다
위젯은 다른 도메인에서 내 API를 호출한다. CORS가 필수다. 여기서 놓치기 쉬운 게 있다. 200 응답뿐 아니라 에러 응답(400, 429, 500)에도 CORS 헤더가 있어야 한다. 안 넣으면 브라우저가 에러 메시지 자체를 차단해서, 위젯에서 “왜 안 되는지” 모르게 된다.
const corsHeaders = {
‘Access-Control-Allow-Origin’: ‘*’,
‘Access-Control-Allow-Methods’: ‘POST, OPTIONS’,
‘Access-Control-Allow-Headers’: ‘Content-Type’,
}
// 429 한도 초과에도 CORS 헤더 필수
return NextResponse.json(
{ error: ‘Monthly limit exceeded’ },
{ status: 429, headers: corsHeaders }
)
다운그레이드하면 데이터가 사라지나?
안 사라진다. PRO에서 FREE로 내려가도 피드백, 클러스터, 로드맵, 체인지로그 전부 남아 있다. 읽기만 된다. 새로운 AI 분석(클러스터링, 인사이트)만 차단된다. checkPlanFeature() 함수가 API 레벨에서 기능별로 플랜을 확인한다. 기능이 비활성이면 403을 반환한다.
이게 중요한 이유가 있다. 다운그레이드했을 때 데이터를 지워버리면 사용자가 다시 올릴 이유가 없어진다. 데이터가 남아 있으면 “다시 AI 분석을 쓰고 싶다”는 동기가 생긴다.