AWS Lambda로 서버리스 Slack 봇 만들기 (4) - AI 추상화와 SAM IaC

sammy·2026년 2월 24일

알고리즘 공부를 자동화하기 위해 만든 Slack 봇 프로젝트 회고 시리즈입니다.
이 글은 4편: AI 멀티 제공자 추상화, AWS SAM IaC를 다룹니다.


이번 편에서 다룰 것

  • GPT / Claude / Gemini를 코드 변경 없이 전환하는 팩토리 패턴
  • solved.ac 문제 정보를 AI 프롬프트에 주입해 리뷰 품질 높이기
  • AWS SAM으로 인프라를 코드로 관리하기 (IaC)

🤖 AI 멀티 제공자 추상화

문제: AI API가 제각각이다

GPT, Claude, Gemini는 각각 API 형태가 다릅니다.

// OpenAI
const result = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: "..." }],
});
const text = result.choices[0].message.content;

// Anthropic Claude
const result = await anthropic.messages.create({
  model: "claude-haiku-4-5",
  messages: [{ role: "user", content: "..." }],
  max_tokens: 4096,
});
const text = result.content[0].text;

// Google Gemini
const model = genai.getGenerativeModel({ model: "gemini-2.0-flash" });
const result = await model.generateContent("...");
const text = result.response.text();

WorkerFunction에서 이 세 가지를 직접 분기 처리하면 AI를 바꿀 때마다 핵심 로직 파일을 열어야 합니다. 좋지 않은 설계입니다.

해결: 인터페이스 + 팩토리 패턴

먼저 공통 인터페이스를 정의합니다.

// src/shared/aiClient.ts

export interface ReviewContext {
  problem?: {
    title: string;
    tier: string;
    tags: string[];
    url: string;
  };
  status?: "solved" | "failed";
}

interface AIClient {
  generateCodeReview(
    code: string,
    language?: string,   // 언어 태그 선택 — Slack이 자동 감지
    context?: ReviewContext,
  ): Promise<string>;

  generateBlogDraft(
    topic?: string,      // 주제 설명 선택
    code?: string,       // 코드 블록 선택
    context?: BlogContext,
  ): Promise<string>;
}

각 AI 제공자를 이 인터페이스로 구현합니다.

class GptClient implements AIClient {
  constructor(
    private readonly model: string,
    private readonly apiKey: string,
  ) {}

  async generateCodeReview(
    code: string,
    language?: string,   // 언어 태그 선택
    context?: ReviewContext,
  ) {
    const openai = new OpenAI({ apiKey: this.apiKey });
    const response = await openai.chat.completions.create({
      model: this.model,
      messages: [
        { role: "system", content: buildSystemPrompt() },
        {
          role: "user",
          content: buildReviewUserMessage(code, language, context),
        },
      ],
    });
    return response.choices[0].message.content ?? "";
  }

  async generateBlogDraft(topic?: string, code?: string, context?: BlogContext) {
    // ... 비슷한 구조
  }
}

// Claude, Gemini도 같은 인터페이스로 구현
class ClaudeClient implements AIClient {
  /* ... */
}
class GeminiClient implements AIClient {
  /* ... */
}

팩토리 함수가 DynamoDB에서 현재 설정을 읽어서 맞는 클라이언트를 반환합니다.

// 모듈 레벨 캐시 (Lambda 웜 컨테이너 재활용)
let cachedClient: AIClient | null = null;

export async function createAIClient(): Promise<AIClient> {
  if (cachedClient) return cachedClient; // 이미 만들었으면 재사용

  // DynamoDB에서 현재 AI 설정 조회
  const config = await getAIConfig();
  // { provider: 'claude', model: 'claude-haiku-4-5', apiKey: 'sk-ant-...' }

  // SSM에서 API 키 조회
  const apiKey = await getParameter("/algo-daily-bot/ai-api-key");

  switch (config.provider) {
    case "gpt":
      cachedClient = new GptClient(config.model, apiKey);
      break;
    case "claude":
      cachedClient = new ClaudeClient(config.model, apiKey);
      break;
    case "gemini":
      cachedClient = new GeminiClient(config.model, apiKey);
      break;
    default:
      throw new Error(`지원하지 않는 제공자: ${config.provider}`);
  }

  return cachedClient;
}

모듈 레벨 캐시
Lambda는 같은 컨테이너를 재사용할 때(웜 스타트) 모듈 레벨 변수가 유지됩니다. AI 클라이언트를 매번 새로 만들면 DynamoDB와 SSM을 매번 호출해야 하지만, 캐시하면 첫 호출 이후로는 생략할 수 있습니다.

WorkerFunction에서는 어떤 AI인지 신경 쓸 필요가 없습니다.

// src/handlers/worker.ts
async function handleReview(payload: ReviewPayload) {
  const ai = await createAIClient(); // GPT든 Claude든 같은 인터페이스

  const review = await ai.generateCodeReview(payload.code, payload.language /* 선택 */, {
    problem: await getProblemById(payload.problemId),
    status: payload.status,
  });

  return review;
}

런타임 AI 전환 - Lambda 재배포 없이

# Claude에서 GPT로 전환
TABLE_NAME=AlgoDailyBotTable \
  npm run setup-ai -- --provider gpt --model gpt-4o-mini --api-key sk-...

이 스크립트가 DynamoDB의 CONFIG#AI_PROVIDER 레코드를 업데이트합니다. Lambda 다음 실행 시 새 설정을 읽어서 GPT를 씁니다. 재배포 불필요.


🧠 문제 맥락 주입 - AI 리뷰 품질 올리기

v1.0의 한계

처음에는 코드만 AI에게 보냈습니다.

AI에게 보낸 메시지

코드 리뷰해줘:

def dijkstra(n, graph):
    ...

그러면 AI는 문제가 뭔지 모른 채 코드만 보고 리뷰해서 뭔가 애매한 답변이 나올 가능성이 있습니다.

v1.1 개선: solved.ac 문제 정보 주입

WorkerFunction이 구성하는 ReviewContext는 이렇습니다.

const context: ReviewContext = {
  problem: {
    title: '최단경로',
    tier: 'Gold IV',
    tags: ['다익스트라', '최단 경로'],
    url: 'https://boj.kr/1753',
  },
  status: 'solved', // 정답 처리된 코드 → 최적화 위주 리뷰
};

getProblemById 함수는 실패해도 리뷰가 중단되지 않도록 graceful degradation 처리합니다.

// src/shared/solvedac.ts
export async function getProblemById(
  problemId: number,
): Promise<ProblemContext | null> {
  try {
    const url = `${BASE_URL}/problem/show?problemId=${problemId}`;
    const data = await fetchWithRetry(url, 2); // 최대 2번 재시도

    return {
      problemId: data.problemId,
      title: data.titleKo,
      tier: getTierName(data.level),
      tags: data.tags.map((t: { key: string }) => t.key),
      url: `https://boj.kr/${data.problemId}`,
    };
  } catch (err) {
    // 조회 실패해도 리뷰는 계속 진행 (문제 정보 없이)
    logger.warn("getProblemById 실패 (무시)", { problemId, err });
    return null;
  }
}

AI에게 보내는 메시지는 맥락을 최대한 담습니다.

// src/shared/aiClient.ts
function buildReviewUserMessage(
  code: string,
  language?: string,   // 언어 태그 선택 — 없으면 AI가 자동 판단
  context?: ReviewContext,
): string {
  const parts: string[] = [];

  // 문제 정보가 있으면 포함
  if (context?.problem) {
    const p = context.problem;
    parts.push(`문제: [${p.tier}] ${p.title} (${p.url})`);
    if (p.tags.length > 0) parts.push(`태그: ${p.tags.join(", ")}`);
  }

  // 정답 여부에 따라 리뷰 방향 지시
  if (context?.status === "solved") {
    parts.push("정답 여부: 정답");
  } else if (context?.status === "failed") {
    parts.push("정답 여부: 오답");
  }

  if (language) parts.push(`언어: ${language}`);
  parts.push(`코드:\n\`\`\`\n${code}\n\`\`\``);

  return parts.join("\n");
}

이제 AI는 이런 맥락을 받습니다.

문제: [Gold IV] 최단경로 (https://boj.kr/1753)
태그: 다익스트라, 최단 경로
정답 여부: 정답
코드:
```
def dijkstra(n, graph):
    ...
```

훨씬 구체적이고 유용한 리뷰가 돌아옵니다.


🏗️ AWS SAM - 인프라를 코드로

IaC(Infrastructure as Code)란?

서버, 데이터베이스, 네트워크 설정을 AWS 콘솔에서 클릭으로 설정하는 대신, YAML 파일에 코드로 정의하는 방식입니다.

장점:

  • Git으로 변경 이력 관리
  • 팀원과 설정 공유 가능
  • 실수 없이 동일한 환경 재현

AWS SAM(Serverless Application Model) 은 Lambda에 특화된 IaC 도구입니다.

핵심 리소스들

# template.yaml 핵심 부분

Transform: AWS::Serverless-2016-10-31

Parameters:
  SlackChannelId: { Type: String }
  ReviewDailyLimit: { Type: Number, Default: 10 }

Resources:

  # ① DynamoDB 테이블
  AlgoDailyBotTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST    # 사용한 만큼만 과금
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true                  # TTL 활성화

  # ② SQS Dead Letter Queue
  WorkerDLQ:
    Type: AWS::SQS::Queue
    Properties:
      MessageRetentionPeriod: 1209600  # 14일 보관

  # ③ Lambda 함수 (공통 설정)
  Globals:
    Function:
      Runtime: nodejs20.x
      Architectures: [arm64]           # ARM이 x86보다 저렴하고 빠름
      Environment:
        Variables:
          TABLE_NAME: !Ref AlgoDailyBotTable

  # ④ 문제 추천 Lambda
  DailyRecommendFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/dailyRecommend.handler
      Timeout: 60
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref AlgoDailyBotTable
        - SSMParameterReadPolicy:
            ParameterName: "algo-daily-bot/*"
      Events:
        DailySchedule:
          Type: ScheduleV2
          Properties:
            ScheduleExpression: "cron(0 9 * * ? *)"
            ScheduleExpressionTimezone: Asia/Seoul

  # ⑤ Slack 이벤트 수신 Lambda
  SlackEventsFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/slackEvents.handler
      Timeout: 10                      # 3초 제한 + 여유
      Events:
        SlackEndpoint:
          Type: HttpApi                # API Gateway HTTP API
          Properties:
            Path: /
            Method: POST

  # ⑥ AI 처리 Lambda
  WorkerFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/worker.handler
      Timeout: 120
      DeadLetterQueue:
        Type: SQS
        TargetArn: !GetAtt WorkerDLQ.Arn
      ReservedConcurrentExecutions: 5  # 최대 5개 동시 실행

배포 흐름

# 1. TypeScript → JavaScript 번들링
sam build

# 2. S3에 코드 업로드 + 패키지 파일 생성
sam package \
  --resolve-s3 \
  --s3-prefix algo-daily-bot \
  --output-template-file .aws-sam/packaged-template.yaml

# 3. CloudFormation으로 배포
aws cloudformation update-stack \
  --stack-name algo-daily-bot \
  --template-body file://.aws-sam/cfn-transformed.json \
  --capabilities CAPABILITY_IAM

📊 비용 - 얼마나 나왔을까?

개인 프로젝트라 사용량이 적지만, 한 달 기준으로 이렇게 나왔습니다.

서비스사용량비용
Lambda일 2회 크론 + 가끔 슬래시 커맨드$0 (프리 티어)
DynamoDB소량 읽기/쓰기$0 (프리 티어 내)
API Gateway월 수십 건$0 (프리 티어 내)
SQS거의 사용 없음$0
SSMStandard Parameter$0 (무료)
합계~$0~$1/월

서버리스의 가장 큰 장점 중 하나입니다. EC2 서버 하나 켜두는 것만으로도 월 $10~20은 나가는데, 개인 프로젝트 수준에서 저비용으로 이용할 수 있습니다.


✏️ 마치며

4편에서는 AI 멀티 제공자 추상화와 SAM IaC를 다뤘습니다.

  • 인터페이스 + 팩토리 패턴으로 GPT / Claude / Gemini를 런타임에 전환
  • solved.ac 문제 정보 주입으로 AI 리뷰 품질 향상
  • SAM으로 Lambda, DynamoDB, SQS를 YAML 한 파일로 관리

다음 편에서는 Lambda 콜드 스타트와 메모리 최적화, 그리고 전체 시리즈 회고를 다루고자 합니다.


📂 GitHub: github.com/sammy0329/algo-daily-bot

시리즈 목차

profile
누군가에게 도움을 주기 위한 개발자로 성장하고 싶습니다.

0개의 댓글