← 목록으로 돌아가기

SolidJS로 AI 챗봇 위젯 만들기

SolidJS AI Chatbot SSE

위젯 스타일 격리와 AI 응답 스트리밍 구현기

포트원 AI 챗봇포트원 AI 챗봇

들어가며

포트원에서는 고객사들이 결제 연동 과정에서 겪는 어려움을 해소하기 위해 AI 챗봇을 도입하기로 했어요. 결제 연동 중에 막히는 부분이 있으면 자연어로 질문을 할 수 있고, 관련 문서에서 답변을 찾아주는 챗봇입니다.

이 챗봇은 헬프센터, 블로그, 개발자센터, 콘솔 등 포트원의 여러 웹 서비스에 임베딩되어 있는데요. npm 패키지로 배포해서, 각 서비스에서 간단하게 import해서 사용할 수 있도록 설계했습니다. 포트원에서는 결제 브릿지 페이지, 홈페이지 등 여러 서비스에 SolidJS를 활용하고 있는데, 이번 챗봇도 SolidJS를 이용해 만들었어요.

이 글에서는 프론트엔드 관점에서 고민했던 부분들을 공유하려고 해요. 여러 서비스에 임베딩되는 위젯을 만들 때 스타일 격리를 어떻게 할지, AI 응답을 스트리밍으로 보여주려면 어떤 방식이 좋을지, 그리고 긴 대화에서 컨텍스트를 어떻게 관리할지 같은 결정들이었습니다.

Shadow DOM + iframe 이중 격리 구조

위젯은 호스트 페이지와 스타일이 충돌하지 않도록 격리해야 해요. 보통 iframe이나 Shadow DOM 중 하나를 선택하는데, 저희는 둘 다 사용했어요.

호스트 페이지
└── Shadow DOM (로더 버튼)
    └── iframe (챗봇 앱 전체)

Shadow DOM은 플로팅 버튼을 위해 사용했어요. 버튼은 항상 화면에 떠 있어야 하고, 호스트 페이지의 CSS가 버튼 스타일을 망가뜨리면 안 되니까요. Shadow DOM 안에 버튼 스타일을 완전히 캡슐화했습니다.

iframe은 챗봇 앱 전체를 격리하기 위해 사용했어요. 챗봇은 자체적으로 Tailwind CSS, UI 컴포넌트 등 별개의 스타일 시스템을 가지고 있는데, 이게 호스트 페이지와 충돌하면 안 되거든요.

// Shadow DOM 생성
const container = document.createElement("div")
const shadowRoot = container.attachShadow({ mode: "open" })

// Shadow DOM 안에 버튼과 iframe 컨테이너 배치
shadowRoot.appendChild(button)
shadowRoot.appendChild(iframeContainer)

SSE 기반 스트리밍 응답

EventSource와 2단계 통신 구조

AI 응답은 생성하는 데 시간이 걸리기 때문에, 전체 응답을 기다리지 않고 생성되는 대로 스트리밍하는 방식으로 구현했습니다. 스트리밍 응답을 받을 때는 ReadableStream으로 직접 읽는 방식이 일반적이에요. POST 한 번으로 요청과 응답 스트림을 처리할 수 있거든요.

하지만, 저희는 서버 타임아웃 때문에 다른 방식을 선택했어요. AI가 응답을 생성하려면 MCP를 통해 여러 문서를 검색해야 하는데, 이 과정이 1분 넘게 걸릴 수 있거든요. 그런데 서버 타임아웃이 1분이고, 다른 서비스와 공유하는 인프라라 챗봇만을 위해 타임아웃 시간을 조정하기가 어려웠어요. 그래서 요청과 응답을 분리하는 구조를 택하게 되었습니다.

포트원 AI 챗봇 diagram

POST로 메시지를 보내면 서버는 바로 sessionId를 반환하고 백그라운드에서 AI 응답 생성을 시작해요. 생성된 응답은 Redis에 저장되고, 클라이언트가 sessionId로 SSE 연결을 열면 서버가 Redis를 폴링하면서 새 데이터가 들어올 때마다 스트림으로 전달해줘요.

이 구조의 장점은 EventSource를 쓸 수 있다는 거예요. GET만 지원하는 API라 보통은 제약이 있는데, 처음 요청시 본문을 보내두었기 때문에 문제가 없었어요. 그리고 서버가 event: response, event: tool 같은 이벤트 타입을 보내면 타입별로 리스너를 등록할 수 있어서 코드를 깔끔하게 관리할 수 있는 장점도 있습니다.

이번에 알게됐는데, tool 이벤트는 AI가 문서를 검색하는 등 도구를 사용할 때 발생해요. 응답 생성이 오래 걸릴 수 있어서, 이걸 활용해 “문서를 검색하고 있어요…” 같은 중간 상태를 보여줄 수 있었습니다.

메시지 크기 관리

대화 히스토리는 서버가 아닌 클라이언트 localStorage에 저장해요. 챗봇 특성상 사용자별로 대화 기록을 오래 보관할 필요가 없어서, 인증이나 DB 없이 간단하게 구현했습니다.

다만, AI가 맥락을 파악하려면 매번 이전 대화 내용을 함께 보내야 해서, 대화가 길어지면 문제가 생겨요.

  1. 토큰 비용 증가 - 컨텍스트가 길어질수록 AI API 비용이 늘어남
  2. API 요청 크기 제한 - 서버나 인프라에서 body 크기 제한이 있을 수 있음

그래서 전송할 메시지의 바이트 크기를 계산해서, 제한을 넘으면 오래된 메시지부터 잘라내도록 했어요. 최근 대화 맥락을 어느정도 유지하면서 비용과 크기 제한을 지킬 수 있었습니다.

마무리

다른 동료들이 생성형 AI 해커톤에서 수상한 게 계기가 되어 포트원에 AI 챗봇을 도입하게 되었는데, 덕분에 좋은 경험을 할 수 있었던 것 같습니다. 좋은 아이디어와 인프라를 제공해준 동료들에게 감사 인사를 전합니다 ☺️