9년 동안 Spring으로 백엔드를 써왔지만 Spring AI 1.0은 새로운 영역이어서 국비지원 과정(스파르타클럽, MSA + Spring AI)을 들었다. 백엔드 구조는 그 강의를 따라 구현했고, 강의가 다루지 않은 프론트는 직접 React로 붙였다. 이 글의 후반부 본체는 그 프론트 구현 과정의 회고다. 동작하는 모습은 라이브 데모에서 확인할 수 있다.
1. 쇼핑몰 검색은 왜 자연어를 못 알아듣는가
검색창에 "겨울에 입을 만한 따뜻한 카디건"이라고 치면 어떤 쇼핑몰도 제대로 된 결과를 주지 못한다. "겨울"과 "따뜻한"과 "카디건"을 각각 키워드로 뜯어서 매칭하기 때문이다. 사용자는 문장으로 말했는데, 시스템은 단어로 받는다. 이 간극이 지금까지의 이커머스 검색이었다.
9년 동안 이커머스 인접 도메인에서 백엔드를 만들어오면서 이 문제를 여러 번 마주쳤다. 해결책으로는 동의어 사전을 두껍게 쌓거나, 카테고리 필터를 많이 붙이거나, 추천 로직을 따로 태우는 방식들이 있었다. 전부 언어를 단어로 분해한 뒤 다시 조립하는 방향의 접근이었다. 그리고 대부분 충분하지 않았다.
Spring AI 1.0이 정식 릴리스되고 나서 이 문제를 다시 들여다볼 기회가 생겼다. 이번엔 문장을 문장 그대로 이해하게 만드는 접근을 시도해보고 싶었다. 임베딩 기반 의미 검색, 이른바 RAG(Retrieval-Augmented Generation)다. Lemuel Mall이라는 사이드 프로젝트는 그 실험장이었다.
목표는 단순했다. "빨간 니트 카디건 있어?"라고 채팅으로 물으면 상품을 찾아 보여주고, "왜 이 제품을 추천했는지"까지 설명하게 만드는 것. 키워드 매칭이 아니라 의미 매칭으로.
2. 왜 Spring AI였나 — 실무 스택과의 정렬
RAG를 시작할 때 Python 생태계가 압도적으로 유리하다는 건 알고 있다. LangChain, LlamaIndex, FastAPI 조합이 튜토리얼도 많고 예제도 풍부하다. 주말에 빠르게 프로토타입을 만들 거라면 Python이 맞다.
그런데도 Spring AI를 택했다. 이유는 세 가지였다.
첫째, 실무 스택과의 정렬. 9년 동안 Java와 Spring으로 백엔드를 만들어왔다. Python으로 RAG를 한 번 돌려본 경험과, Spring으로 RAG를 설계한 경험은 이직 시장에서 전혀 다른 신호가 된다. "Python으로 RAG를 해본 Spring 개발자"가 되고 싶지 않았다. 어디까지나 실무로 돌아갔을 때 바로 꺼내 쓸 수 있는 감각이 필요했다.
둘째, Spring AI 1.0이라는 타이밍. 정식 릴리스가 2024년 5월이었고, 한국어 실전 레퍼런스가 아직 충분하지 않다. 이 타이밍에 실제로 돌아가는 제품을 하나 만들어두면 향후 몇 년간 유효한 차별화 자산이 된다. 2026년 현재 기준으로도 Spring AI 1.0으로 RAG를 프로덕션 수준까지 끌고 간 한국어 글은 생각보다 드물다.
셋째, pgvector와의 운영 친화성. ChromaDB는 개발 편의성이 높지만 결국 별도 인프라 구성 요소다. pgvector는 Postgres의 확장 기능이라, 이미 있는 관계형 DB 안에 벡터 컬럼을 추가하는 방식으로 돌아간다. 상품 테이블에 embedding 컬럼을 하나 얹는 것으로 끝난다. 트랜잭션 경계, 백업 전략, 운영 도구가 전부 기존 Postgres 것을 그대로 쓸 수 있다는 뜻이다. 사이드 프로젝트지만 운영 관점을 놓치고 싶지 않았고, pgvector가 그 관점과 잘 맞았다.
Spring AI 1.0은 ChatClient, VectorStore, EmbeddingModel, DocumentReader, TokenTextSplitter 같은 추상화로 RAG 파이프라인 전반을 감싸준다. 각 인터페이스의 구현체를 갈아끼우는 것만으로 모델과 저장소를 바꿀 수 있어서, 실험 단계에서 벤더 락인 걱정을 미룰 수 있었다. 이 부분은 강의를 들으면서 "Spring 생태계답다"고 느낀 대목이었다. 결국 내가 쓰는 도구가 내가 익숙한 사고방식과 정렬되어 있을 때 학습 곡선이 급격히 완만해진다. 그 경험을 다시 확인했다.
3. 백엔드 구조 — 강의 기반으로 완주한 부분
Spring AI 1.0의 RAG 파이프라인은 다섯 레이어로 정리된다. 상품 데이터 수집, 청크 분할, 임베딩 생성, 벡터 저장, 질의 시 유사도 검색과 프롬프트 조립. 각 레이어에 Spring AI가 대응하는 추상화를 제공한다. DocumentReader가 원천 데이터를 읽고, TokenTextSplitter가 청크를 자르고, EmbeddingModel이 벡터를 만들고, VectorStore가 저장하며, ChatClient가 질의와 응답을 조립한다.
이 구조는 강의에서 배운 그대로 구현했다. 다만 구현하면서 몇 가지 자기 판단이 필요했다. 강의 예제는 일반 문서를 다뤘지만 나는 상품 데이터를 다뤘고, 상품 설명은 보통 문서 한 편보다 훨씬 짧다. 강의 기본값인 청크 크기 500이 내 도메인엔 과해 보였다. 실제로 100~250 범위로 줄여가며 실험했고, 상품명과 상품 설명을 분리해서 메타데이터로 관리하는 쪽이 검색 품질에 유리했다. 이런 판단은 강의에서 직접 가르쳐주지 않는다. 도메인을 아는 사람이 결정해야 하는 영역이다.
임베딩 모델로는 제미나이의 embedding 모델을 사용했다. OpenAI와 비교해 토큰당 비용이 낮고, 한국어 상품 데이터에서도 기대 이상의 품질이 나왔다. 이 선택의 부작용은 섹션 5에서 다시 이야기한다.
여기까지가 백엔드였다. 문제는 이걸 화면에 붙이는 순간 시작됐다.
4. 프론트는 내가 직접 붙였다 — LLM 응답을 위한 UX 재설계
강의는 백엔드까지였다. 프론트는 스스로 붙여야 했고, React를 골랐다. 9년 동안 백엔드로 살아왔지만 JSP, Vue, 제이쿼리, 가끔 React까지 필요한 만큼 만져왔으니 기술 자체는 낯설지 않았다. 낯선 것은 다른 데 있었다. LLM이 붙은 순간, 이커머스 프론트의 기본 가정이 무너졌다는 사실이었다.
4-1. 스트리밍 응답이라는 구조적 차이
전통적인 이커머스 검색은 요청 하나에 응답 하나로 끝난다. GET /products?q=카디건을 던지면 0.3초 안에 JSON 배열이 돌아온다. 프론트는 그걸 받아서 상품 카드를 그리면 끝이다. 9년 동안 내가 다뤄온 프론트-백엔드의 계약은 이 구조였다.
LLM은 그렇지 않다. 토큰을 하나씩 뱉는다. 첫 토큰이 오는 데 1초, 전체 응답이 끝나는 데 5초가 걸리기도 한다. 이걸 "응답이 다 올 때까지 기다렸다가 한 번에 보여주자"로 처리하면 사용자 입장에선 5초 동안 빈 화면이다. ChatGPT가 왜 글자를 하나씩 흘려서 보여주는지 직접 만들어보기 전엔 몰랐다. 그건 연출이 아니라 필연이었다.
Spring AI 백엔드에서 SSE(Server-Sent Events) 엔드포인트로 스트리밍을 열고, React에서는 fetch의 ReadableStream으로 받아 처리했다. 처음엔 도착하는 토큰마다 setState를 호출했는데 리렌더링이 너무 자주 일어나 화면이 버벅였다. 토큰을 일정 간격으로 배치 처리하는 쪽으로 바꿨다. 사용자가 체감하는 "매끄럽게 글자가 흐르는 느낌"은 실제로는 16ms쯤 단위로 모아서 업데이트해야 만들어진다는 걸 배웠다.
백엔드 9년차가 프론트를 붙이면서 처음으로 진지하게 고민한 건 이 지점이었다. API는 요청-응답이 아니라 스트림이 될 수 있다. 그리고 스트림은 전통적 상태 관리 패턴과 맞지 않는다. 채팅 히스토리도 단순히 메시지 배열이 아니라 "완성된 메시지 + 현재 스트리밍 중인 메시지"를 분리해서 관리해야 했다. 이 구조는 강의 어디에도 없었다.
4-2. 5초를 기다리게 하는 법
스트리밍을 붙였다고 해서 지연 문제가 사라지는 건 아니다. 첫 토큰이 오기까지의 1~2초, 그리고 RAG 검색이 포함된 요청의 경우 벡터 검색 + LLM 호출이 합쳐져 총 5~7초가 걸리는 상황도 있었다. 전통적인 이커머스 UX에서 5초는 이탈이 일어나는 시간이다. 스피너 하나로는 부족했다.
단계별로 상태를 드러내는 방식으로 접근했다. "상품을 찾는 중입니다" → "결과를 정리하는 중입니다" → "답변을 생성하는 중입니다" 같은 표현을 흐름에 맞춰 노출했다. 이건 UX를 꾸미는 장식이 아니라, LLM 파이프라인이 내부적으로 무엇을 하고 있는지를 사용자에게 투명하게 공유하는 설계다. 어떤 단계가 얼마나 걸리는지 예상 가능해지면 5초도 참을 만해진다.
구현 관점에서는 백엔드가 파이프라인의 단계 전환마다 SSE로 시그널을 쏴야 한다는 뜻이기도 했다. "검색 시작", "검색 완료, 생성 시작", "생성 완료" 같은 메타 이벤트를 스트림에 함께 흘려보내고, 프론트는 그 이벤트에 따라 상태 문구를 바꾼다. 이 설계 덕분에 나중에 "어느 단계에서 얼마가 걸렸는지"를 로깅으로 관찰하기도 쉬워졌다. UX 요구에서 출발했는데 옵저버빌리티까지 같이 얻은 셈이다.
4-3. 자연어 응답과 상품 그리드가 만날 때
가장 오래 고민한 지점은 여기였다. 사용자가 "겨울에 입을 따뜻한 카디건"이라고 물으면 결과 화면에는 두 가지가 등장해야 한다. LLM이 생성한 자연어 추천문과, 실제 상품 카드 리스트다. 전자는 글이고 후자는 데이터다. 같은 화면에 놓는 순간 충돌이 시작된다.
초기엔 전통적 이커머스의 관성으로 상품 그리드를 위에, 자연어 설명을 아래에 배치했다. 돌려보니 어색했다. 사용자는 자신이 한 질문에 대한 답을 먼저 보고 싶어 하는데, 상품 카드만 먼저 뜨면 "이게 왜 나왔지"라는 인지 공백이 생긴다. 반대로 뒤집어 설명을 위로 올렸더니 설명문이 상품 선택을 방해하는 다른 문제가 생겼다. 사용자는 결국 상품을 보러 온 사람이니까.
최종적으로는 채팅 UI 안에 상품 카드를 인라인으로 삽입하는 방식으로 바꿨다. LLM이 "이 카디건을 추천드려요"라고 말하면 그 문장 바로 아래에 해당 상품 카드가 붙어 나오도록 했다. 이 구조를 만들려면 백엔드 응답 구조를 바꿔야 했다. 자연어 텍스트와 상품 ID 리스트를 별도로 내려보내지 않고, 토큰 스트림 안에 상품 카드 렌더 지시자를 끼워넣는 방식으로 갔다. 프론트는 스트림을 파싱하다가 해당 지시자를 만나면 해당 위치에 상품 카드 컴포넌트를 삽입한다.
이게 이 프로젝트에서 프론트 구조를 한 번 크게 재설계한 유일한 지점이었다. 처음 설계한 "그리드 + 설명문 분리" 구조를 전부 버리고 "대화 흐름에 인라인 삽입" 구조로 다시 만들었다. 백엔드 응답 포맷부터 프론트 파서, 컴포넌트 렌더링까지 전부 손봐야 했지만, 사용자가 느끼는 흐름은 비교할 수 없이 자연스러워졌다. "LLM 응답 안에 UI 컴포넌트가 살아있다" 는 감각은 전통 이커머스 프론트를 만들던 시절에는 없던 것이었다.
출처 표기도 같은 맥락에서 정리됐다. 추천문 아래에 붙는 상품 카드가 곧 출처다. 별도로 "참고한 상품:" 같은 섹션을 두지 않아도, 대화 흐름이 그 자체로 근거를 보여준다. UX가 간결해진 만큼 유지보수도 간결해졌다.
5. 지금 돌아보면 — 비용, 한계, 그리고 다음 스텝
사이드 프로젝트지만 비용은 실제로 발생한다. 제미나이 API가 OpenAI보다 저렴하다고 해도, 상품 데이터를 일괄 임베딩할 때의 비용과, 매번 사용자 질의를 처리할 때의 비용은 다르다. 임베딩은 한 번 만들어 재사용하면 되지만, 질의 시 LLM 호출은 호출 수만큼 선형으로 쌓인다. 데모 사이트로 공개한 뒤에는 낯선 사용량 패턴이 튀는 날이 있었고, 그때마다 대시보드를 열어봤다.
대응은 두 방향이었다. 첫째, 호출 경로별 레이트 리밋과 캐시를 걸었다. 같은 질의가 짧은 시간 안에 반복되면 캐시된 응답을 내려준다. 둘째, 상품 임의 등록 기능의 검증 로직을 강화했다. 초기엔 관리 편의상 상품 등록을 유연하게 뒀는데, 이 경로가 곧 임베딩 호출 비용으로 전이된다는 걸 운영하며 깨달았다. 등록 시점에 필수 필드 검증과 중복 체크를 추가하면서 불필요한 임베딩 생성을 줄였다. 프론트는 이 단계에서도 손을 타야 했다. 에러 메시지를 사용자에게 어떻게 보여줄지, 실패한 등록 요청을 어떻게 재시도하게 할지가 모두 UI 결정이었다.
돌아보면 이 프로젝트는 크게 세 층이 있었다. 강의로 완주한 백엔드, 직접 붙인 프론트, 운영하며 배운 비용 감각. 세 번째 층이 가장 강의가 닿지 않는 영역이었고, 가장 많이 배운 영역이기도 했다.
아직 못 붙인 것들이 많다. 하이브리드 검색(BM25 키워드 + 벡터)은 아직 단일 벡터 검색만 쓰고 있고, 리랭킹 모델도 붙이지 못했다. 검색 품질 평가 지표가 없어서 청크 크기나 모델을 바꿨을 때 실제로 나아졌는지를 수치로 말하기 어렵다. 이 중 평가 지표 도입이 다음 단계로 가장 가치 있다고 본다. "나아졌다"를 느낌이 아니라 숫자로 말할 수 있어야 9년차가 만든 제품이라고 할 수 있을 것 같다.
다음 편에서는 이 프로젝트를 만드는 동안 Claude와 어떻게 일했는지, .claude/commands/ 기반의 에이전트 설계에 대해 쓸 예정이다. 백엔드와 프론트의 UX 재설계 과정에서 Claude를 협업자로 쓴 경험이 꽤 있었고, 그 흔적들을 정리해둘 가치가 있다고 판단했다.
'Software > Maker(Spring & Python & node)' 카테고리의 다른 글
| 반복 산출물 생성을 AI에 맡긴 기록 — 무엇을 넘길 수 있고 무엇은 넘길 수 없었나 (1) | 2026.04.22 |
|---|---|
| Lemuel Mall 프론트를 Claude와 함께 만들며 — 멀티 에이전트 파이프라인을 왜 안 썼나 (0) | 2026.04.22 |
| 바이브 코딩 유의점과 파인튜닝이 필요한 상황 (0) | 2026.04.22 |
| Java 17은 왜 Kotlin과 잘 맞고, Java 25에서는 무엇을 다시 점검해야 할까 (0) | 2026.04.21 |
| 아웃박스 패턴(Outbox Pattern)이란 무엇인가? (0) | 2026.04.21 |