작동하지 않는 요금제를 판매했다
€4.90/월 요금제를 공개했다. 연간 옵션은 매달 €58.80를 청구했을 것이고, 요금제는 어떤 권한도 부여하지 않았으며, 워커는 길이 상한을 무시했다. 세 가지 모두 직접 구매해서 찾았다.
2026년 10월 4일
작성: 2026년 10월 4일 · 수정: 2026년 10월 4일
€4.90/월 요금제가 가격 페이지에 공개되었다. 연간 옵션을 고른 사람은 매달 €58.80를 청구받았을 것이다.
아무도 청구받지 않았다.
단 한 명의 고객도 연간 요금제를 구매하기 전에 잡아냈다. 코드를 읽어서가 아니라, 실제 카드로 우리가 직접 그것을 구매해서였다.
그 점이 중요하다. 빌드는 초록색이었다. TypeScript도 만족했다. API는 200을 돌려줬다. 그 어느 것도 제품이 실제로 작동한다는 증명은 아니었다.
그날 아침 세 가지 결함을 찾았다. 우리의 첫 유료 Solo 고객은 그 세 수정 중 마지막 것이 배포된 이십칠 분 뒤에 구독했다. 반 시간만 반대로 틀어졌다면, 아무 권한도 주지 않는 요금제에 돈을 낸 사람은 그였다.
결함 1: 가격은 맞았고, 결제 주기가 틀렸다
새 입문 요금제 Solo는 연간 결제 시 €4.90/월, 월 단위로는 €8.90이다. €4.90 × 12 = €58.80, 1년에 한 번.
연간 옵션 뒤에 있던 Stripe 가격 객체는 결제 주기가 '월'로 생성되어 있었다.
즉 Stripe는 매달 €58.80 — 의도한 가격의 열두 배를 청구할 준비가 되어 있었다.
가격 페이지는 옳았다. 코드도 올바른 가격 ID를 쓰고 있었다. 결함은 저장소 밖, 대시보드에서 만들어진 Stripe 객체 안에 살고 있었다.
바로 그래서 놓치기 쉬웠다.
Stripe는 기존 가격의 결제 주기를 그 자리에서 바꾸게 해주지 않는다. 새 가격을 만들고 ID를 교체해야 한다. 그래서 결제 세션을 만들기 전에 검사를 하나 넣었다.
const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);
if (price.recurring?.interval !== expected) {
throw new Error("PRICE_INTERVAL_MISMATCH");
}
결제당 API 호출 한 번.
Stripe와 가격 페이지가 서로 다른 말을 하면, 우리는 판매를 거부한다.
잘못된 Stripe 객체는 여전히 존재할 수 있다. 다만 눈에 띄지 않게 결제를 통과할 수는 없다.
결함 2: 요금제는 상업적으로 존재했고, 기능적으로는 없었다
두 번째 결함은 반대 방향으로 실패했다. 고객이 돈을 내고도 무료로 취급될 수 있었다.
요금제를 하나 추가하는 일은 상업적 변경처럼 느껴진다. 실제로는 제품에서 "이 계정은 무엇을 할 수 있는가?"를 묻는 모든 지점을 건드린다.
우리 코드베이스에서 그 질문은 29개 파일에서 답해지고 있었다.
그 검사들은 내보내기, 업로드, 처리 큐, API 엔드포인트, 결제, 대시보드에 걸쳐 오랜 시간에 걸쳐 쌓인 것이었다. 대부분은 그 코드를 추가할 당시 존재했던 요금제에 맞춰 쓰인 문자열 비교였다.
Solo는 새것이었다.
그래서 pro인가, creator인가, studio인가? 같은 검사는 그대로 기본 분기로 떨어졌다. 그리고 기본 분기는 무료였다.
돈을 낸 Solo 계정이 무료 할당량, 무료 업로드 제한, 워터마크, 그 밖의 무료 등급 제약을 받을 수 있었다는 뜻이다.
그 기본값 뒤의 직관은 어리석지 않다. 보안에서는 '닫히는 방향으로 실패하기'가 보통 원하는 바다.
그러나 결제에서는 다른 종류의 고장을 만든다. 고객은 이미 돈을 냈고, 가장 안전한 대비책이 바로 그가 산 것을 주지 않는 장치가 되어버린다.
수정은 "solo"를 29개 조건문에 더하는 것이 아니었다. 요금제의 동작을 기술하는 곳을 한 군데로 대체했다.
export type Plan = "free" | "solo" | "pro" | "creator" | "studio";
export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null
위 이름들은 글을 위해 단순화했지만, 구조는 실제로 배포한 그대로다.
그 파일 맨 위에는 이제 이런 규칙이 적혀 있다. 요금제는 여기에 있기 전까지 존재하지 않는다.
수정 중에 배울 만한 '하마터면'이 두 번 있었다.
첫째, 그 하나의 isPaid() 검사를 기능별 관문까지 포함해 모든 관문에 쓸 뻔했다. 그랬다면 Solo 고객에게 그들이 지불하지 않은 것들 — AI 더빙과 게시 예약 — 을 주었을 것이다. '유료'와 '이 기능에 대한 권한이 있음'은 같은 질문이 아니다.
둘째, 예약 기능에 요금제 검사가 전혀 없다고 잠시 믿었다. 해당 라우트에서 "plan"을 검색해도 아무것도 나오지 않았기 때문이다. 관문은 공용 헬퍼 안, 호출 한 번 거리에 있었다.
빈손으로 돌아온 grep은 답이 아니다.
결함 3: 광고했지만 한 번도 집행하지 않은 상한
가격 페이지는 Solo가 최대 60분 길이의 원본 영상을 받는다고 말한다. 영상을 내려받아 처리하는 워커가 아는 숫자는 둘뿐이었다.
- 무료 계정 30분
- 유료 계정 90분
Solo는 유료다. 그래서 Solo는 90을 받았다.
이 결함은 반대 방향으로 새고 있었다. 누구도 과다 청구되지 않았고 누구도 접근을 잃지 않았다. 다만 요금제에 포함되어야 할 양보다 많은 연산을 공짜로 주고 있었다.
영상 제품에서는 이것이 중요하다. 원본 길이는 전사, 렌더링, 대역폭 비용과 가장 직접적으로 연결된 요소 중 하나다.
워커는 이제 애플리케이션과 같은 상한을 지닌다.
def _max_minutes(plan: str | None) -> int:
"""Mirror of the plan limits used by the app.
What the pricing page announces and what the worker enforces
must be the same number.
A cap announced without being applied is margin walking out;
applied without being announced is a trap.
"""
가격 페이지에만 존재하는 상한은 상한이 아니다. 페이지에 적힌 한 문장일 뿐이다.
실제로 이 버그들을 찾아낸 것
타입 체커가 아니다. 테스트 스위트가 아니다. 코드 리뷰가 아니다.
찾아낸 것은, 사이트를 열고 한 번도 본 적 없는 사람처럼 굴며, 가장 싼 선택지를 누르고, 진짜 카드를 넣는 일이었다.
결제 불일치는 Stripe 자신의 결제 요약에 나타났다. 빠진 권한은 유료 계정이 여전히 무료처럼 행동할 때 나타났다. 길이 문제는 워커에게 "실제로 어떤 상한을 적용할 것인가"를 물었을 때 나타났다.
우리는 같은 교훈을 계속 다시 배운다. 통과하는 빌드는 제품이 작동한다는 증명이 아니다.
모든 버그는, 각각은 지시받은 대로 정확히 일하고 있던 두 시스템 사이의 틈에 살고 있었다.
- 가격 페이지와 Stripe
- 요금제의 이름과 그것이 실제로 주는 권한
- 애플리케이션과 워커
이런 종류의 버그를 찾는 값싼 방법은 놀랍도록 지루하다. 돈을 내는 사람이 쓸 방식대로 제품을 써보는 것이다.
기여 분석에 관한 후기
우리의 첫 Solo 구독자는 같은 날 아침에 나타났다.
한 시간쯤, 우리는 스스로에게 예쁜 이야기를 들려줬다. Solo가 막 출시됐고, 누군가 혼자 그것을 찾아냈고, 무료 한도에 부딪혀 전환했다고.
이벤트 로그는 덜 깔끔한 이야기를 들려줬다.
그 사람은 ChatGPT를 통해 Katto를 찾았고, 가입했고, 무료 요금제를 많이 썼고, 할당량을 다 썼고, 결제를 여러 번 열었다가 돈을 내지 않고 떠났다.
다음 날 아침 결제 이탈 복구 메일이 나갔다. 몇 시간 뒤 그는 구독했다.
그는 연간 결제 €4.90 대신 월 단위 €8.90을 택했다. 열두 달 약정을 피하려고 매달 82% 더 낸 것이다. 전날 눌렀다가 떠난 창업자 할인은 €6였다.
우리는 또한 원본 영상 60분 상한이 그를 Solo로 밀었다고 가정했다. 아니었다. 그가 반복해서 부딪힌 것은 월 2편이라는 편수 할당량이었다.
그래서 가정 두 개가 한꺼번에 죽었다. 전환은 단순히 자연 유입이 아니었고, 복구 메일이 아마 작용했다. 그리고 가장 강한 업그레이드 압력을 만든 제한은 우리가 생각한 그것이 아니었다.
이것은 위 세 버그와 같은 모양이다. 밖에서 보면 깔끔한 이야기를 들려주기 쉬웠다. 그다음 우리는 실제로 무슨 일이 있었는지 들여다봤다.
Katto는 긴 영상을 세로형 클립으로 바꾼다. 우리는 우리가 측정한 수치를 공개한다. 우리를 나쁘게 보이게 하는 수치까지.