
들어가며
배경
SixPro AI Assistant는 식스샵 프로를 기반으로 만든 이커머스 어드민 AI 어시스턴트로, 채팅으로 상품 관리와 CS 응대를 할 수 있는 개인 프로젝트입니다.
작년에 제가 담당하던 웹사이트 에디터 내에 AI를 활용할 수 있는 기능을 넣고자 여러 아이디어를 떠올리면서 PoC를 진행하게 되었습니다. 그 때 처음으로 LangChain, RAG, LLM Observability 등의 개념을 접하면서 직접 LLM을 활용한 서비스를 만들어보는 것에 관심을 가지게 되었습니다.
PoC가 프로젝트로 이어지지는 못했지만, PoC를 진행하며 고민하고 관심 가졌던 것들을 개인 프로젝트로 완성시켜보고 싶었습니다. 이런 관심으로 LLM 관련 강의를 6주간 수강했는데, PoC 때 혼자 막연히 궁금했던 부분들이 많이 해소되었고 강의에서 배운 내용을 이번 프로젝트에 적용해볼 수 있었습니다.
왜 이커머스 어시스턴트인지?
LLM 강의를 듣다보니 뉴스 기사, 보험 약관 등 다양한 공개 데이터로 실습하는 예시들이 많았는데, 저와 관련 없는 데이터보다는 잘 아는 도메인으로 진행하는 게 재미도 있고 결과도 좋을 것이라고 생각했습니다.
식스샵 프로 가이드 문서는 누구나 접근 가능한 공개 페이지이기도 하고, PoC 때 크롤링과 임베딩 과정에서 많은 고민을 했지만 끝맺지 못했던 부분이 있어 이번 기회에 정리를 해보고 싶었습니다.
식스샵 프로의 도메인과 가이드 문서를 활용하는 프로젝트이다보니 프로젝트 이름도 ‘SixPro’로 비슷하게 정했고, 어시스턴트의 이름은 ‘식식이’로 지어주었습니다.
기술 스택
PoC 때는 회사에 파이썬 개발자가 없어 Fastify(Node.js)로 구현했었는데, LLM 쪽은 파이썬 기반이 대부분이라 이번에는 FastAPI(Python)으로 구현했습니다.
DB는 PostgreSQL을 선택했는데, 판매자, 상품, 대화 기록 등 관계형 데이터를 다루면서도 pgvector 확장을 통해 별도의 벡터 DB 없이 하나의 DB에서 관계형 데이터와 임베딩 벡터를 함께 관리하도록 했습니다.
PoC 때는 LangChain을 사용했지만, 강의를 통해 LangChain이 여러 기능을 연동하는 추상화 레이어라는 걸 이해하게 되면서 이번에는 직접 구현하며 프로세스를 익히고자 했습니다. LLM은 OpenAI의 gpt-4o-mini를 사용했습니다.
FE는 채팅이 주요 기능이고 SSR이 필요 없어 React + Vite + TypeScript로 구성했습니다.
API 통신에는 Tanstack Query를 적용했고, shadcn/ui와 Tailwind CSS로 스타일링했습니다.
FSD 아키텍처를 적용하여 도메인별로 API, 타입, UI 컴포넌트 등을 함께 배치하고 관심사를 분리했습니다.
배포는 BE는 Railway, FE는 Vercel을 사용했습니다.
주요 기능
Function Calling 기반 상품 관리
Function Calling은 LLM이 스스로 판단하여 외부 툴(함수)을 호출할 수 있게 하는 기능입니다.
LLM 호출 시 사용 가능한 툴 목록을 함께 전달하면, LLM이 필요한 툴과 인자를 선택해 반환합니다. 서버에서 해당 툴을 실행한 뒤 결과를 다시 LLM에 전달하면, 이를 종합하여 최종 응답을 생성합니다. 즉 LLM 호출이 최소 두 번 일어나는 구조입니다.
상품 조회와 생성 툴을 만들고 잘 동작하는 것을 확인 후, 아직 작업 전인 상품 삭제를 요청하니 실제로는 삭제하지 못했음에도 삭제가 완료됐다고 응답했습니다.

이런 할루시네이션을 방지하기 위해서는 툴 목록을 전달하는 것 외에도 시스템 프롬프트에 툴 사용에 대한 사용 규칙을 추가해줘야 했습니다.
## 도구 사용
- 제공된 도구만 사용할 수 있어요. 도구로 할 수 없는 작업은 솔직하게 안내하세요.
- 도구를 호출하지 않은 작업을 수행한 것처럼 답변하지 마세요.
- 할 수 없는 작업의 우회 방법을 제안하지 마세요.
처음에는 LLM 호출을 2번으로 고정했는데, 추후 상품 수정과 삭제를 추가하다보니 상품 ID 조회가 선행되어야 해서 2번으로는 부족했습니다. 상품 조회까지만 실행되고 정작 수정/삭제는 실행되지 않은 채 응답이 종료됐습니다.

그래서 LLM 응답에 tool_call이 포함되어 있으면 최대 5번까지 반복하는 Agent Loop 방식을 적용했습니다. 이렇게 하니 하나의 질문에 여러 주제가 있는 멀티쿼리에도 대응이 가능해졌습니다.
RAG 파이프라인
LLM은 특정 분야의 깊이 있는 내용이나 학습 시점 이후 정보, 비공개 자료들에 대해서는 알지 못하는 한계가 있습니다. RAG는 LLM 호출 시 필요한 자료들을 검색하여 전달함으로써 LLM의 부족한 컨텍스트를 보충하는 역할을 합니다.
처음 RAG를 접했을 때는 참고해야 할 데이터를 벡터 DB에 넣고, LLM 요청 시 검색 프로세스만 포함하면 되니 별로 어렵지 않다고 생각했는데요. 제대로 구현하려다보니 데이터 파싱 및 청킹 시 고려할 것들이 많았습니다.
식스샵 프로 가이드는 노션 페이지를 Oopy로 배포한 사이트라서 beautifulsoup4를 이용해서 크롤링했습니다. 이 프로젝트에서는 ‘식스샵 프로 활용하기’ 하위 문서들(총 231페이지)만 사용했습니다. 크롤링 시점은 2026년 2월 19일이며, 주기적인 크롤링은 구현하지 않았습니다.
까다로웠던 부분 중 노션의 ‘토글’ 처리가 있었는데, 토글은 클릭해야 숨겨진 텍스트가 동적으로 그려지기 때문에 크롤링만으로 한 번에 데이터를 가져올 수 없었습니다. 그래서 토글 유무를 파악하여 토글이 없는 페이지는 바로 파싱하고, 토글이 있는 페이지는 Playwright을 사용하여 일정 시간 간격으로 토글을 클릭한 뒤 전체 텍스트를 파싱하도록 구현했습니다.
그 외에도 커스텀 slug를 사용하는 페이지는 URL 리다이렉트 대응이 필요했고, 크롤링 후 Oopy 특유의 구조인 브레드크럼, 검색, 푸터의 글자 등 노이즈를 제거하는 과정도 필요했습니다.
이렇게 가져온 콘텐츠는 markdownify을 사용해 마크다운으로 변환했습니다.
구조화된 텍스트가 토큰 효율성과 컨텍스트 이해에 유리하므로 마크다운으로 추출하면 LLM이 문단의 구조를 더 잘 파악하고 활용하게 됩니다.
변환된 텍스트를 OpenAI 임베딩 모델(text-embedding-3-small)로 벡터화한 뒤 pgvector에 저장했습니다.
크롤링에 생각보다 시간이 걸렸지만, ‘Garbage In, Garbage Out’이라는 표현처럼 임베딩하는 데이터는 최대한 깔끔하게 가공해서 넣는 것이 결과에 직접 영향을 미치기 때문에 꼼꼼히 처리하고자 했습니다.
RAG를 처리하는 방식에는 매 질문마다 벡터 검색을 실행해 컨텍스트로 주입하는 방식과, 검색 자체를 툴로 정의하여 LLM이 필요할 때만 호출하는 방식이 있습니다.
이 프로젝트는 가이드 검색이 필요한 요청과 아닌 요청(상품 CRUD)이 섞여 있어서, 매번 검색하기보다 LLM이 판단하는 후자가 적합하다고 판단했습니다.
이미 Function Calling 구조가 있었기 때문에 search_guide도 같은 방식으로 통합했습니다.
청킹 전략 비교 실험
PoC를 진행하면서 크롤링한 데이터를 어떤 단위와 형태로 벡터 DB에 넣어야 하는지가 고민이었는데요, 한 페이지를 통째로 넣기에는 길이 제약과 페이지별 편차가 있어서, 마크다운 구조를 참조해 적당한 길이로 자르는 방식을 시도하다가 복잡해서 포기했었습니다.
이후 LLM 강의를 통해 다양한 청킹 전략과 RAG 성능 평가 방법을 알게 돼서, 4가지 청킹 전략의 성능을 직접 비교해봤습니다.
테스트 결과 ‘고정 길이 + 오버랩’ 방식이 가장 나은 성능을 보여 이를 적용해봤습니다. 하지만 채팅에서 테스트해보니 생각보다 빗나가는 답변이 많았습니다.
검색을 툴로 정의한 방식의 한계가 여기서 드러났는데,
RAG 성능 평가는 질문을 바로 임베딩하여 검색 결과를 비교하지만
실제 서비스에서는 시스템 프롬프트와 Function Calling을 거치면서 search_guide 툴 자체가 호출되지 않는 케이스가 있었습니다.
시스템 프롬프트에 ‘식스샵 프로와 무관한 질문에 답하지 말라’고 작성했더니,
외부 서비스 연동(구글, 카카오 등)이나 커머스 도메인 관련 일반 질문에 대해 답을 할 수 없다고 응답하는 식이었습니다.

시스템 프롬프트를 몇 차례 개선하기는 했지만, 모든 케이스를 커버하는 프롬프트를 만들기는 어려웠습니다. 매번 응답이 달라지는 LLM의 특성상, 프롬프트 관리가 생각보다 까다롭다는 걸 느꼈습니다.
LLM 로그
LLM은 같은 질문에도 응답이 매번 달라지기 때문에, 입출력을 모니터링하고 추적하는 Observability 확보가 필수적입니다.
이전에 LangSmith를 사용해봤는데, 이번에는 프로젝트 내에 직접 로그를 쌓고 LLM 로그 페이지를 구현했습니다. 화면 우측 상단 프로필 영역을 통해 관리자 페이지로 진입하면 대화 내역마다 사용된 시스템 프롬프트, LLM 모델, 토큰량, 응답 속도, 호출된 툴 정보를 확인할 수 있습니다.
명시적인 인증이 없는 프로젝트라 판매자/관리자 페이지 간 역할 전환이 잘 드러나지 않아, 이를 구분하기 위해 판매자 페이지는 라이트 모드, 관리자 페이지는 다크 모드를 적용했습니다.
SSE 스트리밍
LLM 서비스를 사용해보면 응답을 기다렸다가 한 번에 받기보다 몇 글자씩 빠르게 추가되는 UI를 접하게 됩니다. 이런 UI를 구현하기 위해 SSE(Server-Sent Events)를 사용했습니다.
SSE는 서버에서 클라이언트로 단방향으로 데이터를 지속적으로 전송하는 방식입니다. REST API는 요청-응답 한 번으로 연결이 끝나고, WebSocket은 양방향 연결을 유지하는 반면, SSE는 서버→클라이언트 단방향 스트림만 유지하므로 LLM 응답처럼 서버에서 클라이언트로만 데이터를 보내는 상황에 적합합니다.
OpenAI API에 stream=True 옵션을 전달하면 스트리밍 응답을 받을 수 있고, BE 서버는 이를 AsyncGenerator로 처리하여 각 청크를 yield로 클라이언트에 전달합니다.
텍스트 응답뿐 아니라 Function Calling 실행 시에도 현재 진행 상태를 SSE 이벤트로 전송하여, 클라이언트가 실시간으로 진행 상황을 표시할 수 있도록 했습니다.
클라이언트에서는 SSE Client를 정의해서 ReadableStream으로 받은 응답을 디코딩하고, 각 SSE 이벤트를 파싱하여 이벤트 타입별로 핸들러를 호출하도록 구현했습니다. 채팅 상태가 여러 액션(메시지 추가, 상태 설정, 에러 처리, 대화 히스토리 로드, 초기화 등)으로 복잡하여 useReducer를 사용해 이벤트마다 액션을 매칭해서 상태를 관리했습니다.
응답 중 중단(사용자의 중단 요청, 네트워크 끊김)은 AbortController로 처리했습니다.
채팅 UX
채팅은 메시지가 계속 증가하므로 리스트 가상화를 적용했습니다.
일반 리스트와 달리, 채팅은 맨 아래(최신 메시지)가 기준이 되는 역방향 스크롤이 필요하고, 메시지마다 높이가 다르며, 스트리밍 중 실시간으로 높이가 변하기도 합니다. 이런 채팅 특유의 요구사항을 지원하는 React Virtuoso 라이브러리를 사용했습니다.
일반 채팅과 AI 채팅의 차이점을 고려하여 몇 가지 UX 패턴도 적용했습니다.
먼저, 사용자 질문을 채팅 영역 상단으로 고정하고, 그 아래로 답변이 스트리밍되는 패턴입니다. AI 채팅은 짧은 질문과 긴 답변이 하나의 쌍을 이루기 때문에, 질문을 상단에 고정하면 답변이 길어지더라도 사용자가 자신의 속도에 맞춰 차근차근 읽어내려갈 수 있습니다.
답변이 채팅 영역의 높이를 넘어가더라도 스크롤은 답변이 생성 중인 끝부분을 자동으로 따라가지 않습니다. 자동으로 스크롤되도록 구현하면 기존 텍스트가 계속 위로 밀려 올라가 읽기 어렵기 때문입니다. 단, 사용자가 답변 생성 중 직접 스크롤을 바닥으로 내렸다면 이전 내용을 읽은 것으로 간주하여 이후에는 스크롤이 바닥을 따라가도록 했습니다.
두번째는, 이전 대화를 읽는 중 새 질문을 보냈을 때의 처리입니다. 이 경우 사용자가 새 대화를 시도한 것이므로 스크롤을 이동시켜 답변을 바로 확인할 수 있도록 했습니다. 개인적으로 이전 대화를 읽으면서 질문할 때 강제로 스크롤이 내려가는 방식이 불편하다고 느낀 적이 있어서 강제 이동이 되지 않도록 테스트를 해봤는데, 스크롤 이동이 없으니 답변이 오고 있는지 인지가 어려웠고 스크롤 이동의 필요성을 느꼈습니다.
위 패턴들을 React Virtuoso에 통합하는 것이 까다롭기는 했지만, 직접 구현하면서 각 패턴의 필요성을 이해할 수 있었습니다. 참고한 블로그에서 스토리북으로 개발하면 LLM 비용 없이 편리하다고 해서 시도해봤으나, 스크롤 제어나 질문 상단 고정 같은 UX가 텍스트 추가 타이밍에 민감해서 mock으로는 재현이 어려운 엣지 케이스가 있었고, 결국 실제 LLM 응답으로 테스트하며 구현했습니다.
입력창은 textarea로 구현하여 긴 질문이나 개행에도 대응하도록 했습니다.
익명 세션
인증이 필수가 아닌 서비스지만, 모든 사용자가 동일한 채팅 히스토리와 상품 목록을 공유하는 것은 경험상 좋지 않다고 판단했습니다. 그래서 최대한 허들 없이 경험할 수 있도록 회원가입 과정 없이 익명 세션을 적용했습니다.
처음 진입 시 로컬스토리지에 판매자 정보가 없다면 API를 통해 서버에서 토큰(UUID)을 생성하여 반환합니다. 이때 닉네임을 ‘형용사-동물-숫자’ 형태로 랜덤 생성합니다.
판매자 ID는 대화(conversations)와 상품(products) 테이블의 외래 키(FK)로 사용됩니다.
클라이언트는 토큰을 Zustand Persist로 로컬스토리지에 저장하여 이후 진입 시 동일한 토큰을 참조합니다.
온보딩 가이드
프로젝트의 README.md나 블로그에 가이드를 적어둘 수도 있지만, 가이드를 읽지 않아도 프로젝트에 진입했을 때 자연스럽게 기능을 소개하고 싶었습니다.
온보딩 가이드를 제공하려고 다양한 라이브러리들을 테스트해보니, 대부분 처음 접속 시 모든 기능을 한번에 안내하는 방식이었습니다. 저는 평소에 그런 가이드들을 잘 읽지않고 스킵하는 편이라 실제 도움이 되는 방식을 적용하고 싶었습니다.
사용자가 꼭 경험했으면 하는 3가지 액션(메시지 전송, 상품 등록, 관리자 페이지 진입)을 정의하고, 해당 위치에서 순차적으로 툴팁을 띄우는 방식으로 구현했습니다. 툴팁이 뜨기 전에 해당 액션을 실행했으면 이미 기능을 인지한 것이므로 툴팁이 뜨지 않도록 처리했습니다.
온보딩 상태는 Zustand Persist로 로컬스토리지에 저장하여 맨 처음 한 번만 노출되도록 했습니다.
추천 프롬프트
판매자 어드민에 익숙하지 않은 사용자라면 무엇을 질문해야 할지 막막하고, 가이드에 있는 내용을 질문해야 제대로 답하는 구조라 허들이 더 높겠다고 생각했습니다.
마침 청킹 전략 테스트를 하면서 가이드 내용을 바탕으로 추출해둔 질문들이 있어서, 이를 가공하여 추천 프롬프트로 사용했습니다. 상품 CRUD 기능도 자연스럽게 알릴 수 있도록 등록된 상품 데이터를 기반으로 랜덤한 문장을 생성해서 제공했습니다.
마치며
PoC에서 아쉬웠던 부분을 개인 프로젝트로 구현하면서 재밌게 작업했습니다. 기본 개념을 미리 익혀놓은 덕분에 구현에 집중할 수 있었습니다.
프론트엔드 관점에서 LLM 서비스는 기존 REST API 기반 개발과 다른 고민이 필요했습니다.
기존 API는 요청을 보내고 응답을 받아 렌더링하면 끝이었지만, LLM 서비스는 하나의 요청에 대해 스트리밍 응답이 계속 들어오고, 그 사이에 툴이 호출되기도 하고, 사용자가 중단할 수도 있어서 하나의 요청 안에서 관리해야 할 상태가 훨씬 많았습니다.
BE부터 FE까지 직접 구현하면서 LLM 서비스의 전체 흐름을 이해할 수 있었고, 앞으로 LLM 기반 서비스를 만들 때 어떤 부분을 신경 써야 하는지 감을 잡을 수 있었습니다.
하고 싶은 것들이 계속 떠올랐지만 우선 여기서 마무리를 했습니다. 페이지네이션, 테스트 보강, 어드민 통계(툴별 호출 횟수, 자주 검색된 가이드 문서 순위) 등을 틈틈이 개선해볼 예정입니다.