diff --git a/docs/server-provision-architecture.md b/docs/server-provision-architecture.md new file mode 100644 index 0000000..88819eb --- /dev/null +++ b/docs/server-provision-architecture.md @@ -0,0 +1,1326 @@ +# 서버 프로비저닝 API 아키텍처 + +> 비동기 Queue 기반 서버 생성 시스템 설계 문서 + +## 📋 목차 + +1. [개요](#개요) +2. [현재 문제점](#현재-문제점) +3. [목표 아키텍처](#목표-아키텍처) +4. [Cloudflare Queue 설명](#cloudflare-queue-설명) +5. [API 스펙](#api-스펙) +6. [Worker 구조](#worker-구조) +7. [구현 단계](#구현-단계) +8. [보안](#보안) +9. [모니터링](#모니터링) +10. [트러블슈팅](#트러블슈팅) + +--- + +## 개요 + +**현재 상황:** +- 서버 프로비저닝이 동기 처리 방식 +- Telegram Webhook의 10초 타임아웃 제한 +- Linode/Vultr API 응답 시간: 15-30초 +- 타임아웃 발생 시 사용자 경험 저하 + +**목표:** +- Cloudflare Queue 기반 비동기 처리 +- 즉시 응답 (주문 접수 확인) +- 백그라운드 서버 생성 +- 완료 시 Telegram 메시지 알림 + +**기대 효과:** +- ✅ Webhook 타임아웃 해결 +- ✅ 사용자 경험 개선 (즉시 응답) +- ✅ 시스템 안정성 향상 (재시도 메커니즘) +- ✅ 확장성 확보 (Queue 기반) + +--- + +## 현재 문제점 + +### 동기 처리 방식의 한계 + +``` +사용자 → Telegram Webhook → executeServerProvision() + ↓ (15-30초) + Linode/Vultr API + ↓ + 응답 타임아웃 ❌ +``` + +**구체적 문제:** + +| 문제 | 영향 | 예시 | +|------|------|------| +| **Webhook 10초 제한** | 응답 실패 | Linode API 15초 → Telegram 타임아웃 | +| **사용자 대기 시간** | UX 저하 | "응답 없음" 메시지, 재시도 시도 | +| **재시도 없음** | 일시적 장애 시 실패 | API 503 에러 → 주문 실패 | +| **동시 처리 불가** | 성능 병목 | 여러 사용자 동시 주문 시 순차 처리 | + +**현재 코드 위치:** +- `src/server-provision.ts:executeServerProvision()` - 동기 실행 +- `src/index.ts` (callback_query 핸들러) - Webhook 내에서 직접 호출 + +--- + +## 목표 아키텍처 + +### 비동기 Queue 기반 흐름 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 사용자 요청 처리 (즉시 응답) │ +└──────────────────────────────────────────────────────────────┘ + +사용자 클릭 "확인" 버튼 + ↓ +telegram-bot-workers (Producer) + 1. server_orders INSERT (status='pending') + 2. Queue.send({ order_id, user_id }) + 3. Telegram 즉시 응답: "주문 접수 (#123)" + ↓ + 사용자에게 즉시 응답 ✅ (1-2초) + + +┌──────────────────────────────────────────────────────────────┐ +│ 백그라운드 서버 생성 (비동기 처리) │ +└──────────────────────────────────────────────────────────────┘ + +Cloudflare Queue + ↓ (메시지 전달) +server-provision-worker (Consumer) + 1. Queue 메시지 수신 + 2. server_orders 조회 (status='pending') + 3. 잔액 재확인 + 4. Linode/Vultr API 호출 (15-30초) + 5. 결제 처리 (Optimistic Locking) + 6. server_orders 업데이트 (status='active') + 7. user_servers INSERT + 8. Telegram 알림: "서버 생성 완료 🎉" + ↓ + 완료 알림 전송 (백그라운드) + + +┌──────────────────────────────────────────────────────────────┐ +│ 실패 처리 (자동 재시도) │ +└──────────────────────────────────────────────────────────────┘ + +API 실패 (503, timeout 등) + ↓ +Queue 자동 재시도 (3회, 지수 백오프) + ↓ +재시도 실패 시 → Dead Letter Queue + ↓ +관리자 알림 (notifyAdmin) +``` + +--- + +## Cloudflare Queue 설명 + +### Queue란? + +**메시지 큐(Message Queue)**: 비동기 작업을 안전하게 전달하고 처리하는 시스템 + +``` +Producer (생산자) Queue (대기열) Consumer (소비자) + ↓ ↓ ↓ +메시지 전송 → 메시지 저장 → 메시지 처리 +``` + +**Cloudflare Queue 특징:** +- 완전 관리형 (서버리스) +- 자동 재시도 (exponential backoff) +- Dead Letter Queue 지원 +- 무료 티어: 100만 요청/월 + +### Producer (메시지 전송) + +**역할:** Telegram 봇이 주문을 Queue에 전송 + +```typescript +// telegram-bot-workers/src/index.ts (callback_query 핸들러) + +// 기존: 동기 실행 (타임아웃 발생) +const result = await executeServerProvision(env, userId, telegramUserId, orderId); + +// 변경: Queue에 전송 (즉시 응답) +await env.SERVER_PROVISION_QUEUE.send({ + order_id: orderId, + user_id: userId, + telegram_user_id: telegramUserId, + timestamp: Date.now(), +}); + +return "📋 주문 접수 완료! 서버 생성 중입니다... (1-2분 소요)"; +``` + +**메시지 구조:** +```typescript +interface ProvisionMessage { + order_id: number; // server_orders.id + user_id: number; // users.id + telegram_user_id: string; // Telegram Chat ID (알림용) + timestamp: number; // 전송 시각 (디버깅) +} +``` + +### Consumer (메시지 처리) + +**역할:** 백그라운드에서 실제 서버 생성 처리 + +```typescript +// server-provision-worker/src/consumer.ts + +export default { + async queue(batch: MessageBatch, env: Env): Promise { + for (const message of batch.messages) { + const { order_id, user_id, telegram_user_id } = message.body; + + try { + // 1. 실제 서버 생성 (기존 로직 재사용) + const result = await executeServerProvision( + env, + user_id, + telegram_user_id, + order_id + ); + + // 2. 성공 시 사용자 알림 + if (result.success) { + await sendMessage( + env.BOT_TOKEN, + Number(telegram_user_id), + `🎉 서버 생성 완료!\n\n` + + `• IP: ${result.ip_address}\n` + + `• Root 비밀번호: ${result.root_password}\n\n` + + `⚠️ 비밀번호는 이 메시지에서만 확인 가능합니다.` + ); + message.ack(); // 성공 확인 + } else { + throw new Error(result.error || 'Unknown error'); + } + + } catch (error) { + logger.error('서버 생성 실패', error as Error, { order_id }); + + // 3. 실패 시 재시도 (자동) + message.retry(); // Queue가 자동으로 재시도 + } + } + }, +}; +``` + +### 재시도 및 Dead Letter Queue + +**자동 재시도 설정:** +```toml +# wrangler.toml (server-provision-worker) + +[[queues.consumers]] +queue = "server-provision-queue" +max_retries = 3 # 최대 3회 재시도 +max_batch_size = 10 # 배치 크기 +max_batch_timeout = 30 # 배치 타임아웃 (초) +max_concurrency = 5 # 동시 처리 수 +dead_letter_queue = "provision-dlq" # 실패한 메시지 전송 +``` + +**재시도 전략 (Exponential Backoff):** +``` +1회 실패 → 2초 후 재시도 +2회 실패 → 4초 후 재시도 +3회 실패 → 8초 후 재시도 +4회 실패 → Dead Letter Queue로 전송 +``` + +**Dead Letter Queue 처리:** +```typescript +// server-provision-worker/src/dlq-handler.ts + +export default { + async queue(batch: MessageBatch, env: Env): Promise { + for (const message of batch.messages) { + const { order_id, telegram_user_id } = message.body; + + // 1. DB 업데이트 (status='failed') + await env.DB.prepare( + "UPDATE server_orders SET status = 'failed', error_message = ? WHERE id = ?" + ).bind('최대 재시도 횟수 초과', order_id).run(); + + // 2. 사용자 알림 + await sendMessage( + env.BOT_TOKEN, + Number(telegram_user_id), + `❌ 서버 생성 실패 (주문 #${order_id})\n\n` + + `일시적인 문제로 서버를 생성할 수 없습니다.\n` + + `관리자에게 문의하세요.` + ); + + // 3. 관리자 알림 + await notifyAdmin( + 'api_error', + { + service: 'server-provision', + error: '서버 생성 최종 실패', + context: `주문 ID: ${order_id}\n사용자: ${telegram_user_id}`, + }, + { telegram: { sendMessage }, adminId: env.SERVER_ADMIN_ID, env } + ); + + message.ack(); // DLQ에서 제거 + } + }, +}; +``` + +--- + +## API 스펙 + +### POST /provision (내부 API) + +**목적:** Queue 메시지 전송 (Producer) + +**요청:** +```http +POST /provision +Content-Type: application/json +Authorization: Bearer {WEBHOOK_SECRET} + +{ + "order_id": 123, + "user_id": 456, + "telegram_user_id": "789012345" +} +``` + +**응답 (성공):** +```json +{ + "success": true, + "order_id": 123, + "message": "주문이 접수되었습니다. 서버 생성 중..." +} +``` + +**응답 (실패):** +```json +{ + "success": false, + "error": "주문을 찾을 수 없습니다." +} +``` + +**에러 코드:** +| 코드 | 설명 | +|------|------| +| 400 | 잘못된 요청 (필수 파라미터 누락) | +| 401 | 인증 실패 (WEBHOOK_SECRET 불일치) | +| 404 | 주문 없음 (order_id 불일치) | +| 500 | Queue 전송 실패 | + +### GET /status/:order_id (사용자 API) + +**목적:** 주문 상태 조회 + +**요청:** +```http +GET /status/123 +Authorization: Bearer {WEBHOOK_SECRET} +``` + +**응답:** +```json +{ + "order_id": 123, + "status": "provisioning", + "spec_id": 456, + "region": "tokyo", + "price_paid": 15000, + "created_at": "2026-01-24T10:30:00Z", + "updated_at": "2026-01-24T10:32:15Z", + "ip_address": null, + "error_message": null +} +``` + +**상태 값:** +| 상태 | 설명 | 사용자 메시지 | +|------|------|--------------| +| `pending` | 주문 접수 | "주문 대기 중" | +| `provisioning` | 서버 생성 중 | "서버 생성 중... (1-2분 소요)" | +| `active` | 생성 완료 | "서버 운영 중 ✅" | +| `failed` | 생성 실패 | "서버 생성 실패 ❌" | +| `cancelled` | 사용자 취소 | "주문 취소됨" | +| `terminated` | 서버 종료 | "서버 종료됨" | + +### Queue 메시지 스키마 + +**메시지 타입:** +```typescript +interface ProvisionMessage { + order_id: number; // server_orders.id (필수) + user_id: number; // users.id (필수) + telegram_user_id: string; // Telegram Chat ID (필수) + timestamp: number; // Unix timestamp (선택) + retry_count?: number; // 재시도 횟수 (내부 사용) +} +``` + +**메시지 크기 제한:** +- 최대 128KB (Cloudflare Queue 제한) +- 현재 메시지: ~100 bytes + +**메시지 보관 기간:** +- 기본: 4일 (96시간) +- Dead Letter Queue: 14일 + +--- + +## Worker 구조 + +### 디렉토리 구조 + +``` +server-provision/ +├── wrangler.toml # Worker 설정 + Queue 바인딩 +└── src/ + ├── index.ts # Producer API (POST /provision, GET /status) + ├── consumer.ts # Queue Consumer (서버 생성 처리) + ├── dlq-handler.ts # Dead Letter Queue 핸들러 + ├── server-provision.ts # 기존 로직 (재사용) + ├── types.ts # 타입 정의 + └── utils/ + ├── logger.ts # 로깅 (기존 재사용) + ├── optimistic-lock.ts # Optimistic Locking (기존 재사용) + └── retry.ts # Retry 로직 (기존 재사용) +``` + +### wrangler.toml + +```toml +name = "server-provision-worker" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +# Queue Producer 설정 +[[queues.producers]] +queue = "server-provision-queue" +binding = "SERVER_PROVISION_QUEUE" + +# Queue Consumer 설정 (이 Worker가 메시지 처리) +[[queues.consumers]] +queue = "server-provision-queue" +max_retries = 3 +max_batch_size = 10 +max_batch_timeout = 30 +max_concurrency = 5 +dead_letter_queue = "provision-dlq" + +# Dead Letter Queue Consumer +[[queues.consumers]] +queue = "provision-dlq" +max_retries = 0 # DLQ는 재시도 없음 + +# D1 Database 바인딩 (기존 telegram-conversations) +[[d1_databases]] +binding = "DB" +database_name = "telegram-conversations" +database_id = "YOUR_DATABASE_ID" + +# CLOUD_DB 바인딩 +[[d1_databases]] +binding = "CLOUD_DB" +database_name = "cloud-instances-db" +database_id = "YOUR_CLOUD_DB_ID" + +# KV Namespace 바인딩 (Rate Limiting 등) +[[kv_namespaces]] +binding = "RATE_LIMIT_KV" +id = "YOUR_KV_ID" + +# 환경 변수 +[vars] +OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/..." +SERVER_ADMIN_ID = "123456789" + +# Secrets (wrangler secret put으로 설정) +# - BOT_TOKEN +# - WEBHOOK_SECRET +# - LINODE_API_KEY +# - VULTR_API_KEY +# - OPENAI_API_KEY +``` + +### src/index.ts (Producer) + +```typescript +import { sendToQueue } from './queue-producer'; +import type { Env, ProvisionMessage } from './types'; + +export default { + // HTTP 요청 처리 (POST /provision, GET /status) + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // POST /provision - Queue에 메시지 전송 + if (request.method === 'POST' && url.pathname === '/provision') { + return await handleProvision(request, env); + } + + // GET /status/:order_id - 주문 상태 조회 + if (request.method === 'GET' && url.pathname.startsWith('/status/')) { + return await handleStatus(request, env); + } + + return new Response('Not Found', { status: 404 }); + }, + + // Queue Consumer (consumer.ts 파일에서 import) + async queue(batch: MessageBatch, env: Env): Promise { + const { default: consumer } = await import('./consumer'); + return consumer.queue(batch, env); + }, +}; + +async function handleProvision(request: Request, env: Env): Promise { + // 인증 검증 + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response('Unauthorized', { status: 401 }); + } + const token = authHeader.substring(7); + if (token !== env.WEBHOOK_SECRET) { + return new Response('Forbidden', { status: 403 }); + } + + // 요청 파싱 + const body = await request.json() as { + order_id: number; + user_id: number; + telegram_user_id: string; + }; + + if (!body.order_id || !body.user_id || !body.telegram_user_id) { + return new Response('Bad Request: Missing required fields', { status: 400 }); + } + + // Queue에 메시지 전송 + try { + await env.SERVER_PROVISION_QUEUE.send({ + order_id: body.order_id, + user_id: body.user_id, + telegram_user_id: body.telegram_user_id, + timestamp: Date.now(), + }); + + return new Response(JSON.stringify({ + success: true, + order_id: body.order_id, + message: '주문이 접수되었습니다. 서버 생성 중...', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + return new Response(JSON.stringify({ + success: false, + error: 'Queue 전송 실패', + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +async function handleStatus(request: Request, env: Env): Promise { + // 인증 검증 (동일) + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response('Unauthorized', { status: 401 }); + } + const token = authHeader.substring(7); + if (token !== env.WEBHOOK_SECRET) { + return new Response('Forbidden', { status: 403 }); + } + + // order_id 추출 + const url = new URL(request.url); + const orderId = Number(url.pathname.split('/').pop()); + if (!orderId) { + return new Response('Bad Request: Invalid order_id', { status: 400 }); + } + + // DB 조회 + const order = await env.DB.prepare( + `SELECT id, status, spec_id, region, price_paid, created_at, updated_at, ip_address, error_message + FROM server_orders WHERE id = ?` + ).bind(orderId).first(); + + if (!order) { + return new Response('Not Found', { status: 404 }); + } + + return new Response(JSON.stringify(order), { + headers: { 'Content-Type': 'application/json' }, + }); +} +``` + +### src/consumer.ts (Consumer) + +```typescript +import { createLogger } from './utils/logger'; +import { executeServerProvision } from './server-provision'; +import { sendMessage } from './telegram'; +import type { Env, ProvisionMessage } from './types'; + +const logger = createLogger('provision-consumer'); + +export default { + async queue(batch: MessageBatch, env: Env): Promise { + logger.info('Queue 메시지 수신', { count: batch.messages.length }); + + for (const message of batch.messages) { + const { order_id, user_id, telegram_user_id } = message.body; + + logger.info('서버 생성 시작', { + order_id, + user_id, + telegram_user_id, + attempt: message.attempts, + }); + + try { + // 1. 실제 서버 생성 (기존 로직 재사용) + const result = await executeServerProvision( + env, + user_id, + telegram_user_id, + order_id + ); + + if (result.success) { + // 2. 성공 시 사용자 알림 + await sendMessage( + env.BOT_TOKEN, + Number(telegram_user_id), + `🎉 서버 생성 완료!\n\n` + + `• 사양: ${result.plan_label}\n` + + `• 리전: ${result.region}\n` + + `• IP: ${result.ip_address}\n` + + `• Root 비밀번호: ${result.root_password}\n\n` + + `⚠️ 비밀번호는 이 메시지에서만 확인 가능합니다.\n` + + `분실 시 서버 콘솔에서 재설정하세요.\n\n` + + `SSH 접속: ssh root@${result.ip_address}` + ); + + logger.info('서버 생성 성공', { + order_id, + instance_id: result.instance_id, + ip_address: result.ip_address, + }); + + message.ack(); // 성공 확인 + + } else { + // 실패 케이스 (executeServerProvision 내부 에러) + throw new Error(result.error || 'Unknown error'); + } + + } catch (error) { + logger.error('서버 생성 실패', error as Error, { + order_id, + attempt: message.attempts, + }); + + // 재시도 (Queue가 자동으로 exponential backoff 적용) + message.retry(); + } + } + }, +}; +``` + +### src/dlq-handler.ts (Dead Letter Queue) + +```typescript +import { createLogger } from './utils/logger'; +import { notifyAdmin } from './services/notification'; +import { sendMessage } from './telegram'; +import type { Env, ProvisionMessage } from './types'; + +const logger = createLogger('provision-dlq'); + +export default { + async queue(batch: MessageBatch, env: Env): Promise { + logger.warn('DLQ 메시지 수신', { count: batch.messages.length }); + + for (const message of batch.messages) { + const { order_id, user_id, telegram_user_id } = message.body; + + logger.error('서버 생성 최종 실패', new Error('최대 재시도 횟수 초과'), { + order_id, + user_id, + telegram_user_id, + }); + + // 1. DB 업데이트 (status='failed') + await env.DB.prepare( + "UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind('최대 재시도 횟수 초과 (DLQ)', order_id).run(); + + // 2. 사용자 알림 + await sendMessage( + env.BOT_TOKEN, + Number(telegram_user_id), + `❌ 서버 생성 실패 (주문 #${order_id})\n\n` + + `일시적인 문제로 서버를 생성할 수 없습니다.\n` + + `관리자에게 문의하세요.` + ); + + // 3. 관리자 알림 + await notifyAdmin( + 'api_error', + { + service: 'server-provision', + error: '서버 생성 최종 실패 (DLQ)', + context: `주문 ID: ${order_id}\n사용자 ID: ${user_id}\nTelegram ID: ${telegram_user_id}`, + }, + { + telegram: { + sendMessage: (chatId: number, text: string) => + sendMessage(env.BOT_TOKEN, chatId, text), + }, + adminId: env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID || '', + env, + } + ); + + message.ack(); // DLQ에서 제거 + } + }, +}; +``` + +--- + +## 구현 단계 + +### Phase 1: Queue 생성 및 기본 설정 + +**목표:** Cloudflare Queue 인프라 구축 + +**작업:** +1. Queue 생성 + ```bash + wrangler queues create server-provision-queue + wrangler queues create provision-dlq + ``` + +2. `wrangler.toml` 설정 추가 + ```toml + [[queues.producers]] + queue = "server-provision-queue" + binding = "SERVER_PROVISION_QUEUE" + ``` + +3. 환경 변수 설정 + ```bash + wrangler secret put WEBHOOK_SECRET + wrangler secret put BOT_TOKEN + wrangler secret put LINODE_API_KEY + wrangler secret put VULTR_API_KEY + ``` + +**검증:** +```bash +# Queue 목록 확인 +wrangler queues list + +# Queue 정보 확인 +wrangler queues describe server-provision-queue +``` + +**예상 소요:** 30분 + +--- + +### Phase 2: Producer 구현 (메시지 전송) + +**목표:** Telegram 봇에서 Queue로 메시지 전송 + +**작업:** +1. `src/index.ts` (telegram-bot-workers) 수정 + - callback_query 핸들러에서 `executeServerProvision()` 제거 + - Queue 메시지 전송 로직 추가 + + ```typescript + // 기존 코드 (타임아웃 발생) + const result = await executeServerProvision(env, userId, telegramUserId, orderId); + + // 새 코드 (즉시 응답) + await env.SERVER_PROVISION_QUEUE.send({ + order_id: orderId, + user_id: userId, + telegram_user_id: telegramUserId, + timestamp: Date.now(), + }); + + return "📋 주문 접수 완료! 서버 생성 중입니다... (1-2분 소요)"; + ``` + +2. 타입 정의 추가 (`src/types.ts`) + ```typescript + export interface ProvisionMessage { + order_id: number; + user_id: number; + telegram_user_id: string; + timestamp: number; + } + ``` + +**검증:** +```bash +# 로컬 테스트 +wrangler dev + +# Queue에 메시지 전송 테스트 +curl -X POST http://localhost:8787/provision \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer test-secret" \ + -d '{"order_id":123,"user_id":456,"telegram_user_id":"789012345"}' + +# Queue 메시지 확인 +wrangler queues consumer server-provision-queue pull +``` + +**예상 소요:** 1시간 + +--- + +### Phase 3: Consumer 구현 (서버 생성) + +**목표:** Queue 메시지를 받아 실제 서버 생성 + +**작업:** +1. 새 Worker 프로젝트 생성 + ```bash + mkdir server-provision + cd server-provision + npm init -y + npm install wrangler --save-dev + ``` + +2. `src/consumer.ts` 구현 + - Queue 메시지 수신 + - `executeServerProvision()` 호출 (기존 로직 재사용) + - 성공 시 Telegram 알림 + - 실패 시 `message.retry()` + +3. `src/server-provision.ts` 복사 (기존 파일 재사용) + - `telegram-bot-workers/src/server-provision.ts` → `server-provision/src/` + +4. 의존성 추가 + ```bash + npm install --save \ + @cloudflare/workers-types \ + # 기타 필요한 패키지 + ``` + +**검증:** +```bash +# 로컬 Consumer 실행 +wrangler dev + +# Queue에 테스트 메시지 전송 (Phase 2 사용) +# → Consumer가 자동으로 처리하는지 확인 + +# 로그 확인 +wrangler tail +``` + +**예상 소요:** 2시간 + +--- + +### Phase 4: Dead Letter Queue 처리 + +**목표:** 재시도 실패 시 최종 처리 + +**작업:** +1. `src/dlq-handler.ts` 구현 + - DLQ 메시지 수신 + - `server_orders.status` → `'failed'` 업데이트 + - 사용자 알림 + - 관리자 알림 (`notifyAdmin`) + +2. `wrangler.toml`에 DLQ Consumer 추가 + ```toml + [[queues.consumers]] + queue = "provision-dlq" + max_retries = 0 # DLQ는 재시도 없음 + ``` + +**검증:** +```bash +# 강제 실패 테스트 (API 키 제거) +wrangler secret delete LINODE_API_KEY + +# Queue 메시지 전송 → 재시도 3회 → DLQ 전송 확인 +# 로그 확인 +wrangler tail --format pretty +``` + +**예상 소요:** 1.5시간 + +--- + +### Phase 5: 통합 테스트 및 배포 + +**목표:** 전체 플로우 검증 및 프로덕션 배포 + +**작업:** +1. 통합 테스트 + - 정상 케이스: 주문 → Queue → 서버 생성 → 알림 + - 실패 케이스: API 에러 → 재시도 → DLQ → 알림 + - 타임아웃 케이스: Telegram Webhook 10초 내 응답 확인 + +2. 모니터링 설정 + - Cloudflare Dashboard → Queue 메트릭 확인 + - `wrangler tail` 로그 확인 + - 관리자 알림 동작 확인 + +3. 프로덕션 배포 + ```bash + # telegram-bot-workers 배포 (Producer) + cd telegram-bot-workers + wrangler deploy + + # server-provision-worker 배포 (Consumer) + cd ../server-provision + wrangler deploy + ``` + +4. 문서 업데이트 + - `CLAUDE.md` - 새 아키텍처 반영 + - `README.md` - 배포 가이드 추가 + - `docs/server-provision-architecture.md` - 본 문서 최종 업데이트 + +**검증 체크리스트:** +- [ ] Telegram 봇에서 서버 주문 시 즉시 응답 (1-2초) +- [ ] 백그라운드 서버 생성 완료 (1-2분) +- [ ] 완료 알림 Telegram 메시지 수신 +- [ ] API 실패 시 자동 재시도 확인 (로그) +- [ ] DLQ 처리 시 관리자 알림 수신 +- [ ] DB `server_orders.status` 올바르게 업데이트 +- [ ] Cloudflare Dashboard Queue 메트릭 정상 + +**예상 소요:** 3시간 + +--- + +## 보안 + +### Service Binding (Worker 간 통신) + +**문제:** 공개 URL로 Worker 호출 시 보안 위험 + +**해결:** Cloudflare Service Binding 사용 + +**설정:** +```toml +# telegram-bot-workers/wrangler.toml + +[[services]] +binding = "SERVER_PROVISION_API" +service = "server-provision-worker" +environment = "production" +``` + +**사용 예시:** +```typescript +// telegram-bot-workers/src/index.ts + +// ❌ 공개 URL (보안 위험) +const response = await fetch('https://server-provision.workers.dev/provision', { + method: 'POST', + headers: { 'Authorization': `Bearer ${env.WEBHOOK_SECRET}` }, + body: JSON.stringify({ order_id, user_id, telegram_user_id }), +}); + +// ✅ Service Binding (내부 통신) +const response = await env.SERVER_PROVISION_API.fetch('https://internal/provision', { + method: 'POST', + body: JSON.stringify({ order_id, user_id, telegram_user_id }), +}); +``` + +**장점:** +- 외부 네트워크 노출 없음 +- 인증 헤더 불필요 (내부 통신) +- 속도 향상 (Cloudflare 내부 네트워크) + +### API 키 관리 + +**원칙:** 모든 민감 정보는 Secrets로 관리 + +**설정:** +```bash +# Telegram +wrangler secret put BOT_TOKEN +wrangler secret put WEBHOOK_SECRET + +# Cloud Provider +wrangler secret put LINODE_API_KEY +wrangler secret put VULTR_API_KEY + +# Admin +wrangler secret put SERVER_ADMIN_ID +``` + +**절대 금지:** +- ❌ 코드에 API 키 하드코딩 +- ❌ `wrangler.toml`에 평문 저장 +- ❌ Git에 `.env` 파일 커밋 + +### Rate Limiting + +**목적:** Queue 메시지 폭주 방지 + +**구현:** +```typescript +// telegram-bot-workers/src/index.ts (callback_query 핸들러) + +// 사용자별 주문 제한 (10개/시간) +const rateLimitKey = `server_order_${userId}`; +const orderCount = await env.RATE_LIMIT_KV.get(rateLimitKey); + +if (orderCount && Number(orderCount) >= 10) { + return "⚠️ 시간당 주문 제한 (10개)을 초과했습니다."; +} + +await env.RATE_LIMIT_KV.put( + rateLimitKey, + String((Number(orderCount) || 0) + 1), + { expirationTtl: 3600 } // 1시간 +); + +// Queue 메시지 전송 +await env.SERVER_PROVISION_QUEUE.send({ ... }); +``` + +--- + +## 모니터링 + +### Cloudflare Dashboard + +**Queue 메트릭 확인:** +1. Cloudflare Dashboard 로그인 +2. Workers & Pages → Queues +3. `server-provision-queue` 선택 + +**주요 지표:** +| 메트릭 | 설명 | 정상 범위 | +|--------|------|----------| +| **Messages Sent** | 전송된 메시지 수 | - | +| **Messages Received** | 처리된 메시지 수 | Sent와 동일 | +| **Messages Retried** | 재시도된 메시지 수 | <5% | +| **Messages DLQ** | DLQ로 전송된 메시지 수 | <1% | +| **Queue Depth** | 대기 중인 메시지 수 | <100 | +| **Consumer Lag** | 처리 지연 시간 | <5분 | + +**알람 설정 (권장):** +- Queue Depth > 500: 백로그 증가 +- Messages DLQ > 10: 시스템 장애 가능성 +- Consumer Lag > 10분: 성능 문제 + +### 로그 확인 + +**실시간 로그 스트리밍:** +```bash +# Producer 로그 +cd telegram-bot-workers +wrangler tail --format pretty + +# Consumer 로그 +cd server-provision +wrangler tail --format pretty +``` + +**로그 필터링:** +```bash +# 에러만 확인 +wrangler tail --format pretty | grep ERROR + +# 특정 order_id 추적 +wrangler tail --format pretty | grep "order_id: 123" + +# JSON 형식 (파싱용) +wrangler tail --format json > logs.json +``` + +### 성공/실패 지표 + +**DB 쿼리로 확인:** +```sql +-- 상태별 주문 수 +SELECT status, COUNT(*) as count +FROM server_orders +WHERE created_at > datetime('now', '-1 day') +GROUP BY status; + +-- 최근 실패 주문 +SELECT id, error_message, created_at +FROM server_orders +WHERE status = 'failed' +ORDER BY created_at DESC +LIMIT 10; + +-- 평균 프로비저닝 시간 +SELECT + AVG((julianday(provisioned_at) - julianday(created_at)) * 24 * 60) as avg_minutes +FROM server_orders +WHERE status = 'active' AND provisioned_at IS NOT NULL; +``` + +**로컬 실행:** +```bash +wrangler d1 execute telegram-conversations --command "SELECT ..." +``` + +**대시보드 구축 (선택):** +- Cloudflare Analytics Engine 연동 +- Grafana + Prometheus +- Custom Admin Panel + +--- + +## 트러블슈팅 + +### 일반적인 문제 + +#### 1. Queue 메시지가 처리되지 않음 + +**증상:** +- Telegram 봇에서 "주문 접수" 메시지 받음 +- 서버 생성 완료 알림이 오지 않음 +- Queue Depth가 계속 증가 + +**원인:** +- Consumer Worker가 배포되지 않음 +- Consumer Worker가 에러로 중단됨 +- D1 Database 바인딩 누락 + +**해결:** +```bash +# Consumer Worker 배포 확인 +wrangler deployments list --name server-provision-worker + +# Consumer 로그 확인 +wrangler tail --name server-provision-worker + +# Queue Consumer 설정 확인 +wrangler queues list +wrangler queues describe server-provision-queue +``` + +--- + +#### 2. 재시도가 즉시 발생함 (Exponential Backoff 안됨) + +**증상:** +- 로그에 "재시도" 메시지가 1초 간격으로 연속 출력 +- Queue 메트릭에서 Messages Retried 급증 + +**원인:** +- `message.retry()` 대신 `message.ack()` + 재전송 사용 +- Cloudflare Queue의 자동 재시도 메커니즘 무시 + +**해결:** +```typescript +// ❌ 잘못된 패턴 +try { + await provision(); +} catch (error) { + // 즉시 재전송 (Exponential Backoff 없음) + await env.SERVER_PROVISION_QUEUE.send(message.body); + message.ack(); +} + +// ✅ 올바른 패턴 +try { + await provision(); + message.ack(); // 성공 시 +} catch (error) { + message.retry(); // 실패 시 (Queue가 자동으로 Exponential Backoff 적용) +} +``` + +--- + +#### 3. DLQ 메시지가 무한 누적 + +**증상:** +- DLQ에 메시지가 쌓이지만 처리되지 않음 +- `server_orders.status`가 여전히 `provisioning` + +**원인:** +- DLQ Consumer가 배포되지 않음 +- DLQ 핸들러 로직 누락 + +**해결:** +```bash +# DLQ Consumer 설정 확인 +wrangler queues describe provision-dlq + +# wrangler.toml에 DLQ Consumer 추가 확인 +[[queues.consumers]] +queue = "provision-dlq" +max_retries = 0 + +# DLQ 메시지 수동 처리 (임시) +wrangler queues consumer provision-dlq pull +``` + +--- + +#### 4. Optimistic Locking 에러 (잔액 차감 실패) + +**증상:** +- 서버는 생성되었지만 잔액이 차감되지 않음 +- `server_orders.status` = `failed` +- 로그: "Version mismatch on balance update" + +**원인:** +- 동시에 여러 출금 요청 발생 +- Optimistic Locking 재시도 실패 + +**해결:** +```typescript +// utils/optimistic-lock.ts에서 재시도 횟수 증가 +export async function executeWithOptimisticLock( + db: D1Database, + operation: (attempt: number) => Promise, + maxRetries = 5 // 기본 3회 → 5회로 증가 +): Promise { + // ... +} +``` + +**관리자 대응:** +1. 서버 생성 완료 확인 (Linode/Vultr 대시보드) +2. DB에서 수동 잔액 차감 + ```sql + -- user_deposits.balance 수동 차감 + UPDATE user_deposits SET balance = balance - 15000 WHERE user_id = 123; + + -- deposit_transactions 기록 + INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) + VALUES (123, 'withdrawal', 15000, 'confirmed', '수동 처리: 서버 #456', CURRENT_TIMESTAMP); + + -- server_orders 상태 업데이트 + UPDATE server_orders SET status = 'active' WHERE id = 456; + ``` + +--- + +#### 5. Telegram 알림이 전송되지 않음 + +**증상:** +- 서버 생성 완료되었지만 Telegram 메시지 없음 +- DB `server_orders.status` = `active` + +**원인:** +- `BOT_TOKEN` 만료/잘못됨 +- `telegram_user_id` 형식 오류 (문자열 vs 숫자) +- Telegram API Rate Limit + +**해결:** +```bash +# BOT_TOKEN 재설정 +wrangler secret put BOT_TOKEN + +# Consumer 로그 확인 +wrangler tail --name server-provision-worker | grep "Telegram" + +# 수동 알림 전송 (임시) +curl -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + -d "chat_id=123456789" \ + -d "text=서버 생성 완료 (#456)" +``` + +--- + +### 로그 명령어 + +**Producer (telegram-bot-workers):** +```bash +# 실시간 로그 +wrangler tail --name telegram-summary-bot --format pretty + +# JSON 로그 (파싱용) +wrangler tail --name telegram-summary-bot --format json + +# 특정 사용자 추적 +wrangler tail --name telegram-summary-bot | grep "telegram_user_id: 123456789" +``` + +**Consumer (server-provision-worker):** +```bash +# 실시간 로그 +wrangler tail --name server-provision-worker --format pretty + +# 에러만 확인 +wrangler tail --name server-provision-worker | grep -E "ERROR|failed" + +# 특정 order_id 추적 +wrangler tail --name server-provision-worker | grep "order_id: 123" +``` + +**Queue 상태:** +```bash +# Queue 메트릭 +wrangler queues describe server-provision-queue + +# DLQ 메트릭 +wrangler queues describe provision-dlq + +# 수동으로 메시지 가져오기 (디버깅) +wrangler queues consumer server-provision-queue pull --batch-size 1 +``` + +--- + +## 참고 자료 + +**Cloudflare 공식 문서:** +- [Queues Documentation](https://developers.cloudflare.com/queues/) +- [Queue Consumer](https://developers.cloudflare.com/queues/configuration/consumer-concurrency/) +- [Dead Letter Queues](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/) +- [Service Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) + +**프로젝트 내부 문서:** +- `CLAUDE.md` - 개발자 가이드 +- `README.md` - 사용자 가이드 +- `docs/ARCHITECTURE.md` - 시스템 아키텍처 + +**관련 코드:** +- `src/server-provision.ts` - 기존 프로비저닝 로직 +- `src/services/linode-api.ts` - Linode API 클라이언트 +- `src/services/vultr-api.ts` - Vultr API 클라이언트 +- `utils/optimistic-lock.ts` - Optimistic Locking 유틸리티 +- `utils/retry.ts` - Retry 로직 + +--- + +**문서 작성일:** 2026-01-24 +**작성자:** Claude Code (Coder Agent) +**버전:** 1.0 +**최종 업데이트:** 2026-01-24