Files
telegram-bot-workers/docs/server-provision-architecture.md
kappa 2494593b62 docs: add server provisioning architecture document
- Queue-based async architecture design
- API spec (POST /provision, GET /status)
- Implementation phases (5 steps, ~8 hours)
- Troubleshooting guide

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:09:21 +09:00

35 KiB

서버 프로비저닝 API 아키텍처

비동기 Queue 기반 서버 생성 시스템 설계 문서

📋 목차

  1. 개요
  2. 현재 문제점
  3. 목표 아키텍처
  4. Cloudflare Queue 설명
  5. API 스펙
  6. 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에 전송

// 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분 소요)";

메시지 구조:

interface ProvisionMessage {
  order_id: number;          // server_orders.id
  user_id: number;           // users.id
  telegram_user_id: string;  // Telegram Chat ID (알림용)
  timestamp: number;         // 전송 시각 (디버깅)
}

Consumer (메시지 처리)

역할: 백그라운드에서 실제 서버 생성 처리

// server-provision-worker/src/consumer.ts

export default {
  async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
    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

자동 재시도 설정:

# 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 처리:

// server-provision-worker/src/dlq-handler.ts

export default {
  async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
    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)

요청:

POST /provision
Content-Type: application/json
Authorization: Bearer {WEBHOOK_SECRET}

{
  "order_id": 123,
  "user_id": 456,
  "telegram_user_id": "789012345"
}

응답 (성공):

{
  "success": true,
  "order_id": 123,
  "message": "주문이 접수되었습니다. 서버 생성 중..."
}

응답 (실패):

{
  "success": false,
  "error": "주문을 찾을 수 없습니다."
}

에러 코드:

코드 설명
400 잘못된 요청 (필수 파라미터 누락)
401 인증 실패 (WEBHOOK_SECRET 불일치)
404 주문 없음 (order_id 불일치)
500 Queue 전송 실패

GET /status/:order_id (사용자 API)

목적: 주문 상태 조회

요청:

GET /status/123
Authorization: Bearer {WEBHOOK_SECRET}

응답:

{
  "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 메시지 스키마

메시지 타입:

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

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)

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<Response> {
    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<ProvisionMessage>, env: Env): Promise<void> {
    const { default: consumer } = await import('./consumer');
    return consumer.queue(batch, env);
  },
};

async function handleProvision(request: Request, env: Env): Promise<Response> {
  // 인증 검증
  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<Response> {
  // 인증 검증 (동일)
  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)

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<ProvisionMessage>, env: Env): Promise<void> {
    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),
            `🎉 <b>서버 생성 완료!</b>\n\n` +
            `• 사양: ${result.plan_label}\n` +
            `• 리전: ${result.region}\n` +
            `• IP: <code>${result.ip_address}</code>\n` +
            `• Root 비밀번호: <code>${result.root_password}</code>\n\n` +
            `⚠️ <b>비밀번호는 이 메시지에서만 확인 가능합니다.</b>\n` +
            `분실 시 서버 콘솔에서 재설정하세요.\n\n` +
            `SSH 접속: <code>ssh root@${result.ip_address}</code>`
          );

          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)

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<ProvisionMessage>, env: Env): Promise<void> {
    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),
        `❌ <b>서버 생성 실패</b> (주문 #${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 생성

    wrangler queues create server-provision-queue
    wrangler queues create provision-dlq
    
  2. wrangler.toml 설정 추가

    [[queues.producers]]
    queue = "server-provision-queue"
    binding = "SERVER_PROVISION_QUEUE"
    
  3. 환경 변수 설정

    wrangler secret put WEBHOOK_SECRET
    wrangler secret put BOT_TOKEN
    wrangler secret put LINODE_API_KEY
    wrangler secret put VULTR_API_KEY
    

검증:

# 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 메시지 전송 로직 추가
    // 기존 코드 (타임아웃 발생)
    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)

    export interface ProvisionMessage {
      order_id: number;
      user_id: number;
      telegram_user_id: string;
      timestamp: number;
    }
    

검증:

# 로컬 테스트
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 프로젝트 생성

    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.tsserver-provision/src/
  4. 의존성 추가

    npm install --save \
      @cloudflare/workers-types \
      # 기타 필요한 패키지
    

검증:

# 로컬 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 추가

    [[queues.consumers]]
    queue = "provision-dlq"
    max_retries = 0  # DLQ는 재시도 없음
    

검증:

# 강제 실패 테스트 (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. 프로덕션 배포

    # 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 사용

설정:

# telegram-bot-workers/wrangler.toml

[[services]]
binding = "SERVER_PROVISION_API"
service = "server-provision-worker"
environment = "production"

사용 예시:

// 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로 관리

설정:

# 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 메시지 폭주 방지

구현:

// 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분: 성능 문제

로그 확인

실시간 로그 스트리밍:

# Producer 로그
cd telegram-bot-workers
wrangler tail --format pretty

# Consumer 로그
cd server-provision
wrangler tail --format pretty

로그 필터링:

# 에러만 확인
wrangler tail --format pretty | grep ERROR

# 특정 order_id 추적
wrangler tail --format pretty | grep "order_id: 123"

# JSON 형식 (파싱용)
wrangler tail --format json > logs.json

성공/실패 지표

DB 쿼리로 확인:

-- 상태별 주문 수
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;

로컬 실행:

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 바인딩 누락

해결:

# 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의 자동 재시도 메커니즘 무시

해결:

// ❌ 잘못된 패턴
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 핸들러 로직 누락

해결:

# 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 재시도 실패

해결:

// utils/optimistic-lock.ts에서 재시도 횟수 증가
export async function executeWithOptimisticLock<T>(
  db: D1Database,
  operation: (attempt: number) => Promise<T>,
  maxRetries = 5 // 기본 3회 → 5회로 증가
): Promise<T> {
  // ...
}

관리자 대응:

  1. 서버 생성 완료 확인 (Linode/Vultr 대시보드)
  2. DB에서 수동 잔액 차감
    -- 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

해결:

# 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):

# 실시간 로그
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):

# 실시간 로그
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 상태:

# Queue 메트릭
wrangler queues describe server-provision-queue

# DLQ 메트릭
wrangler queues describe provision-dlq

# 수동으로 메시지 가져오기 (디버깅)
wrangler queues consumer server-provision-queue pull --batch-size 1

참고 자료

Cloudflare 공식 문서:

프로젝트 내부 문서:

  • 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