Skip to content

스마트 스피커를 써 본 사람이라면 이런 경험이 한 번쯤 있었을 것이다.

"거실 불 꺼줘"라고 말했는데, 딴 장치가 켜진다든가, 아예 못 알아듣는다든가.

이번 글은 그런 삽질을 조금이라도 줄이기 위해 진행한 STT 텍스트 → 디바이스 실행 매핑 PoC(기술검증) 결과 정리다.
정확히는, "음성 인식 결과 텍스트를 받아서 어떤 장치에 어떤 동작을 내릴지 라즈베리파이에서 어떻게 결정할 것인가" 를 5가지 방법론으로 비교해 본 기록이다.


이 PoC에서 실제로 풀고 싶었던 문제

음성 기반 스마트홈 시스템은 대략 이렇게 생겼다.

text
[사용자 음성] → [STT: 음성→텍스트] → [텍스트 분석 엔진] → [DeviceManager] → [실제 장치 실행]

이번 PoC에서 내가 만든 건 가운데에 있는 이 부분이다.

text
텍스트 ("거실 불 꺼줘")

Engine (텍스트 분석)

CommandResult(
  device_id = "living_room_light",
  action    = "off",
  params    = {},
  confidence= 0.95
)

그리고 이 엔진은 라즈베리파이에서 돌아가야 한다.

  • CPU: ARM, PC 대비 5~10배 느림
  • RAM: 1~8GB
  • GPU: 없음
  • 응답 시간: 500ms 이내

LLM을 아무 생각 없이 올렸다가는, 한 번 명령할 때마다 2~3초씩 기다려야 하는 그림이 나온다. 그래서 "라즈베리파이에서도 버틸 수 있는 설계인지"를 끝까지 붙들고 갔다.


시스템 구조와 도메인 모델 한 번 훑기

먼저 전체 흐름을 텍스트 기준으로 단순화하면 아래와 같다.

text
사용자 발화 텍스트 (예: "거실 불 꺼줘")


   ┌─────────────┐
   │  Engine     │  ← 5가지 방법론 중 하나
   └──────┬──────┘


   ┌─────────────────────┐
   │   CommandResult      │
   │  - device_id         │
   │  - action            │
   │  - params            │
   │  - confidence        │
   └──────┬──────────────┘


   ┌─────────────┐
   │DeviceManager│  ← 장치 상태 변경 시뮬레이션
   └─────────────┘

여기서 핵심 타입은 두 가지다.

  • Device

    • device_id: "living_room_light" 처럼 시스템 내부 고유 ID
    • name: "거실 조명" — 사람이 읽는 이름
    • aliases: "거실 불", "거실 라이트" 같이 사용자가 부를 수 있는 별칭들
    • location: "거실", "침실"
    • device_type: "light", "ac", "fan"
    • actions: ["on", "off", "up", "down", "set", ...]
  • CommandResult

    • device_id: 어느 장치인지
    • action: on/off/up/down/set/...
    • params: 온도나 밝기 같은 추가 파라미터
    • confidence: 0.0~1.0, 얼마나 확실한지

테스트용으로는 15개 스마트홈 장치를 등록했다. (거실/침실 조명, 에어컨, TV, 난방, 선풍기, 커튼, 스피커, 로봇청소기, 가습기, 도어락, 무드등 등) 이름과 장소, 별칭, 가능한 동작을 꽤 구체적으로 정의해서 실제 서비스에 가깝게 맞췄다.


이번에 비교한 5가지 방법론

PoC에서 검증한 방법론은 총 다섯 가지다.

방법론핵심 아이디어외부 AI 모델RPi 적합도(감각적)
Rule-Based한국어 형태소 + 규칙 매칭필요 없음★★★★★
SLM소형 언어모델로 JSON 생성필요★★☆☆☆
Vector Similarity임베딩 기반 유사도 검색필요★★★☆☆
HybridRule 우선 + Vector 보조필요★★★★☆
Intent Classification의도 태그 분류필요(정식 버전)★★★★☆

이 중 실제로 정량 벤치마크를 돌린 건 Rule-Based와 Intent 엔진 두 개고, 나머지 세 개는 구조까지는 구현해 두고 모델 다운로드/튜닝을 나중 과제로 밀어뒀다.

조금 더 풀어서 정리하면 다음과 같다.

1) Rule-Based — "사전을 들고 다니는 엔진"

  • Kiwi 형태소 분석으로 문장을 자르고
  • 장치 별칭, 장소 + 장치 타입, 문맥 힌트(춥다/어둡다/시끄럽다 등)를 순서대로 매칭하고
  • 동작 키워드를 우선순위에 따라 on/off/up/down/set으로 맵핑하고
  • 숫자 파라미터(온도, 밝기, 세기)를 추출해서 params에 넣고
  • 마지막으로 장치가 지원하는 동작인지 검증한다.

장점은 아주 명확하다.

  • 라즈베리파이에서도 0.1~0.2ms 수준으로 매우 빠르다.
  • 메모리는 0.07MB밖에 쓰지 않는다.
  • 규칙이 눈에 보이니까 디버깅이 편하다.

단점도 그대로다.

  • 새로운 표현이 나오면 규칙을 계속 추가해야 한다.
  • "거싫 붏 켜줘" 같은 오타에는 거의 무력하다.
  • "분위기 좀 바꿔줘"처럼 뉘앙스만 있는 문장은 한 번에 잡기 어렵다.

2) SLM — "작은 AI 비서에게 맡기기"

두 번째 축은 작은 언어모델(Small Language Model)을 올려서, 시스템 프롬프트에 장치 목록을 전부 넣어두고 JSON을 뽑아내는 방식이다.

  • 프롬프트 예시는 대략 이런 느낌이다.
    • "당신은 스마트홈 디바이스 제어 시스템입니다. 등록 장치: living_room_light(거실 조명), ..."
    • "사용자 명령: 거실 불 꺼줘\nJSON 응답:"
  • 모델이 {"device_id": "living_room_light", "action": "off", "params": {}} 같은 JSON을 돌려주면 그대로 CommandResult로 변환한다.

PoC에서는 llama.cpp 기반 구조까지만 만들어 두고, 실제 Qwen2.5-0.5B 같은 모델은 아직 올리지 않았다. 이유는 단순하다. RPi에서 0.5B 모델만 돌려도 수 초 단위 응답이 나올 가능성이 높아서다.

장점은 자연어 유연성이 가장 좋다는 것. 단점은 속도와 메모리, 그리고 할루시네이션이다.

3) Vector Similarity — "장치 설명을 벡터로 바꿔서 검색하기"

세 번째 축은 장치 설명과 사용자 문장을 임베딩 벡터로 바꾸고, 코사인 유사도로 가장 가까운 장치를 찾는 방식이다.

  • 사전 준비
    • "거실 조명 거실 불 거실 라이트..." → 384차원 임베딩 벡터
    • "거실 에어컨 거실 냉방..." → 또 다른 벡터
  • 실행 시
    • "거실 불 꺼줘"를 임베딩으로 바꾸고
    • 벡터 검색으로 가장 가까운 장치를 찾고
    • 동작은 별도의 키워드 매칭으로 뽑는다.

여기서는 all-MiniLM-L6-v2 ONNX 모델(~80MB)을 기준으로 잡았다. "조명" 대신 "전등", "불빛"을 써도 어느 정도는 따라잡을 수 있다는 게 장점이고, 반대로 모델 로딩/추론 비용이 생긴다는 게 단점이다.

4) Hybrid — "모르면 그때 AI에게 물어보기"

하이브리드는 사실 컨셉이 간단하다.

  1. 먼저 Rule 엔진을 돌려 본다.
  2. confidence가 0.5 이상이면 그냥 그 결과를 쓴다.
  3. 아니라면 Vector 엔진으로 폴백한다.

실제 사용 패턴을 생각해 보면, 전체 명령의 80~90%는 꽤 정형적이다.
이 80~90%는 Rule로 1ms 미만에 처리하고, 나머지 애매한 10~20%만 벡터 검색에 태우는 구조다. 개인적으로는 이 조합이 RPi 환경에서는 가장 현실적인 타협점이라고 느꼈다.

5) Intent Classification — "먼저 의도부터 붙이고 보자"

마지막 축은 텍스트를 바로 "의도(Intent) 라벨"로 보내버리는 방식이다.

  • "거실 불 꺼줘"LIGHT_OFF
  • "좀 춥다"HEATER_ON
  • "문 잠가줘"LOCK

그리고 각 의도는 다시 장치 타입 + 동작으로 풀린다.

  • LIGHT_OFFdevice_type="light", action="off"
  • 장소 "거실"과 조합해 living_room_light를 찾는다.

정석대로라면 DistilBERT 같은 모델을 파인튜닝해서 쓰는 게 맞는데, 이번 PoC에서는 키워드 기반 의도 분류기로 구조만 맞춰 둔 상태다. 나중에 모델만 갈아끼우면 된다.


테스트 셋 설계 — 100개 명령을 어떻게 만들었나

테스트 데이터는 총 100개 명령이고, 세 가지 카테고리로 나눴다.

  • 정형(Formal) 50개
    • 장치명 + 동작이 명확히 들어간 문장
    • 예: "거실 불 켜줘", "에어컨 꺼", "침실 조명 밝기 올려줘"
  • 비정형(Informal) 30개
    • 장치명 없이 상황만 말하는 문장
    • 예: "좀 춥다", "어두운데", "시끄러워", "분위기 좀 바꿔줘"
  • 엣지 케이스(Edge) 20개
    • 오타, 빈 입력, 존재하지 않는 장소, 애매한 표현 등
    • 예: "불", "", "거싫 붏 켜줘", "2층 조명 켜줘", "불켜불꺼불켜"

의도적으로 실제 서비스에서 나올 법한 찝찝한 케이스들을 많이 넣었다. 덕분에 결과가 꽤 현실적으로 나왔다.


핵심 숫자만 모은 벤치마크 결과

먼저 두 엔진의 최종 지표부터 보자.

지표Rule-BasedIntent
장치 매칭 정확도95.0%95.0%
동작 매칭 정확도95.0%91.0%
전체 정확도(장치+동작)93.0%91.0%
정형 명령 정확도100.0%100.0%
비정형 명령 정확도100.0%96.7%
엣지 케이스 정확도65.0%60.0%
평균 응답 시간4.20ms*0.06ms
P50 응답 시간0.12ms0.06ms
P95 응답 시간0.23ms0.11ms
최대 응답 시간408ms**0.54ms
메모리 사용량0.07MB0.06MB
오답 수7개9개
  • * Rule-Based 평균에는 Kiwi 형태소 분석기 첫 초기화(408ms) 가 포함돼 있다.
  • ** 실제로는 한 번 로드 후에는 0.2ms 이하라, 실서비스에서는 이 숫자는 거의 신경 쓰지 않아도 된다.

숫자로만 보면,

  1. 정형 명령은 두 엔진 모두 100% 맞춘다.
  2. 비정형도 Rule이 100%, Intent가 96.7%라 꽤 선방한다.
  3. 진짜 문제는 엣지 케이스(60~65%)다.
  4. 속도와 메모리는 두 엔진 모두 RPi 기준으로도 완전히 여유 있다.

즉, "거실 불 켜줘" 같은 무난한 명령은 이미 기술적으로 끝났다. 흥미로운 부분은 이상한 입력, 오타, 애매한 말을 어디까지 봐 줄 것인가 쪽에 있다.


Rule-Based 엔진을 조금 더 가까이서 보면

Rule 엔진의 파이프라인을 한 줄로 요약하면 이렇다.

text
별칭 매칭 → 장소+장치타입 조합 → 문맥 힌트 → 동작 추출 → 후처리 → 숫자 파라미터 → 동작 유효성 검증

각 단계는 다음과 같은 역할을 한다.

  • 별칭 매칭
    • "거실 불"living_room_light
    • 가장 긴 별칭을 우선(greedy) 매칭해서 애매함을 줄였다.
  • 장소 + 장치 타입
    • "주방" + "불"kitchen_light
    • 별칭이 없더라도 장소와 유형만으로 장치를 찾는다.
  • 문맥 힌트
    • "좀 춥다"thermostat + on
    • "시끄러워"living_room_tv + down
    • "분위기 좀 바꿔줘"living_room_mood_light + on
  • 동작 추출 + 우선순위
    • on/off/up/down/set/lock 각각에 키워드 목록을 붙였다.
    • 여러 개가 잡힐 때는 구체적인 것(up/down/set) > on/off 순으로 우선.
  • 숫자 파라미터
    • "24도"{temperature: 24}
    • "3으로"{speed: 3}
  • 후처리/유효성 검증
    • 장치가 지원하지 않는 동작이면 적절히 보정하거나 거절한다.

여기까지만 들으면 "그냥 룰 잘 만들었네" 정도인데, 실제로 돌려 보니 오탐 방지 규칙이 꽤 중요했다.

예를 들어 "시끄러워"에는 "끄"가 들어 있다. 단순 문자열 매칭으로 가면 "끄"를 보고 off로 오인하기 쉽다. 그래서 "시끄"가 같이 보이면 "끄" 매칭을 건너뛰도록 false positive 컨텍스트를 별도 관리했다.

python
false_positive_contexts = {
    "끄": ["시끄"],
    "열": ["열심"],
    "틀": ["틀리"],
}

이런 자잘한 장치를 안 해두면, 테스트 숫자는 나름 괜찮게 찍히더라도 실제 사용감이 아주 안 좋아진다.


Intent 엔진의 관점 — "의도부터 태깅하고 장치를 찾자"

Intent 엔진은 조금 다른 흐름을 따른다.

text
텍스트

장치 타입 감지 (에어컨/조명/선풍기/...)

동작 감지 (on/off/up/down/set)

장치타입+동작 → Intent 라벨 생성

장소 정보와 조합해서 실제 장치 선택

예를 들어 "거실 불 꺼줘"의 경우:

  • "불"device_type="light"
  • "꺼"action="off"
  • → Intent: LIGHT_OFF
  • "거실" + light 조합으로 living_room_light 결정

이 구조의 장점은, 나중에 DistilBERT 같은 모델로 교체할 때 의도 레이블링과 슬롯 추출을 그대로 재사용할 수 있다는 점이다.
PoC 단계에서는 키워드 기반으로만 구현했지만, 실제 모델을 올렸을 때도 파이프라인 전체는 크게 달라지지 않는다.


오답 분석 — 어디서 틀렸고, 그게 왜 중요한지

이제부터는 재미있는(?) 부분이다. 두 엔진이 어디서 어떻게 틀렸는지 다 까봤다.

Rule-Based 오답 7건 (모두 엣지 케이스)

대표적인 패턴만 뽑아 보면:

  • "불" → (거실 조명, 동작 없음)
    • 단어 하나만으로 장치를 매칭했다. 명령으로 보긴 애매하고, 실제로도 실행은 못 하는 상태.
  • "거싫 붏 켜줘"
    • "거실 불 켜줘"의 오타 버전인데, 규칙 기반은 오타를 전혀 이해하지 못했다.
  • "2층 조명 켜줘"
    • 등록되지 않은 장소인데, "조명" 키워드만 보고 기본 조명인 거실 조명을 골라버렸다.
  • "켜줘", "그거 꺼줘"
    • 장치 없이 동작만 있는 경우, 혹은 대명사만 있는 경우.
  • "주방 에어컨 켜"
    • 주방에는 에어컨이 없는데, "에어컨" 키워드 때문에 거실 에어컨을 골랐다.
  • "불켜불꺼불켜"
    • 띄어쓰기 없는 이상한 문자열. "불" + "켜" 조합이 어떻게든 매칭돼 버렸다.

정리하면 Rule 엔진의 약점은 이렇다.

  1. 오타 처리 불가
  2. 존재하지 않는 장소/장치 조합에 대한 검증 부족
  3. 장치 또는 동작만 들어온 불완전한 입력
  4. 띄어쓰기도 안 된 비정상적인 입력

재미있는 건, 이런 오답 대부분의 confidence가 0.2~0.7 사이로 **"스스로도 확신이 없다"**는 신호를 내고 있다는 점이다.
그래서 실서비스에서는 아예 confidence < 0.7 정도를 임계값으로 잡고, 이 아래로 내려가면 실행하지 않고 "다시 말해 달라"고 요청하는 게 안전하다는 결론이 나왔다.

Intent 엔진 오답 9건

Intent 쪽은 Rule과 겹치는 문제도 있고, 이쪽에만 있는 문제도 있었다.

  • "안방 좀 어둡게 해줘"
    • 기대: 침실 조명 down
    • 실제: 침실 조명 on
    • "어둡게"를 밝기 감소로 해석하지 못했다.
  • "거실 불 켜줘 아니 꺼줘"
    • 기대: off
    • 실제: on
    • 정정 패턴(앞 명령 취소 후 새로운 명령)을 전혀 지원하지 않는다.
  • "침실 에어컨 온도 좀"
    • 기대: 침실 에어컨 down 혹은 최소한 set 계열
    • 실제: 단순 on
    • 암시적인 동작 추론에 약하다.
  • "선풍기 바람 세기 3으로"
    • 기대: set
    • 실제: on
    • "3으로" 패턴을 세기 설정으로 이해하지 못했다.

공통점은, Intent 엔진은 틀리면서도 confidence가 0.8 정도로 높게 나오는 경우가 많다는 것이다.
Rule은 틀릴 때 대체로 "나도 자신 없다"는 제스처를 취하는데, Intent는 꽤 과신하는 경향이 있다. 이게 실서비스에서 더 위험할 수 있다.


Confidence 분포가 말해 주는 것

숫자만 다시 모아 보면:

  • Rule 오답의 confidence
    • 0.20: 4건
    • 0.70: 2건
    • 0.88: 1건
  • Intent 오답의 confidence
    • 0.00, 0.10: 각 1건
    • 0.80: 7건

여기서 얻은 실무적인 인사이트는 하나다.

Rule 엔진은 confidence threshold를 잘 잡으면 '틀릴 것 같은 응답'을 꽤 많이 걸러낼 수 있다.
반대로 Intent는 threshold만으로는 걸러내기 어려운 과신 문제가 있다.

그래서 "라즈베리파이 위에서 안전하게 돌릴 수 있는 스마트홈 음성 명령 엔진"이라는 원래 목표를 생각하면, PoC 단계에서는 Rule 쪽에 조금 더 무게를 둘 수밖에 없었다.


코드 구조와 실행 방법 — 나중에 다시 볼 나 자신을 위한 메모

프로젝트 루트는 11_jivi_poc/이고, 구조는 대략 이렇게 잡았다.

text
11_jivi_poc/
├── requirements.txt              # 공통 의존성 (kiwipiepy)
├── requirements-vector.txt       # Vector 엔진용
├── requirements-slm.txt          # SLM 엔진용
├── requirements-intent.txt       # Intent 엔진용

├── src/
│   ├── core/
│   │   ├── models.py             # CommandResult, ExecutionResult
│   │   ├── device_registry.py    # Device, DeviceRegistry
│   │   └── device_manager.py     # Mock DeviceManager
│   │
│   ├── engines/
│   │   ├── base.py               # BaseEngine 추상 클래스
│   │   ├── rule_engine.py        # Rule-Based
│   │   ├── slm_engine.py         # SLM
│   │   ├── vector_engine.py      # Vector Similarity
│   │   ├── hybrid_engine.py      # Hybrid
│   │   └── intent_engine.py      # Intent Classification
│   │
│   └── utils/
│       └── korean_tokenizer.py   # Kiwi 래퍼

├── data/
│   ├── devices.json              # 15개 장치 정의
│   └── test_commands.json        # 100개 테스트 명령

├── benchmarks/
│   ├── run_benchmark.py          # 벤치마크 실행 스크립트
│   └── results/                  # 결과 JSON들

├── tests/
│   ├── conftest.py
│   ├── test_core.py              # core 모듈 테스트 (17개)
│   └── test_engines.py           # 엔진 테스트 (22개)

├── TECH_VERIFICATION_PLAN.md     # 기술검증 계획서
└── REPORT.md                     # 이 글의 원본 리포트

실제로 돌려보려면:

bash
cd 11_jivi_poc
source .venv/bin/activate
pip install -r requirements.txt

# 테스트
python -m pytest tests/ -v  # 39개 테스트 전체 통과

# 벤치마크 (Rule + Intent만)
python benchmarks/run_benchmark.py --engines rule intent

# Vector/Hybrid까지
pip install -r requirements-vector.txt
python benchmarks/run_benchmark.py --engines rule intent vector hybrid

# SLM까지 전부
pip install -r requirements-slm.txt
export SLM_MODEL_PATH=/path/to/model.gguf
python benchmarks/run_benchmark.py --engines rule intent vector hybrid slm

개별 엔진을 장난감처럼 써 보려면 아래 정도 코드로 충분하다.

python
from src.core.device_registry import DeviceRegistry
from src.core.device_manager import DeviceManager
from src.engines.rule_engine import RuleEngine

registry = DeviceRegistry.from_json("data/devices.json")
engine = RuleEngine(registry)
manager = DeviceManager(registry)

cmd = engine.parse("거실 불 꺼줘")
print(cmd.device_id, cmd.action, cmd.confidence)

result = manager.execute(cmd.device_id, cmd.action, cmd.params)
print(result.success, result.message)

최종 결론과, 지금이라면 어떻게 설계할지

이제 PoC 전체를 한 줄로 정리해 보자.

  1. 정형 명령은 이미 기술적으로 끝났다.
    • Rule, Intent 둘 다 100% 정확도.
  2. 비정형도 Rule이 100%, Intent가 96.7%로 꽤 괜찮다.
  3. 진짜 문제는 엣지 케이스와 오타, 애매한 입력 처리다.
  4. 속도/메모리는 두 엔진 모두 RPi에서 전혀 문제가 없다.

그래서 최종 추천은 이렇게 잡았다.

  • 1순위: Hybrid (Rule + Vector)
    • 대부분의 명령(80~90%)은 Rule이 즉시 처리(<1ms)
    • 애매한 10~20%만 Vector에 넘긴다.
    • 평균 응답은 10ms 이내로 충분히 500ms 제약을 만족한다.
    • 장치가 늘어날수록 Vector 쪽 확장성이 도움 된다.
  • 2순위: Rule-Based 단독
    • 장치 수가 20개 이하이고
    • 사용자가 비교적 정형화된 명령만 쓴다면
    • 외부 모델 없이도 93% 정확도, 0.1ms 응답이면 충분히 실전 투입 가능하다.

반대로 SLM은 지금 시점에서는 RPi 온디바이스 용도로는 비추천이다.

  • 0.5B 모델만 올려도 응답이 2~5초에 걸릴 수 있고
  • 500ms 제약을 지키려면 결국 서버 오프로드 구조를 짜야 한다.
  • 그럴 바엔 이 PoC의 목적(엣지 디바이스 상주)을 벗어난다.

앞으로 손대고 싶은 부분들

보고서 마지막에는 향후 과제도 적어뒀다. 나중에 이 글을 다시 보는 나를 위해, 우선순위만 다시 적어본다.

  • 단기 (1~2주)
    • Vector 엔진에 실제 임베딩 모델을 붙이고 벤치마크 돌리기
    • Hybrid 엔진에서 Rule → Vector fallback 기준(threshold) 튜닝
    • 라즈베리파이 실기기에서 성능 측정
    • Rule 엔진에 장소+장치 존재 여부 검증 추가 (없는 조합은 거절)
  • 중기 (1~2개월)
    • Intent Classification 전용 모델 학습 (합성 데이터 → DistilBERT 파인튜닝)
    • ONNX/양자화를 통한 RPi 최적화
    • 다중 장치 명령 ("에어컨이랑 불 다 꺼줘") 지원
    • 대화 컨텍스트 관리 ("그거 꺼줘" → 직전에 언급한 장치)
  • 장기 (3개월+)
    • 실 사용자 로그 기반 규칙/모델 고도화
    • SLM 서버 오프로드 아키텍처 검토
    • 사용자별 별칭 학습
    • 실제 IoT 하드웨어와 연동

솔직히 말하면, 여기까지 와 보니 "라즈베리파이에서도 생각보다 많은 걸 할 수 있다"는 감각을 다시 얻었다. 동시에, 오타 하나, 모호한 표현 하나 때문에 사용 경험이 확 무너질 수도 있다는 것도 다시 확인했고.
혹시 비슷한 PoC를 준비하고 있다면, 위 숫자들과 삽질 포인트들이 대략적인 레이더를 잡는 데 도움이 되었으면 한다.

삽질 테크 블로그