개인 블로그에 AI 챗봇 붙이기: Claude API + RAG
챗봇을 넣은 이유
블로그를 새로 만들면서 방문자가 글을 더 쉽게 탐색할 수 있으면 좋겠다고 생각했어요. 검색 기능은 이미 있지만 검색은 정확한 키워드를 알아야 하잖아요. "이 블로그에서 Cloudflare 관련 글 있어?" 같은 자연어 질문에 답해줄 수 있으면 훨씬 편하겠다 싶었어요.
그래서 블로그 콘텐츠를 기반으로 답변하는 AI 챗봇을 만들기로 했어요.
전체 구조
빌드 타임:
MDX 파일 → build-rag-chunks.ts → rag-chunks.json (500단어 청크)
코드베이스 → build-codebase-summary.ts → codebase-summary.txt
런타임:
사용자 질문
→ 벡터 검색으로 관련 청크 검색
→ 시스템 프롬프트 + 코드베이스 요약 + 관련 청크 + 대화 히스토리
→ Claude API (Haiku)
→ 스트리밍 응답 → 프론트 렌더링
크게 세 부분으로 나뉘어요.
- RAG 데이터 준비: 빌드 타임에 블로그 글을 청크로 쪼개서 JSON으로 생성
- API 라우트: 질문이 들어오면 관련 청크를 찾아서 Claude에게 전달
- 프론트엔드: 플로팅 챗 위젯 + 응답 애니메이션
RAG: 키워드 매칭에서 시맨틱 검색으로
RAG(Retrieval-Augmented Generation)는 AI 모델이 답변할 때 외부 데이터를 검색해서 함께 참고하도록 하는 기법이에요. 모델이 학습하지 않은 정보(여기서는 블로그 글)도 정확하게 답변할 수 있게 해주는 거예요.
처음에는 벡터 DB 없이 단순 키워드 매칭으로 시작했어요. 글이 수백 개도 안 되니까 이 정도면 충분하다고 생각했거든요.
빌드 타임 청크 생성
빌드 스크립트가 MDX 파일을 읽어서 마크다운 문법을 제거하고, 500단어 단위로 쪼갰어요. 청크 간 50단어를 겹쳐서 문맥이 끊기지 않도록 했어요.
// scripts/build-rag-chunks.ts
const CHUNK_SIZE = 500;
const OVERLAP = 50;
function chunkText(text: string): string[] {
const words = text.split(/\s+/);
const chunks: string[] = [];
for (let i = 0; i < words.length; i += CHUNK_SIZE - OVERLAP) {
chunks.push(words.slice(i, i + CHUNK_SIZE).join(" "));
if (i + CHUNK_SIZE >= words.length) break;
}
return chunks;
}결과물은 이런 형태예요.
{
"slug": "cloudflare-d1-blog",
"title": "Cloudflare D1으로 블로그에 좋아요, 댓글, 조회수 붙이기",
"chunkIndex": 0,
"content": "왜 Cloudflare였나 블로그를 새로 만들면서..."
}런타임 키워드 매칭
사용자 질문에서 단어를 추출하고, 각 청크에 해당 단어가 몇 번 등장하는지 세서 점수를 매겨요. 상위 5개를 컨텍스트로 사용했어요.
function findRelevantChunks(chunks: RagChunk[], query: string, limit = 5): RagChunk[] {
const queryWords = query.toLowerCase().split(/\s+/).filter((w) => w.length > 1);
const scored = chunks.map((chunk) => {
const text = `${chunk.title} ${chunk.content}`.toLowerCase();
const score = queryWords.reduce((acc, word) => {
const matches = text.match(new RegExp(word, "g"));
return acc + (matches?.length ?? 0);
}, 0);
return { chunk, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((s) => s.chunk);
}처음에는 이 키워드 매칭 방식으로 충분했어요. 하지만 블로그 검색에서 "admin"으로 검색하면 찾아지는데 "어드민"으로 검색하면 못 찾는 문제를 발견했어요. 영어와 한국어가 같은 의미인데도 단순 키워드 매칭으로는 연결할 수가 없었던 거예요.
이 문제를 계기로 블로그 검색과 챗봇 RAG 모두 Cloudflare Workers AI의 bge-m3 임베딩 모델과 Vectorize 벡터 DB로 전환했어요. 텍스트를 1024차원 벡터로 변환해서 의미 기반 유사도 검색을 하는 방식이에요.
개선 후 달라진 점:
- "admin" ↔ "어드민", "sapporo" ↔ "삿포로"처럼 언어가 달라도 의미적으로 매칭돼요
- "블로그 배포는 어떻게 해?"처럼 직접적인 키워드가 없는 질문도 관련 청크를 찾을 수 있어요
- 글이 많아져도 검색 품질이 유지돼요. 키워드 매칭은 글이 늘어날수록 노이즈가 많아지지만 벡터 검색은 의미 기반이라 영향이 적어요
비용 면에서도 Cloudflare Workers AI와 Vectorize 모두 무료 티어가 넉넉해서 개인 블로그 규모에서는 부담 없이 사용할 수 있어요.
코드베이스 컨텍스트
블로그 글에는 "앞으로 SEO를 개선할 예정이에요"라고 적혀 있는데 실제로는 이미 구현이 끝난 경우가 있어요. 이런 차이 때문에 챗봇이 부정확한 답변을 할 수 있어서 빌드 타임에 코드베이스 현황도 함께 생성하도록 했어요.
// scripts/build-codebase-summary.ts
// 라우트, 컴포넌트, 게시글 목록, 구현된 기능을 자동 감지구현된 기능은 파일 존재 여부와 특정 문자열 포함 여부로 판단해요. 예를 들어 sitemap.ts가 있으면 "SEO: sitemap 자동 생성"으로 감지하고, layout.tsx에 google-site-verification이 포함되어 있으면 "Google Search Console 인증 완료"로 판단하는 식이에요.
이 요약은 시스템 프롬프트에 포함돼서 게시글 내용과 실제 구현 상태가 다를 때 코드베이스 현황을 우선으로 참고하도록 했어요.
작성자 소개 컨텍스트
챗봇에는 "이 블로그 누가 만들었어?"나 "작성자 경력이 어떻게 돼?" 같은 질문도 들어올 수 있어요. 블로그 글만으로는 답하기 어려운 부분이라 작성자 소개 데이터도 시스템 프롬프트에 함께 넣었어요.
// src/app/api/chat/route.ts
const ABOUT_CONTEXT = [
"아래는 블로그 작성자 이승우(Seungwoo Lee) 소개예요.",
"",
"[경력]",
...careers.map((c) => `- ${c.company} ${c.role} (${c.period}): ${c.description}`),
"",
"[기술 스택]",
...skillCategories.map((c) => `- ${c.label}: ${c.skills.map((s) => s.name).join(", ")}`),
// 주요 성과, 사이드 프로젝트도 같은 방식으로 이어붙여요
].join("\n");careers, skillCategories 같은 데이터는 About 페이지에서 쓰는 것과 같은 소스(@/data/about)를 재사용해요. About 페이지와 챗봇이 같은 정보를 보니 동기화 걱정을 하지 않아도 돼요.
Claude를 선택한 이유
LLM API를 고를 때 OpenAI, Gemini, Claude 세 개를 놓고 비교했어요. 최종적으로 Claude를 선택한 이유는 한국어 응답 품질이었어요. 같은 프롬프트를 넣고 비교해봤을 때 Claude가 자연스러운 한국어로 답변하는 비율이 높았어요. 블로그 글 자체가 한국어라서 RAG 컨텍스트를 이해하고 한국어로 답변하는 능력이 가장 중요했거든요.
그리고 prompt caching이 결정적이었어요. 시스템 프롬프트에 코드베이스 요약처럼 매번 똑같이 들어가는 큰 덩어리가 있는데 이 부분을 캐싱해서 입력 비용을 90%까지 줄일 수 있어요. OpenAI에도 비슷한 기능이 있지만 Anthropic 쪽이 cache_control 필드 하나로 적용할 수 있어서 더 간단했어요.
Claude API 연동
Anthropic SDK 사용
@anthropic-ai/sdk로 Claude API를 호출해요. Edge Runtime에서도 잘 동작하고 타입 지원도 좋아요. 응답은 스트리밍으로 받아서 도착하는 텍스트 델타를 그대로 프론트에 흘려보내요.
const client = new Anthropic({ apiKey });
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const events = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
system,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
stream: true,
});
for await (const event of events) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
controller.enqueue(encoder.encode(event.delta.text));
}
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});모델은 Haiku를 선택했어요. 개인 블로그 챗봇에서 Sonnet이나 Opus 급의 추론 능력은 필요 없고, 빠른 응답 속도와 낮은 비용이 더 중요했어요.
Prompt Caching으로 비용 절감
Anthropic의 prompt caching은 시스템 프롬프트의 반복 부분을 캐싱해서 입력 토큰 비용을 90% 절감해주는 기능이에요. 별도 인프라 없이 cache_control 필드 하나만 추가하면 돼요.
const system: Anthropic.Messages.TextBlockParam[] = [
{ type: "text", text: SYSTEM_PROMPT },
{ type: "text", text: ABOUT_CONTEXT, cache_control: { type: "ephemeral" } },
];
if (cachedCodebaseSummary) {
system.push({
type: "text",
text: cachedCodebaseSummary,
cache_control: { type: "ephemeral" },
} as Anthropic.Messages.TextBlockParam);
}
if (contextBlock) {
system.push({ type: "text", text: contextBlock });
}처음엔 RAG 컨텍스트에도 똑같이 cache_control을 적용했어요. 그런데 prompt caching은 프롬프트 앞부분이 바이트 단위로 똑같아야 맞아요. 코드베이스 요약은 매 요청 고정이라 잘 맞아요. 반면, RAG 컨텍스트는 질문마다 검색 결과가 달라서 거의 안 맞았어요. 오히려 매번 캐시를 새로 쓰는 비용만 더 들었죠. 그래서 RAG 컨텍스트는 캐싱에서 빼고 고정된 코드베이스 요약만 캐싱하도록 바꿨어요.
프론트엔드: 챗 위젯
반응형 레이아웃
디바이스에 따라 두 가지 형태로 나뉘어요.
- 데스크탑: 우측 하단에 고정된 380x600 플로팅 패널
- 모바일: 화면 92% 높이의 바텀시트 (드래그로 닫기 지원)
// 모바일 바텀시트 - 드래그 제스처
<motion.div
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 300) {
setOpen(false);
}
}}
>Framer Motion의 drag 속성으로 바텀시트의 드래그 닫기를 구현했어요. 아래로 100px 이상 끌거나 빠르게 스와이프하면 닫히도록 했어요.
응답 애니메이션
처음에는 JSON으로 응답을 한 번에 받아서 문단별로 순차 fade-in 했어요. staggerChildren으로 각 블록(p, ul, ol)이 0.12초 간격으로 나타나는 방식이에요.
// 처음 방식: 응답을 다 받은 뒤 문단별로 순차 fade-in
const blockVariants = {
hidden: { opacity: 0, y: 6 },
visible: { opacity: 1, y: 0, transition: { duration: 1 } },
};
function AnimatedMarkdown({ content }: { content: string }) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.12 } },
}}
>
<Markdown
components={{
p: ({ children }) => (
<motion.p variants={blockVariants}>{children}</motion.p>
),
}}
>
{content}
</Markdown>
</motion.div>
);
}그런데 이 방식은 응답이 완성될 때까지 기다렸다가 한 번에 렌더링해요. 답변이 길수록 빈 화면을 보는 시간이 길어졌어요. 그래서 응답을 스트리밍으로 받으면서 도착하는 대로 화면에 그리는 방식으로 바꿨어요.
바꾸고 나니 새로운 문제가 생겼어요. 스트리밍 텍스트를 일반 텍스트로 그리다가 응답이 끝나면 마크다운으로 렌더링했더니 그 순간 문단과 줄바꿈이 다시 계산되면서 레이아웃이 밀렸어요. 그래서 스트리밍 중에도 완성과 똑같은 마크다운으로 렌더링해 레이아웃을 처음부터 고정하고 텍스트만 단어 단위로 감싸 각각 fade-in 시켰어요.
const wordTransition = { duration: 0.4, ease: "easeOut" as const };
// 마크다운이 렌더한 텍스트를 단어 단위로 감싸 각각 페이드인한다.
// 문단·리스트 같은 블록 구조는 그대로라 레이아웃이 고정된다.
function fadeWords(children: ReactNode): ReactNode {
if (typeof children === "string") {
return children.split(/(\s+)/).map((part, i) =>
part.trim() === "" ? part : (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={wordTransition}
>
{part}
</motion.span>
),
);
}
// 볼드 같은 요소가 섞여 배열로 들어오면 문자열 조각만 재귀로 페이드한다
if (Array.isArray(children)) {
return children.map((child, i) =>
typeof child === "string" ? (
<Fragment key={i}>{fadeWords(child)}</Fragment>
) : (
child
),
);
}
return children;
}
const fadeComponents = {
p: ({ children }) => <p>{fadeWords(children)}</p>,
li: ({ children }) => <li>{fadeWords(children)}</li>,
};key를 단어 단위로 주니까 이미 나타난 단어는 다시 페이드되지 않고 새로 붙는 단어만 부드럽게 등장해요.
또한, Claude가 보내는 청크는 토큰 단위라 여러 단어가 한꺼번에 도착할 때가 있어요. 그대로 보여주니 덩어리째 끊겨 보였어요. 그래서 받은 내용을 버퍼에 쌓아두고 requestAnimationFrame으로 조금씩 따라가며 표시했어요.
// 매 프레임 남은 양의 일부만 더 보여줘 글자가 매끄럽게 이어지게 한다
displayed += Math.max(1, Math.ceil(remaining / 40));애니메이션 재생 방지
단어 페이드는 진행 중인 마지막 메시지에만 적용해요. 완성된 메시지는 페이드 없는 기본 마크다운으로 렌더링하기 때문에 새 답변을 보내거나 대화를 새로 열어 복원해도 이전 메시지가 다시 페이드되지 않아요.
시행착오
API 키 종류 혼동: OAuth 토큰 vs API 키
가장 고생했던 부분이에요. 운영에서 invalid x-api-key 401 에러가 간헐적으로 나는 거예요. 배포 직후에는 되다가 시간이 지나면 안 되는 패턴이었어요.
Cloudflare Edge 문제인 줄 알고 캐시 버스팅, 재시도 로직, 별도 프록시 서버(Vercel)까지 시도했는데 전부 소용없었어요.
결국 에러 응답에 디버그 정보를 넣어서 확인해보니 API 키의 prefix가 문제였어요.
sk-ant-oat01-... ← OAuth Access Token (만료됨)
sk-ant-api03-... ← API Key (영구)
Anthropic 콘솔에서 발급받은 키가 OAuth Access Token(sk-ant-oat01)이었던 거예요. 이 토큰은 만료 기간이 있어서 배포 직후에는 되다가 시간이 지나면 401이 나는 패턴이었어요. 만료되지 않는 영구 API 키(sk-ant-api03)로 교체하니 바로 해결됐어요.
비용
Haiku 모델 기준이에요.
| 항목 | 비용 |
|---|---|
| 입력 | $1.00 / 1M 토큰 |
| 출력 | $5.00 / 1M 토큰 |
| 캐시 읽기 | $0.10 / 1M 토큰 (90% 할인) |
개인 블로그 수준의 트래픽이면 비용은 크게 신경쓰지 않아도 될 거 같아요. Prompt caching까지 적용했으니 시스템 프롬프트 부분은 거의 무료에 가깝고요.
마무리
챗봇을 처음부터 완벽하게 설계하겠다는 생각보다 먼저 만들고 하나씩 개선해 나가자고 생각했는데 그런 방식이 잘 맞았던 거 같아요. 개인 블로그라는 프로젝트라 여러 방식을 적용해보며 비교하는 과정 자체가 흥미로웠고 RAG나 임베딩, 스트리밍 응답 등과 같은 기술들을 부담 없이 실험해볼 수 있었어요.
앞으로도 챗봇의 기능이나 UX, 답변 퀄리티 등을 꾸준히 개선할 것이고 그 내용도 게시글에 업데이트할게요.
마지막으로, 챗봇은 이 블로그 우측 하단의 채팅 버튼을 눌러서 직접 써볼 수 있어요!

