Logo

고지민 개발 블로그

AI로 데이터 콜드 스타트 극복하기 (클라이언트 편)
  • #AI
  • #Project
  • #ModuReview

AI로 데이터 콜드 스타트 극복하기 (클라이언트 편)

고지민

2026-03-03

서버에서 구현한 외부 리뷰 검색 및 요약 기능을 사용자에게 제공하기 위해 단계형 챗봇 인터페이스를 설계하며 고민한 과정을 정리한 글입니다.

시작하며

지난 1편에서는 '데이터 콜드 스타트' 문제를 해결하기 위해 무엇을 고민했는지, 실제로 문제를 해결하기 위해 서버 단에서 AI(Tavily, Groq)를 활용한 검색 및 요약 기능을 어떻게 구현했는지 정리했습니다.

하지만 서버에서 아무리 좋은 기능을 만들었다 한들, 사용자가 쓰기 어려워한다면 그 기능은 결국 좋은 기능이라고 볼 수 없다고 생각합니다.

이번 글에서는 이 기능을 사용자에게 어떻게 전달할 것인가, 즉 클라이언트에서의 UX에 대해 정리해보려고 합니다.

왜 하필 챗봇이었을까?

이 기능을 구현하면서 가장 먼저 고민한 것은 ‘어떤 UI로 제공할 것인가?’였는데요.

단순히 모달을 띄워 검색 폼을 제공할 수도 있었고, 검색 결과 페이지 한편에 작은 배너를 둘 수도 있었습니다. 하지만 제가 최종적으로 선택한 형태는 '단계형 챗봇 UI'였습니다.

여기에는 두 가지 의사결정이 깔려있었는데요.

AI는 여전히 어렵다

최근 수많은 AI 서비스들이 등장했지만, 일반 사용자에게 텅 빈 텍스트 박스 하나를 던져주고 "무엇이든 물어보세요"라고 하는 것은 여전히 불친절하다고 생각합니다.

사용자는 '프롬프트 엔지니어링'을 고민하고 싶지 않습니다. 그저 "프레드 피자 진짜 맛있나?"라는 본질적인 궁금증만 해결하고 싶을 뿐입니다.

챗봇이라는 익숙한 대화형 인터페이스를 제공하면, 사용자가 무엇을 어떻게 입력해야 할지 막막해하는 상황을 방지하고 자연스럽게 질문을 유도할 수 있었습니다.

복잡한 입력 단계를 ‘대화’ 뒤로 숨기기

우리 서비스의 외부 AI 검색은 정확도를 높이기 위해 내부적으로 꽤 여러 단계를 거치는데요.

키워드 입력 ⇒ 유효성 검사 ⇒ 검색 카테고리 선택 ⇒ AI 검증 및 요약 대기 ⇒ 결과 확인

만약 이것을 하나의 정적인 폼 화면으로 구성했다면, 사용자는 한 번에 너무 많은 선택(키워드 작성, 셀렉트 박스로 카테고리 고르기 등)을 강요받아 피로감을 느꼈을 것입니다.

하지만 이것을 챗봇의 'Step'으로 나누고 대화에 녹여낸다면 어떨까요?

  • 모후봇: "어떤 제품의 후기가 궁금하신가요?" (Step 1. 키워드 입력)
  • 모후봇: "정확한 분석을 위해 카테고리를 알려주세요!" (Step 2. 카테고리 선택)

이렇게 사용자는 자신이 '검색 필터'를 세팅하고 있다는 사실조차 모른 채, 그저 봇의 질문에 하나씩 대답하는 것만으로 원하는 결과를 얻게 됩니다.

이렇게 검색 프로세스를 대화로 포장하는 것이 챗봇을 선택한 가장 큰 이유였습니다. 이제 챗봇을 구현하면서 특히 고민했던 4개의 문제들을 하나씩 정리해보겠습니다.

(이 글에선 ‘코드’보다 ‘사용자 경험’에 집중하고자 합니다. 해당 기능에 대한 모든 코드는 링크로 첨부하겠습니다.)

1. 검색 결과가 없을 때 대안 제시하기

우리 서비스를 이용하는 사용자가 가장 크게 실망하는 순간이 언제일까 고민해봤습니다. 아마 원하는 후기를 검색하기 위해 키워드를 입력했는데, ‘검색 결과가 없습니다.’라는 텅 빈 화면을 마주하는 그 찰나라고 생각합니다.

image.png

이 순간 대부분의 사용자는 주저 없이 뒤로 가기를 누르거나 혹은 탭을 닫아버릴 것입니다. 저였어도 필요 없는 서비스라고 생각해 이탈할 겁니다.

챗봇을 구현했어도 화면 구석에 얌전히 있는 챗봇 플로팅 버튼을 사용자가 스스로 발견하고 눌러주길 기대할 수는 없습니다. 따라서 내부 데이터가 없어 결과를 보여주지 못할 경우 챗봇이 먼저 사용자에게 말을 걸 수 있어야 했습니다.

function 키워드검색페이지() { // 키워드 검색 // results => 검색 결과 배열 const {results} = 데이터요청훅() // 채팅 전역 스토어 // openChat => 챗봇 오픈 함수 // limitState => 사용량 상태 const {isOpen, openChat, limitState} = useChatStore( useShallow(state => ({ isOpen: state.isOpen, openChat: state.openChat, limitState: state.limitState, })), ); // 검색 결과가 없고, 사용량(remaining)이 남아있다면 useEffect(() => { if (isOpen) return; if (results.length === 0 && limitState.remaining > 0) { openChat(); // 챗봇을 먼저 열어주기 } }, [results.length, limitState.remaining, openChat]); }

하지만 단순히 챗봇 창을 띄우기만 해선 부족합니다. 만약 챗봇이 열렸는데 “무엇을 검색해 드릴까요?”라고 빈 입력창을 내민다면 어떨까요? 사용자는 속으로 ‘아니 방금 검색창에 쳤잖아…’라며 피로감을 느낄 수도 있습니다.

우리 프로젝트는 키워드 검색 페이지를 /search/:keyword 로 라우팅하고 있습니다. 따라서 현재 머물고 있는 URL에서 검색 키워드를 추출해 챗봇으로 전달했습니다.

export function useChatRouteSync() { const pathname = usePathname(); // 채팅 전역 스토어 // setKeyword => 키워드 setter // setStep => 단계 setter const {setKeyword, setStep} = useChatStore( useShallow(state => ({ setKeyword: state.setKeyword, setStep: state.setStep, })), ); useEffect(() => { if (pathname.startsWith('/search/')) { const segments = pathname.split('/'); const rawKeyword = segments[2]; if (rawKeyword) { const decodedKeyword = decodeURIComponent(rawKeyword); setKeyword(decodedKeyword); setStep('ask'); // 키워드 입력을 건너뛰고 바로 확인 단계로 이동 } } }, [pathname, setKeyword, setStep]); }

이 커스텀 훅에 의해 현재 URL이 /search/프레드피자 일 경우, 컴포넌트가 마운트 될 때 URL을 파싱해 챗봇 스토어에 ‘프레드피자’라는 키워드를 자동으로 주입하게 됩니다.

  • 위 코드 스니펫엔 생략했으나 검색 페이지를 벗어나면 keyword, step은 모두 기본값으로 되돌리고 있습니다.

검색결과없음.gif

결과적으로 사용자는 빈 검색 결과 화면을 보자마자 “제가 대신 검색해서 요약해 드릴 수 있어요!”라는 챗봇의 친절한 제안을 받게 되는데요. 사용자는 검색어를 다시 입력할 필요 없이 그저 수락하는 버튼 하나만 누르면 됩니다.

적어도 이탈하려는 사용자의 발걸음을 1초라도 다시 붙잡을 수 있지 않을까 생각합니다.

2. 에러 메세지도 ‘대화’처럼 보이게 만들기

챗봇 UI를 선택하면서 가장 신경 썼던 사용자 경험은 ‘사용자가 딱딱한 입력 폼을 다루고 있다는 생각이 들지 않게 만들자’ 였는데요.

image.png

일반적인 검색창이나 폼 UX에서 사용자가 입력을 잘못했을 때, 입력 필드 바로 아래에 빨간색 경고 텍스트를 띄우곤 합니다. 우리 프로젝트의 메인 검색바도 그렇게 구현되어 있습니다.

하지만 이 UI는 ‘챗봇’입니다. 만약 사용자가 한 글자만 입력하고 전송을 눌렀는데, 갑자기 입력창 아래에 경고문이 뜬다면 어떨까요?

아마 사용자는 챗봇과 대화하고 있다는 몰입이 깨질 거라고 생각했습니다. 기계적인 폼을 채우고 있다는 느낌을 주니까요.

그래서 에러 상황도 하나의 ‘대화’로 보이게 만들고자 했습니다. 사용자의 잘못된 입력에 대해 에러를 뱉는 대신, ‘모후봇’이 말풍선을 통해 친근하게 다시 입력을 요청하도록 만들었습니다.

안내 메세지 표시하기

먼저 ‘안내’를 위해 공통 UI로 BotResponse, ChatBubble이라는 컴포넌트를 구현했는데요. 각각 ‘봇이 보냄’, ‘채팅 메세지’라는 의미입니다.

export function BotResponse({children}: Props) { return ( <article> <BotAvatar /> <div> <span>모후봇</span> {children} </div> </article> ); } export function ChatBubble({children, variant}: Props) { return <div className={chatBubbleVariants({variant})}>{children}</div>; }

BotResponse 컴포넌트는 자식 요소로 전달된 요소들을 봇이 보낸 영역으로 표시하고, ChatBubble 컴포넌트는 말 그대로 텍스트 요소를 메세지 버블로 표시해줍니다.

<BotResponse> <ChatBubble> 안녕하세요! <strong>모후봇</strong>이에요. </ChatBubble> <ChatBubble>궁금한 제품의 후기를 요약해 드릴게요.</ChatBubble> </BotResponse>

두 컴포넌트를 조합하면 사용자에게 보낼 안내 메세지를 대화 형태로 표시할 수 있게 됩니다.

image.png

에러 상태 표시하기

대부분의 사용자는 입력 필드에 무언가를 타이핑한 후 엔터를 눌렀을 때, 제출이 되길 기대합니다.

챗봇 UI에서도 사용자는 텍스트를 입력한 후 마우스 클릭보다는 자연스럽게 엔터 키를 눌러 메시지가 전송되길 기대할 것인데요.

이 기본적인 기대 동작을 별도의 키보드 이벤트 처리 없이 구현하기 위해 단일 입력 필드였음에도 <form> 태그로 감싸 네이티브 폼 제출 방식을 활용했습니다.

현재 프로젝트는 폼의 상태를 선언적으로 관리하기 위해 react-hook-form을 사용하고 있습니다. react-hook-form의 errors 객체를 구독하면 에러 상태를 화면에 그려낼 수 있습니다.

const form = useForm<FormSchemaType>({ defaultValues: { keyword: '', }, }); const {errors} = form.formState; {/* 에러가 있다면 봇의 말풍선으로 렌더링 */} {errors.keyword && ( <BotResponse> <ChatBubble variant='error'> {errors.keyword.message} </ChatBubble> </BotResponse> )}

이렇게 에러가 발생하면, 위에서 만들어둔 공통 UI인 BotResponseChatBubble 컴포넌트를 사용해 화면에 봇이 메세지를 보낸듯 표시했습니다.

2자 미만 검증 에러.gif

덕분에 사용자는 “두 글자 이상 입력하세요.”라는 차가운 경고 대신, 땀 흘리는 봇의 친근한 대답을 보게 됩니다.

추가로 react-hook-form은 기본적으로 에러 발생 이후엔 사용자가 타이핑할 때마다(onChange) 재검증을 실행하는데요. 그럼 사용자가 글자를 지우고 쓸 때마다 봇의 말풍선이 깜빡거리며 나타났다 사라졌다를 반복하게 됩니다. 대화 흐름상 굉장히 부자연스럽습니다.

const form = useForm<FormSchemaType>({ resolver: zodResolver(FormSchema), mode: 'onSubmit', reValidateMode: 'onSubmit', });

그래서 reValidateModeonSubmit으로 설정해 사용자가 수정을 마치고 다시 제출할 때까지 봇의 안내 메세지가 유지되도록 설정했습니다.

3. 가독성과 신뢰성 확보하기

사용자가 키워드와 카테고리를 선택하고 나면, 드디어 서버를 거쳐 AI가 작성한 요약 결과를 받게 됩니다. 이 단계가 챗봇의 가장 핵심인 결과(result) 단계입니다.

이 단계에서 크게 신경 썼던 UX는 두 가지였는데요. 가독성신뢰성이었습니다.

통짜 텍스트 분리하기 (가독성)

Tavily의 단점 중 하나는 요약 결과의 포맷을 프롬프트로 수정할 수 없다는 점인데요. 서버에서 넘어온 요약 결과(summary)를 보면, 줄바꿈 없이 아주 긴 한 덩어리의 텍스트로 내려옵니다.

image.png

이 상태 그대로 챗봇 말풍선에 집어넣으면 위 사진과 같이 화면을 꽉 채우는 거대한 글씨 덩어리가 등장하게 됩니다. 저는 이 텍스트 덩어리를 보자마자 눈이 피곤해져 창을 닫아버리고 싶었습니다.

그나마 다행인 점이라면, 적어도 문장 단위를 마침표로 구분은 해주고 있었습니다. 그래서 아주 단순하지만 효과적인 방식으로 클라이언트 단에서 텍스트를 가공했는데요.

export function FormattedSummary({text}: Props) { // 마침표와 공백('. ')을 기준으로 문장 분리 const sentences = text.split('. ').filter(s => s.trim().length > 0); return ( <div className="flex flex-col gap-2"> {sentences.map((sentence, index) => { const cleanSentence = sentence.trim(); // 분리하면서 떨어져 나간 마침표 붙이기 const finalSentence = cleanSentence.endsWith('.') ? cleanSentence : `${cleanSentence}.`; return ( <p key={index}> {finalSentence} </p> ); })} </div> ); }

문장 단위(마침표)로 스플릿해 단락을 나눴습니다. 완벽한 의미 단위의 분리는 아니긴 했지만, 적어도 한 덩어리로 뭉쳐있던 텍스트를 시각적으로 숨이 쉬어질만하게 분리할 수 있었습니다. (PoC 단계에서 비용 대비 효과가 가장 큰 가공이기도 했습니다. 돈이 안 들었기 때문..)

image.png

이거 AI가 지어낸 거 아닌가? (신뢰성)

AI 요약은 빠르고 편리합니다. 하지만 사용자는 ‘이거 진짜 있는 후기 맞아? 지어낸 거 아니야?’라는 의심을 가질 수 있습니다.

NextResponse.json({ status: 'success', summary: tavilyResponse.answer, sources: tavilyResponse.results.map(item => ({ title: item.title, url: item.url, snippet: item.content, })), });

서버는 요약 정보(summary) 외에도 요약에 사용된 출처(sources) 정보를 함께 제공하고 있습니다. 따라서 이 출처 정보를 클라이언트 측에서 표기해 신뢰성을 확보하고자 했는데요.

이미 요약 정보로 텍스트가 가득한 상태에서 출처 정보마저 날것의 텍스트로 제공한다면 사용자는 너무 많은 양의 텍스트로 피로감을 느낄 수 있겠다고 생각했습니다.

그래서 출처를 단순한 링크 목록이 아니라, 해당 출처의 제목과 스니펫(미리보기), 그리고 출처 도메인을 함께 보여주는 ‘카드 형태’로 시각화했습니다.

export function SourceCard({source}: Props) { const getDomain = (url: string) => { try { // URL 객체를 사용해 호스트네임만 추출 return new URL(url).hostname.replace('www.', ''); } catch { return 'link'; } }; return ( <a href={source.url} target="_blank" rel="noopener noreferrer"> <h4>{source.title}</h4> <p>{source.snippet}</p> <div> <LucideIcon name="Link" /> <span>{getDomain(source.url)}</span> {/* 도메인만 깔끔하게 표시 */} </div> </a> ) }

도메인을 재가공한 이유는 url에 인코딩된 불필요한 문자열은 사용자에게 불필요한 정보라고 판단했기 때문인데요.

사용자에겐 이 정보가 ‘네이버 블로그’에서 왔는지 혹은 ‘티스토리’에서 왔는지가 중요하지, 뒤에 붙는 복잡한 주소는 알 필요가 없다고 생각했습니다.

  • https://blog.naver.com/%EB%B0%94%EC%A7%EB%A7blog.naver.com
export default function SourceCarousel({sources}: Props) { return ( <div> <Carousel> {sources.map((source, idx) => ( <SourceCard key={idx} source={source} /> ))} </Carousel> </div> ); }

이렇게 만들어진 출처 카드들은 세로로 길게 늘어지지 않게 캐러셀 컴포넌트로 감싸 말풍선 하단에 배치했습니다.(캐러셀은 react-multi-carousel을 사용했기 때문에 별도의 코드 첨부는 하지 않겠습니다.)

출처 표시.gif

결과적으로 사용자는 편안하게 문단이 나뉜 요약을 읽고, 의심이 가는 부분이 있다면 아래의 출처 카드를 통해 원본 글을 쉽게 교차 검증할 수 있게 됐습니다.

4. 상태관리 - 챗봇을 작은 어플리케이션으로 보기

처음 챗봇 UI를 기획할 때만 해도 상태 관리를 깊게 고민하진 않았는데요. 그냥 상위 컴포넌트에서 useStatestep 상태 하나만 들고 조건부 렌더링으로 화면을 갈아끼우면 충분할 줄 알았습니다.

// 처음 생각했던 아주 단순한 구조 const [step, setStep] = useState('input'); {step === 'input' && <Input />} {step === 'ask' && <Ask />} {step === 'result' && <Result />} // ...

하지만 위에서 짚어본 여러 UX들을 하나씩 구현하다 보니, 챗봇이 다뤄야할 상태가 점점 많아지고 복잡해졌습니다. 단순히 단계(step)만 알아야 하는 것이 아니었습니다.

  1. useChatRouteSync 훅은 URL에서 추출한 키워드를 챗봇에 밀어 넣어야 했습니다.
  2. 챗봇 상단의 헤더 컴포넌트는 현재 남은 사용량을 보여줄 수 있어야 했습니다.
  3. 각 단계(step) 컴포넌트는 다음 단계로 변경하거나, 키워드 혹은 카테고리를 지정할 수 있어야 했습니다.
  4. 결과 단계는 요약에 성공한 후 서버 세션과 동기화된 일일 사용량을 차감할 수 있어야 했습니다.
function ChatWindow() { const [step, setStep] = useState('input'); const [keyword, setKeyword] = useState(''); const [category, setCategory] = useState('all'); const [limitState, setLimitState] = useState({ usage: 0, maxLimit: 0, remaining: 0 }) // ... 상태가 계속 늘어남 return ( <section> {/* 헤더는 잔여량 표시를 위해 사용량 정보가 필요 */} <ChatWindowHeader limitState={limitState} /> {step === 'input' && <Input setStep={setStep} setKeyword={setKeyword} />} {step === 'ask' && <Ask setStep={setStep} keyword={keyword} />} {step === 'search' && <Search setStep={setStep} setCategory={setCategory} />} {step === 'result' && <Result keyword={keyword} category={category} setStep={setStep} />} </section> ); }

이 상태들을 상위 컴포넌트에서 전부 들고 props로 내려주려고 하니 코드가 점점 복잡해지기 시작했습니다.

이쯤 되니 ‘챗봇이 단순한 UI 컴포넌트가 아니구나’ 생각하게 됐는데요. 창 안에서 자신만의 라우팅(step)을 가지고 데이터를 주고받는, 어떻게 보면 웹 서비스 안의 또 다른 작은 어플리케이션이라는 생각도 들었습니다.

그런 이유로 챗봇만을 위한 전역 스토어를 도입하게 됩니다. (코드가 길어 일부만 첨부합니다.)

export const useChatStore = create(set => ({ isOpen: false, step: 'input', keyword: '', category: 'all', limitState: { usage: 0, maxLimit: 0, remaining: 0 }, openChat: () => set({ isOpen: true }), setStep: (step) => set({ step }), setKeyword: (keyword) => set({ keyword }), // ... 생략 }));

이렇게 챗봇의 상태를 하나로 모아두니 코드가 놀라울 정도로 깔끔해졌는데요.

function ChatWindow() { // 상위 컴포넌트는 오직 '현재 어떤 화면(step)인가'만 구독 const step = useChatStore(state => state.step); return ( <section> <ChatWindowHeader /> {step === 'input' && <Input />} {step === 'ask' && <Ask />} {step === 'search' && <Search />} {step === 'result' && <Result />} </section> ); } export default function Ask() { // 하위 컴포넌트는 필요한 상태와 액션 함수만 구독 const {keyword, setStep} = useChatStore( useShallow(state => ({ keyword: state.keyword, setStep: state.setStep, })), ); return ( <Step> <BotResponse> <ChatBubble> 혹시 <strong>"{keyword}"</strong>에 대한 후기를 못 찾으셨나요? </ChatBubble> <ChatBubble>제가 대신 검색해서 요약해 드릴 수 있어요!</ChatBubble> </BotResponse> <button onClick={() => setStep('search')}> 네, 찾아주세요! </button> </Step> ); }

이렇게 상위 컴포넌트인 ChatWindow는 ‘어떤 화면을 띄울지’에만 집중하게 됩니다. 헤더나 각 단계 UI 같은 하위 컴포넌트들은 상위에서 props를 내려줄 필요 없이, 스토어에서 자신이 필요한 상태와 액션 함수만 구독해 사용하게 됩니다.

불필요한 API 요청 막기

전역 스토어를 도입하고 나서 가장 크게 체감한 장점은 1편에서 다룬 ‘사용량 제한’을 클라이언트 단에서 처리할 때였습니다.

사용자가 검색을 요청한 뒤 서버가 “어? 너 횟수 다 썼는데?”라고 에러를 뱉는건 너무 늦습니다. 사용자는 이미 키워드를 고민하고 카테고리까지 고르는 수고를 했기 때문입니다.

export default function 세션조회프로바이더({children}: Props) { const {session} = 세션조회훅(); // 앱 초기 로드 시 세션 조회 const setChatLimit = useChatStore(state => state.setLimit); // 사용량 setter useEffect(() => { if (session) { // 서버에서 계산해준 남은 사용량을 챗봇 스토어에 주입 setChatLimit({ usage: session.searchLimit.usage, maxLimit: session.searchLimit.maxLimit, remaining: session.searchLimit.remaining, }); } }, [session]); return children; }

그래서 앱이 처음 로드될 때, 서버에서 세션 정보와 함께 내려주는 사용량 정보(searchLimit)를 챗봇 스토어의 사용량 상태(limitState)에 주입했습니다.

image.png

이제 클라이언트는 굳이 API를 요청하지 않아도, 스토어의 상태만 확인하면 ‘사용자가 검색 요청 자격이 있는지’ 혹은 ‘횟수가 얼마나 남았는지’를 미리 판단하고 사전에 챗봇 진입을 차단할 수 있게 됩니다.

전역 스토어가 없었다면 이 값을 챗봇까지 전달하기 위해 두 가지 선택지 중 하나를 선택했어야 합니다.

  1. 앱 최상단에서 챗봇까지 props로 계속 내려보낸다.
  2. 챗봇의 가장 최상단 지점에 사용량 조회 API를 따로 구현해 호출한다.

1번은 많은 컴포넌트를 거쳐 전달해야 하니 유지보수 측면에서 좋지 않았고(props drilling), 2번은 추가적인 네트워크 비용이 늘어나는 문제와 ‘요청 전에 사전에 차단한다’는 UX 목표와도 맞지 않았습니다.

그래서 세션에서 받은 사용량을 전역 스토어에 한 번만 주입하고 어디서든 동일한 기준으로 조회하는 구조가 가장 단순하고 설득력 있는 선택이었습니다.

마치며

1편에서는 백엔드 리소스가 부족한 상황에서 '데이터 콜드 스타트'를 해결하기 위해 프론트 주도로 외부 AI 검색을 붙인 '기술적 의사결정'을 다뤘습니다.

그리고 이번 2편에서는 그렇게 만들어진 기능을 어떻게 사용자에게 가장 익숙한 형태(챗봇, 대화형 에러, 가독성 높은 UI)로 제공할 것인지에 대한 고민 과정을 정리해봤습니다.

개발을 하다 보면 종종 '어떻게 구현할 것인가(How)'에 매몰되어 정작 '사용자가 이것을 사용할 때 어떤 기분일까?'를 놓치는 경우가 생깁니다.

이번에 챗봇을 개발하면서 아무리 복잡한 서버 로직과 좋은 AI 모델을 사용하더라도 결국 사용자가 사용하는 UI/UX가 친절하지 않으면 가치가 떨어질 수 있겠구나 생각했습니다.

‘검색 결과 없음’에서 사용자가 이탈하기 전에 조금이라도 발을 붙잡는 로직, 에러를 대화처럼 표시하는 등 프론트엔드 개발자로서 사용자 경험을 더 깊게 생각해본 좋은 시간이었던 것 같습니다.

긴 글 읽어주셔서 감사합니다. 혹시 더 좋은 구현 아이디어나 피드백이 있다면 언제든 문의주시면 감사하겠습니다!!

부록: 기능적 한계와 리스크에 대한 고민

지금까지 1편과 2편의 긴 글을 통해 ‘AI 검색 및 요약’과 ‘챗봇 구현 과정에서의 고민들’을 정리해봤습니다. 사실 이 기능에는 치명적인 문제가 하나 있습니다.

바로 외부 AI 검색 서비스인 'Tavily'에 강하게 의존하고 있다는 점인데요. 만약 Tavily의 요금 정책이 갑자기 변경되거나 서비스가 종료된다면, 기껏 만들어둔 이 핵심 기능이 통째로 휘청거릴 수 있다는 리스크가 존재합니다.

하지만 1편 서두에서 언급했듯, 이 AI 검색 기능의 본질은 결국 '데이터 콜드 스타트'를 해결하기 위한 수단입니다. 그리고 우리 프로젝트의 핵심 목표인 ‘여러 플랫폼에 흩어진 후기 정보를 한 곳에서 쉽게 탐색할 수 있는 경험’이 과연 사용자에게 유의미한 가치를 제공하는지 빠르게 확인하기 위한 PoC 단계이기도 합니다.

‘검색 결과가 없으면 사용자는 이탈한다’는 문제를 막기 위해 '지금 당장 무언가를 보여주는 것'이 최우선 목표였습니다. 만약 외부 API(Tavily) 의존성이라는 리스크 때문에 한 달, 혹은 두 달 동안 완벽한 자체 검색 엔진을 개발하고 있었다면, 아직도 이 기능은 출시되지 못했을 것입니다.

검증만 된다면, 해답은 있지 않을까

만약 빠른 출시를 통해 이 기능이 사용자들에게 좋은 반응을 얻어 정식 스펙으로 올라간다면 어떨까요? 그때는 만들어둔 클라이언트의 챗봇과 서버의 검증 로직을 그대로 두고, 뒤단의 검색 API만 따로 구축한 서비스로 갈아 끼우면 됩니다.

예를 들어, duckduckgo-search 같은 오픈소스 라이브러리를 활용해 웹 문서를 수집하고, DeepSeek처럼 API 호출 비용이 저렴한 모델을 붙여 요약하는 검색 및 요약 서비스를 개발할 수도 있습니다.

물론 자체 엔진을 개발할 때까지 버틸 시간도 충분하다고 생각합니다. Tavily의 가격 정책은 초기 서비스가 감당하기에 꽤 합리적인 편인데요.

서비스가 유의미하다고 판단될 시점에는 월 30달러면 현재 무료 크레딧의 4배, 월 100달러면 15배의 넉넉한 횟수를 제공받을 수 있습니다. 우리가 검색 엔진과 요약 서비스를 개발할 때까지의 다리 역할로는 충분합니다.

추가로 비용에 대한 현실적인 고민도 하게 됐는데요. AI가 문서를 검색하고 요약하는 대기 시간 동안, 사용자가 납득할 수 있는 수준의 가벼운 광고를 노출해 API 호출 비용을 상쇄하는 방식도 충분히 고려해 볼 수 있습니다. 이후 우리가 직접 검색 엔진 서비스를 만들게 된다면 이 비용 효율은 더 높아질 거라고 생각합니다.

결론적으로 외부 API에 의존한다는 리스크는 분명 존재합니다. 하지만 MVP 및 PoC 단계에서는 '미리 걱정하기'보다 '빠르게 시장에 검증하기'가 훨씬 중요하다고 판단했습니다. 사용자의 반응이 이 기능의 가치를 증명해 준다면, 그때 기술적인 해답은 얼마든지 찾아낼 수 있기 때문입니다.

AI로 데이터 콜드 스타트 극복하기 (서버 편)

AI로 데이터 콜드 스타트 극복하기 (서버 편)