왜 포트폴리오 사이트에 AI 챗봇을 붙였는가 — 도입기 1편

AI 챗봇 Alpine.js LLM PostgreSQL

내 포트폴리오 사이트를 훑어보다가 문득 궁금해진다. "이 프로젝트엔 어떤 기술 스택을 썼지?", "이 사람 최근에 뭘 만들었지?" 하지만 답을 얻을 방법이 없다. 프로젝트 카드를 하나씩 열어보거나, 블로그 글을 처음부터 끝까지 읽거나, 아니면 그냥 페이지를 닫는 수밖에 없었다.

사이트가 스스로 방문자의 질문에 답할 수는 없을까?

이 질문 하나에서 시작한 작업이 홈서버 AI 챗봇 도입기의 1편이다.

이미 있던 재료, 아직 쓰이지 않은 인프라

사실 이번 작업 전에도 AI를 호출할 수 있는 준비는 되어 있었다. SUH-AIder라는, Ollama 호환 REST API를 감싼 자체 운영 LLM 게이트웨이 연동 클라이언트를 이미 구축해둔 상태였다. 다만 그건 순수한 인프라였을 뿐, 실제로 그 인프라를 소비하는 기능은 하나도 없었다.

말하자면 전화선은 깔려 있는데 전화기가 없는 상태였다. 이번 작업은 그 전화기를 만드는 일이었고, 동시에 "전화선을 앞으로도 여러 대의 전화기가 나눠 쓸 수 있어야 한다"는 조건이 붙었다. 챗봇 하나만 잘 돌아가면 되는 게 아니라, 나중에 블로그 요약이나 다른 AI 기능이 추가돼도 서로 얽히지 않아야 했다.

Phase 1: 무엇을 만들 것인가

브레인스토밍 단계에서 정한 것들은 크게 세 갈래였다.

첫째, 챗봇의 역할. 단순히 자유 주제로 대화하는 범용 챗봇이 아니라, 포트폴리오 AI 어시스턴트 + 사이트 네비게이션 도우미를 겸하기로 했다. "이 프로젝트 뭐야?" 같은 질문엔 실제 프로젝트/블로그 데이터를 근거로 답하도록.

둘째, 운영 방식. 로그인 없이 누구나 쓸 수 있게 하되, 외부 LLM 서버를 호출하는 만큼 IP당 분당 5회로 남용을 막기로 했다. 대화 내역은 서버에 남기지 않고 브라우저 세션에만 유지한다.

셋째, 확장성. 프롬프트와 사용 모델을 코드가 아니라 Admin 화면에서 관리하고 싶었다. 그리고 챗봇 하나만을 위한 구조가 아니라, 기능(feature)별로 독립된 프롬프트·모델 설정을 가질 수 있어야 했다. 지금은 chatbot 기능 하나뿐이지만, 나중에 다른 기능이 추가돼도 서로의 설정에 영향을 주지 않아야 한다는 뜻이다.

class PromptTemplate(models.Model):
    class Feature(models.TextChoices):
        CHATBOT = 'chatbot', '사이트 챗봇'
        # 새 기능이 생기면 choice만 추가하면 된다

    feature = models.CharField(max_length=50, choices=Feature.choices)
    system_prompt = models.TextField()
    model = models.CharField(max_length=100)
    is_active = models.BooleanField(default=False)

같은 feature 안에서는 항상 활성 프롬프트가 하나뿐이도록 저장 로직을 짰고, 나중에 리뷰 과정에서 이 배타성을 DB 레벨 제약(UniqueConstraint)으로 한 번 더 못박았다. 비활성화된 레코드는 지우지 않아서, 이 테이블 자체가 프롬프트를 얼마나 여러 번 바꿔왔는지 보여주는 이력이 된다.

Phase 2: 진짜 RAG는 아닌, RAG 흉내

챗봇이 "관련 프로젝트를 찾아줘" 같은 질문에 답하려면 뭔가 검색이 필요하다. 여기서 처음 떠오르는 건 임베딩과 벡터 검색이지만, 이번 1편에서는 그 방향으로 가지 않았다.

대신 훨씬 단순한 방법을 썼다. 사용자 메시지를 공백과 구두점 기준으로 토큰 단위로 쪼갠 뒤, 그 토큰이 프로젝트 제목이나 설명에 부분 문자열로 포함되는지만 확인하는 방식이다. 즉 의미를 이해하는 검색이 아니라 글자가 겹치는지만 보는 검색이다.

def _build_project_section(tokens: list[str]) -> str:
    query = reduce(or_, (
        Q(title__icontains=token) | Q(description__icontains=token)
        for token in tokens
    ))
    projects = Project.objects.filter(query)[:3]
    ...

한계는 명확하다. "백엔드 인프라 관련 프로젝트 있어?"라고 물어도 제목·설명에 정확히 그 단어가 없으면 걸리지 않는다. 하지만 이번 1편의 목표는 "일단 사이트가 스스로 답할 수 있게 만드는 것"이었고, 진짜 의미 기반 검색은 의도적으로 다음 편의 몫으로 남겨뒀다.

Phase 3: 리뷰가 잡아낸 것들

구현이 끝났다고 끝이 아니었다. 여러 차례의 코드 리뷰를 거치면서, 혼자서는 쉽게 놓쳤을 문제들이 하나씩 드러났다.

가장 뼈아팠던 건 검색 매칭 방향이 거꾸로 되어 있던 버그였다. Q(title__icontains=user_message)처럼 사용자 메시지 원문 전체를 그대로 넣으면, "이 필드가 메시지 전체를 포함하는가"를 검사하는 꼴이 된다. 실제로 필요한 건 정반대, "메시지 안에 이 필드의 내용이 등장하는가"였다. 방향을 토큰 단위 매칭으로 뒤집고 나서야 제대로 동작했다.

다음으로 발견된 건 클라이언트와 서버 사이의 계약 불일치였다. 서버는 대화 이력을 20개까지만 받아주도록 제한을 뒀는데, 위젯은 세션에 쌓인 전체 대화를 자르지 않고 그대로 보내고 있었다. 그러다 보니 대화가 20개를 넘는 순간부터 모든 전송이 거부되고, 그 실패 메시지마저 대화 목록에 쌓이면서 대화가 스스로 복구 불가능한 상태로 빠지는 문제였다. 개별 기능은 각자 잘 만들어졌는데, 두 기능이 만나는 지점에서 문제가 생긴 셈이다.

배포 설정에서도 하나 걸렸다. 챗봇은 최대 60초 걸리는 외부 API를 호출하는데, 서버는 단일 워커에 스레드 설정이 없었다. 이 상태로는 챗봇에게 말을 거는 요청 하나가 사이트 전체를 잠깐 멈춰 세울 수 있었다. --threads 8을 추가해 해결했는데, 여기서 또 하나의 함정이 있었다. 워커 수를 늘리는 방식으로 해결했다면, 프로세스별로 따로 도는 요청 제한 카운터가 쪼개지면서 "IP당 분당 5회"라는 제한이 워커 수만큼 느슨해졌을 것이다. 그래서 워커가 아니라 스레드로 늘렸다.

혼자 짠 코드를 혼자 검토하면, 자기가 만든 가정을 의심하기 어렵다.

이 문장이 이번 작업에서 가장 크게 체감한 부분이다. 여러 관점에서 반복적으로 검토하는 과정이 없었다면 위 세 가지 모두 운영 중에야 발견됐을 문제들이다.

결과

지금은 사이트 우하단에 챗봇 버튼이 떠 있고, 로그인 없이 질문을 던지면 프로필·프로젝트·블로그를 근거로 답이 돌아온다. 대화는 브라우저에만 남고, 프롬프트와 사용 모델은 Admin에서 바꿀 수 있으며, 나중에 다른 AI 기능이 추가돼도 이번에 만든 인프라를 그대로 가져다 쓸 수 있는 구조로 남겨뒀다.

정성적으로 보면, "사이트가 콘텐츠에 대해 스스로 답한다"는 것 자체가 이전엔 없던 경험이다. 정량적으로는 키워드 매칭 검색 → 검색 결과 3건 이내로 컨텍스트 구성 → 대화 이력 최근 10턴까지만 유지라는 형태로 범위를 좁혀서, 첫 버전을 무겁지 않게 완성했다.

다음 편 예고: 진짜 RAG로

이번 1편에서 만든 검색은 어디까지나 "글자가 겹치는지"만 보는 수준이다. 다음 편에서 도입하고 싶은 기술은 이렇다.

  • 임베딩 기반 시맨틱 검색: 텍스트를 벡터로 변환해 의미가 비슷한 콘텐츠를 찾는 방식. PostgreSQL은 이미 쓰고 있으니 pgvector 확장을 얹으면 별도 벡터 DB 없이도 도입할 수 있다.
  • 한국어 형태소 분석: 지금은 "프로젝트에"처럼 조사가 붙은 토큰이 "프로젝트"와 매칭되지 않는다. 형태소 분석기를 붙이면 이 문제부터 먼저 개선된다.
  • 스트리밍 응답: 지금은 답변이 완성될 때까지 기다려야 한다. 응답을 조금씩 흘려보내는 방식으로 바꾸면 체감 속도가 달라진다.
  • Function Calling: 매 요청마다 무조건 검색부터 하는 대신, LLM이 필요할 때만 검색을 호출하도록 만드는 방식.
  • 대화 요약: 지금은 오래된 대화를 그냥 잘라내는데, 잘라내는 대신 요약해서 맥락을 더 오래 유지하는 방식도 고려해볼 만하다.

다음 편에서는 이 중 임베딩 기반 검색부터 다뤄볼 예정이다.

71 ❤️ 0

댓글

댓글을 작성하려면 GitHub 로그인이 필요합니다.

첫 댓글을 남겨보세요.

AI 챗봇
...