R&E 연구노트 ⑩ 음성 명령을 액션으로, n8n과 OpenRouter
연구노트 시리즈.
"아메리카노 한 잔"을 행동으로
Whisper가 음성을 텍스트로 바꿔줬다면, 이제 그 텍스트를 키오스크가 이해하는 동작으로 옮겨야 한다. "아메리카노 차갑게 한 잔"이라는 말이 곧바로 장바구니 추가로 이어지려면 자연어를 구조화된 명령으로 해석하는 단계가 필요하다. 여기에 n8n 워크플로우와 OpenRouter LLM을 썼다.
흐름은 이렇다. 음성 텍스트가 webhook으로 n8n 워크플로우에 들어오면 OpenRouter의 LLM이 해석해 JSON 액션을 반환하고, 그 결과를 UI에 반영한다.
인프라: 자체 호스팅
n8n은 직접 호스팅해 데이터 처리와 자동화 워크플로우를 구성했다.
- 운영 환경: Debian.
- Docker 기반 서비스 구성, Portainer로 컨테이너 관리와 모니터링.
- Nginx를 Reverse Proxy로 두어 외부 요청을 내부 서비스로 안전하게 라우팅하고 포트 노출을 최소화했다.
반복적인 데이터 처리와 분석을 자동화하면서 다양한 AI 모델을 유연하게 갈아 끼울 수 있는 확장성도 확보했다.
1단계: 명령을 액션으로 분류
LLM에게 키오스크 음성 명령 해석 역할을 주고, 한국어 명령을 다음 액션 중 하나의 JSON으로 변환하게 했다.
| 액션 | 의미 |
|---|---|
| addToCart | 메뉴를 장바구니에 추가 |
| clearCart | 장바구니 비우기 |
| placeOrder | 주문하기 |
| changeAge | 연령대 변경 (child/teen/adult/senior) |
| selectCategory | 카테고리 선택 (커피/티/디저트) |
| showMenu | 특정 메뉴 상세 보기 |
| removeFromCart | 장바구니에서 제거 |
addToCart가 아닌 액션은 정해진 params와 message만 채워 바로 응답한다. 까다로운 건 addToCart다.
2단계: 메뉴 옵션 파싱
addToCart인 경우 음료의 세부 옵션까지 해석해야 한다.
- name: 메뉴 이름 (메뉴 목록의 정확한 이름 사용)
- quantity: 수량
- size: s, m, l (소, 중, 대)
- sweet: 0, 1, 2 (무가당, 50%, 100%)
- ice: 0, 1, 2 (적게, 보통, 많이)
여기서 현실적인 디테일이 들어간다. 사용자가 언급하지 않은 옵션을 어떻게 처리할 것인가. name과 size를 말하지 않으면 none, sweet과 ice를 말하지 않으면 9(미지정 표식), quantity를 말하지 않으면 1로 둔다. 한 잔을 시키면서 수량을 말하지 않는 사용자 특성을 반영한 기본값이다.
3단계: 누락 옵션 되묻기
옵션이 비어 있다고 무작정 기본값으로 처리하면 오주문이 난다. 그래서 if 노드로 none과 9 값을 검사해, 빠진 옵션이 있으면 그 필드들을 missingFields에 모으고 LLM으로 되묻는 문장을 생성했다. 예를 들면 "사이즈를 포함하여 다시 말해주세요" 같은 문장이다. 반대로 옵션이 모두 채워졌다면 LLM이 params를 사람이 읽기 좋은 한국어 안내 문장으로 바꾼다. "아메리카노 1잔, 미디움 사이즈, 무가당, 얼음 보통 추가되었습니다!" 같은 식이다.
결과
최종적으로 respond to webhook으로 action, params, message 세 필드를 JSON으로 돌려주면 UI가 그 message를 사용자에게 보여준다. 스크립트에 없는 명령, 예컨대 "아무 음식이나 담아줘" 같은 모호한 요청에도 LLM이 가장 일반적인 해석을 골라 유연하게 대처했다.
규칙을 사람이 일일이 짜는 대신 LLM에 맥락(메뉴, 옵션, 규칙)을 주고 출력 형식만 JSON으로 고정한 게 이 파이프라인의 핵심이었다. 메뉴가 바뀌어도 프롬프트의 목록만 갱신하면 된다.