음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

AI 견적서 만들기 — 고객 문의 메일 + 원가표로 견적 초안 자동 생성


견적 작성, 손이 가는 부분은 늘 정해져 있다

견적 업무는 패턴이 늘 같습니다. 고객이 메일로 "이러이러한 품목들 견적 좀 주세요"라고 보냅니다. 그러면 담당자가 그 메일을 읽고, 품목을 하나씩 원가표에서 찾아, 단가를 옮겨 적고, 수량을 곱해 견적서를 만듭니다. 품목이 열 개면 열 번 표를 뒤집니다.

이 과정의 90%는 기계가 할 수 있는 일입니다. 담당자가 실제로 하는 일을 풀어 쓰면 이렇습니다.

  1. 고객 메일(자연어)에서 품목·규격·수량을 뽑아낸다
  2. 그 품목을 내 원가표와 맞춰 단가를 찾는다
  3. 단가 × 수량으로 1차 견적 초안을 만든다

사람은 마지막에 초안을 검토·조정만 하면 됩니다. 이 글에서는 이 셋을 LLM과 코드로 묶어 "고객 메일을 던지면 견적 초안이 나오는" 자동화를 만듭니다. ChatGPT·Gemini 같은 LLM이면 무엇으로든 됩니다(여기서는 Gemini로 구현).

고객 메일 → 품목 추출 → 원가 매칭 → 견적 초안
고객 메일 → 품목 추출 → 원가 매칭 → 견적 초안

전체 그림

구성은 단순합니다. 크게 세 덩어리로 나뉩니다.

  • LLM 파서 — 비정형 메일을 구조화된 품목 목록(JSON)으로
  • 원가표 — 품목별 단가 데이터 (스프레드시트/CSV)
  • 매칭기 — 추출된 품목을 원가표와 맞추는 로직

이 솔루션을 굴러가게 하는 건 "원가표를 어딘가 올려두고, 고객 메일 원문을 그대로 시스템에 던지는" 단순한 구조 하나입니다. 그러면 시스템이 원가표를 참조해 1차 견적 초안을 만듭니다.

실제로 이 패턴을 앱으로 구현하면 이런 화면이 됩니다. 고객 정보·견적 항목·설정을 한 화면에서 다루고, 고객 메일을 붙여넣으면 항목이 자동으로 채워집니다.

이 패턴을 실제 앱으로 구현한 견적 작성 화면 (회사·고객 정보는 가림)
이 패턴을 실제 앱으로 구현한 견적 작성 화면 (회사·고객 정보는 가림)

1단계 — 고객 메일을 품목 JSON으로 (Gemini)

고객 메일은 제각각입니다. "U볼트 M10 50개", "유볼트 m10 50ea", "U-BOLT(M10) 수량 50"이 모두 같은 뜻입니다. 이 비정형 텍스트를 구조화하는 게 LLM이 가장 잘하는 일입니다.


import google.generativeai as genai
import json

genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel("gemini-2.0-flash")

PROMPT = """너는 견적 요청 메일에서 품목을 추출하는 파서다.
아래 메일에서 품목명(name)·규격(spec)·수량(qty)을 뽑아 JSON 배열로만 답하라.
규격이 없으면 spec은 빈 문자열로 둔다.

메일:
{email}"""

def parse_items(email_text: str) -> list:
    resp = model.generate_content(
        PROMPT.format(email=email_text),
        generation_config=genai.types.GenerationConfig(
            temperature=0.1,                        # 환각 억제 — 있는 것만 뽑게
            response_mime_type="application/json",  # JSON 형식 강제
        ),
    )
    return json.loads(resp.text)

여기서 두 가지가 중요합니다.

  • temperature=0.1 — 낮게 둡니다. 견적 파싱에서 필요한 건 정확성뿐입니다. 온도가 높으면 메일에 없는 품목을 지어냅니다.
  • response_mime_type="application/json" — Gemini가 JSON만 반환하도록 강제합니다. 이게 없으면 "네, 추출하겠습니다…" 같은 설명이 섞여 json.loads가 깨집니다.

입력과 출력은 이렇게 됩니다.


입력(메일): "안녕하세요, U볼트 M10 50개랑 육각볼트 M8*30 200ea 견적 부탁드립니다."

출력(JSON):
[
  {"name": "U볼트",   "spec": "M10",    "qty": 50},
  {"name": "육각볼트", "spec": "M8*30",  "qty": 200}
]
비정형 메일 한 통이 구조화된 품목 목록으로
비정형 메일 한 통이 구조화된 품목 목록으로

2단계 — 원가표 준비

원가표는 거창할 필요 없습니다. 스프레드시트(또는 CSV) 한 장이면 됩니다. 최소 컬럼은 품목명·규격·단가입니다.


name,      spec,    unit_price
U볼트,      M10,     1200
U볼트,      M12,     1500
육각볼트,    M8*30,   90
육각볼트,    M10*40,  130

import pandas as pd
cost = pd.read_csv("cost_table.csv")   # name, spec, unit_price

3단계 — 품목 매칭 (이게 진짜 어려운 부분)

여기가 핵심이자 함정입니다. 고객이 쓴 품목명과 내 원가표의 품목명이 글자 그대로 일치하는 경우는 거의 없습니다. "U볼트" vs "U-BOLT", "육각볼트" vs "육각 볼트(HEX)". 그래서 정확히 일치(=)로 찾으면 대부분 못 찾습니다.

해결책은 유사도 기반 fuzzy 매칭입니다. 이름과 규격의 유사도를 따로 재서 가중 합산합니다.


from difflib import SequenceMatcher

def sim(a: str, b: str) -> float:
    return SequenceMatcher(None, a, b).ratio()

def match(item: dict, cost) -> tuple:
    best, best_score = None, 0.0
    for _, row in cost.iterrows():
        name_score = sim(item["name"], row["name"])
        spec_score = sim(item.get("spec", ""), row["spec"])
        # 이름 비중을 더 크게(0.6), 규격은 0.4
        score = name_score * 0.6 + spec_score * 0.4
        if score > best_score:
            best, best_score = row, score
    return best, best_score

가중치는 경험에서 나왔습니다. 이름 0.6 / 규격 0.4 — 이름이 더 결정적이라 비중을 높이되, 규격(M10 vs M12)이 단가를 가르므로 규격도 무시 못 합니다. 규격이 정확히 일치하면 보너스 점수를 더 주는 식으로 다듬을 수도 있습니다.


4단계 — 견적 초안 생성

매칭이 끝나면 단가 × 수량으로 줄별 금액을 만듭니다. 핵심은 확신이 낮은 항목은 자동 확정하지 말고 사람에게 넘기는 것입니다.


def build_quote(items: list, cost) -> list:
    quote = []
    for item in items:
        row, score = match(item, cost)
        flagged = score < 0.5          # 애매하면 사람 검수
        unit_price = int(row["unit_price"]) if row is not None else 0
        quote.append({
            "name": item["name"],
            "spec": item.get("spec", ""),
            "qty": item["qty"],
            "unit_price": unit_price,
            "amount": unit_price * item["qty"],
            "matched": None if row is None else row["name"],
            "needs_review": flagged,     # 사람이 확인할 항목 표시
        })
    return quote

매칭 점수가 0.5 미만이면 needs_review로 표시해서, 사람이 그 줄만 확인하게 합니다. AI가 만든 건 어디까지나 "초안"이고, 최종 책임은 사람이라는 선을 코드로 그어 두는 겁니다.

원가표 매칭 → 줄별 금액이 채워진 견적 초안 (검수 필요 항목 표시)
원가표 매칭 → 줄별 금액이 채워진 견적 초안 (검수 필요 항목 표시)

실제로 만들며 부딪힌 것들

  • 품목명 표기 흔들림 — 같은 제품을 고객마다 다르게 씁니다(U볼트/유볼트/U-BOLT). 정확 일치로는 절대 못 찾습니다. fuzzy 매칭이 필수인 이유입니다.
  • 규격 표기 혼재 — 인치(1/2", 3/8")와 미터(M8, M10)가 섞입니다. 규격 정규화 전처리를 한 번 거치면 매칭률이 올라갑니다.
  • LLM 환각 — 온도를 안 낮추면 메일에 없는 품목을 친절히 "추가"합니다. temperature=0.1 + JSON 강제가 안전장치입니다.
  • 자동 확정의 유혹 — 매칭이 그럴듯해 보여도 단가는 돈입니다. 점수 낮은 항목은 반드시 사람이 보게 막아야 합니다.

견적을 넘어서 — 같은 패턴의 다른 쓸모

이 구조를 한 줄로 추상화하면 이렇습니다.

내 데이터(원가표) + 고객의 자연어 요청(메일) → LLM이 1차 초안

견적은 그 첫 사례일 뿐입니다. 같은 패턴으로:

  • 고객 문의 메일 → 제품 스펙표 참조 → 답변 초안
  • 자재 요청 → 재고표 참조 → 발주서 초안
  • 민원 → 규정집 참조 → 답변 초안

"비정형 요청을 받아 내 데이터에 비춰 정형 초안을 만든다"는 일은 어디에나 있습니다. 구조화는 LLM에게 JSON 강제로 맡기면 되고, 이름과 규격이 제각각 흔들리는 건 fuzzy 매칭이 흡수해 줍니다. 그리고 확신이 낮은 항목은 자동으로 확정하지 말고 사람 손에 남겨 두면 됩니다. 이 세 가지만 지키면 도메인이 견적이든 답변이든 발주든 같은 뼈대로 굴러갑니다.


자주 묻는 질문

Q. ChatGPT로도 되나요?

A. 됩니다. 여기서는 Gemini를 썼지만, JSON 출력을 강제할 수 있는 LLM이면 무엇이든 같은 구조로 동작합니다. 핵심은 모델이 아니라 "구조화 + 매칭 + 검수" 패턴입니다.

Q. 원가표가 수백 개면 매칭이 느리지 않나요?

A. 항목이 많으면 전수 비교 대신 임베딩 기반 검색(벡터 유사도)으로 바꾸면 빨라집니다. 다만 수백 개 수준이면 fuzzy 전수 비교로도 충분히 빠릅니다.

Q. AI가 만든 견적을 그대로 보내도 되나요?

A. 안 됩니다. 단가는 금액이라 오차가 곧 손실입니다. AI는 1차 초안까지, 확정과 발송은 사람이 검토한 뒤에 하는 것을 권합니다.


이 글의 코드·표·수치는 모두 설명용 예시입니다. 실제 원가·거래처·고객 정보는 포함하지 않습니다. (2026년 6월 기준)

댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기