퍼스널 페이지에 LLM 연동하기 2편 – Astro에 챗봇 연결하기 (feat. Gemini)
TL;DR
퍼스널 페이지에 RAG 기반 LLM 챗봇을 붙이기 위해 Jekyll을 사용했지만, 챗봇 UI 구현과 실시간 처리에 한계를 느껴 Astro + Vercel로 전환했다. Astro의 SSR과 컴포넌트 기반 구조 덕분에 Gemini API와 통신하는 LLM 챗봇 프론트를 구현할 수 있었고, 기존 정적 페이지를 더 유연한 형태로 확장할 수 있게 되었다.
지난 글에서는 Jekyll 기반 퍼스널 페이지에서 Markdown 콘텐츠를 수집하고, 이를 벡터화해 FAISS로 저장하는 지식베이스 구축 과정을 소개했다. 프로젝트의 궁극적인 목표는 이 콘텐츠를 기반으로 작동하는 RAG(Retrieval-Augmented Generation) 챗봇을 구현하는 것이었고, 이를 위한 기초 작업까지는 Jekyll과 GitHub Pages 환경에서도 충분히 진행할 수 있었다.
Jekyll은 정적 사이트 생성 도구로서 빠른 배포와 간편한 유지보수가 가능해 퍼스널 페이지 제작에 매우 적합했고, GitHub Pages와의 연동으로 정적 웹사이트를 자동 배포하는 환경 또한 효율적이었다. 하지만 챗봇 UI를 실제로 웹에 통합하기 시작하면서, 기술적 전환이 불가피해졌다.
내가 만들고자 한 챗봇은 단순한 GPT 인터페이스가 아니라, 웹사이트 내 콘텐츠를 바탕으로 질문에 응답하는 퍼스널 비서형 챗봇이었다. 이를 위한 벡터 검색 및 LLM 연동 구조(RAG)는 이미 준비되어 있었지만, 사용자 인터페이스와 실시간 서버 통신을 결합하는 단계에서 기존 스택의 구조적 한계가 드러났다.
Jekyll은 정적 HTML 생성에 특화된 도구로, 동적인 상호작용을 위한 JS 통합이나 실시간 처리를 염두에 두고 만들어진 구조가 아니었다. React 등 컴포넌트 기반 프론트엔드 프레임워크와의 연동도 제한적이고, 동적 기능을 붙이기 위해선 구조적으로 많은 우회를 필요로 했다. 이런 제약은 챗봇처럼 UI와 실시간 기능이 유기적으로 결합된 경험을 구현하는 데에 결정적인 제약으로 작용했다.
이에 따라 프로젝트는 보다 유연한 기술 스택으로의 전환이 필요했다. 콘텐츠 중심의 정적 퍼포먼스를 유지하면서도, 인터랙티브 한 UI 구성과 외부 API 연동, 서버리스 함수 실행을 자연스럽게 지원하는 환경이 요구되었다. 이 조건을 만족시키기 위해 선택한 조합이 바로 Astro + Vercel이었다. Astro는 Markdown 기반 콘텐츠 처리를 그대로 유지하면서도, React 등 현대 프레임워크를 활용한 UI 구성이 쉬웠고, 필요 시 SSR(Server-Side Rendering)까지 유연하게 도입할 수 있는 아키텍처를 제공했다. Vercel은 정적 페이지와 서버리스 함수(Function)를 함께 지원함으로써, Gemini API 호출 및 벡터 검색 처리를 위한 백엔드 기능까지 매끄럽게 연결할 수 있었기에 마이그레이션을 결정했다.
Jekyll에서 Astro로
Astro로 마이그레이션 한 이후, 전체 웹사이트는 여전히 정적으로 빌드되지만, 챗봇 기능이 필요한 일부 경로에만 SSR을 적용하는 하이브리드 구조로 전환되었다. /api/chat과 같은 경로에는 export const prerender = false를 명시해 해당 엔드포인트가 서버에서만 실행되도록 설정했고, 이를 통해 사용자의 질문을 Gemini API로 전달하고 실시간 응답을 받아오는 흐름이 가능해졌다.
이러한 구조적 전환은 초기 계획에 없던 선택이었지만, 챗봇 UI를 직접 구현하는 과정에서 자연스럽게 도출된 결과였다. 결국 GitHub Pages에서 Vercel로의 배포 환경 이전까지도 이어졌고, 결과적으로 더 나은 확장성과 유연성을 갖춘 기술 스택으로 진화하는 전환점이 되었다.
채팅 UI부터 구현해 보기
프론트엔드 작업의 시작은 사이드바 하단에 배치할 채팅 UI였다. 메시지 입력창, 전송 버튼, 메시지 표시 영역으로 구성된 기본 UI를 만들고, 사용자와 챗봇의 메시지를 각각 연보라색/연파란색 배경의 말풍선으로 시각적으로 구분되도록 했다. 사용자 메시지는 오른쪽, Gemini 응답은 왼쪽으로 정렬해 처리했으며, DaisyUI를 활용해 입력창과 버튼의 디자인을 사이트 전체 스타일에 통일시켰다.
<!-- 채팅 입력창 UI 예시 -->
<div class="flex flex-col h-full">
<div id="chat-messages" class="flex-1 overflow-y-auto p-4"></div>
<form id="chat-input" class="flex gap-2 p-2 border-t">
<input type="text" class="input input-bordered flex-1" />
<button class="btn btn-primary">Send</button>
</form>
</div>
사전 정의된 메시지(첫 번째 welcome 메시지)의 경우에는 스타일 적용이 잘 되었지만, 새로운 입력이 JavaScript로 동적으로 추가되면 클래스 기반 스타일링이 적용되지 않는 문제가 있었다. 이는 Astro의 CSS 스코프 처리 특성 때문이었고, 결국 모든 메시지에 인라인 스타일을 적용하는 방식으로 전환했다. 메시지의 역할(user, assistant)에 따라 스타일을 다르게 주는 함수는 다음과 같다.
function styleMessage(el, role) {
el.style.backgroundColor = role === 'user' ? 'rgba(147,112,219,0.2)' : 'rgba(135,206,235,0.2)';
el.style.borderRadius = role === 'user' ? '16px 16px 0px 16px' : '16px 16px 16px 0px';
el.style.marginLeft = role === 'user' ? 'auto' : '0';
el.style.marginRight = role === 'user' ? '0' : 'auto';
el.style.padding = '0.75rem';
el.style.width = 'fit-content';
el.style.maxWidth = '80%';
}
모바일 환경에서는 사이드바 공간이 부족해지는 문제가 있었기 때문에, 채팅 UI를 토글 방식으로 접었다 펼칠 수 있게 만들었다. 처음에는 모바일에서만 동작하도록 구현했지만, 이후 PC 환경에서도 동일한 방식으로 UI를 제어할 수 있도록 확장했다.
// 접기/펼치기 토글 기능 예시
document.querySelector("#chat-toggle").addEventListener("click", () => {
const container = document.querySelector(".chat-container");
container.classList.toggle("collapsed");
});
레이아웃 구성에서도 사용성을 고려해 입력창과 버튼이 스크롤 시에도 항상 고정되도록 만들었다. overflow-hidden, flex-1, mt-auto 등의 Tailwind 클래스 조합을 통해 스크롤 가능한 메시지 영역과 고정된 입력 영역을 구분했다.
채팅 기록은 localstorage에 저장되며, 한 세션 내에서는 페이지를 이동해도 유지된다. 단, 세션이 새로 시작될 경우나 우측 상단 리셋 아이콘을 통해 수동으로 데이터를 삭제할 경우 새로 채팅을 시작할 수 있다.
Gemini API 연결과 서버 통신 구조
기본 UI가 갖춰진 뒤에는 실질적인 대화 기능을 만들기 위해 /api/chat API를 구성했다. 이 API는 클라이언트로부터 메시지 목록을 받아 Gemini API에 전달하고, 생성된 응답을 다시 클라이언트로 반환하는 역할을 한다. Astro에서 이 API는 서버에서만 동작하도록 prerender = false 옵션을 붙여 구성했다.
// src/pages/api/chat.ts
export const prerender = false;
import { GoogleGenerativeAI } from '@google/generative-ai';
export async function POST({ request }) {
const { messages } = await request.json();
const genAI = new GoogleGenerativeAI(import.meta.env.PUBLIC_GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });
const result = await model.generateContent({ contents: messages });
const response = result.response.text();
return new Response(JSON.stringify({ response }));
}
이 구조는 서버 기능이 필요한 Gemini API 호출을 안정적으로 처리해 주며, 클라이언트는 fetch 요청을 통해 응답을 실시간으로 받아 사용할 수 있다.
WebSocket 없이 서버 연결 상태 추적하기
초기에는 서버 연결이 끊기면 사용자가 이를 인지할 수 없었다. 이는 서버 에러로 인한 문제인지, 아니면 기능 구현 자체가 되지 않은 것인지를 구별할 수 없게 만드는 문제로 이어졌다. 이에 이를 해결하기 위해 WebSocket 대신 주기적으로 /api/health 엔드포인트를 호출해 서버 상태를 확인하는 로직을 구현했다. 만약 서버가 응답하지 않으면 최대 5회까지 재시도하고, 실패 시 오프라인 모드로 전환되어 안내 메시지를 출력한다.
function attemptReconnect() {
let attempts = 0;
const maxAttempts = 5;
const interval = setInterval(() => {
fetch('/api/health')
.then(res => res.json())
.then(data => {
if (data.status === 'ok') {
isServerConnected = true;
clearInterval(interval);
}
})
.catch(() => {
attempts++;
if (attempts >= maxAttempts) {
clearInterval(interval);
switchToOfflineMode();
}
});
}, 3000);
}
// src/pages/api/health.ts
export async function GET() {
return new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
오프라인 모드에서는 사용자가 질문을 해도 실제 Gemini API 호출 없이 미리 정의된 안내 메시지가 출력된다. 이런 전환은 사용자에게 기술적 문제를 명확하게 알려주고, 갑작스러운 오류에 당황하지 않도록 도와준다. 또한 단순히 오프라인이라는 응답만을 주는 것이 아니라, 질문에 이름, 소개 등 특정 키워드가 포함된 경우 사전에 정의된 간단한 답변을 제공할 수 있도록 구성했다.
+) 타이핑 인디케이터로 시스템 동작을 사용자에게 알려주기 (feat. 도널드 노먼)
Gemini 응답을 기다리는 동안 아무런 피드백이 없으면 사용자는 시스템이 멈춘 줄 알 수 있다. 이 때문에, 지금 LLM이 응답을 생성 중이라는 정보를 사용자에게 보여줄 필요가 있었다. 이는 HCI 및 제품 디자인 분야의 대가, 도널드 노먼(Donald A. Norman)의 '10가지 디자인 원칙' 중 하나인, '시스템의 동작을 사용자가 알 수 있게 하라'라는 원칙을 지키고자 한 결과이다. 이를 위해 타이핑 인디케이터를 추가했다. 세 개의 점이 점차 나타나는 애니메이션을 통해 Gemini가 응답 중임을 시각적으로 알리며, 응답이 도착하면 자동으로 제거된다.
function addTypingIndicator() {
const typing = document.createElement('div');
typing.className = 'typing-indicator';
typing.style.display = 'flex';
typing.style.gap = '4px';
typing.style.alignItems = 'center';
for (let i = 0; i < 3; i++) {
const dot = document.createElement('span');
dot.style.width = '6px';
dot.style.height = '6px';
dot.style.borderRadius = '50%';
dot.style.background = '#87ceeb';
dot.style.animation = `typing 1s infinite ease-in-out ${i * 0.2}s`;
typing.appendChild(dot);
}
chatMessages.appendChild(typing);
}
아직 완성은 아니다
현재 챗봇은 Gemini API와 연결되어 일반적인 대화는 가능하지만, 앞서 1편에서 구축한 벡터 지식베이스와는 아직 연결되어 있지 않다. 궁극적으로는 이 두 시스템을 통합해, “내 포트폴리오에 대해 질의응답을 해주는 페르소나 챗봇”이 되게 만들고자 한다. 사용자가 내 프로젝트, 논문, 관심 주제에 대해 자유롭게 질문하고, 정확하고 풍부한 답변을 받을 수 있도록 만드는 것이 최종 목표다.
다음 글에서는 Astro 프로젝트 안에서 벡터 검색 기반 문서 검색을 연동하고, 이를 Gemini API에 통합해 실질적인 RAG 챗봇을 구현하는 과정을 소개할 예정이다. 정적인 퍼스널 페이지에서 출발했지만, SSR과 LLM을 더하면서 점차 지능형 퍼스널 웹앱으로 진화하는 이 흐름이, 나와 비슷한 고민을 하는 분들에게 좋은 참고가 되었으면 한다.
'개인공부&프로젝트' 카테고리의 다른 글
퍼스널 페이지에 LLM 연동하기 1편 - 지식베이스 자동 구축 (0) | 2025.03.30 |
---|---|
왜 RNN보다 트랜스포머가 좋다는걸까? (feat. 혁펜하임의 Easy! 딥러닝) (0) | 2025.02.15 |
PARA 프레임워크로 생산성 관리하기 (feat. LLM Agent) (0) | 2025.02.15 |
GPU 병렬화 기법 (0) | 2025.02.04 |
디퓨전 모델과 ELBO 정리: DDPM 논문을 중심으로 (0) | 2025.02.01 |
댓글