서버리스 환경에서 풀스택 앱을 만들어보고 싶었다. AWS Lambda는 설정이 번거롭고, Vercel은 DB 연동이 따로 필요하고… 그러다가 Cloudflare Workers + D1 조합을 발견했다. Workers에서 바로 SQL 쿼리를 날릴 수 있고, 배포도 wrangler deploy 한 줄이면 끝이다. 여기에 OpenAI API를 연동해서 할 일을 자동으로 분류해주는 기능까지 넣어봤다.

프로젝트 초기화

먼저 Wrangler CLI가 필요하다:

npm install -g wrangler
wrangler login

프로젝트를 생성한다:

npm create cloudflare@latest ai-todo -- --type=hello-world
cd ai-todo

D1 데이터베이스 생성

Cloudflare D1은 SQLite 기반의 서버리스 데이터베이스다. 콘솔에서 만들 수도 있지만 CLI가 더 편하다:

wrangler d1 create ai-todo-db

실행하면 database_id가 출력된다. 이걸 wrangler.toml에 넣어줘야 한다:

name = "ai-todo"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"
database_name = "ai-todo-db"
database_id = "여기에-출력된-ID"

테이블을 만들자. schema.sql 파일을 작성한다:

CREATE TABLE IF NOT EXISTS todos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  content TEXT NOT NULL,
  category TEXT DEFAULT 'uncategorized',
  completed INTEGER DEFAULT 0,
  created_at TEXT DEFAULT (datetime('now'))
);
wrangler d1 execute ai-todo-db --file=./schema.sql

Worker 코드 작성

src/index.ts를 작성한다. 일단 기본적인 CRUD부터:

export interface Env {
  DB: D1Database;
  OPENAI_API_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // CORS 헤더
    const corsHeaders = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
      'Access-Control-Allow-Headers': 'Content-Type',
    };

    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: corsHeaders });
    }

    try {
      if (path === '/api/todos' && request.method === 'GET') {
        const results = await env.DB.prepare(
          'SELECT * FROM todos ORDER BY created_at DESC'
        ).all();
        return Response.json(results.results, { headers: corsHeaders });
      }

      if (path === '/api/todos' && request.method === 'POST') {
        const body = await request.json() as { content: string };
        
        // AI로 카테고리 자동 분류
        const category = await classifyTodo(body.content, env.OPENAI_API_KEY);
        
        const result = await env.DB.prepare(
          'INSERT INTO todos (content, category) VALUES (?, ?)'
        ).bind(body.content, category).run();

        return Response.json(
          { id: result.meta.last_row_id, content: body.content, category },
          { headers: corsHeaders }
        );
      }

      if (path.startsWith('/api/todos/') && request.method === 'PUT') {
        const id = path.split('/').pop();
        const body = await request.json() as { completed: number };
        
        await env.DB.prepare(
          'UPDATE todos SET completed = ? WHERE id = ?'
        ).bind(body.completed, id).run();

        return Response.json({ success: true }, { headers: corsHeaders });
      }

      if (path.startsWith('/api/todos/') && request.method === 'DELETE') {
        const id = path.split('/').pop();
        await env.DB.prepare('DELETE FROM todos WHERE id = ?').bind(id).run();
        return Response.json({ success: true }, { headers: corsHeaders });
      }

      return new Response('Not Found', { status: 404 });
    } catch (error) {
      return Response.json(
        { error: 'Internal Server Error' },
        { status: 500, headers: corsHeaders }
      );
    }
  },
};

AI 카테고리 분류 함수

OpenAI API를 호출해서 할 일의 카테고리를 자동으로 분류한다:

async function classifyTodo(content: string, apiKey: string): Promise<string> {
  const categories = ['업무', '개인', '공부', '운동', '쇼핑', '기타'];
  
  try {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-3.5-turbo',
        messages: [
          {
            role: 'system',
            content: `다음 할 일을 카테고리로 분류해주세요. 카테고리: ${categories.join(', ')}. 카테고리 이름만 답해주세요.`,
          },
          { role: 'user', content },
        ],
        max_tokens: 10,
        temperature: 0,
      }),
    });

    const data = await response.json() as any;
    const result = data.choices[0].message.content.trim();
    return categories.includes(result) ? result : '기타';
  } catch {
    return '기타';
  }
}

API 키는 시크릿으로 관리한다:

wrangler secret put OPENAI_API_KEY

프론트엔드

간단하게 static 폴더에 HTML을 하나 만들었다. Workers에서 정적 파일도 서빙할 수 있지만, 나는 프론트엔드를 Cloudflare Pages에 따로 올리는 방식을 선택했다. API만 Workers에서 처리하고, 프론트는 분리하면 나중에 React나 Vue로 갈아끼우기도 쉽다.

핵심 로직만 보면:

async function addTodo() {
  const input = document.getElementById('todo-input');
  const content = input.value.trim();
  if (!content) return;

  const res = await fetch(`${API_URL}/api/todos`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content }),
  });

  const todo = await res.json();
  renderTodo(todo);
  input.value = '';
}

카테고리별로 색상을 다르게 표시해서 한눈에 구분이 되게 했다. 업무는 파란색, 개인은 초록색, 공부는 보라색 이런 식으로.

배포

배포는 정말 간단하다:

wrangler deploy

이 한 줄이면 Workers에 배포가 완료된다. D1 데이터베이스도 자동으로 연결되고, 시크릿도 이미 설정해뒀으니 바로 동작한다.

느낀 점

Cloudflare Workers + D1 조합은 소규모 프로젝트에 정말 좋다. 무료 티어가 넉넉해서 개인 프로젝트로는 비용이 거의 안 든다. Workers는 하루 10만 요청, D1은 5GB 저장공간이 무료다. AI 기능을 넣으면 OpenAI API 비용이 들긴 하지만, gpt-3.5-turbo로 짧은 분류 작업만 하면 거의 무시할 수준이다.

다만 D1이 아직 베타인 점은 감안해야 한다. 프로덕션 서비스에 쓰기엔 좀 이르고, 개인 프로젝트나 프로토타입용으로 적합하다고 본다. SQLite 기반이라 복잡한 쿼리나 대용량 데이터 처리에는 한계가 있다.

전체적으로 서버리스 풀스택 개발 경험이 상당히 매끄러웠다. Wrangler CLI 하나로 DB 생성부터 배포까지 다 되니까 DevOps에 시간 쓸 필요가 없었다. 다음에는 Durable Objects를 써서 실시간 동기화 기능도 넣어볼 생각이다.