Portfolio.
projects

AI Agent

NUNCHI-AI

Backend / AI 서버 설계·구현·캡스톤 · 시연 2026.06

"주문할 때 눈치 보지 마세요, AI가 당신의 눈치를 파악해요" — 키오스크 앞에서 막막해하는 노인·외국인도 한 마디면 주문되도록 만든 음성 AI 키오스크입니다. 동국대 졸업 캡스톤(4인 팀)에서 백엔드와 AI 서버를 맡았습니다.

FastAPI · fastmcp · LangGraph · Redis · OpenAI · Gemini · SSE

CapstoneDgu
NUNCHI-AI 화면

read

주문하는 능력을 LLM 바깥으로 꺼낸 이야기 — 못 믿을 곳일수록 권한을 좁히기까지

키오스크 앞에서 막막해하는 분들을 보며 시작했습니다

동국대 졸업 캡스톤에서 백엔드와 AI 서버를 맡은 임현우입니다. 저희 팀이 만든 NUNCHI-AI는 "주문할 때 눈치 보지 마세요, AI가 당신의 눈치를 파악해요"라는 한 문장에서 출발한 음성 AI 키오스크인데요. 4인 팀에서 제가 백엔드와 AI 서버 설계·구현을 맡았습니다.

요즘은 식당이든 카페든 키오스크가 먼저 손님을 맞습니다. 그런데 메뉴가 한 화면에 다 안 들어가고, 옵션 하나 고르려면 화면을 몇 번씩 넘겨야 하죠. 익숙한 사람한테는 별것 아니지만 노인이나 외국인 손님한테는 그 흐름 자체가 벽이 됩니다. 뒷사람 눈치를 보다 결국 아무 버튼이나 누르고 마는 장면, 한 번쯤 보셨을 거예요.

그래서 저희가 잡은 목표는 단순했습니다. "단백질 많은 거 하나 주세요" 한 마디면 주문이 끝나게 하자. 말로 풀면 쉬운데, 막상 만들려니 음성 주문 위에 까다로운 제약이 하나 더 얹혀 있었습니다.

동국대 학생식당 키오스크 시작 화면 — 터치와 시선 두 방식, 그리고 AI 대화 주문·눈치 추천을 함께 안내합니다.
동국대 학생식당 키오스크 시작 화면 — 터치와 시선 두 방식, 그리고 AI 대화 주문·눈치 추천을 함께 안내합니다.

왜 만들었나 — 모델 하나에 주문 흐름 전체가 묶이는 게 문제였습니다

음성으로 주문을 받는다는 건 결국 사용자의 말을 알아듣고 거기 맞는 행동(메뉴 담기, 장바구니 수정, 결제 진입)을 실행한다는 뜻입니다. 가장 빠른 길은 LLM한테 함수 호출을 시키는 방식이죠. 모델이 "cart에 비빔밥 담기" 같은 함수를 직접 부르게 하는 겁니다.

그런데 여기서 막혔습니다. 이렇게 짜면 주문을 실행하는 로직이 특정 LLM 벤더의 함수 호출 규격에 통째로 묶여요. OpenAI로 짰는데 나중에 Gemini로 바꾸고 싶어지면? 같은 주문 흐름을 다른 모델 규격에 맞춰 다시 짜야 합니다. 캡스톤이라 여러 모델을 비교해 보고 싶었던 저희한테는 이게 꽤 뼈아픈 지점이었거든요.

이 제약을 먼저 박아둔 게 결과적으로 설계 방향을 정해 줬습니다. 모델에 종속되지 않으려면 주문하는 능력을 LLM 안쪽이 아니라 바깥에 두어야 했거든요.

어떻게 풀었나 (1) — 주문 능력을 MCP 도구로 꺼냈습니다

그래서 고른 게 MCP(Model Context Protocol)입니다. 2024년 11월에 막 나온 표준인데, LLM이 외부 도구를 호출하는 방식을 모델 벤더와 무관한 표준 인터페이스로 정의해 줍니다. 저희는 키오스크의 주문 기능을 이 MCP 서버의 도구로 만들었습니다.

구체적으로는 다섯 개입니다. 메뉴 조회(menu), 장바구니(cart), 주문(order), 결제(payment), 세션(session). 이 다섯 기능을 fastmcp로 도구화해서, LLM은 "무엇을 할지"만 판단하고 "실제 실행"은 MCP 도구가 맡도록 역할을 갈랐습니다.

python
from fastmcp import FastMCP mcp = FastMCP("kiosk") @mcp.tool() def add_to_cart(menu_id: str, qty: int = 1) -> dict: """장바구니에 메뉴를 담습니다. LLM은 이 도구를 '호출'만 하고 실제 담는 로직은 여기(LLM 바깥)에 있습니다.""" ... # menu / cart / order / payment / session 다섯 기능을 각각 도구로 등록 # → 모델이 바뀌어도 도구 정의는 그대로 재사용됩니다.
주문 능력을 MCP 도구로 노출 — LLM 규격이 아니라 표준 인터페이스에 등록합니다.

이렇게 떼어 놓으니 효과가 분명했습니다. 주문 흐름은 MCP 도구 쪽에 그대로 있고 모델은 그 도구를 부르는 클라이언트일 뿐이라, 모델을 바꿔도 주문 로직을 다시 짤 필요가 없어졌습니다.

직접 함수 호출MCP 도구로 분리 (선택)
초기 설계 비용낮음조금 더 듦 (서버 분리)
모델·프레임워크 교체주문 흐름 전체 재작성사실상 0 — 클라이언트만 교체
여러 LLM 비교어려움한 코드베이스에서 바꿔 끼우며 비교

초기 설계 비용이 늘어나는 건 인정해야 합니다. 함수 하나 부르면 될 걸 도구 서버로 분리하니까요. 다만 그 비용을 한 번 치르고 나면 모델 교체 비용이 거의 0이 됩니다. 저희한테는 "종속되지 않는다"는 제약이 더 중요했기 때문에, 이 트레이드오프를 받아들였습니다.

어떻게 풀었나 (2) — 가장 못 믿을 부분일수록 권한을 좁혔습니다

도구와 판단을 갈라 놓은 덕에, 실제로 한 코드베이스에서 OpenAI와 Gemini를 바꿔 끼우며 비교할 수 있게 됐습니다. 주문을 실행하는 부분은 그대로 두고 판단하는 모델만 갈아 끼우는 구조였거든요.

"두 모델을 다 지원한다"는 건 MCP로 도구를 바깥에 빼니 따라온 결과지, 그 자체가 자랑할 일은 아니었어요. 도구와 판단을 갈라 놓고 나서야 진짜 고민이 시작됐는데요. 판단을 맡긴 LLM이 거짓말을 한다는 거였습니다. 초기 테스트에서 에이전트가 장바구니에 담는 도구를 부르지도 않고 "비빔밥 담았어요"라고 답하는 일이 있었거든요. 돈과 재고가 오가는 키오스크에서 이건 그냥 버그가 아니라 사고예요.

그래서 "모델이 거짓말을 안 하게 프롬프트를 잘 쓰자"는 방향은 처음부터 접었습니다. 모델 말을 믿는 한 결국 또 새어 나갈 테니까요. 대신 모델이 "했다"고 말한 것과 도구가 실제로 한 일을 대조하게 했습니다. 도구를 안 불렀으면 장바구니를 다시 조회해 정직하게 답하고, 모델이 말한 메뉴와 실제 담긴 메뉴가 다르면 실제 결과로 바꿔 말하게요. 모델의 '이해'는 어차피 틀릴 수 있으니, 그 이해를 실행 바깥에 두고 사실은 도구 결과로만 확정한 거죠.

점원 아바타가 "단백질이 풍부한 메뉴를 추천해 드릴게요"라며 대화로 주문을 이끄는 음성 상호작용 화면.
점원 아바타가 "단백질이 풍부한 메뉴를 추천해 드릴게요"라며 대화로 주문을 이끄는 음성 상호작용 화면.

어떻게 풀었나 (3) — 되묻고 재시도하는 대화를, 프롬프트가 아니라 그래프로 그렸습니다

주문 도구를 분리했어도 음성 대화 자체가 또 다른 난관이었습니다. 실제 주문은 한 번에 깔끔하게 안 끝나거든요. "매운 거 빼고요" 하고 옵션을 바꾸고, 못 알아들으면 되묻고, 다 고르면 결제로 넘어가는 분기가 계속 생깁니다.

이걸 프롬프트 한 덩어리에 다 넣고 모델이 알아서 판단하게 둘 수도 있었습니다. 처음엔 그 방향도 고민했는데요. 문제는 대화가 어디서 어떻게 흘러갔는지가 프롬프트 속에 암묵적으로 숨어 버려서, 흐름이 꼬였을 때 디버깅할 방법이 없다는 점이었습니다.

그래서 LangGraph의 StateGraph로 대화 분기를 명시적인 상태 전이로 모델링했습니다. 되묻기, 재시도, 결제 진입 같은 흐름을 프롬프트 속 암묵적 규칙이 아니라 코드 위의 노드와 엣지로 드러낸 거죠.

그래프로 그리니 흐름이 보이는 것 말고도 좋은 게 하나 더 있었어요. 노드마다 줄 수 있는 권한을 다르게 줄 수 있다는 점이었습니다. 주문 노드는 도구를 다 쓰게 하고, 추천 노드는 읽기 전용 도구만, 결제 노드는 도구를 아예 안 주는 식으로요. 하나의 만능 프롬프트였다면 "결제는 함부로 하지 마"라고 부탁하는 수밖에 없었을 텐데, 노드를 갈라 두니 부탁이 아니라 구조로 막을 수 있게 됐습니다.

python
from langgraph.graph import StateGraph graph = StateGraph(OrderState) graph.add_node("understand", understand_user) # 의도 파악 graph.add_node("clarify", ask_again) # 되묻기 graph.add_node("order", call_mcp_tools) # MCP 도구 실행 graph.add_node("checkout", enter_payment) # 결제 진입 # 못 알아들으면 clarify로, 확정되면 order로 — 분기를 엣지로 명시 graph.add_conditional_edges("understand", route_by_confidence)
대화 분기를 StateGraph로 명시 — 흐름이 코드에 드러나 디버깅할 수 있습니다.
대화 분기 처리프롬프트에 맡기기StateGraph로 명시 (선택)
흐름 가시성프롬프트 속에 숨음노드·엣지로 코드에 드러남
디버깅재현·추적 어려움어느 단계에서 꼬였는지 추적 가능
비용작성 빠름상태를 코드로 드러내는 수고 필요

이 방식에도 대가는 있습니다. 상태를 일일이 코드로 그려야 해서 손이 더 갑니다. 다만 되묻기나 결제 진입처럼 중요한 흐름이 디버깅 가능한 구조가 된다는 게, 그 수고를 치를 이유로는 충분했습니다.

어떻게 풀었나 (4) — 음성이 끊겨 들리지 않게, 체감 지연을 줄였습니다

마지막 과제는 속도였습니다. 음성 주문은 응답이 1~2초만 비어도 "멈췄나?" 싶어집니다. 화면 위 텍스트라면 모를까, 말로 오가는 대화에서는 그 공백이 유독 길게 느껴지거든요.

그래서 응답을 다 만들고 한 번에 내보내는 대신, SSE 토큰 스트리밍으로 생성되는 대로 흘려보냈습니다. 모델이 문장을 다 끝낼 때까지 기다리지 않고, 만들어지는 토큰부터 사용자한테 닿게 한 거죠. 응답 전체 시간이 같아도 "반응이 시작되는" 순간이 당겨지면 체감 지연이 확 줄어듭니다.

스트리밍만으로는 부족했습니다. 인사나 눈치 반응처럼 LLM까지 갈 필요 없는 말은 규칙 노드에서 LLM 없이 즉답하게 했고, "오늘 뭐가 인기예요?" 같은 뻔한 다음 질문은 미리 계산해 캐시에 넣어 두는 prefetch를 붙였습니다. 캐시에 맞으면 LLM을 한 번도 안 부르고 바로 답이 나가요. 단, 이 미리 계산은 읽기 전용 도구만 쓰게 막아서 진짜 장바구니는 절대 못 건드리게 했습니다 — 미리 계산하되 미리 저지르지는 않게요.

그리고 이 모든 판단은 감이 아니라 단계별 ms 타이밍 로그로 했습니다. 재 보니 정작 느린 건 MCP 도구도(수십 ms) 일반 API도(1초 미만) 아니고 LLM 그 자체였어요. "시스템이 느린 게 아니라 모델이 느리다"를 숫자로 확인하고 나니, 그다음 손댈 곳도 분명해졌습니다. 분류·단계 판단 같은 가벼운 노드는 더 작고 빠른 모델로 옮기는 거죠. 풀 응답 한 턴은 여전히 길지만, 첫 마디가 빨리 나오고 뻔한 답이 즉답되니 대화로는 쓸 만해졌습니다.

그래서 무엇이 남았나 — 모델을 바꿔도 주문은 그대로인 구조

결과를 과장 없이 정리하면 이렇습니다. menu·cart·order·payment·session 다섯 기능을 MCP 서버의 도구로 분리했고, 그 위에서 OpenAI·Gemini 두 모델을 바꿔 끼울 수 있는 구조를 만들었습니다. 캡스톤 산출물로서, 막 나온 MCP 표준을 학부 과제에 정식으로 끌어와 동작시켜 본 게 핵심 성취입니다.

항목결과
MCP 도구5개 (menu·cart·order·payment·session)
결제 에이전트 도구0개 — 오결제를 구조로 차단
대화 분기LangGraph StateGraph로 명시적 모델링
역할백엔드·AI 서버 설계·구현 (4인 캡스톤)
시선으로 주문 — 바라보는 메뉴가 초록 테두리로 강조되고, 손 없이 눈으로 고르는 '눈치' 모드.
시선으로 주문 — 바라보는 메뉴가 초록 테두리로 강조되고, 손 없이 눈으로 고르는 '눈치' 모드.

배운 것과 남은 과제 — 제약을 먼저 거는 게 설계였습니다

이 프로젝트에서 제가 가장 크게 배운 건, "모델·프레임워크에 종속되지 않는다"는 제약을 처음부터 걸어두면 설계가 자연스럽게 도구와 판단을 분리하는 방향으로 정리된다는 점입니다. 좋은 구조를 찾으려 애쓰기 전에, 무엇을 포기하지 않을지부터 정하는 게 먼저였습니다.

남은 과제도 솔직히 적자면, 아직 검증해 보지 못한 게 많습니다. 실사용 환경에서 음성 응답이 얼마나 빠른지, 노인·외국인 손님이 실제로 한 마디로 주문을 끝낼 수 있는지는 시연을 넘어 더 측정해 봐야 합니다. MCP로 모델을 바꿔 끼울 수 있게 해 둔 만큼, 다음엔 어떤 모델이 이 주문 흐름에서 가장 잘 동작하는지를 수치로 비교해 보고 싶습니다.

키오스크 앞에서 눈치 보지 않게 만들겠다는 처음의 목표는, 결국 "주문하는 능력을 LLM 바깥으로 꺼내는" 구조 결정으로 이어졌습니다. 무엇을 포기하지 않을지부터 정하고 설계를 시작하는 습관은, 이 경험 이후 제가 어떤 시스템을 짜든 가장 먼저 꺼내는 질문이 됐습니다.

다른 작업연락하기