
- #AI
- #Project
- #ModuReview
AI로 데이터 콜드 스타트 극복하기 (서버 편)
고지민
2026-02-24
검색 결과가 없는 초기 서비스 문제를 해결하기 위해 Tavily와 Groq을 활용한 외부 리뷰 검색 및 요약 기능을 3일 만에 PoC로 구현한 과정과 비용 최적화, 서버 설계등을 정리한 글입니다.
시작하며
얼마 전, ‘모두의 후기’라는 커뮤니티 서비스를 개발했습니다. ‘여러 플랫폼에 흩어진 후기 정보를 한 곳에서 쉽게 탐색할 수 있는 공간을 만드는 것’이 우리 프로젝트의 가장 큰 목표였습니다.
하지만 아주 커다란 벽을 마주치고 마는데요. 바로 ‘검색했는데 아무것도 안 나오는데?’ 입니다. 커뮤니티는 여러 사람이 모여 자신의 경험을 공유해 나가며 내부 데이터가 쌓이게 됩니다.
하지만 서비스 초기 단계, 내부 DB에 쌓인 리뷰는 거의 없습니다. 이 상태에서 사용자가 서비스에 들어와 어떤 가게나 제품을 검색했을 때 결과가 없다면, 그 순간 우리 서비스는 ‘쓸모없다’고 판단될 수 있습니다.
그래서 ‘데이터가 쌓일 때까지 기다릴 것인가?’, 아니면 ‘지금 당장 무언가를 보여줄 것인가?’ 사이에서 저희는 후자를 선택했습니다.
이번 글에서는 이 구현 과정 중 문제를 어떻게 풀어내려고 했는가에 대한 고민과 실제로 서버 구현을 어떻게 했는지에 대한 내용을 정리해보려고 합니다. 이후 다음 글에서 클라이언트 측에서 고민한 부분을 추가로 정리해보려 합니다.
문제 정의
초기 서비스에서 가장 무서운 문제는 데이터 콜드 스타트 입니다. 나름 이 문제에 대한 용어가 존재하더라구요.
- 데이터 콜드 스타트: AI, 추천 시스템, 혹은 데이터 기반 모델이 초기 데이터나 사용자/아이템 정보가 충분하지 않아 신뢰할 수 있는 예측이나 추천을 하지 못하는 문제
의미 그대로, 사용자는 자신이 궁금한 상품의 리뷰를 기대하고 들어오지만 리뷰가 없고(검색 결과가 없고) 서비스를 이탈하게 됩니다. 정말 정직해서 잔인하기도 합니다.
주기적으로 프론트 팀원과 회의를 진행하며 우리 프로젝트의 현재 문제점이나 개선 가능한 방향을 이야기하곤 했는데, 이번에 외부 데이터를 대신 가져와 AI로 요약해 보여주자는 아이디어를 제안하게 됩니다.
즉 검색하면 무조건 결과가 나오게 만들어, 최소한 ‘아무것도 없음’은 피할 수 있는 기능을 만들게 됩니다.
구현 전략 - 백엔드 없이 3일 안에 검증하기
현재 팀은 프론트 2명, 백엔드 2로 구성되어 있습니다. 하지만 1차 출시로 필요한 기능들을 모두 개발한 이후, 백엔드 리소스가 충분히 나오지 않는 상황이었습니다. (취업 준비, 개인 사정)
DB 설계(회의는 덤) => API 설계(회의는 덤) => 인증 처리(회의는 덤)
등등.. 리소스가 부족한 상태에서 이 과정을 모두 기다리면 기능 검증까지 최소 한 달, 길어지면 두 달까지 시간이 소요될 수 있었습니다.
그래서 프론트 주도로 해당 기능을 빠르게 검증(PoC)하는 방식을 선택하게 됩니다. (프론트 기술 스택으로 Next.js를 사용하고 있어, 서버 활용이 가능했습니다.)
결론적으로 ‘완벽한 설계’ 보다 ‘빠른 실험’이라는 명확한 목표를 세우게 됐습니다. 3일 안에 붙이고, 실제로 쓸 만한지 확인해보는걸 최우선 목표로 실제로 구현에 성공했고, 실제 동작은 아래와 같습니다.
내부 검색 결과가 없을 때

챗봇을 열어 직접 검색

저장된 결과 다시 보기

검색 공급자 선정하기
우선 ‘외부 데이터를 어떻게 가져오는가?’가 가장 큰 문제였습니다. 개발 소요 시간 3일 중 1.5일은 꼬박 관련 기술 리서치에만 사용한 것 같습니다.
정말 돌고 돌아 선정하게 됐는데, 그 과정을 남겨보려 합니다.
1. 직접 크롤링하기 (기각)
옛날에 아르바이트하던 매장에 입력 값 검증 프로그램을 만들어준 경험이 있습니다. 간단하게 타겟 사이트의 입력 필드를 모두 크롤링해 별도 사이트에서 필드별 검증 기능을 제공한 프로젝트입니다. (타겟 사이트는 검증 로직이 없어 오기입 문제가 자주 발생하곤 했습니다.)
개발 과정에서 셀레니움을 활용한 크롤링을 경험해봤기에 그냥 크롤링하면 되지 않을까 생각했습니다. 하지만 곧 현실을 깨닫게 됩니다.
- 음식 관련 리뷰 ⇒ 블로그(티스토리, 네이버), 다이닝코드 등
- 전자기기 ⇒ 블로그(티스토리, 네이버), 퀘이사존, 유튜브 등
- 자동차 ⇒ 블로그(티스토리, 네이버), 카페(네이버, 다음), 뉴스, 커뮤니티 등
이렇게 사용자가 검색한 키워드의 범주마다 크롤링할 타겟 사이트가 달라집니다. 그리고 가장 큰 문제로 ‘유지보수 지옥’이라는 문제가 존재했습니다.
당시, 타겟 사이트의 DOM 구조가 한 번 바뀌니 크롤링이 불가능해 프로젝트가 다 터져버려서 변경된 DOM 구조를 반영하느라 하루를 꼬박 날렸던 기억이 새록새록 떠오릅니다.
또, 타겟 사이트별로 어떻게 필요한 데이터를 가져올지, 크롤링 과정에서 차단 당하지는 않을지, 브라우저 업데이트(크롬 버전 올라간 것만으로도 셀레니움이 돌아가지 않았던 기억도 납니다) 등, PoC 단계에서 도저히 감당할 리스크가 아니라고 판단했습니다.
2. Google Custom Search JSON API (기각..? 시도도 못함)
다음으로 생각한 방법은 구글의 검색 결과를 JSON 형태로 받아 LLM에 넘겨 가공하는 방식이었습니다.
검색 타겟 사이트 설정은 'site:blog.naver.com' 으로 설정해줄 수도 있으니 정말 최고의 방법이라고 생각했습니다.
즉시 관련 API 공식 문서를 찾아보며, ‘이렇게 요청하면 되겠다!’ 생각하고 API 키 발급을 찾던 찰나.. 최근 Google이 Vertex AI 도입과 함께 신규 사용자의 무료 API 키 발급을 제한했다는 사실을 알게 됩니다.
Vertex AI는 쿼리 1,000개당 4달러를 과금하는 구조로 합리적이지만, PoC 단계에서부터 유료 과금 모델을 도입할 수 없어 결국 기각하게 됩니다.
3. Serper.dev + Google AI Studio (기각)
해외 커뮤니티에선 ‘Google Search 보다 Serper가 더 빠르다!’라는 의견이 있을 정도로 꽤 인지도가 있는 서비스로 보였습니다.
우선 마음에 들었던 점은 처음 가입 시, 2,500개의 검색 크레딧을 제공해줬습니다. 물론 소진 이후 결제가 필요했지만, PoC 단계에선 문제가 되지 않았습니다. 또한 검색 결과를 JSON 형태로 제공해주기도 했습니다.
하지만 이 방식도 결국 기각하게 되었는데, 크게 2가지 문제점이 존재했습니다.
- Google AI Studio의 무료 제공 사용량 감소: 2025.12 기준, Google AI Studio의 프리 티어 RPD(하루 요청 제한)가 250회에서 20회로 대폭 감소했습니다. 반환된 JSON 데이터를 가공해 제공하기엔 턱없이 부족한 횟수였습니다. (결국 이 시점에 Google Search가 살아있더라도 이 방법은 실현 불가능했겠다고 생각했습니다.)
- 이중 과금 문제: PoC 단계에선 무료 사용량에 기반해 검증하니 문제가 되진 않습니다. 하지만 이후 상용화 단계에 이르러선 결국 비용 문제를 고민하게 될텐데, 현재 구조에선 검색(Serper) 비용과 요약(LLM) 비용이 이중으로 발생하게 됩니다.
따라서 결국 해당 방법도 기각하게 됩니다.
4. Tavily AI (최종 선택)
최종적으로 검색과 요약을 하나의 서비스에서 처리할 수 있는 Tavily AI를 채택하게 됩니다.
이유는 굉장히 매력적이었습니다.
- 검색과 요약을 단일 API로 처리 가능
- 월 1,000회의 무료 사용량 제공
- 검색과 요약 모두 가능하지만 합리적인 과금 비용
- (30달러: 월 4,000회, 100달러: 월 15,000회, 220달러: 월 38,000회, 500달러: 월 100,000회)
여기서 무엇보다 ‘검색하고 요약해줘’가 한 번에 처리 가능하다는 점이 컸습니다.
PoC 결과도 나쁘지 않았습니다. 검색 소스의 범위가 ‘블로그’, ‘유튜브’, ‘뉴스’ 등 넓은 스펙트럼을 가지고 있었으며, 검색한 키워드에 대한 의미있는 요약을 제공했습니다.
따라서 최종적으로 Tavily AI를 채택해 기능 개발에 들어가게 됩니다.
비용 최적화를 위한 AI 기반 검증
개발에 들어가기에 앞서 한 가지 고민을 하게 됐는데요. Tavily는 PoC 단계에선 차고 넘치는 월 1,000회의 무료 사용량을 제공합니다.
하지만 누군가 ‘비트코인 시세’나 ‘프레드피자 창업자’ 같은 단순 검색을 여러 번 시도하게 되면, 제한된 크레딧으로 인해 정작 이 기능을 필요로하는 사용자가 이용하지 못하는 경우가 생길 수도 있었습니다.
즉, 사용자의 검색 의도를 파악해 정상적인 검색인지를 판단할 수 있어야 했습니다. 이건 PoC 단계에서의 문제라기 보단, 이 기능을 가치 있게 제공하기 위해 결국 필요한 고민이었습니다.
프로그래밍을 통한 검색 의도 파악엔 한계가 분명 존재합니다. 사용자가 검색한 키워드에 일반적으로 사용하지 않는 단어가 포함되진 않았는지 등으로는 이 검색이 정상적인지 판단할 수 없다는 의미이기도 합니다.
오히려 잘못 판단해 사용자를 불쾌하게 만들 수도 있게 됩니다. 그래서 AI를 통해 최소한 이 검색 의도가 비정상적이진 않은지 판단하는 기능을 추가하게 됩니다.
Groq
Tavily의 월 1,000회의 사용량을 커버할 수 있으면서도, 최소한 하루 제공량이 적어도 500회는 제공되길 원했습니다. (너무 도둑놈 심보인가요? 하지만.. 돈이 없는 취준생은 이렇게 구질구질하게 찾게 되더라구요..)
하지만 위에서 잠시 설명했듯, Google AI Studio의 RPD는 20회 수준으로 이용이 불가능한 상황이었습니다. 따라서 검색 의도를 파악하기 위한 AI 선정에 고민이 많았습니다.
그러다 발견한 서비스가 바로 Groq 입니다. (진짜로 강추..!)
Groq은 하나의 SDK를 통해 다양한 모델을 사용할 수 있고, 무료 티어를 사용함에도 각 모델별 RPD가 비교적 넉넉한 편입니다. 그중 제가 선택한 모델은 qwen/qwen3-32b 입니다.
왜 32B인지?
사실 처음부터 32B 모델을 쓰진 않았습니다. (RPD는 하루 요청 제한, TPD는 하루 토큰 제한입니다.)
- llama-3.1-8b-instant: RPD(14,400회), TPD(500,000)
- meta-llama/llama-4-scout-17b-16e-instruct: RPD(1,000회), TPD(500,000)
- qwen/qwen3-32b: RPD(1,000회), TPD(500,000)
- llama-3.3-70b-versatile: RPD(1,000회), TPD(100,000)
이렇게 8B, 17B, 32B, 70B 총 4개의 모델을 테스트해보며 비교해봤습니다. 각 모델별로 체감 차이는 분명 존재했습니다.
테스트에 사용한 각 입력들에 대한 예시는 아래와 같습니다.
case 1: 정상 케이스 keyword: '프레드 피자' category: '음식' case 2: 카테고리 미스매치 keyword: '프레드 피자' category: '전자제품' case 3: 혼합 검색 keyword: '프레드 피자 맛있는데, 창업자 누구?' category: '음식' case 4: 단순 검색 keyword: '오늘 날씨' category: '음식'
- 8B, 17B의 경우 1번과 2번 같은 대부분의 단순 케이스는 잘 처리했습니다.
- 하지만 3번 케이스와 같이 의도가 애매한 경우 잘못 해석해 검증을 통과시키기도 했으며, 브랜드, 작품명에 대한 처리(개미 - 책, 아바타 - 영화)가 불가능해 검증이 실패하는 경우가 빈번했습니다.
- 32B, 70B의 경우 애매한 케이스에서도 일관된 판단이 가능했으며, 특히 브랜드, 작품명에 대한 처리가 안정적이었습니다.
사실 이 작업은 단순 문자열 매칭이 아니라 다음을 동시에 요구하는데요.
- 키워드가 해당 카테고리에 속하는지 (지식)
- 검색 목적이 리뷰/후기인지 (의도)
- 애매한 표현을 정상화할 수 있는 능력 (언어 이해)
- 경계선 케이스에서의 일관된 판단 능력
겉으로 보기엔 단순한 분류 작업으로 보일 수 있지만, 실제로는 어느정도의 추론 + 상식 + 모호성 처리가 섞인 작업이기도 합니다.
- ‘프레드 피자 맛있는데, 창업자 누구?’라는 키워드가 ‘음식 카테고리에 속하는지’ 혹은 ‘정상적인 검색인지 판단’해보려고 하니 위에서 나열한 요구사항이 필요하다는걸 느낄 수 있었습니다.
하여튼 8B와 17B는 평균적으로 잘 동작하지만, 가끔 검증이 실패하는 경우가 발생했습니다. 사실 이런 문지기 역할에선 안정적으로 판단이 가능한지가 중요했고 32B나 70B 모델에선 확실히 안정적인 느낌을 받았습니다.
왜 70B를 선택하진 않았는지?
그럼 ‘당연히 32B보다 70B가 더 좋지 않냐’ 생각하실 수 있는데요. 하지만 이 기능의 목적은 정교하게 글을 작성하는게 아닌 승인과 차단을 판단하는 문지기 역할입니다.
입력도 keyword, category 두 개 뿐이고, 출력도 아래처럼 단순합니다.
{ "isValid": boolean, "message": string | null }
즉, 이 문제는 긴 문맥 추론이나 복잡한 단계의 추리가 필요한 작업이 아닙니다. 70B 모델이 당연히 더 정교하게 실패 이유에 대한 문장을 생성할 수는 있겠지만, 결국 모델이 커짐에 따라 지연 시간 증가, 비용 증가와 같은 문제에 대비해 얻는 이점이 크지 않았습니다.
- 입력 토큰 + 출력 토큰 = 700
- 32B 모델의 평균 응답 속도: 300-400ms, 하루 토큰 제한 500,000, 하루 평균 약 700회
- 70B 모델의 평균 응답 속도: 400-550ms, 하루 토큰 제한 100,000, 하루 평균 약 140회
결국 70B는 RPD는 1,000회였지만, 빡빡한 토큰 제한(100,000)으로 인해 선택이 불가능했습니다.
또, 입력이 제한된 구조에선 모델이 아무리 똑똑해봤자 활용할 수 있는 정보 자체가 적습니다. 어차피 입력이 ‘키워드 + 카테고리’로 한정되어 있어서 70B가 아무리 똑똑해도 추가 정보가 없어 그 똑똑함을 쓸 수도 없었습니다.
결론적으로 32B가 정확도, 속도, 비용의 균형 사이에서 가장 적합해 선택하게 됩니다.
프롬프트 잘 짜기
사실 모델의 크기만으로는 안정성을 확보할 수 없습니다. 에러 코스트를 확실하게 줄이는 데 도움이 됐던 방법은 모델 키우기 보단 잘 작성된 프롬프트였습니다.
Role: Search Intent Classifier Input: Keyword="""${keyword}""", Category="""${category}""" [Logic] 1. **REJECT (Category Mismatch):** If keyword is CLEARLY unrelated to "${category}" (e.g., "iPhone" in "Food", "Pizza" in "Shopping"). 2. **ACCEPT (Ambiguity Rule):** If keyword is a Proper Noun (Title, Brand) that *could* be in "${category}", ACCEPT it (e.g., "Housemaid" in "Book"). Give benefit of the doubt. 3. **REJECT (Invalid Types):** - Person/History (Founders, CEO) -> "인물이나 기업 정보는 알 수 없어요." - Navigation/Facts (Where to buy, Stock, Weather) -> "단순 정보나 구매처는 알 수 없어요." - Mixed Intent ("Pizza and iPhone") -> "한 번에 하나의 주제만 검색해 주세요!" [Examples] - "Fred Pizza" (Food) -> {"isValid": true, "message": null} - "iPhone 15" (Food) -> {"isValid": false, "message": "음식 카테고리와 맞지 않아요."} - "The Housemaid" (Book) -> {"isValid": true, "message": null} (Title assumption) - "Galaxy S24" (Book) -> {"isValid": false, "message": "책 카테고리와 맞지 않아요."} - "Fred Pizza owner" (Food) -> {"isValid": false, "message": "인물 정보는 알 수 없어요."} Output JSON ONLY: { "isValid": boolean, "message": string | null }
특히 중요하게 뒀던 부분은 Ambiguity Rule과 출력 형식을 강제한 점인데요.
브랜드나 작품명 등의 경우 애매하더라도 일단 허용시키고 있는데, 애매함을 과하게 차단하면 오히려 사용자 경험을 해칠 수 있기 때문이었습니다.
- 예: 하우스메이드 - 책, 아바타 - 영화 등
{ "isValid": boolean, "message": string | null }
또, 모델이 불필요하게 설명하지 않도록 반환 형식을 고정한 것인데요. 덕분에 아웃풋 토큰이 낭비되지 않고 응답 구조를 일관되게 반환 받을 수 있었습니다.
플로우
검색 공급자, 검증을 위한 모델 선택과 프롬프트 설계가 끝났다면 이제 실제로 해당 기능을 구현해야 합니다.
우선 이 기능은 단순히 한 번의 API 호출로 끝나는 간단한 기능은 아닙니다. Tavily의 한정된 크레딧을 보호하기 위한 몇 가지 검증 단계 등이 섞여있는데요.
전체 플로우를 간단히 정리하면 다음과 같습니다.
1. 진입
- Case A: 검색 결과가 없을 경우 자동으로 챗봇 오픈
- 검색에 사용한 키워드 자동 입력, 바로 2번을 거쳐 4번으로 이동
- Case B: 플로팅 버튼 클릭을 통한 챗봇 오픈
- 2번으로 이동
2. 잔여 횟수 검증 - 클라이언트
- 세션 정보를 통해 사용자의 일일 잔여 횟수 확인
- 잔여 없음: 즉시 차단 및 비로그인 사용자일 경우 로그인 유도(API 요청 X)
- 잔여 있음: 3번으로 이동
3. 키워드 입력
- '궁금한 제품의 후기를 요약해 드릴게요.' 문구 표시
- 검색 키워드 입력 후 4번으로 이동
4. 카테고리 선택
- 사용자가 입력한 키워드 표시
- 키워드가 속하는 카테고리 선택 후 5-1번으로 이동
- 예: "프레드피자" ⇒ "음식"
5-1. 잔여 횟수 검증 - 서버
- 쿠키에 저장된 일일 잔여 횟수 확인
- 잔여 없음: 즉시 차단 및 에러 코드 반환
- 잔여 있음: 5-2번으로 이동
5-2. 검색 의도 검증 - 서버 (Qwen-32B)
- 프롬프트 기반으로 키워드/카테고리 적합성 판단
- 부적합: 즉시 차단 (Tavily 요청 X)
- 적합: 6번으로 이동
6. 검색 및 요약 실행 - Tavily
- 검색 정확도를 높이기 위해 카테고리 기반 suffix 부여
- 예: 키워드 - "프레드 피자", 카테고리 - "음식"일 경우 "프레드피자 맛 평가 양 가성비 솔직 후기 메뉴 추천"
- Tavily API를 사용해 검색 및 리뷰 요약 요청 (크레딧 소모)
7. 결과 반환 - 서버
- 요약 정보 + 출처 반환
- 사용량 차감한 쿠키 업데이트
8. 결과 표시 - 클라이언트
- 요약 결과 화면 표시
- 클라이언트 상태 잔여량 감소
위 플로우에 나와있듯, Tavily 크레딧은 모든 검증을 통과한 경우에만 소모됩니다.
구현하기 - 서버
프론트에서 1차 검증을 마친 뒤, 실제 핵심 기능(Tavily API 호출)은 Next.js 라우트 핸들러에서 실행됩니다. (외부 API 키 노출을 피하기 위해 서버 사용이 사실상 필수였습니다.)
전체 구조는 다음과 같습니다.
1. 클라이언트 요청 2. 쿠키 기반 사용량 확인 3. Groq(32B) 의도 검증 4. Tavily 검색 및 요약 5. 사용량 차감 후 응답 반환
글이 너무 길어질 것 같아 핵심 코드만 포함하겠습니다. 서버 설정에 대한 전체 코드는 링크에 있습니다!
1. 쿠키 기반 사용량 확인
가장 먼저 Groq, Tavily 호출 전에 사용량을 확인합니다. (전체 사용량을 개인이 무분별하게 사용하지 못하게 막기 위한 최소한의 검증 단계입니다.)
// 요청 헤더의 쿠키 정보를 전달해 일일 사용량 조회 const limitStatus = getSearchLimitStatus(MAX_LIMIT, limitCookie); if (!limitStatus.isBlocked) { return NextResponse.json( { title: 'DAILY_LIMIT_EXCEEDED', detail: '오늘의 무료 검색 횟수(3회)를 모두 사용했어요. 내일 다시 시도해주세요.', status: 429, }, {status: 429}, ); }
여기서 중요한 점은 쿠키 기반으로 일 단위 사용량을 관리한다는 점인데요.
이유는 PoC 단계에선 빠른 검증이 우선이었고, 백엔드 측에 DB가 존재하는 상태에서 이 기능만을 위한 DB를 설계하기엔 리소스 낭비라고 생각했기 때문입니다.
물론 쿠키를 삭제하면 사용량이 초기화된다는 문제는 존재합니다. 하지만 이 기능을 쓰기 위해 쿠키를 지우는 수고까지 감수하는 사용자는 오히려 고관여 유저일 가능성이 높다고 판단했습니다.
추후 이 기능이 유의미하다고 판단되어 백엔드에서 사용량 테이블을 추가할 수 있습니다. 하지만 클라이언트 UI/UX는 그대로 유지한 채, 내부 로직만 교체할 수 있어 서비스 중단 없는 고도화가 가능해 쿠키 기반 사용량 관리 방법을 선택하게 됩니다.
export function getSearchLimitStatus(maxLimit: number, cookie?: RequestCookie) { const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul', }); const data = parseLimitCookie(cookie?.value, today); return { isBlocked: data.usage >= maxLimit, remaining: Math.max(0, maxLimit - data.usage), currentUsage: data.usage, lastSearchDate: data.lastSearchDate, today, }; }
내부에선 쿠키를 검증(parseLimitCookie)해 일 사용량 정보를 반환합니다.
- isBlocked: 요청이 불가능한지
- remaining: 남은 사용량
- currentUsage: 현재 사용량
- lastSearchDate: 마지막 이용 날짜
- today: 오늘 날짜
const SearchLimitSchema = z.object({ usage: z.number().int().min(0), lastSearchDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }); export type SearchLimitData = z.infer<typeof SearchLimitSchema>; const createDefaultLimitData = (today: string): SearchLimitData => ({ usage: 0, lastSearchDate: today, }); function parseLimitCookie(cookieValue: string | undefined, today: string): SearchLimitData { if (!cookieValue) return createDefaultLimitData(today); try { const json = JSON.parse(cookieValue); const result = SearchLimitSchema.safeParse(json); if (!result.success) { return createDefaultLimitData(today); } if (result.data.lastSearchDate !== today) { return createDefaultLimitData(today); } return result.data; } catch { return createDefaultLimitData(today); } }
parseLimitCookie 함수는 전달된 쿠키를 신뢰하지 않고 항상 검증합니다. 쿠키가 없거나, 파싱에 실패하거나 혹은 정상적인 쿠키가 아닐 경우 안전하게 기본값 쿠키 정보를 반환하게 됩니다.
검증에 성공한 경우, 전달된 쿠키 정보를 읽어 마지막 이용 날짜가 오늘이 아닌 경우 사용량을 리셋하게 됩니다.
2. 2차 검증 - 검색 의도 판단
다음으로 Tavily 호출 전, 반드시 Groq을 거쳐 검색 의도를 파악합니다. Groq SDK에 대한 사용법은 해당 글에서 다루진 않습니다!
const validation = await validateQueryWithGroq({ keyword, category, TIMEOUT_MS: 2000, }); if (!validation.isValid) { return NextResponse.json({ status: 'fail', summary: validation.message || '적절한 검색어가 아닌 것 같아요. 😅', sources: [], }); }
타임아웃을 걸어둔 이유는 외부 API의 지연 가능성 때문입니다. 만약 Groq이 5초, 최악의 경우 10초 이상 지연된다면 사용자는 요청을 기다리다 이탈해버릴 수도 있습니다.
export async function validateQueryWithGroq({category, keyword, TIMEOUT_MS}: Props): Promise<ValidationResult> { const timeoutPromise = new Promise<ValidationResult>((_resolve, reject) => { setTimeout(() => reject(new Error('Validate Timeout')), TIMEOUT_MS); }); try { const result = await Promise.race([_fetchGroqValidation(keyword, category), timeoutPromise]); return result; } catch (error) { console.error('Groq GateKeeper Error:', error); return { isValid: false, message: '현재 서비스가 원할하지 않아요. 잠시 후 다시 시도해주세요.', }; } }
따라서 외부 인자로 받은 TIMEOUT_MS가 지난 후 reject 시키는 프로미스 객체를 생성하고, Promise.race를 통해 실제 검증 요청과 동시에 실행시켜 시간이 초과되면 요청을 닫도록 구현했습니다.
불완전한 검증 상태로 Tavily API를 호출하는 것보다 안전하다고 판단했기 때문입니다.
3. 검색 및 요약 요청
검증을 모두 통과한 경우에만 Tavily API를 호출합니다. 마찬가지로 Tavily 사용 방법에 대해선 다루지 않습니다.
const suffix = CATEGORY_SUFFIX[category] || CATEGORY_SUFFIX['all']; const enhancedQuery = `"${keyword}" ${suffix}`; const client = tavily({apiKey: tavilyApiKey}); const tavilyResponse = await client.search(enhancedQuery, { topic: 'general', // 경제, 뉴스 등 설정 가능 searchDepth: 'basic', // 어느정도 깊게 검색할 것인가, 'advanced' 설정 시 크레딧 소모 2 includeAnswer: 'advanced', // 검색 소스를 사용한 LLM 요약 여부 includeImages: false, // 이미지 검색 결과 포함 여부 country: 'south korea', // 한국어 검색 결과만 수집 maxResults: 8, // 최대 20개까지 수집 가능, 기본값: 5 });
여기서 핵심은 suffix를 부여해 검색 정확도 향상 시킨 점인데요.
const CATEGORY_SUFFIX: Record<CategoryLabel, string> = { food: '맛 평가 양 가성비 솔직 후기 메뉴 추천', car: '시승기 승차감 연비 결함 장단점', cosmetic: '발색 지속력 제형 트러블 솔직 후기', clothes: '사이즈 팁 재질 핏 착샷 코디 후기', device: '스펙 발열 배터리 성능 장단점 개봉기', book: '책 서평 독후감 줄거리 요약 솔직 리뷰', sports: '착용감 내구력 효과 사용기 장단점', movie: '관람평 러닝타임 쿠키 개봉일', all: '솔직 후기 장점 단점 내돈내산 추천', };
테스트 중 ‘아바타’ 보다 suffix를 붙인 ‘”아바타” 관람평 러닝타임 쿠키 개봉일’로 검색했을 때 광고성 결과가 감소했으며, 리뷰 중심의 소스 탐색 가능성이 높아져 요약 품질이 올라갔습니다.
4. 사용량 차감 후 반환
Tavily API 호출 성공 이후에만 사용량을 차감하는데요.
const newLimitData = JSON.stringify({ usage: limitStatus.currentUsage + 1, lastSearchDate: limitStatus.today, }); const response = NextResponse.json({ status: 'success', summary: tavilyResponse.answer, sources: tavilyResponse.results.map(item => ({ title: item.title, url: item.url, snippet: item.content, })), }); response.cookies.set('search_limit', newLimitData, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 2, }); return response;
요청이 성공했다면, 요청한 사용자의 사용량을 차감해야 합니다. 사용량을 쿠키에 기록하기 때문에 응답 헤더의 Set-Cookie 헤더에 새로운 쿠키 정보를 기록합니다.
getSearchLimitStatus 함수로 반환된 사용량 정보의 currentUsage는 현재 사용량을 의미합니다. 다음으로 반환된 today는 요청 시점의 현재 날짜를 의미합니다.
따라서 새로운 쿠키 정보로 사용량을 증가시키고, 마지막 검색 날짜 값을 오늘 날짜로 설정합니다. 마지막으로 응답 헤더의 Set-Cookie 헤더에 쿠키를 설정하고 클라이언트로 전달하게 됩니다.
별도로 클라이언트 사이드에서 조작할 수 없게 httpOnly 디렉티브와 sameSite: ‘strict’를 부여해 해당 도메인 내에서만 이용이 가능하게 설정했습니다.
5. 클라이언트에게 잔여 횟수 알려주기
클라이언트가 소진 상태를 오직 서버의 응답으로만 알 수 있다면, 좋은 사용자 경험은 아닐 것입니다.
const limitStatus = getSearchLimitStatus(MAX_LIMIT, limitCookie); if (!limitStatus.isBlocked) { return NextResponse.json( { title: 'DAILY_LIMIT_EXCEEDED', detail: '오늘의 무료 검색 횟수(3회)를 모두 사용했어요. 내일 다시 시도해주세요.', status: 429, }, {status: 429}, ); }
클라이언트 측 UI에서 현재 사용량을 표시하고, 만약 사용량이 소진됐다면 요청 자체를 막을 수 있어야 합니다. 만약 열심히 작성해서 요청을 보냈는데 위 코드로 인해 에러 응답을 받게 된다면 유쾌한 경험은 아닐 거라고 생각합니다.
다행히 우리 프로젝트는 어플리케이션의 초기 로드 시점에 사용자 세션 정보를 반환하는 라우트 핸들러(GET /api/auth)가 존재합니다.
그렇다면 해당 라우트 핸들러에서 이미 만들어진 getSearchLimitStatus 함수를 통해 사용량 정보를 읽고, 함께 내려주면 되겠다고 생각했습니다.
export async function GET() { const cookieStore = await cookies(); const isLoggedIn = cookieStore.has('refreshToken'); const MAX_LIMIT = isLoggedIn ? 3 : 1; const limitCookie = cookieStore.get('search_limit'); const {currentUsage, remaining} = getSearchLimitStatus(MAX_LIMIT, limitCookie); const searchLimit = { usage: currentUsage, maxLimit: MAX_LIMIT, remaining, }; if (!isLoggedIn) { return NextResponse.json({ isLoggedIn: false, userNickname: null, userEmail: null, searchLimit, }); } const userNicknameCookie = cookieStore.get('userNickname'); const userEmailCookie = cookieStore.get('userEmail'); // 닉네임, 이메일 예외 처리.. const userNickname = userNicknameCookie.value; const userEmail = userEmailCookie.value; return NextResponse.json( { isLoggedIn: true, userNickname, userEmail, searchLimit, }, {status: 200}, ); }
이렇게 로그인 사용자와 비로그인 사용자의 요청 제한 횟수를 다르게 설정해 클라이언트에게 반환합니다.
단순히 기능을 제한했다기 보단, 1회 체험 후 자연스럽게 로그인을 유도하기 위해 사용자 상태에 따라 사용량을 차등 지급하고 있습니다.
'로그인하면 더 사용할 수 있어요'라는 베네핏을 제공해 자연스럽게 전환율을 높이기 위한 장치입니다.
useEffect(() => { if (session) { setChatLimit({ usage: session.searchLimit.usage, maxLimit: session.searchLimit.maxLimit, remaining: session.searchLimit.remaining, }); } }, [session]);
이렇게 서버에서 내려준 searchLimit을 전역 스토어에 저장해 클라이언트는 API 요청 전 1차로 사용 가능 여부를 판단할 수 있게 됩니다.
여기까지
이번 글에서는 데이터 콜드 스타트 문제를 해결하기 위해 고민한 내용을 정리해봤습니다.
AI를 답변을 생성하는 도구 정도로 사용하는게 아니라 요청 이전에 검증을 수행하는 역할로도 활용하고자 고민한 내용을 전달하는게 핵심이기도 했습니다.
다음 글에서는 이 기능을 실제로 사용자에게 연결하기까지 어떤 고민을 했는지 정리해보려고 합니다.
여기까지 긴 글 읽어주셔서 감사합니다. 혹시 틀린 내용이 있다면, 문의 남겨주시면 감사하겠습니다!!

Jest와 RTL로 프로젝트 테스트 도입하기

