Hono + Cloudflare Workers + D1로 라이선스 서버 만들기: Lemon Squeezy 웹훅 완전 가이드
사이드 프로젝트에 Pro 라이선스를 붙이려면 서버가 필요합니다. Cloudflare Workers + Hono + D1로 주말에 구축한 라이선스 서버, 그리고 Lemon Squeezy 웹훅 이벤트 순서 함정으로 하루를 날린 이야기를 공유합니다.
1. 문제 상황: 크롬 확장에 Pro 라이선스를 붙이려면
사이드 프로젝트로 운영 중인 크롬 확장에 Pro 티어를 붙이기로 했습니다. 요구 사항을 정리하면 다음과 같았습니다.
- 결제는 외부 프로세서에 위임: Stripe는 한국 계정 개설이 까다로워서 Lemon Squeezy(Merchant of Record 서비스)로 결정
- 라이선스 키 검증: 구매자에게 키를 발급하고, 확장이 그 키를 서버에 보내 유효성 확인
- 1기기 제한: 한 라이선스는 한 기기에서만 활성
- 재활성화 허용: 재설치·PC 교체 시 기존 기기에서 자동 전환 가능
- 저비용 + 저유지보수: 월간 결제 몇 건 정도의 작은 규모에 맞는 구조
1.1 클라이언트-only로는 안 되는 이유
순간적으로 "그냥 클라이언트에서 라이선스 키 형식만 검증하면 안 될까?" 라는 유혹이 듭니다. 결론부터 말하면 안 됩니다.
- 기기 1대 제한을 강제하려면 누가 이미 활성화했는지 아는 중앙 저장소가 필요합니다
- Lemon Squeezy의 웹훅을 받아 DB에 기록하려면 HTTPS 엔드포인트가 필요합니다
- 재활성화를 허용하려면 "이 라이선스의 현재 주인이 누구인가"를 업데이트할 수 있는 서버 로직이 필요합니다
즉, 최소한의 서버는 불가피합니다. 문제는 "최소한의 서버"를 어디에 올릴 것인가입니다.
1.2 대안 후보와 탈락 이유
| 후보 | 탈락 이유 |
|---|---|
| Supabase | Postgres + Realtime + Auth + Storage — 라이선스 3개 테이블용으로는 과함 |
| Firebase | 벤더락인, 프라이싱이 사용량에 따라 튀기 쉬움 |
| Railway/Render + Node.js | 풀 서버, 유지보수 부담, 콜드 스타트 |
| AWS Lambda | 설정 복잡도, CORS/API Gateway 번거로움 |
| Vercel Functions | 가능하지만 DB를 별도로 구해야 함 |
| Cloudflare Workers + D1 | 선택 |
최종 결정은 Cloudflare Workers + Hono + D1. 이유를 정리합니다.
- Workers: 무료 티어 100K req/day, 글로벌 엣지, 콜드 스타트가 거의 없음
- Hono: Express/Koa 스타일의 경량 프레임워크, Workers/Bun/Node.js/Deno 어디서나 동작, TypeScript 친화
- D1: SQLite at edge. 무료 티어로 5GB/5억 rows, SQL 그대로 쓸 수 있음
- Lemon Squeezy: Merchant of Record라 세금·부가세 처리를 대신해 줌. Stripe 대체로 많이 쓰임
전부 하나의 Cloudflare 계정에서 관리되고, 서버 한 대 띄우는 것보다 설정이 적습니다.
2. 원인 분석: Cloudflare Workers + Hono가 왜 이 작업에 맞나
Workers의 런타임 특성을 이해하면 왜 라이선스 서버라는 사이즈의 작업에 잘 맞는지 보입니다.
2.1 Workers의 실행 모델
Cloudflare Workers는 V8 아이솔레이트(isolate) 기반입니다. 컨테이너도 VM도 아닌, 한 V8 인스턴스 안에서 여러 스크립트가 격리되어 실행됩니다.
- 콜드 스타트: 밀리초 단위 (Lambda와는 다른 차원)
- CPU 제한: 요청당 무료 10ms, 유료 50ms (IO 대기 시간은 제외)
- 메모리: 128MB 공유
- 지역성: 사용자와 가장 가까운 데이터센터에서 실행
즉 "길게 계산하는 작업"에는 부적합하지만 "DB 한 번 읽고 JSON 응답" 같은 패턴에는 이상적입니다. 라이선스 검증은 정확히 이 패턴입니다.
2.2 Hono를 택한 이유
Workers용 HTTP 프레임워크는 몇 개 있습니다(itty-router, worktop, hono, 등). Hono를 택한 이유:
- Express 스타일 API:
app.post('/activate', handler)— 학습 비용 제로 - 미들웨어 체인: CORS, rate limit, 인증을 조합 가능
- Zero dependencies: Workers 번들 크기에 영향 적음
- TypeScript First:
Env타입 제네릭으로 바인딩 자동 추론
// server/src/index.ts
import { Hono } from 'hono';
export type Env = {
DB: D1Database;
LEMON_WEBHOOK_SECRET: string;
};
const app = new Hono<{ Bindings: Env }>();
// c.env.DB, c.env.LEMON_WEBHOOK_SECRET가 타입 안전하게 추론됨
Env 제네릭 덕분에 c.env.DB에 자동완성이 뜨고, 환경 변수를 오타 내면 TypeScript가 잡아줍니다. wrangler.toml에 선언한 바인딩이 코드에서 그대로 타입으로 흐릅니다.
2.3 D1의 한계를 미리 알고 가기
D1은 SQLite를 엣지에 올린 제품입니다. 좋은 점과 함께 조심해야 할 점도 있습니다.
- ✅ 읽기: 엣지에 복제본이 있어 전 세계 어디서나 빠름
- ✅ SQL: SQLite 문법 그대로, Prisma 같은 ORM 불필요
- ⚠️ 쓰기: primary region에 라우팅되어 지연 발생 (50~200ms)
- ⚠️ ALTER COLUMN 없음: SQLite의 ALTER TABLE이 제한적, 스키마 변경 시 테이블 재생성 필요
- ⚠️ 트랜잭션: 제한적 (Workers 요청 단위로 묶임)
라이선스 검증의 특성상 읽기가 압도적으로 많은 워크로드이므로 쓰기 지연은 문제가 아닙니다. 웹훅으로 쓰기가 몰릴 때도 초당 몇 건 수준이라 전혀 문제가 없습니다.
3. 해결 방법: 주말 하나로 완성하는 라이선스 서버
실제로 구현한 순서 그대로 따라가 보겠습니다.
3.1 스키마 설계: licenses 테이블
-- migrations/0001_create_licenses.sql
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
license_key TEXT NOT NULL UNIQUE,
device_id TEXT,
activated_at INTEGER,
purchased_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_license_key ON licenses(license_key);
CREATE INDEX IF NOT EXISTS idx_email ON licenses(email);
처음엔 이 스키마로 충분해 보였습니다. license_key를 NOT NULL UNIQUE로 잡고, 웹훅에서 한 번에 넣으면 되니까요. 그런데 이 NOT NULL 제약이 나중에 하루를 날리는 원인이 됩니다(3.6절 복선).
3.2 Hono 앱 구조
라우트를 세 덩어리로 나눕니다.
// server/src/index.ts
const app = new Hono<{ Bindings: Env }>();
// CORS: chrome-extension:// origin만 허용
app.use('/api/*', cors({
origin: (origin) => {
if (origin && origin.startsWith('chrome-extension://')) {
return origin;
}
return null;
},
allowMethods: ['POST', 'OPTIONS'],
allowHeaders: ['Content-Type'],
}));
// Rate limit — /api/*에만 적용 (webhook은 HMAC으로 보호)
app.use('/api/*', rateLimit);
// 라우트 마운트
app.route('/api', api); // 확장 → 서버 (검증용)
app.route('/webhook', webhook); // Lemon Squeezy → 서버 (구매 이벤트)
app.route('/', landing); // 공개 랜딩 페이지
app.route('/', privacy); // 공개 프라이버시 페이지
export default app;
CORS를 chrome-extension://만 허용하는 게 포인트입니다. 일반 웹사이트에서 /api/activate를 호출할 수 없게 차단하는 방어막입니다. 악의적인 웹사이트가 fetch()로 라이선스 키를 떠보는 공격을 기본적으로 막습니다.
Rate limit은 /api/*에만 적용합니다. 웹훅은 HMAC 서명 검증으로 이미 보호되므로 rate limit이 오히려 정상 이벤트를 떨어뜨릴 수 있습니다.
3.3 /api/activate — 1기기 제한과 재활성화
가장 복잡한 엔드포인트입니다. Zod로 입력을 검증하고, 상태에 따라 분기합니다.
// server/src/routes/api.ts
import { z } from 'zod';
const licenseRequestSchema = z.object({
licenseKey: z.string().min(1).max(255),
deviceId: z.string().min(1).max(255),
});
api.post('/activate', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = licenseRequestSchema.safeParse(body);
if (!parsed.success) {
return c.json({ valid: false, error: 'invalid_request' }, 400);
}
const { licenseKey, deviceId } = parsed.data;
// 1. 라이선스 키 존재 여부
const row = await c.env.DB.prepare(
'SELECT id, device_id, activated_at FROM licenses WHERE license_key = ?'
)
.bind(licenseKey)
.first<{ id: number; device_id: string | null; activated_at: number | null }>();
if (!row) {
return c.json({ valid: false, error: 'invalid_key' });
}
// 2. 같은 기기에서 재확인 — 멱등
if (row.device_id === deviceId) {
return c.json({ valid: true });
}
// 3. 다른 기기 → 전환 (1기기 제한의 의미: "현재 기기")
await c.env.DB.prepare(
'UPDATE licenses SET device_id = ?, activated_at = ? WHERE id = ?'
)
.bind(deviceId, Date.now(), row.id)
.run();
return c.json({ valid: true });
});
주목할 디자인 포인트 두 개:
① 1기기 제한은 "동시" 제한이 아니라 "현재" 제한. 사용자가 PC를 교체하거나 재설치하면 기존 활성화가 자동으로 덮어쓰기 됩니다. 엄격한 lock을 걸지 않는 이유는 UX입니다. 디바이스 5개씩 쓰는 대기업 툴도 아니고, 크롬 확장의 "Pro"는 가볍게 쓰는 성격이라 "디바이스 교체 시 지원팀 문의" 같은 흐름은 마찰이 큽니다.
② 같은 기기 재확인은 멱등. 이미 활성화된 기기에서 다시 /activate를 호출해도 valid: true만 돌려줍니다. DB를 건드리지 않아 쓰기 비용이 없고, 클라이언트는 기동마다 안전하게 활성화 체크를 반복할 수 있습니다.
3.4 /api/verify — 경량 재검증
/activate보다 가벼운 엔드포인트로, 확장이 24시간마다 한 번씩 호출합니다.
api.post('/verify', async (c) => {
// ... Zod 검증 동일 ...
const row = await c.env.DB.prepare(
'SELECT device_id FROM licenses WHERE license_key = ?'
)
.bind(licenseKey)
.first<{ device_id: string | null }>();
if (!row) {
return c.json({ valid: false, reason: 'invalid_key' });
}
if (row.device_id !== deviceId) {
return c.json({ valid: false, reason: 'device_mismatch' });
}
return c.json({ valid: true });
});
이 엔드포인트의 설계 의도는 "환불된 라이선스의 지연된 비활성화" 입니다. 사용자가 환불하면 Lemon Squeezy가 웹훅을 보내고, 서버는 해당 row를 삭제합니다. 그 다음번 /verify 호출에서 invalid_key가 돌아가고, 클라이언트는 Pro 상태를 해제합니다. 실시간은 아니지만 24시간 내에 수렴합니다.
클라이언트 측에서 이 호출이 실패했을 때 graceful하게 처리하는 것도 중요합니다. 네트워크 에러, Workers 장애 등으로 /verify가 실패하면 현재 상태를 유지해야 합니다. 그러지 않으면 사용자가 오프라인일 때 Pro가 사라지는 버그가 생깁니다.
// extension/background.js에서의 처리 (의사 코드)
async function checkProStatus() {
try {
const res = await fetch(`${API}/verify`, { /* ... */ });
const data = await res.json();
if (data.valid) markPro(true);
else if (data.reason === 'invalid_key') markPro(false); // 확실한 무효
// 그 외 reason은 네트워크/서버 일시 장애로 간주, 상태 유지
} catch (e) {
// 네트워크 실패: 현재 상태 유지, 24시간 후 재시도
console.debug('[verify] network error, keeping current state');
}
}
3.5 Rate Limit: 메모리 기반의 한계를 이해하기
Workers에서 "정직한" rate limit은 어렵습니다. 여러 isolate가 독립 실행되기 때문에 글로벌 상태를 공유할 수 없습니다.
// server/src/middleware/rate-limit.ts
const MAX_REQUESTS = 10;
const WINDOW_MS = 60 * 1000;
// isolate 단위 in-memory (글로벌 아님 — best-effort)
const requestCounts = new Map<string, { count: number; resetAt: number }>();
export async function rateLimit(c: Context, next: Next) {
const ip = c.req.header('cf-connecting-ip')
|| c.req.header('x-forwarded-for')
|| 'unknown';
const now = Date.now();
// map 크기 제한 — 1000 엔트리 넘으면 stale 청소
if (requestCounts.size > 1000) {
for (const [key, val] of requestCounts) {
if (now > val.resetAt) requestCounts.delete(key);
}
}
const entry = requestCounts.get(ip);
if (!entry || now > entry.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
await next();
return;
}
entry.count++;
if (entry.count > MAX_REQUESTS) {
return c.json(
{ error: 'rate_limited', message: 'Too many requests. Try again later.' },
429
);
}
await next();
}
이건 완벽한 글로벌 rate limit이 아닙니다. 한 공격자가 여러 지역에서 동시에 요청하면 각 지역 isolate가 별도로 카운트합니다. 대신 이 방식은:
- 봇 abuse 방지에는 충분: 단일 IP가 몇 분 안에 수천 번 때리는 순진한 공격은 막힘
- 의존성 제로: Redis도 KV도 필요 없음
- 지연 없음: 모든 처리가 메모리에서 동기적
진짜 엄격한 rate limit이 필요하면 Cloudflare Dashboard의 Rate Limiting Rules (WAF) 를 설정해야 합니다. 그건 글로벌이고 L7에서 동작합니다. 이 글의 범위에선 애플리케이션 레이어의 "매너 있는" 방어선만 두고, 더 무거운 방어는 인프라 레이어에 위임했습니다.
한 가지 더 — if (requestCounts.size > 1000) 체크는 메모리 유출 방지입니다. 이걸 빼먹으면 map이 무한히 자라서 isolate가 OOM 이벤트로 재시작됩니다. "한 번 쓰면 끝"처럼 보이는 데이터에도 GC 전략을 넣어야 합니다.
3.6 웹훅 함정: 하루를 날린 이벤트 순서 문제
이 글 전체에서 가장 중요한 섹션입니다. 웹훅이 단순해 보이는 건 "이벤트 순서가 보장된다" 고 가정할 때뿐입니다.
3.6.1 나이브한 초기 구현
처음엔 Lemon Squeezy 대시보드에서 order_created 이벤트를 보고 "아, 여기 data.attributes.urls.license_key 필드가 있네!" 하고 이렇게 짰습니다.
// ❌ 초기 구현 — 버그
if (eventName === 'order_created') {
const email = payload.data?.attributes?.user_email;
const lemonLicenseKey = payload.data?.attributes?.urls?.license_key
|| payload.meta?.custom_data?.license_key;
const finalKey = lemonLicenseKey || crypto.randomUUID();
await c.env.DB.prepare(
'INSERT OR IGNORE INTO licenses (email, license_key, purchased_at) VALUES (?, ?, ?)'
)
.bind(email, finalKey, Date.now())
.run();
}
테스트 주문을 돌려봤더니 DB에 row는 생기는데, licenseKey가 URL처럼 생겼습니다.
license_key = "https://app.lemonsqueezy.com/license/abc123..."
사용자에게 이메일로 전달되는 "라이선스 키"는 ABCD-1234-EFGH-5678 형태의 짧은 문자열인데, DB에는 URL이 들어가 있었습니다. 이걸 가지고 클라이언트가 /api/activate를 호출하면 당연히 invalid_key가 돌아옵니다.
3.6.2 진실: urls.license_key는 키가 아니라 "키를 볼 수 있는 페이지 URL"
Lemon Squeezy 문서를 다시 읽어봤습니다. data.attributes.urls.license_key는 고객 대시보드에서 라이선스 키를 조회할 수 있는 페이지의 URL이었습니다. 이름이 license_key니까 그게 키라고 착각한 거죠. 실제 라이선스 키는 별도 이벤트에서 옵니다.
라이선스 키 발급 이벤트는 license_key_created 이고, data.attributes.key 필드에 실제 키 값이 있습니다.
그리고 더 큰 문제 — order_created와 license_key_created의 순서가 보장되지 않습니다. 두 이벤트는 같은 주문에서 발생하지만 다른 시점에 웹훅으로 날아옵니다. 대부분 order_created가 먼저지만, 1~2초 차이로 역전될 수도 있습니다.
3.6.3 해결: 스키마를 이벤트 순서로부터 독립시키기
문제의 근본 원인은 스키마의 license_key NOT NULL 제약이었습니다. 이벤트 순서에 상관없이 먼저 오는 쪽을 저장하고 나중 오는 쪽을 합치려면, 한쪽 필드가 NULL을 허용해야 합니다.
SQLite는 ALTER COLUMN이 없어서 테이블을 재생성해야 합니다.
-- migrations/0002_allow_null_license_key.sql
CREATE TABLE licenses_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
license_key TEXT UNIQUE, -- ← NOT NULL 제약 제거
device_id TEXT,
activated_at INTEGER,
purchased_at INTEGER NOT NULL
);
INSERT INTO licenses_new SELECT * FROM licenses;
DROP TABLE licenses;
ALTER TABLE licenses_new RENAME TO licenses;
CREATE INDEX IF NOT EXISTS idx_license_key ON licenses(license_key);
CREATE INDEX IF NOT EXISTS idx_email ON licenses(email);
그리고 웹훅 핸들러를 두 이벤트에 대해 병행하도록 고쳤습니다.
// ✅ 수정 구현
if (eventName === 'order_created') {
const email = payload.data?.attributes?.user_email;
if (!email || typeof email !== 'string' || !email.includes('@')) {
console.error('[webhook] Missing email in order_created payload');
return c.json({ ok: true });
}
// order_created에선 이메일만 저장 — 키는 아직 없음
await c.env.DB.prepare(
'INSERT OR IGNORE INTO licenses (email, purchased_at) VALUES (?, ?)'
)
.bind(email, Date.now())
.run();
}
if (eventName === 'license_key_created') {
const licenseKey = payload.data?.attributes?.key; // ← 진짜 키 위치
const email = payload.meta?.custom_data?.email
|| payload.data?.attributes?.user_email;
if (!licenseKey || typeof licenseKey !== 'string'
|| !email || typeof email !== 'string' || !email.includes('@')) {
console.error('[webhook] Missing key or email in license_key_created payload');
return c.json({ ok: true });
}
// UPSERT: 이미 order_created가 만든 row가 있으면 키만 채움
// 없으면 새 row (license_key_created가 먼저 온 경우)
await c.env.DB.prepare(
`INSERT INTO licenses (email, license_key, purchased_at) VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET license_key = excluded.license_key
WHERE licenses.license_key IS NULL`
)
.bind(email, licenseKey, Date.now())
.run();
}
ON CONFLICT(email) DO UPDATE 구문이 핵심입니다. 이걸 풀어쓰면:
- 이메일이 유일 키이므로 같은 이메일로 다시 INSERT하면 충돌
- 충돌 시
UPDATE SET license_key = excluded.license_key로 덮어씀 - 단,
WHERE licenses.license_key IS NULL조건 — 이미 키가 있는 row는 건드리지 않음
마지막 WHERE 절이 특히 중요합니다. 이게 없으면 재구매 시 기존 라이선스가 덮어씌워집니다. 같은 이메일로 새 구매가 들어오면 새 키가 기존 키를 밀어내고, 기존 라이선스는 DB에서 사라집니다.
3.6.4 웹훅 이벤트 순서를 다룰 때의 일반 규칙
이 경험을 일반화하면 이렇게 정리됩니다.
- 웹훅 페이로드의 필드명만 보고 의미를 추측하지 말 것: Lemon Squeezy 뿐 아니라 Stripe, PayPal, Shopify 모두 필드명이 오해를 부르는 경우가 있습니다. 문서를 한 번 더 확인하세요.
- 이벤트 순서를 절대 가정하지 말 것: HTTP 웹훅은 고유하게 재시도되며, 네트워크 상황에 따라 순서가 뒤집힐 수 있습니다.
- 스키마를 이벤트에 독립시킬 것: 어떤 이벤트가 먼저 도착해도 동일한 최종 상태에 수렴해야 합니다 (idempotency + merge).
- UPSERT 패턴을 기본으로 삼을 것:
INSERT ... ON CONFLICT ... DO UPDATE로 "있으면 합치고, 없으면 만든다"를 한 쿼리로 처리하세요. WHERE ... IS NULL로 덮어쓰기 방지: 이미 설정된 값을 나중 이벤트가 덮는 걸 조건부로 막아야 합니다.
3.7 HMAC 서명 검증: Constant-Time Compare가 핵심
웹훅을 신뢰하려면 Lemon Squeezy가 보낸 건지를 반드시 검증해야 합니다. 검증 없이 받으면 누구든 가짜 license_key_created 이벤트를 쏴서 공짜 라이선스를 만들 수 있습니다.
// server/src/routes/webhook.ts
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
// 타이밍 공격 방지용 상수 시간 비교
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
async function verifySignature(
secret: string,
body: string,
signature: string
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
const expected = new Uint8Array(sig);
const provided = hexToBytes(signature);
return constantTimeEqual(expected, provided);
}
왜 constantTimeEqual()을 직접 구현해야 하나? === 나 Buffer.compare() 같은 비교 함수는 첫 번째 불일치 바이트에서 바로 리턴합니다. 공격자가 "첫 바이트 맞는 시그니처"와 "둘째 바이트 맞는 시그니처"를 여러 번 보내면서 응답 시간의 미세한 차이를 측정하면, 바이트 단위로 올바른 시그니처를 추출할 수 있습니다(타이밍 공격).
상수 시간 비교는 길이가 맞으면 항상 전체를 순회합니다. result |= a[i] ^ b[i]는 불일치 비트를 쌓기만 하고 루프를 빠져나가지 않습니다. 전부 돌고 난 뒤 result === 0만 검사합니다. 응답 시간은 입력에 독립적입니다.
Node.js에는 crypto.timingSafeEqual()이 있지만 Workers에는 없어서 직접 구현했습니다. 6줄짜리 함수지만 이 6줄이 전체 웹훅의 보안을 책임집니다.
3.8 전체 요청 흐름 다이어그램
[Lemon Squeezy]
│
│ 1. order_created (HMAC 서명 포함)
│ 2. license_key_created (순서 미보장)
▼
[Cloudflare Workers]
│
│ 3. HMAC 검증 (constant-time)
│ 4. UPSERT into D1
▼
[D1: licenses table]
│
│ (데이터 정착)
▼
[User installs extension]
│
│ 5. 라이선스 키 입력 → POST /api/activate
│ {licenseKey, deviceId}
▼
[Workers /api/activate]
│
│ 6. Zod 검증
│ 7. SELECT WHERE license_key = ?
│ 8. UPDATE device_id = new_device
▼
[Client: Pro 활성]
│
│ 24시간마다
▼
[POST /api/verify]
│
│ 9. device_id 일치 확인
│ 10. 환불된 라이선스는 여기서 invalid_key
▼
[Pro 상태 유지 or 해제]
4. 핵심 개념 정리
4.1 컴포넌트별 역할
| 파일 | 역할 | 핵심 개념 |
|---|---|---|
src/index.ts |
Hono 앱 엔트리, 미들웨어 체인, 라우트 마운트 | CORS 제한, rate limit 범위 설정 |
src/routes/api.ts |
확장 ↔ 서버 검증 엔드포인트 | Zod 입력 검증, 멱등 활성화 |
src/routes/webhook.ts |
Lemon Squeezy 결제 이벤트 처리 | HMAC 검증, UPSERT 병합 |
src/middleware/rate-limit.ts |
/api/* 기본 rate limit |
isolate 단위 in-memory map |
migrations/0001_create_licenses.sql |
초기 스키마 | 단일 licenses 테이블 |
migrations/0002_allow_null_license_key.sql |
license_key NULL 허용 재구성 |
SQLite의 ALTER COLUMN 부재 대응 |
wrangler.toml |
Workers + D1 바인딩 | [[d1_databases]] 선언 |
4.2 엔드포인트별 보안 레이어
| 엔드포인트 | 인증 방식 | 보조 방어 |
|---|---|---|
POST /api/activate |
없음 (공개) | CORS (chrome-extension://만), Rate limit, Zod |
POST /api/verify |
없음 (공개) | CORS, Rate limit, Zod |
POST /webhook/lemon |
HMAC-SHA256 constant-time | 페이로드 JSON 검증 |
GET / |
없음 (공개 랜딩) | — |
4.3 이벤트 순서에 대한 결론
웹훅은 "언제든지 어떤 순서로든" 올 수 있는 메시지입니다. 스키마와 로직은 그 가정 위에서 설계해야 하고, "대체로 순서대로 온다"는 경험적 관찰에 의존하면 안 됩니다.
5. 베스트 프랙티스
5.1 Workers + D1 라이선스 서버 체크리스트
스키마
email,license_key모두 UNIQUE 인덱스- 이벤트 순서 독립성을 위해 선택적 필드는 NULL 허용
- 쓰기가 드물면
CREATE INDEX망설이지 말 것 (읽기 성능이 주력)
API 보안
- CORS는 명시적 오리진만
- 모든 입력은 Zod로 범위 검증 (길이, 타입, 형식)
- Rate limit은 앱 레이어 + WAF 레이어로 이중
- JSON 파싱 실패는
catch(() => null)로 조용히 400
웹훅
- 서명 검증은 먼저 수행 — 페이로드 파싱보다 앞에
- 상수 시간 비교 필수 (
===,Buffer.compare금지) - 이벤트 타입별 핸들러 분리
- UPSERT +
WHERE IS NULL로 덮어쓰기 방지 - ACK(
200 ok)는 빠르게, 실패해도 ACK 고려 (웹훅 재시도 지옥 방지)
클라이언트 통합
/verify실패 시 현재 상태 유지 (네트워크 장애 ≠ 라이선스 무효)- 24시간 간격 재검증
- 명확한 에러 reason만 상태 변경 (
invalid_key,device_mismatch)
5.2 해서는 안 되는 것
- ❌
Buffer.compare()나===로 HMAC 비교 - ❌ 웹훅에서
order_created만 듣고 키를 추출 (Lemon Squeezy의 경우) - ❌
license_key NOT NULL제약으로 이벤트 순서를 강제 - ❌ rate limit 맵에 크기 제한 없이 IP 쌓기
- ❌ 웹훅 실패를 500으로 응답해서 재시도 루프 유발
- ❌ 클라이언트에서
/verify실패를 "라이선스 무효"로 해석
6. FAQ
Q1. 왜 Stripe가 아니라 Lemon Squeezy인가요?
A. Stripe는 미국·영국·EU 등 주요 국가 사업자 등록이 있어야 계정을 만들 수 있고, 개인 개발자가 한국 계좌로 이용하는 루트는 복잡합니다. Lemon Squeezy는 Merchant of Record 모델이라 세금·부가세를 대신 처리해 주고, 계정 개설이 개인도 가능합니다. 수수료가 5% + $0.50로 Stripe 2.9%보다 비싸지만, "사이드 프로젝트를 빠르게 수익화"하는 목적에는 맞습니다. 규모가 커지면 Stripe로 넘어가면 됩니다.
Q2. D1 대신 Cloudflare KV를 쓰면 안 되나요?
A. KV는 key-value 저장소라 "라이선스 키로 조회"만 하면 충분해 보입니다. 하지만 이메일로 UPSERT가 필요하고(웹훅 순서 문제), 관리 쿼리(환불 처리, 활성화 기기 조회)에는 SQL이 압도적으로 편합니다. D1의 읽기 지연은 KV보다 약간 느리지만(수 ms), 라이선스 검증 엔드포인트의 전체 응답 시간에서 차지하는 비중이 작습니다.
Q3. Workers의 10ms CPU 제한이 걸릴 일은 없나요?
A. 이 API는 대부분 D1 쿼리 + JSON 응답만 수행합니다. 실제 CPU 사용량은 1~2ms 수준이고, 나머지는 D1 I/O 대기 시간(CPU에 카운트되지 않음)입니다. HMAC 계산도 crypto.subtle이 네이티브로 수행하므로 CPU 소모가 무시할 만합니다. 무료 티어로 충분합니다.
Q4. device_id는 어떻게 생성하나요?
A. 확장 쪽에서 최초 설치 시 crypto.randomUUID()로 만들어 chrome.storage.local에 저장합니다. 이후 요청마다 이 UUID를 함께 보냅니다. 재설치하면 새 UUID가 생성되지만, /activate 로직이 "다른 기기면 transfer"로 설계되어 있어 마찰 없이 작동합니다.
Q5. 환불은 어떻게 처리하나요?
A. Lemon Squeezy가 order_refunded 이벤트를 보냅니다. 이 이벤트에서 해당 row를 DELETE하거나 별도 refunded_at 컬럼을 두고 조회 시 필터링합니다. 클라이언트는 다음 /verify 호출에서 invalid_key를 받고 Pro 상태를 해제합니다. 이 글에선 생략했지만 실제 프로덕션에선 필수 핸들러입니다.
Q6. 여러 지역 isolate에서 rate limit 우회가 가능한데 괜찮나요?
A. 앱 레이어 rate limit은 일상적인 abuse를 막는 용도입니다. 진지한 DDoS나 분산 공격은 Cloudflare Dashboard의 WAF + Rate Limiting Rules가 인프라 레이어에서 처리해야 합니다. 두 층이 역할이 다르고, 양쪽 다 필요합니다.
Q7. 웹훅 핸들러가 실패하면 어떻게 되나요?
A. Lemon Squeezy는 ACK(200)을 못 받으면 지수 백오프로 재시도합니다. 그래서 핸들러 안에서 DB 쓰기 실패가 발생해도 로그만 남기고 200을 리턴하는 게 일반적입니다. 그러지 않으면 수 분 간격으로 같은 이벤트가 밀려옵니다. 단, 치명적 에러(페이로드 파싱 불가, 시그니처 불일치)는 4xx를 반환해서 재시도를 멈춥니다.
Q8. Hono 대신 그냥 addEventListener('fetch', ...)로 작성하면 안 되나요?
A. 됩니다. 이 규모의 서버는 raw Workers API만으로도 작성 가능합니다. 다만 CORS, 라우팅, 미들웨어 체인을 직접 쓰면 금방 100줄 넘어갑니다. Hono는 이런 코드를 15줄로 줄여주고, 나중에 엔드포인트를 추가할 때 확장성이 좋습니다.
7. 참고 자료
- Hono 공식 문서
- Cloudflare D1 문서
- Cloudflare Workers — 무료 티어 한도
- MDN — SubtleCrypto.sign() (HMAC)
- Lemon Squeezy Webhooks 공식 문서 — 검색 키워드:
Lemon Squeezy webhooks docs - SQLite — UPSERT 구문 (
ON CONFLICT) - Timing attack — Wikipedia
8. 다음 단계
이 글은 서버 측을 다뤘습니다. 반대편인 확장 측의 이야기 — 라이선스 상태가 활성 공간에 따라 달라지는 멀티테넌트 구조는 어떻게 구현하는가 — 는 다음 글에서 다룹니다.
시리즈 (크롬 확장 Pro 라이선스 계열):
- Hono + Cloudflare Workers + D1로 라이선스 서버 만들기: Lemon Squeezy 웹훅 완전 가이드 ← 현재 글
- 단일 크롬 확장에서 멀티테넌트 구현하기: Space Isolation 패턴 (예정)