LLM AI로 간식 추천 서비스 만들기

AILLMReact

2026. 9. 15.

스낵링크는 사무실 간식 큐레이션 서비스였습니다. 회사 총무 담당자가 탕비실 간식을 고르는 데 생각보다 시간이 많이 드는데, 그걸 1분 안에 끝내 보자는 게 목표였어요.

AI 챗은 대표님의 오더로 시작했습니다. "사무실 간식 5만원어치 알려줘"처럼 말만 하면 간식 구성을 짜 주는 기능이요. 개발은 백엔드 개발자 한 분과 저, 둘이서 했습니다. GPT를 부르고 결과를 가공하는 건 백엔드, 그 응답을 받아 화면에 풀어내는 건 제 몫이었어요. 둘이서 서비스 전체를 만든 이야기는 따로 써 보려고 합니다.

이번 글은 그중 AI 챗 이야기입니다. 백엔드와 프론트가 LLM을 어떻게 나눠 맡았는지, 그리고 AI가 없는 간식을 추천하던 문제를 어떻게 잡았는지를 정리했습니다.

스낵링크 AI 추천 챗. 말로 요청하면 간식 구성이 돌아온다
스낵링크 AI 추천 챗. 말로 요청하면 간식 구성이 돌아온다
글에 실린 화면은 서비스 종료 후 만든 데모에서 다시 찍었습니다. UI는 원래 코드 그대로지만, 화면 속 답변 문장은 GPT가 아닌 데모용 모델이 만든 것입니다.

화면이 먼저 나왔습니다

챗 화면은 GPT가 붙기 전에 먼저 만들었습니다. 2024년 10월 말, 백엔드에 아직 아무것도 없을 때 더미 데이터로 시작했어요.

1export const SAMPLE_DATA = [
2  {
3    id: 11123,
4    input_text: '신상위주의 과자를 추천해주세요.',
5    output_text: '신상위주의 과자를 추천해드릴게요.',
6    snack_list: [...SNACK_LIST],
7    // ...
8  },
9];

AI가 뭐라고 답할지는 몰라도, 답에 무엇이 들어올지는 미리 정할 수 있었습니다. 설명 문장 하나와 상품 목록이요. 나중에 곁들이면 좋은 상품이 추가되면서 응답 모양은 이렇게 굳어졌습니다.

1interface iChatResponse {
2  message: string;              // AI가 쓴 설명
3  snack_list: iChatSnack[];     // 추천 상품 (수량·가격 포함)
4  recommend_message?: string;   // "이런 간식은 어떠세요?"
5  recommend_list?: iChatSnack[];// 곁들임 추천
6}

이 모양만 지켜지면 뒤에서 GPT가 어떻게 바뀌든 화면은 그대로 둘 수 있었어요. 실제로 백엔드는 이후 몇 번이나 방식이 바뀌었는데, 프론트는 거의 손대지 않았습니다.

백엔드가 LLM을 부르는 구조

처음부터 이런 구조였습니다. 프론트엔드(챗 화면)는 LLM API(OpenAI)를 직접 부르지 않고 백엔드(Django)에만 요청합니다. LLM API를 부르고 그 결과를 가공해서 돌려주는 건 백엔드의 일입니다.

프론트엔드는 백엔드만 부르고, LLM API는 백엔드 뒤에 있다. 예산을 말했을 때만 추천 모델을 한 번 더 거친다
프론트엔드는 백엔드만 부르고, LLM API는 백엔드 뒤에 있다. 예산을 말했을 때만 추천 모델을 한 번 더 거친다

LLM이 주는 건 상품 코드와 설명 문장뿐이고, 상품 이름, 이미지, 가격, 재고는 전부 우리 DB에서 옵니다. 예산을 말하면 회사에 있던 자동 큐레이션 모델에 예산을 넘겨 수량을 맞췄고요. 이 구조가 왜 중요했는지는 바로 다음에 나옵니다.

없는 간식을 추천한다

사전 프롬프트는 역할을 정해 주는 데서 시작했습니다.

# 역할
- 당신은 Json을 반환하는 Assistant이자, 스낵을 추천할 수 있는 전문가입니다.

그리고 판매 중인 간식 목록을 JSON 파일(snack_list.json)로 OpenAI에 올려 두고, 그 파일에서 골라 상품 코드와 이름을 돌려달라고 했습니다.

근데 이런 간식이 있었나?

할루시네이션이 생각보다 심했습니다. 목록에 없는 간식을 그럴듯한 이름으로 만들어서 추천하기도 했어요. 사용자가 "모두 담기"를 눌렀는데 없는 상품이 섞여 있으면, 챗 하나가 아니라 서비스 전체를 못 믿게 됩니다.

그래서 두 겹으로 막았습니다.

첫 번째는 프롬프트. 11월 말부터 프롬프트에 이런 줄이 들어갔습니다.

1# 필수 조건
2- code, name3 가 정확히 있는 상품만 선정.
3- 과자를 추천해 달라는 의미가 들어가 있다면 category1은 과자인 상품만 선정.
4- 절대 만들어내서는 안됨. 절대 안됨.

"절대 안됨"을 두 번 쓴 데서 당시 분위기가 느껴지네요. 12월에는 조금 더 구체적으로 바뀝니다.

- snack_list.json 파일에서만 추천합니다. code와 name3는 생성하면 안됩니다. 파일안에서만 추천합니다.

두 번째는 백엔드 검증. 프롬프트를 아무리 세게 써도 확률을 낮출 뿐, 0으로 만들지는 못합니다. 그래서 모델이 준 상품 코드를 DB에 다시 물어봤어요.

1code_list = [row["code"] for row in result_dict["snack_list"]]
2snack_list = Snack.objects.filter(
3    code__in=code_list, is_activate=True, main_stock__warehouse="메인", main_stock__current_qt__gte=1,
4)

코드가 실제로 있는지, 판매 중인지, 재고가 있는지까지 한 번에 거릅니다. 모델이 지어낸 상품은 여기서 그냥 사라지고, 화면에는 실제로 주문할 수 있는 상품만 나갑니다. 가격도 모델이 아니라 DB가 계산한 값이 붙고요.

프롬프트를 지나도 DB에 없거나, 팔지 않거나, 재고가 없으면 버린다
프롬프트를 지나도 DB에 없거나, 팔지 않거나, 재고가 없으면 버린다

이 두 겹을 넣고 나서 추천 결과를 믿을 수 있게 됐고, 서비스 신뢰도도 확실히 올라갔습니다. 프롬프트는 이상한 답이 나올 확률을 낮추고, 검증은 이상한 답이 사용자에게 닿지 않게 합니다. 둘 중 하나만으로는 부족했어요.

참고로 백엔드는 이 방식 전에 다른 접근도 시도했습니다. GPT에게 상품을 직접 고르게 하지 않고, 질문에서 조건만 뽑게 하는 방식이었어요. 카테고리, 맛, 예산, 포장 형태 같은 걸 미리 준 목록 안에서만 고르게 하고, 실제 상품 검색은 DB 쿼리가 하는 식입니다. 없는 상품이 나올 수가 없는 구조라 안전한 방식이었죠. 왜 바꿨는지는 백엔드 쪽 사정이라 자세히는 모르지만, 결국 목록을 주고 직접 고르게 한 뒤 검증하는 쪽으로 정리됐어요. 응답 모양이 그대로라 프론트는 따라 바꿀 게 없었습니다.

기다리는 시간 채우기

여기서부터가 제가 한 일입니다.

GPT 응답은 금방 오지 않았습니다. 백엔드가 OpenAI에 요청을 넣고 처리가 끝날 때까지 기다린 다음 DB 검증까지 하니까, 몇 초씩 걸리는 게 보통이었어요. 그 몇 초 동안 화면이 멈춰 있으면 사용자는 고장 난 줄 압니다.

그래서 응답 하나를 세 단계로 나눠 보여줬습니다.

1const initialState = {
2  inProgress: false,
3  p00apiLoading: false, // 1. 응답 기다리는 중 (로딩 점)
4  p01ongoing: false,    // 2. 추천 메시지 + 상품 목록
5  p02ongoing: false,    // 3. 곁들임 추천
6  // ...
7};
챗 화면의 상태 전환. 곁들임 추천이 없으면 그 단계를 건너뛰고, 실패하면 에러 문구를 띄운다
챗 화면의 상태 전환. 곁들임 추천이 없으면 그 단계를 건너뛰고, 실패하면 에러 문구를 띄운다

질문을 보내면 AI 캐릭터 '스링이'의 말풍선에 로딩 점이 뜨고, 입력창은 잠기면서 "AI 스링이가 간식을 구성중입니다..."로 바뀝니다. 응답이 오면 설명 문장이 한 글자씩 타이핑되고, 다 쳐지면 1.4초 뒤에 상품 목록이 나옵니다. 그다음 곁들임 추천이 같은 방식으로 한 번 더 이어지고요.

1const onChatDone = () => {
2  setChatStats(STATUS.DONE);
3  setListStats(STATUS.ONGOING);
4
5  setTimeout(() => {
6    onListDone(); // 목록까지 끝나면 다음 단계(곁들임 추천)로
7  }, 1400);
8};

타이핑 효과는 react-simple-typewriter를 썼습니다. 응답은 이미 다 받아 둔 상태라 사실 한 번에 보여줄 수도 있었는데, 일부러 천천히 풀었어요. 사람이랑 대화하는 느낌도 나고, 설명을 읽을 틈이 생겨야 바로 아래 상품 목록이 "왜 이게 나왔는지" 이해가 되거든요.

단계를 나눈 게 구현에서도 도움이 됐습니다. 각 말풍선이 끝나면 onCompleted로 다음 단계를 부르는 식이라, 곁들임 추천이 없는 응답이면 그 단계만 건너뛰면 됐어요.

1단계. 응답을 기다리는 동안
1단계. 응답을 기다리는 동안
2단계. 설명이 한 글자씩 타이핑된다
2단계. 설명이 한 글자씩 타이핑된다
3단계. 상품 목록, 이어서 곁들임 추천
3단계. 상품 목록, 이어서 곁들임 추천
질문 하나에 돌아오는 세 단계 응답
질문 하나에 돌아오는 세 단계 응답

답을 받으면 바로 담게

추천만 하고 끝나면 사용자는 상품을 하나하나 검색해서 다시 담아야 합니다. 그래서 상품 말풍선에서 바로 장바구니로 넘어가게 했어요.

  • 상품마다 수량을 정해서 담기
  • "모두 담기 (개수)"로 추천된 상품 한 번에 담기
  • "자세히 보기"를 누르면 목록을 크게 펼치기 (PC는 오른쪽 사이드바, 모바일은 아래에서 올라오는 창)

곁들임 추천은 바로 펼치지 않고 "살펴보기" 버튼으로 접어 뒀습니다. 본 추천과 섞이면 뭐가 메인인지 헷갈리니까요.

추천 결과에서 바로 담고, 자세한 목록은 사이드바에서
추천 결과에서 바로 담고, 자세한 목록은 사이드바에서
모바일에서는 자세히 보기를 누르면 간식 목록이 아래에서 올라온다
모바일에서는 자세히 보기를 누르면 간식 목록이 아래에서 올라온다

뭘 물어볼지 모를 때

챗 화면을 처음 연 사람은 뭘 물어봐야 할지 모릅니다. 그래서 예시 질문 버튼을 뒀어요. PC는 오른쪽 사이드바에, 모바일은 입력창 옆 + 버튼을 누르면 입력창 바로 위에 뜹니다.

['단짠단짠 간식', '사무실 간식 5만원어치 알려줘', '부모님 간식 추천']

누르면 입력창에 그대로 들어가서 바로 전송됩니다. 예산이 들어간 질문을 하나 넣어 둔 건 "예산을 말해도 된다"는 걸 알려주고 싶어서였습니다.

처음 온 사람을 위한 예시 질문
처음 온 사람을 위한 예시 질문

지난 대화는 백엔드가 최근 5개를 돌려주고, 화면에서는 날짜가 바뀔 때마다 구분선을 넣어 보여줍니다. 스크롤을 올려 예전 대화를 보고 있으면 맨 아래로 내려가는 버튼도 뜨고요.

지난 대화는 날짜별로 나눠 보여 준다
지난 대화는 날짜별로 나눠 보여 준다

실패했을 때

요청이 실패하거나 서버가 200이 아닌 응답을 주면 이 문구를 띄웠습니다.

죄송합니다. 조금 더 자세히 말씀해주시면 꼭 맞는 간식을 추천드리겠습니다.

"서버 오류"라고 하는 것보다 다시 물어볼 이유를 주는 쪽이 낫다고 생각했어요. 간식과 상관없는 질문이면 백엔드가 "간식에 해당하는 질문에만 답변 드릴 수 있어요!"라고 답해 주는데, 이건 정상 응답이라 일반 말풍선으로 나옵니다.

...그런데 이 글 쓰면서 코드를 다시 보니, 에러 문구가 AI 말풍선이 아니라 사용자 말풍선 쪽에 뜨고 있네요. 서비스가 끝난 지금은 고칠 방법도 없고요.

돌아보며

응답 형식을 프롬프트로만 부탁했습니다. 백엔드는 모델 응답에서 {부터 }까지 잘라서 JSON으로 파싱했는데, 모델이 앞뒤에 말을 붙이면 깨지기 쉬운 방식입니다. 지금이라면 OpenAI의 Structured Outputs처럼 스키마로 형식을 강제하는 기능을 쓸 것 같아요.

스트리밍이 아니었습니다. 응답을 끝까지 받은 다음 타이핑 효과로 흉내 낸 거라, 첫 글자가 뜨기까지는 결국 전체 응답 시간을 기다려야 했습니다. 실제 스트리밍이었다면 체감이 훨씬 빨랐을 거예요.

할루시네이션은 결국 코드로 막았습니다. 프롬프트를 여러 번 고쳤지만 마지막까지 믿을 수 있었던 건 DB 검증이었어요. LLM이 준 값은 사용자에게 보내기 전에 한 번은 우리 데이터와 대조한다. 이게 이 프로젝트에서 얻은 가장 큰 교훈입니다.

마치며

돌아보면 프론트에서 한 일은 LLM 자체보다 LLM을 기다리는 시간을 다루는 일이 더 많았습니다. 응답을 어떻게 받느냐보다 어떻게 보여주느냐가 사용자가 느끼는 품질을 더 크게 좌우했어요.

서비스는 종료됐지만, 이때 만든 챗 화면은 지금 봐도 꽤 마음에 듭니다. 에러 말풍선 위치만 빼고요.

참고 자료

이 글이 도움되셨나요?

공유해주시면 더 많은 사람들이 볼 수 있어요!