
알고리즘 공부를 자동화하기 위해 만든 Slack 봇 프로젝트 회고 시리즈입니다.
이 글은 4편: AI 멀티 제공자 추상화, AWS SAM IaC를 다룹니다.
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;
}
# 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에게 보냈습니다.
AI에게 보낸 메시지
코드 리뷰해줘:
def dijkstra(n, graph): ...
그러면 AI는 문제가 뭔지 모른 채 코드만 보고 리뷰해서 뭔가 애매한 답변이 나올 가능성이 있습니다.

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 콘솔에서 클릭으로 설정하는 대신, YAML 파일에 코드로 정의하는 방식입니다.
장점:
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 |
| SSM | Standard Parameter | $0 (무료) |
| 합계 | ~$0~$1/월 |
서버리스의 가장 큰 장점 중 하나입니다. EC2 서버 하나 켜두는 것만으로도 월 $10~20은 나가는데, 개인 프로젝트 수준에서 저비용으로 이용할 수 있습니다.
4편에서는 AI 멀티 제공자 추상화와 SAM IaC를 다뤘습니다.
다음 편에서는 Lambda 콜드 스타트와 메모리 최적화, 그리고 전체 시리즈 회고를 다루고자 합니다.
📂 GitHub: github.com/sammy0329/algo-daily-bot
시리즈 목차
- 1편: 아키텍처 설계
- 2편: EventBridge와 DynamoDB 단일 테이블
- 3편: Slack 연동과 비동기 패턴
- 4편: AI 추상화와 SAM IaC ← 현재 글
- 5편: Lambda 콜드 스타트 최적화