LLM AI로 상품 큐레이션 도구 만들기
2026. 9. 15.
친하게 지내는 MD 동료의 부탁으로 만든 상품 큐레이션 도구가 있습니다. 화면 헤더에 "for Awesome MDs"라고 적어 둔 것도 그래서예요.
고객사별로 간식 구성을 짜는 웹앱인데요, 가격표와 운영 가능 상품, 고객사별 특별단가가 들어 있는 엑셀을 올리면 브라우저 안에 상품 DB가 만들어지고, 거기서 검색해 고객사 탭마다 장바구니를 채운 다음 정해진 양식의 엑셀로 내보냅니다.
서버는 없습니다. Vite + React로 만든 SPA이고, 데이터는 전부 브라우저의 IndexedDB(Dexie)에 들어갑니다. 여기에 LLM(Gemini)을 붙인 과정을 정리해 봤습니다.
글의 화면은 촬영용으로 만든 샘플 엑셀(상품 164개, 고객사 5곳)로 다시 찍었습니다. 상품명·고객사·단가는 모두 가짜이고, AI 결과는 실제 Gemini 응답입니다.


규칙으로 할 수 있는 데까지
AI부터 붙인 건 아닙니다. 추천 기능은 이렇게 늘어났어요.
처음엔 랜덤 추천이었습니다. 버튼 한 번에 상품 30개를 무작위로 보여 줍니다. 뭘 담을지 막막할 때 둘러보라는 용도였어요.

다음 날 연관 상품 추천을 붙였습니다. 품목명 옆 반짝이 아이콘을 누르면 그 상품과 비슷한 상품 30개를 보여 줍니다. "비슷하다"는 점수로 정했습니다.
1for (const p of products) {
2 if (p.itemCode === source.itemCode) continue
3 let score = 0
4 if (source.brand && p.brand === source.brand) score += RELATED_WEIGHT_BRAND // 3
5 if (source.category && p.category === source.category) score += RELATED_WEIGHT_CATEGORY // 5
6 if (source.supplier && p.supplier === source.supplier) score += RELATED_WEIGHT_SUPPLIER // 1
7 for (const t of tokenize(p.itemName)) {
8 if (sourceTokens.has(t)) score += RELATED_WEIGHT_TOKEN // 1
9 }
10 if (score > 0) scored.push({ p, score })
11}
둘 다 쓸 만했지만 한계가 분명했습니다. 점수표는 가중치를 그대로 따라가서, 카테고리가 넓게 잡혀 있으면 브랜드 점수가 순위를 좌우합니다. 위 화면처럼 감자칩의 "연관 상품"에 같은 브랜드 초콜릿이 끼어드는 식이죠.
더 큰 문제는 둘 다 상품에서 출발한다는 점이었습니다. 실제로 필요한 건 "오피스 간식, 음료·과자 골고루, 30만원 선"처럼 요구사항에서 출발하는 추천이었어요. 이런 문장을 점수표로 풀 수는 없어서, 같은 날 Gemini를 붙였습니다.
AI 추천: 상품 DB에서 골라 주기
첫 번째 AI 기능은 상품 검색 영역의 "AI 추천"입니다. 요구사항을 적으면 상품 DB에서 최대 30개를 골라 검색 결과 자리에 보여 줍니다.

상품은 한 줄에 하나씩 압축해서 넣습니다.
1// 압축 포맷: itemCode|품목명|1차>2차>3차|브랜드
2return products
3 .map((p) => {
4 const cat = [p.category1, p.category2, p.category3].filter(Boolean).join('>')
5 return [p.itemCode, p.itemName, cat, p.brand]
6 .map((v) => (v ?? '').replace(/[|\n\r]/g, ' '))
7 .join('|')
8 })
9 .join('\n')replace 한 줄이 은근히 중요합니다. 엑셀에서 온 품목명에 |나 줄바꿈이 섞여 있으면 상품 하나가 두 줄로 쪼개지고, 모델은 있지도 않은 상품을 보게 되거든요.
시스템 프롬프트에서 가장 신경 쓴 건 마지막 줄입니다.
1- 출력 개수는 사용자가 지정한 최대값을 절대 초과하지 마세요.
2- itemCode는 반드시 카탈로그에 존재하는 값만 사용하세요. 임의로 생성·변형하지 마세요.
3- 각 추천에 한국어로 간결한 이유(최대 20자)를 첨부하세요.
4- 관련성이 낮으면 수를 채우기보다 적게 추천하는 것이 낫습니다."30개"라고 하면 모델은 어떻게든 30개를 채우려고 합니다. 억지로 채운 몇 개가 섞이면 MD는 결과 전체를 다시 봐야 해서, 차라리 적게 달라고 했어요. 실제로 "오후 회의 때 먹을 가벼운 간식. 당 적은 걸로, 음료랑 과자 골고루"라고 넣었더니 30개가 아니라 20개만 돌아왔습니다. 제로 음료, 저당 라떼, 오트밀 쿠키, 견과, 프로틴바 같은 것들이었어요.
응답은 responseSchema로 { picks: [{ itemCode, reason }] } 모양을 강제하고, 돌아온 itemCode는 실제 상품 맵에서 다시 찾습니다. 없는 코드나 중복은 버려요.
1const codeMap = new Map(products.map((p) => [p.itemCode, p]))
2for (const pick of picks) {
3 if (!pick?.itemCode) continue
4 if (seen.has(pick.itemCode)) continue
5 const prod = codeMap.get(pick.itemCode)
6 if (!prod) continue
7 seen.add(pick.itemCode)
8 results.push(prod)
9 if (pick.reason) reasons.set(pick.itemCode, pick.reason)
10 if (results.length >= maxPicks) break
11}
추천 이유는 결과 행에 마우스를 올리면 툴팁으로 보여 줍니다. 왜 이 상품이 나왔는지 알아야 MD가 빼든 말든 판단할 수 있으니까요. 다만 이유의 질은 기대만큼은 아니었습니다. "펩시 제로 콜라", "닥터유 프로틴바"처럼 상품명을 거의 그대로 되풀이한 게 대부분이었어요. thinking을 최소로 둔 Flash-Lite에 20자 제한까지 걸어 두니, 이유라기보다 라벨에 가깝게 나옵니다.
카탈로그는 추리지 않고 업로드된 상품 전체를 보냅니다. MD가 찾는 조건은 카테고리 규칙으로 미리 거르기 어려운 경우가 많고, 괜히 걸렀다가 필요한 상품이 후보에서 빠지는 게 더 곤란했어요. 호출이 많은 도구가 아니라서 토큰을 좀 더 쓰는 쪽을 택했습니다.
샘플 엑셀(상품 164개)로 재 보니 AI 추천 한 번에 입력 6,259토큰, 응답까지 3.3초 정도였습니다. 상품 한 줄에 40토큰 안팎이라, 실제 데이터처럼 상품이 늘면 입력 토큰도 거의 그만큼 늘어납니다.
AI 큐레이션: 장바구니 채워 주기
두 번째는 장바구니의 "AI 큐레이션"입니다. 요구사항과 예산을 넣으면 지금 열려 있는 고객사 탭을 채워 줍니다.

AI 추천과 다른 점은 가격이 들어간다는 겁니다. 같은 상품도 고객사마다 적용단가가 달라서, 카탈로그를 만들 때 그 고객사의 단가를 계산해 한 칸 더 붙입니다.
itemCode|품목명|카테고리|브랜드|적용단가모델에게는 "예산을 넘지 않도록 노력하라"고만 하고, 실제로 넘는지는 코드가 확인합니다. 넘으면 목록 뒤쪽, 그러니까 모델이 덜 중요하게 본 상품부터 수량을 줄이고, 1개까지 줄었는데도 넘으면 뺍니다.
1// 예산 초과 시 뒤쪽(낮은 우선순위) 항목부터 수량을 줄이고, 그래도 초과면 제거
2let i = items.length - 1
3while (totalAmount > budget && i >= 0) {
4 const it = items[i]
5 if (it.quantity > 1) {
6 const reducible = Math.min(it.quantity - 1, Math.ceil((totalAmount - budget) / it.unitPrice))
7 it.quantity -= reducible
8 totalAmount -= it.unitPrice * reducible
9 } else {
10 totalAmount -= it.unitPrice * it.quantity
11 items.splice(i, 1)
12 }
13 i--
14}예산이 남아도 채우지는 않습니다. 남은 예산은 MD가 직접 채우는 게 맞다고 봤어요. 그런데 막상 돌려 보면 모델은 "넘지 말라"는 말에 꽤 몸을 사립니다. 예산 30만원을 줬더니 공급가 14만원어치만 담아 온 적도 있어요.

옵션도 몇 개 뒀습니다. 예산이 참고치일 뿐인 고객사도 있어서 "자동 조정" 대신 "경고만"을 고를 수 있고, 기존 장바구니에 더할지 비우고 담을지, 이미 있는 상품이면 수량을 합칠지 건너뛸지도 고를 수 있습니다. 결과는 토스트로 알려 줍니다.

예산을 넘으면 얼마나 넘었는지도 같이 보여 줍니다. "100명이 먹을 거라 1인 1개씩은 꼭 돌아가게"라고 적고 예산은 3만원만 줘 봤더니, 모델은 예산 대신 요구사항을 택했습니다. 제로 콜라와 감자칩을 50개씩 담아 예산의 세 배를 넘겼어요. "경고만"이라 코드는 줄이지 않고 초과 금액만 알려 줍니다.

히스토리
AI 결과는 매번 조금씩 다릅니다. 같은 요구사항, 같은 예산 30만원으로 두 번 돌렸는데 한 번은 14종, 한 번은 12종이 나왔어요. 어제 괜찮았던 구성을 다시 보고 싶을 때가 있어서, 실행할 때마다 요구사항과 결과를 IndexedDB에 저장하고 히스토리 페이지에서 다시 적용할 수 있게 했습니다. 장바구니 구성은 수량, 예산, 총액, 고객사까지 같이 남습니다.

삽질 1: 응답이 잘린다
AI 큐레이션을 붙이자마자 가끔 "AI 응답을 파싱할 수 없습니다"가 떴습니다. 응답을 찍어 보니 JSON이 중간에 끊겨 있었고, finishReason이 MAX_TOKENS였어요. 출력 토큰 한도에 걸린 겁니다.
처음 코드는 응답을 너무 낙관했습니다.
const textOut = data?.candidates?.[0]?.content?.parts?.[0]?.text
parsed = JSON.parse(textOut)첫 번째 파트만 읽고, 바로 파싱하고, 실패하면 끝. 이걸 이렇게 바꿨습니다.
maxOutputTokens를 32768로 넉넉하게- 스키마에
maxItems를 넣어서 개수 제한을 프롬프트가 아니라 스키마로 - thinking은 최소로
- 생각(thought) 파트는 빼고 실제 텍스트 파트만 모으기
finishReason별로 에러 메시지를 나누기 (SAFETY,MAX_TOKENS등)- 잘렸으면 완성된 항목까지만 살리기
마지막 게 제일 쓸모 있었습니다. 30개 중 27개까지 온 응답을 버릴 이유는 없잖아요.
1function salvageTruncatedJson(text: string): string {
2 // picks 배열에서 마지막으로 완성된 { } 까지만 남기고 배열과 객체를 닫는다
3 const start = text.indexOf('[')
4 if (start < 0) return text
5 let depth = 0
6 let lastGoodClose = -1
7 for (let i = start; i < text.length; i++) {
8 const ch = text[i]
9 if (ch === '{') depth++
10 else if (ch === '}') {
11 depth--
12 if (depth === 0) lastGoodClose = i
13 }
14 }
15 if (lastGoodClose < 0) return text
16 return text.slice(0, lastGoodClose + 1) + ']}'
17}한도에 걸리면 토큰 사용량(usageMetadata)을 콘솔에 남겨서, 생각에 몇 토큰, 출력에 몇 토큰을 썼는지 볼 수 있게 했습니다. 글을 쓰면서 maxOutputTokens만 300으로 줄여 다시 걸어 봤는데, JSON이 중간에서 끊겼지만 완성된 상품 여섯 개까지는 살아서 장바구니에 담겼습니다.

그런데 이 글을 쓰면서 문서를 다시 보니, 처음 쓴 2.5 Flash-Lite는 thinking이 기본으로 꺼져 있다고 나옵니다. 생각 토큰이 출력 한도를 같이 쓰는 건 맞지만(문서에 "including thought tokens"라고 적혀 있습니다), 당시 잘림의 주범은 thinking보다 개수 제한이 스키마에 없었던 쪽이었을 가능성이 커 보여요. 어쨌든 두 설정 다 남겨 둬서 손해 볼 건 없습니다.
삽질 2: 모델을 올렸더니 400
모델은 몇 번 바꿨습니다. 5월에 3.1로 옮긴 건 무료 티어 일일 한도가 20회에서 500회로 늘어서였고(당시 기준), 8월에는 3.5로 올렸다가 같은 날 되돌리고 다시 올렸습니다.

3.5로 모델 이름만 바꿨더니 모든 요청이 400 INVALID_ARGUMENT로 튕겼습니다. 범인은 thinking을 끄려고 넣어 둔 thinkingBudget: 0이었어요. 3.5 계열은 숫자로 예산을 주는 thinkingBudget 대신 단계로 고르는 thinkingLevel을 받습니다.
쓰고 있는 사람이 있는데 도구가 멈춰 있으면 안 되니까 일단 되돌려 놓고, 파라미터를 바꿔서 다시 올렸습니다.
- thinkingConfig: { thinkingBudget: THINKING_BUDGET_DISABLED },
+ thinkingConfig: { thinkingLevel: THINKING_LEVEL_MINIMAL },
에러 메시지가 "Request contains an invalid argument." 한 줄뿐이라, 어느 파라미터가 문제인지는 알려 주지 않습니다. 모델 이름 말고는 바꾼 게 없으니 요청 본문을 하나씩 빼 보는 수밖에 없었어요.
문서를 보면 3.5 Flash-Lite는 기본값이 이미 minimal입니다. 그래도 명시해 두는 편이 나중에 모델을 또 바꿀 때 덜 헷갈려요.
모델 이름은 문자열 하나라 바꾸기 쉬워 보이는데, 세대가 바뀌면 받는 파라미터도 바뀔 수 있습니다. 한 번 데이고 나서는 모델을 올릴 때 요청 한 번은 꼭 직접 날려 보고 커밋합니다.
제일 찝찝한 부분: 키가 번들에 들어간다
서버가 없으니 Gemini는 브라우저에서 바로 부릅니다. 키는 VITE_GEMINI_API_KEY 환경변수에 넣었어요.
문제는 VITE_로 시작하는 환경변수는 빌드할 때 실제 값으로 치환돼서 JS 파일에 그대로 들어간다는 겁니다. Vite 문서에도 대놓고 적혀 있습니다.
"VITE_* variables should not contain sensitive information such as API keys. The values of these variables are bundled into your source code at build time."가짜 키로 빌드해 보면 바로 확인할 수 있어요.

배포 환경의 환경변수 설정 화면에서 값을 가려 둬도 소용없습니다. 빌드할 때 이미 JS 파일에 들어가 버리니, 배포된 사이트를 열고 개발자 도구에서 문자열 하나만 검색하면 누구나 키를 가져갈 수 있습니다.
근데 Next.js로 다시 만들어야 하나?
처음엔 그렇게 생각했는데, 따져 보니 그럴 필요가 없었습니다. Next.js라고 키가 저절로 숨겨지는 게 아니에요. 키가 숨겨지는 건 fetch가 서버에서 실행될 때입니다. Route Handler나 Server Action 안에서 NEXT_PUBLIC_이 없는 환경변수로 부르면 숨겨지지만, 클라이언트 컴포넌트에서 NEXT_PUBLIC_GEMINI_API_KEY로 부르면 Vite랑 똑같이 번들에 들어갑니다.
이 도구는 엑셀 파싱도, DB도, 상태 관리도 전부 브라우저에서 돕니다. 서버가 필요한 건 Gemini 호출 딱 하나예요. 그거 하나 때문에 프레임워크를 갈아엎는 건 과합니다.
결국 필요한 건 Gemini를 부르는 부분 하나만 서버로 옮기는 일입니다. 브라우저는 내 서버 주소만 부르고, 키는 서버 쪽 환경변수에만 두는 거죠. 이 부분은 직접 옮겨 보고 따로 정리하겠습니다.
아쉬운 점
취소가 안 됩니다. Gemini 호출 함수는 AbortSignal을 받게 만들어 놓고, 정작 호출하는 쪽에서 안 넘기고 있어요. 응답이 늦어도 멈출 방법이 없습니다.
토큰을 얼마나 쓰는지 모릅니다. 한도에 걸릴 때만 사용량을 찍고 있어서, 평소 요청이 얼마나 드는지는 이번처럼 따로 재 봐야 합니다.
예산 기준이 헷갈립니다. 코드가 비교하는 금액은 부가세를 뺀 적용단가 합계인데, 장바구니 하단 총액은 부가세를 포함합니다. 토스트에는 "총 ₩80,381.818"처럼 소수점까지 그대로 찍히고요. MD 입장에서는 예산 30만원이 어느 쪽 기준인지부터 정해 줘야 할 것 같습니다.
마치며
만들어 놓고 보니 Gemini가 하는 일은 문장을 읽고 상품을 고르는 것까지입니다. 목록에 있는 상품인지 확인하고, 단가를 붙이고, 예산을 넘는지 보고, 잘린 응답을 살리는 건 전부 평범한 코드였어요.
서버 없이 브라우저만으로 여기까지 온 건 만족스럽지만, 키 문제는 계속 찝찝하게 남아 있었습니다. Gemini 호출을 서버로 옮기고 나면 후기를 덧붙이겠습니다.
참고 자료
이 글이 도움되셨나요?
공유해주시면 더 많은 사람들이 볼 수 있어요!