트립픽 개발 기록 · 4/5
MVP의 경계 긋기 — '안 만들 용기'와 한도 시스템(quota)을 전환 설계로 보기
1인 개발 회고 시리즈 4장. 기능을 넣는 것보다 빼는 게 더 어려운 이유, 그리고 비회원·무료·패스로 나뉜 등급별 사용 한도(quota)를 단순한 제한이 아니라 비용 방어와 무료→유료 전환을 동시에 푸는 제품 설계로 보는 이야기. IP 기반 비회원 한도와 회원별 누적 한도를 서버에서 강제하는 구조를 코드로 정리합니다.
이 글은 전자책 「혼자 만든 트립픽」 4장을 기술 블로그 톤으로 다시 풀어 쓴 글입니다. 책은 회고·내러티브 중심, 이 시리즈는 의사결정의 기술적 근거를 더 깊이 다룹니다.
1장에서 트립픽이 푸는 문제를 정의했고,
2장에서 "혼자"라는 제약이 모든 아키텍처를
어떻게 지배했는지, 3장에서 "직접
하기보다 편한가" 를 EventLog로 측정 가능하게 만든 이야기를 했어요.
이번 장의 질문은 칼을 드는 일입니다.
"무엇을 만들지 말까?"
빼는 것이 더 어렵다
MVP를 만들 때 사람들은 보통 "무엇을 넣을까" 를 고민해요. 1인 개발자에게 진짜 어려운 건 정반대였습니다. 무엇을 안 넣을까.
기능을 넣는 건 즐거워요. 아이디어는 끝이 없고, 하나하나가 다 좋아 보입니다. 하지만 1인에게 모든 기능은 영원히 내가 유지보수해야 할 빚이에요. 화면 하나를 추가하면 그 화면의 버그, CS, 엣지 케이스가 평생 따라옵니다. 그래서 트립픽 MVP는 "넣고 싶은 것 목록"이 아니라 "빼도 죽지 않는 것 목록" 에서 시작했어요.
기준은 단순했어요. "날씨에 맞는 제주 코스를 자동으로 만들어준다." 이 한 문장에 직접 기여하지 않는 기능은 1순위에서 전부 뺐습니다. 3장의 나침반 — "직접 하기보다 확실히 편한가?" — 을 그대로 기능 목록에 들이댄 거예요.
"있으면 좋은 것" vs "없으면 안 되는 것"
이 두 가지를 가르는 칼이 MVP의 본질이에요. 코스 생성은 후자, 나머지 대부분은 전자였어요. 전자는 전부 다음으로 미뤘습니다.
| 기능 | 분류 | MVP 결정 |
|---|---|---|
| 날짜·지역 입력 → 코스 자동 생성 | 없으면 안 되는 것 | 남김 |
| 날씨 기반 실내·야외 배치 + 동선 최적화 | 없으면 안 되는 것 | 남김 |
| 코스 저장·수정·공유 | 없으면 안 되는 것 | 남김 |
| 소셜 로그인 + 사용 한도 | 없으면 안 되는 것 | 남김 |
| 자체 회원가입·비밀번호 | 있으면 좋은 것 | 소셜로 대체 |
| 실시간 채팅 상담·커뮤니티·리뷰 | 있으면 좋은 것 | 미룸 |
| 숙소·항공 예약 연동 | 있으면 좋은 것 | 미룸 |
| 복잡한 추천 개인화(ML 학습) | 있으면 좋은 것 | 미룸 |
2장에서 LLM 설명문을 "있으면 좋은 것" 으로 분류해 비용 방어의 토대로 삼았던 것과 같은 칼이에요. 그때는 한 기능 안에서 핵심과 부가를 갈랐다면, 이번엔 제품 전체에 그 칼을 들이댄 거죠.
1인에게 "있으면 좋은 것"은 대부분 함정이에요. 그 좋음의 대가는 평생의 유지보수인데, 그 청구서는 출시한 다음에야 날아오거든요.
한도(quota) 또한 제품의 일부다
가장 신경 쓴 "기능" 중 하나는 의외로 제한이었어요. 비회원 3회, 무료회원 평생 30회, 패스 사용자 하루 300회 같은 등급별 한도.
이건 인색함이 아니에요. 1인 운영에서 사용량은 곧 비용이자 리스크 입니다. 네이버·기상청 같은 외부 API도, 모델 호출도 전부 돈이에요(2장의 킬스위치가 그 '최후의 방어선'이었다면, 한도는 그보다 앞단의 '평상시 방어선'이에요). 동시에 한도는 무료→유료 전환의 자연스러운 길을 깔아주는 제품 설계이기도 합니다.
등급마다 무엇을 세느냐도, 어떻게 리셋되느냐도 일부러 다르게 잡았어요. 이게 이번 장의 기술적 핵심이라 표로 먼저 정리할게요.
| 등급 | 코스 생성 한도 | 저장 | 카운트 방식 | 설계 의도 |
|---|---|---|---|---|
| 비회원 | 3회 (24시간 단위) | — | IP별 누적, 24시간 후 만료 | 가입 유도 |
| 무료회원 | 평생 30회(도달 시 만료) | 3개(평생) | 회원 레코드의 평생 누적 카운터 | 가입 보상 |
| 패스 | 하루 300회 | 100개 | 회원 레코드의 일일 카운터(자정 리셋) | 일회성 결제 보상 |
핵심은 무료 30회가 '매일 충전'이 아니라 '평생 한 번' 이라는 점이에요. 무료회원이 30회를 다 쓰면 그날로 끝이 아니라 영구적으로 새 코스를 못 만들어요(이미 저장한 코스는 계속 볼 수 있고요). 매일 채워지는 한도는 패스(하루 300회)뿐입니다. 이 비대칭이 의도예요 — 무료는 "충분히 써보고 판단하라" 는 평생 체험권이고, 패스는 "여행 기간 동안 마음껏 쓰라" 는 일일 정액권이거든요.
한도를 어디 긋느냐는 "사용자가 가치를 느끼기엔 충분하지만, 내가 파산하진 않을" 지점을 찾는 줄타기예요. 비회원 3회는 "한번 써보면 가치를 안다"는 미끼인 동시에, 로그인 없는 트래픽의 비용 상한이고요.
한도는 반드시 서버에서 강제한다
여기서부터가 기술적으로 중요해요. 한도 설계의 제1원칙은 단순합니다. 클라이언트를 절대 믿지 않는다.
비회원은 IP를 기준으로, 회원은 회원 레코드를 기준으로 카운트를 쌓되, 그 검사는 전부 서버에서 합니다. 프런트의 버튼을 비활성화하는 건 UX일 뿐, 진짜 방어선이 아니에요. 누군가 API를 직접 두들기면 UI 따위는 무의미하니까요.
아래 코드는 개념을 보여주기 위한 의사코드예요. 실제 모델명·필드명·에러 코드와 1:1로 같지는 않고, 등급별 동작의 차이만 정확히 반영했습니다.
// 코스 생성 라우트의 입구 — 한도 게이트는 항상 서버에서 (개념 예시)
async function assertQuota(ctx: RequestContext): Promise<void> {
if (!ctx.userId) {
// 비회원: IP로 식별 (로그인을 안 했으니 userId가 없다)
// - IP별 단일 레코드에 24시간 만료시각(expiresAt)을 둔다
// - 24시간이 지나면 그 레코드는 만료 → 다시 0부터
const used = await getGuestCount(ctx.ip); // 만료분은 0으로 취급
if (used >= GUEST_TOTAL_LIMIT) { // 24시간당 3회
throw new GuestLimitError(); // → "가입하면 1일 패스 무료"
}
return;
}
const user = await getUser(ctx.userId);
if (user.hasActivePass) {
// 패스 보유: '일일' 카운터. 마지막 생성일이 오늘이 아니면 0으로 리셋된 셈.
const today = todayKST();
const usedToday = user.lastGenDate === today ? user.dailyGenerations : 0;
if (usedToday >= PASS_DAILY_LIMIT) { // 하루 300회
throw new PassDailyLimitError(); // → "내일 자정에 다시 충전"
}
return;
}
// 무료회원: '평생' 누적 카운터. 리셋이 없다 — 도달하면 영구 만료.
if (user.freeGenerationsUsed >= FREE_LIFETIME_LIMIT) { // 평생 30회
throw new FreeQuotaExhaustedError(); // → "이제 패스가 필요해요"
}
}비회원을 IP로 식별하는 건 완벽하진 않아요(공유 IP, IP 변경 등). 하지만 "가입을 유도할 만큼의 마찰" 이 목적이지 "완벽한 차단" 이 목적이 아니에요. 진짜로 막아야 할 폭주는 2장의 비용 킬스위치가 잡습니다. 한도와 킬스위치는 역할이 다른 두 겹의 방어막이에요.
리셋 방식도 등급마다 일부러 다르게 잡았어요.
- 비회원: KST 자정이 아니라 IP별 레코드에 24시간 만료시각을 박아두는 방식이에요. 만료된 레코드는 조회할 때 정리(lazy cleanup)하고, 24시간이 지나면 같은 IP라도 다시 0부터 시작해요. 정해진 시각이 아니라 "마지막 사용으로부터 24시간" 굴러가는(rolling) 창입니다.
- 무료회원: 리셋이 아예 없어요. 평생 누적 카운터라, 30회를 다 쓰면 그걸로 끝이에요.
- 패스: 회원 레코드의 일일 카운터에 마지막 생성 날짜를 같이 저장해 두고, 요청이 들어오면 그 날짜가 오늘인지만 봐요. 어제면 카운트를 0으로 쳐서 사실상 자정에 리셋된 것처럼 동작합니다.
세 방식 모두 별도의 리셋 배치(cron)에 의존하지 않는다는 게 공통점이에요 — 비회원은 만료시각으로, 패스는 '오늘 날짜인가' 비교로 알아서 갈리니까요. 돌아가는 부품 하나를 줄이는 게 1인 운영에서 얼마나 소중한지는 굳이 설명 안 할게요. (만료 레코드 정리 같은 가벼운 청소만 따로 돌립니다.)
표의 저장 한도(무료 3개·패스 100개)도 같은 서버 강제 원칙을 따라요. 다만 저장은 일일 생성처럼 매일 도는 게 아니라 누적 상한이라, 위 게이트와는 검사하는 지점만 다를 뿐 "클라이언트를 믿지 않는다"는 원칙은 똑같이 적용됩니다.
// 생성에 성공한 뒤에야 카운트를 올린다 (실패한 요청은 한도를 깎지 않는다) — 개념 예시
async function recordUsage(ctx: RequestContext): Promise<void> {
if (!ctx.userId) {
// 비회원: IP별 단일 레코드를 upsert. 없거나 만료됐으면 24시간 TTL로 새로 만들고,
// 살아있으면 count를 +1 한다 (요청마다 새 row를 만드는 게 아니다).
await upsertGuestCount(ctx.ip, { ttlHours: 24 });
return;
}
const user = await getUser(ctx.userId);
if (user.hasActivePass) {
// 패스: 오늘 날짜를 같이 기록하며 일일 카운터 +1 (날짜가 바뀌면 1부터)
await bumpDailyCounter(ctx.userId, todayKST());
return;
}
// 무료: 평생 카운터 +1 (원자적 증가, 줄어드는 일은 없다)
await incrementLifetimeCounter(ctx.userId);
}한도는 "사용자를 막는 벽"이 아니라 "등급 사이의 계단" 이에요. 3회를 다 쓴 비회원에게 보여줄 화면은 에러가 아니라 "가입하면 1일 패스를 무료로 드려요" 라는 다음 계단이어야 합니다. 같은 '한도 초과'라도, 어느 등급에서 터졌느냐에 따라 사용자에게 보여줄 전환 제안이 달라지는 이유예요.
한도와 전환을 연결하다
3장에서 EventLog로 전환 깔때기를 측정한 이야기를 했죠. 한도
시스템은 그 깔때기의 계단 그 자체예요. 비회원이 3회를 다 쓴
순간, 무료회원이 평생 30회 벽에 닿은 순간 — 이게 바로 다음 등급으로
올라설지 결정하는 전환 지점입니다.
그래서 한도 초과는 단순히 막는 데서 끝나지 않고, 측정 대상이 돼요. 실제로는 등급별로 응답 코드가 따로 갈려요(비회원·무료·패스·쓰로틀이 각각 다른 코드로 내려가요). 아래는 그걸 하나로 단순화한 개념 예시예요.
// 한도에 닿은 순간 = 전환을 권할 결정적 타이밍. 그래서 기록한다. (개념 예시)
catch (e) {
if (e instanceof QuotaError) {
await logEvent("quota_hit", { tier: e.tier }); // guest | free | pass
return upsellResponse(e.tier); // guest→가입(+1일 패스), free→패스 안내
}
}quota_hit이 guest에서 많이 찍히는데 가입 전환은 낮다면, 미끼(3회)가
약하거나 가입 보상(1일 패스)이 매력적이지 않다는 신호예요. 추상적인
"전환이 안 돼요"가 어느 계단에서 미끄러지는지까지 보이는 숫자가
됩니다. 한도는 비용 방어 장치이면서, 동시에 전환 측정의 눈금자예요.
결제 진입장벽 낮추기 — 가격·기간 쪼개기
가격에서도 같은 고민을 했어요. 처음엔 비회원·무료에게 최대한 혜택을 주려 했어요. 그런데 한 문장이 생각을 바꿨습니다. "고객은 실제로 돈을 냈을 때 비로소 가장 현실적이고 냉정한 평가를 들려준다."
무료 사용자의 "좋아요"는 시장이 아니에요(3장에서 payRate를 최종
판정선으로 둔 이유와 같아요). 지갑을 연 사람의 반응이 진짜죠.
그래서 방향을 틀었어요. 혜택을 늘리는 대신, 결제 진입장벽을
낮추는 쪽으로.
| 패스 | 가격 | 핵심 |
|---|---|---|
| 1일 | ₩900 | "커피 한 잔보다 싸게 일단 질러보기" |
| 3일 | ₩1,900 | 주말 여행 단위 |
| 7일 | ₩2,900 | 한 주짜리 여행 단위 |
패스를 짧게 쪼개고(1·3·7일), 가격도 부담 없게(900·1,900·2,900원), 자동갱신은 없게(미사용 시 7일 내 환불) 했어요. 월 구독이었다면 단기 여행자에겐 부담이었을 거예요. "여행 한 번에 커피 한 잔 값" 이면 한번 질러보고 진짜 평가를 들려줍니다. 가격을 쪼개는 건 매출을 포기하는 게 아니라, 진짜 피드백을 더 싸게 사는 일이었어요.
여기서는 가격을 쪼갠 결정의 동기까지만 다룰게요. 결제 모델 자체를 어떻게 설계하고 구현했는지는 뒤의 결제 편(11장 예정)에서 따로 풀어요.
"나중에"라고 적힌 코드
MVP 경계를 긋는다는 건, 안 한 것들을 어딘가에 적어두는 일이기도
했어요. 코드 곳곳에 // TODO: 런칭 후, // P2: 장기 개선 표시를
남겼습니다. Toss 결제 웹훅 보강, 일부 라우트 리팩토링, 테스트 코드 —
"지금은 아니지만, 잊지는 않는다" 의 표식이에요.
예를 들면 한도 시스템도 의도적으로 v1만 만들고, 나머지는 이런 식의 가정형 메모로 남길 만했어요(실제 주석을 그대로 옮긴 건 아니고, 결정의 모양을 보여주는 예시예요).
// quota 시스템도 의도적으로 v1만 만든다. 나머지는 '표식'으로 남길 만한 것들 — 예시
// TODO(런칭 후): 비회원 식별을 IP → IP+디바이스 핑거프린트로 강화해볼 것
// P2(장기): 패스 등급 세분화 + 시간대별 동적 한도 검토
// 지금은 단순한 게 옳다. "잊지 않기" 위해 적어둘 뿐, 지금 만들진 않는다.이 표식이 중요한 이유는, 경계를 긋는 죄책감을 덜어주기 때문이에요. 안 하는 게 아니라 미루는 것이라고 스스로에게 약속하면, 오늘 자를 결정이 가벼워집니다. 'TODO'는 게으름의 흔적이 아니라, 집중을 위해 의도적으로 미룬 것들의 명단이에요.
경계는 "이걸 영영 안 한다"가 아니라 "이걸 지금은 안 한다" 예요. 그 둘의 차이가 1인 개발자의 마음을 지킵니다. 자르되, 명단에 적어두세요. 그게 'TODO'의 진짜 쓸모예요.
다음 장 미리보기
다음 장(5장 "기술 스택 선택의 진짜 기준")에서는, 이렇게 그은 MVP의 경계 안에서 어떤 도구로 그걸 짓기로 했는지, 그 선택의 기준을 다뤄요. Next.js 모놀리식, Prisma + MySQL, AWS Amplify, NextAuth 소셜 로그인 — 각각이 "더 좋아서" 가 아니라 "혼자 끝까지 감당할 수 있어서" 선택된 과정을, 이번 장의 한도 시스템처럼 결정의 기준에 초점을 맞춰 풀어볼게요. (스택 디테일은 5~8장에 걸쳐, Toss 결제 설계 자체는 11장에 따로 다룹니다.)
💭 그때의 나에게: 너는 좋은 아이디어가 떠오를 때마다 "이것도 넣자"고 했지. 근데 1인한테 모든 기능은 평생 갚을 빚이야. 안 만들 용기가 만들 능력보다 귀했어. 그리고 한도(quota)를 짤 때 인색하다고 미안해하지 마 — 그건 너를 파산에서 지키는 동시에, 무료 사용자를 진짜 고객으로 바꾸는 계단이었어. 자르되, 잊지는 마. 그게 'TODO'의 진짜 쓸모야.