본문으로 건너뛰기

← 블로그 · Projects

실험 노트: 블로그 챗봇과 OpenAI API — 무엇을 검증하려 했고 무엇을 배웠나

#Projects #LLM #OpenAI #CaseStudy

실험 노트: 블로그 챗봇과 OpenAI API

이 글은 완성된 제품의 소개가 아니라 실험 기록입니다. 개인 블로그에 OpenAI API 기반 챗봇을 붙이면서 무엇을 검증하려 했고, 어떤 설계를 택했으며, 어디서 방향을 접었고, 그 과정에서 무엇을 배웠는지를 정리합니다.

문제 정의 — 무엇을 검증하려 했나

검증하고 싶었던 질문은 크게 두 가지였습니다.

  1. 개인 블로그 규모의 서비스에 LLM 챗봇을 붙이는 데 필요한 최소 구성은 무엇인가. 별도 백엔드 없이, 블로그가 이미 올라가 있는 Next.js 안에서 해결할 수 있는가.
  2. 블로그 콘텐츠를 답변에 반영하려면 어디까지의 장치가 필요한가. 프롬프트만으로 충분한가, 아니면 RAG 같은 검색 계층이 필요한가.

즉 이 프로젝트의 목표는 “좋은 챗봇”이 아니라, 개인 프로젝트 규모에서 LLM 기능의 적정 복잡도를 찾는 것이었습니다.

배경 지식 정리 — LLM은 어떻게 답을 만드나

설계에 들어가기 전에, API 뒤에서 일어나는 일을 스스로 정리했습니다. 입력과 출력 사이의 동작을 모르면 비용과 한계를 추정할 수 없기 때문입니다.

생성형 AI와 LLM

  • 생성형 AI는 대화, 이야기, 이미지, 동영상, 음악 등 새로운 콘텐츠를 만들어내는 AI의 한 갈래입니다.
  • **LLM(대규모 언어 모델)**은 그중 텍스트의 이해와 생성을 담당하는 기술입니다.

입력에서 출력까지

  1. 자연어 입력을 받는다
  2. Context를 토큰 단위로 쪼갠다
  3. 다음에 올 토큰을 고른다
  4. 3번을 계속 반복한다
  5. 적절한 시점에 생성을 끊는다
  6. 자연어로 출력한다

핵심은 3번, “다음 토큰 고르기”입니다. 내부적으로는 다음 과정을 거칩니다.

토큰 배열 → 임베딩 벡터 배열 → (복잡한 연산) → Context 벡터
→ 다음 토큰의 확률 분포 → 다음 토큰 선택

학습한다는 것의 의미

  • 입력 언어를 하나의 Context 벡터로 치환하는 방법을 학습합니다. 토큰별로 적절한 임베딩 벡터를 매핑하고, 연산 과정의 무수한 가중치를 업데이트하는 일입니다.
  • 그 Context 벡터로부터 다음 토큰의 확률 분포를 계산하는 방법을 학습합니다. Context 벡터를 토큰별 확률 분포로 바꾸는 행렬을 업데이트하는 일입니다.

ChatGPT의 3단 구성

  • Pre-Training — 자연어를 이해하고 토큰을 잘 예측하도록 학습
  • Fine-Tuning & RLHF — 사람의 지시를 잘 따르도록 조정
  • Prompting — 누구나 쉽게 쓸 수 있도록 기본 Context를 작성

이 정리에서 얻은 실용적 결론은 하나였습니다. API 호출 비용은 결국 토큰 수에 비례하고, 컨텍스트에 무엇을 넣느냐가 곧 비용 설계라는 점입니다. 이 관점이 뒤의 RAG 판단에 그대로 이어집니다.

설계 — Next.js API Route 하나로 시작

첫 번째 질문(최소 구성)에 대한 답은 단순했습니다. 블로그가 Next.js(Pages Router) 위에 있으므로, /api 핸들러 하나가 챗봇 백엔드의 전부가 될 수 있었습니다.

  • 클라이언트는 대화 메시지 배열을 POST로 보낸다
  • 서버 핸들러가 system 프롬프트를 앞에 붙여 OpenAI Chat Completions API를 호출한다
  • 응답 메시지를 배열에 덧붙여 돌려준다

당시 작성한 핸들러는 다음과 같습니다.

import type { NextApiRequest, NextApiResponse } from 'next';
import OpenAI from 'openai';
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs';

const openai = new OpenAI({
  apiKey: process.env.NEXT_PUBLIC_OPENAI_API,
  organization: process.env.NEXT_PUBLIC_ORGANIZATION_API
});

type CompletionsResponse = {
  messages: ChatCompletionMessageParam[];
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<CompletionsResponse>
) {
  if (req.method !== 'POST') return res.status(405).end();

  const messages = req.body.messages as ChatCompletionMessageParam[];
  const response = await openai.chat.completions.create({
    messages: [
      {
        role: 'system',
        content:
          '이 챗봇은 해당 블로그의 개발 질문에 성실하게 대답하는 전용 챗봇입니다.'
      },
      ...messages
    ],
    model: 'gpt-3.5-turbo'
  });

  messages.push(response.choices[0].message);

  res.status(200).json({ messages });
}

구조 자체는 의도대로 최소한이었습니다. 다만 지금 다시 보면 명백한 결함이 하나 있습니다. API 키 환경 변수에 NEXT_PUBLIC_ 접두사를 쓴 것입니다. Next.js에서 이 접두사는 값을 클라이언트 번들에 노출시키므로, 서버 전용 시크릿에는 절대 쓰면 안 되는 이름입니다. 서버 핸들러 안에서만 사용했더라도 명명 자체가 사고의 씨앗이며, 이 실험에서 코드보다 값진 교훈 중 하나로 남았습니다.

두 번째 질문 — RAG 도입 시도와 철회

블로그 콘텐츠를 답변에 반영하기 위해, 처음에는 RAG(Retrieval-Augmented Generation)를 도입하려 했습니다. 블로그 포스트를 임베딩해 벡터 데이터베이스에 저장하고, 사용자 질문과 관련된 콘텐츠를 검색해 컨텍스트로 제공하는 설계였습니다.

진행하며 드러난 문제

1. API 비용

  • 블로그 콘텐츠 전체를 임베딩하는 과정에서 상당한 API 비용이 발생했습니다.
  • 사용자 질문마다 검색 결과를 컨텍스트로 밀어 넣으면서 호출당 토큰이 늘었고, 비용이 예상보다 높아졌습니다.
  • 개인 프로젝트 수준의 트래픽에서 이 추가 비용은 부담이었고, 비용 대비 답변 품질 개선은 크지 않았습니다.

2. 오버엔지니어링

  • 개인 블로그 챗봇의 사용량과 요구 복잡도를 놓고 보면, RAG는 문제 크기보다 큰 해법이었습니다.
  • 단순한 프롬프트 엔지니어링만으로도 목적에 충분한 답변 품질이 나왔습니다.
  • 벡터 DB와 검색 파이프라인이 들어오면서 시스템 복잡도와 유지보수 부담이 함께 커졌습니다.

철회 결정

비용과 복잡도, 두 축 모두에서 RAG는 이 프로젝트의 실제 필요를 넘어섰다고 판단해 도입을 철회했고, 기본적인 프롬프트 엔지니어링 방식으로 단순화했습니다. 실패라기보다는, 두 번째 검증 질문에 대한 답을 얻은 것에 가깝습니다. 개인 블로그 규모에서는 프롬프트만으로 충분하다.

한계

이 실험이 다루지 못한 것도 분명히 적어 둡니다.

  • 답변이 모델의 사전 학습 지식과 system 프롬프트에 의존하므로, 블로그의 최신 글을 정확히 인용하는 수준의 응답은 보장하지 못합니다. RAG를 철회하며 의도적으로 받아들인 한계입니다.
  • 요청 인증·사용량 제한 같은 운영 장치는 실험 범위 밖이었습니다. 공개 서비스로 키우려면 가장 먼저 필요한 부분입니다.
  • 위에서 언급한 환경 변수 명명 문제처럼, 최소 구성이라는 목표가 보안 관행의 점검을 건너뛰게 만든 지점이 있었습니다.

배운 것

  1. 기술 도입은 비용과 복잡도 대비 실제 필요로 판단해야 합니다. 특히 LLM에서는 컨텍스트 설계가 곧 비용 설계이고, “더 정확해질 것 같다”는 기대만으로 검색 계층을 얹으면 비용이 먼저 도착합니다.
  2. 작은 프로젝트에는 단순한 해법이 더 적합할 수 있습니다. API Route 하나 + system 프롬프트라는 최소 구성이 이 규모에서는 정답이었습니다.
  3. 오버엔지니어링을 피하고, 실제 필요에 맞는 최소한의 솔루션에서 시작해 필요가 증명될 때 확장하는 편이 낫습니다. RAG를 먼저 만들고 철회한 순서가 아니라, 프롬프트로 시작해 부족함이 증명되면 RAG를 검토하는 순서였다면 임베딩 비용을 아꼈을 것입니다.
  4. 시크릿 관리는 최소 구성에서도 최소가 아닙니다. 환경 변수 이름 하나가 노출 사고로 이어질 수 있다는 점을 코드 리뷰 항목으로 남겼습니다.

이 실험의 결론을 한 문장으로 줄이면 이렇습니다. 기술의 복잡도와 비용은 프로젝트의 실제 필요와 균형을 이뤄야 하며, 그 균형점은 생각보다 단순한 쪽에 있다.