스마트 스피커를 써 본 사람이라면 이런 경험이 한 번쯤 있었을 것이다.
"거실 불 꺼줘"라고 말했는데, 딴 장치가 켜진다든가, 아예 못 알아듣는다든가.
이번 글은 그런 삽질을 조금이라도 줄이기 위해 진행한 STT 텍스트 → 디바이스 실행 매핑 PoC(기술검증) 결과 정리다.
정확히는, "음성 인식 결과 텍스트를 받아서 어떤 장치에 어떤 동작을 내릴지 라즈베리파이에서 어떻게 결정할 것인가" 를 5가지 방법론으로 비교해 본 기록이다.
이 PoC에서 실제로 풀고 싶었던 문제
음성 기반 스마트홈 시스템은 대략 이렇게 생겼다.
[사용자 음성] → [STT: 음성→텍스트] → [텍스트 분석 엔진] → [DeviceManager] → [실제 장치 실행]이번 PoC에서 내가 만든 건 가운데에 있는 이 부분이다.
텍스트 ("거실 불 꺼줘")
↓
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초씩 기다려야 하는 그림이 나온다. 그래서 "라즈베리파이에서도 버틸 수 있는 설계인지"를 끝까지 붙들고 갔다.
시스템 구조와 도메인 모델 한 번 훑기
먼저 전체 흐름을 텍스트 기준으로 단순화하면 아래와 같다.
사용자 발화 텍스트 (예: "거실 불 꺼줘")
│
▼
┌─────────────┐
│ Engine │ ← 5가지 방법론 중 하나
└──────┬──────┘
│
▼
┌─────────────────────┐
│ CommandResult │
│ - device_id │
│ - action │
│ - params │
│ - confidence │
└──────┬──────────────┘
│
▼
┌─────────────┐
│DeviceManager│ ← 장치 상태 변경 시뮬레이션
└─────────────┘여기서 핵심 타입은 두 가지다.
Devicedevice_id:"living_room_light"처럼 시스템 내부 고유 IDname:"거실 조명"— 사람이 읽는 이름aliases:"거실 불","거실 라이트"같이 사용자가 부를 수 있는 별칭들location:"거실","침실"…device_type:"light","ac","fan"…actions:["on", "off", "up", "down", "set", ...]
CommandResultdevice_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 | 임베딩 기반 유사도 검색 | 필요 | ★★★☆☆ |
| Hybrid | Rule 우선 + 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에게 물어보기"
하이브리드는 사실 컨셉이 간단하다.
- 먼저 Rule 엔진을 돌려 본다.
confidence가 0.5 이상이면 그냥 그 결과를 쓴다.- 아니라면 Vector 엔진으로 폴백한다.
실제 사용 패턴을 생각해 보면, 전체 명령의 80~90%는 꽤 정형적이다.
이 80~90%는 Rule로 1ms 미만에 처리하고, 나머지 애매한 10~20%만 벡터 검색에 태우는 구조다. 개인적으로는 이 조합이 RPi 환경에서는 가장 현실적인 타협점이라고 느꼈다.
5) Intent Classification — "먼저 의도부터 붙이고 보자"
마지막 축은 텍스트를 바로 "의도(Intent) 라벨"로 보내버리는 방식이다.
"거실 불 꺼줘"→LIGHT_OFF"좀 춥다"→HEATER_ON"문 잠가줘"→LOCK
그리고 각 의도는 다시 장치 타입 + 동작으로 풀린다.
LIGHT_OFF→device_type="light",action="off"- 장소
"거실"과 조합해living_room_light를 찾는다.
정석대로라면 DistilBERT 같은 모델을 파인튜닝해서 쓰는 게 맞는데, 이번 PoC에서는 키워드 기반 의도 분류기로 구조만 맞춰 둔 상태다. 나중에 모델만 갈아끼우면 된다.
테스트 셋 설계 — 100개 명령을 어떻게 만들었나
테스트 데이터는 총 100개 명령이고, 세 가지 카테고리로 나눴다.
- 정형(Formal) 50개
- 장치명 + 동작이 명확히 들어간 문장
- 예:
"거실 불 켜줘","에어컨 꺼","침실 조명 밝기 올려줘"
- 비정형(Informal) 30개
- 장치명 없이 상황만 말하는 문장
- 예:
"좀 춥다","어두운데","시끄러워","분위기 좀 바꿔줘"
- 엣지 케이스(Edge) 20개
- 오타, 빈 입력, 존재하지 않는 장소, 애매한 표현 등
- 예:
"불","","거싫 붏 켜줘","2층 조명 켜줘","불켜불꺼불켜"
의도적으로 실제 서비스에서 나올 법한 찝찝한 케이스들을 많이 넣었다. 덕분에 결과가 꽤 현실적으로 나왔다.
핵심 숫자만 모은 벤치마크 결과
먼저 두 엔진의 최종 지표부터 보자.
| 지표 | Rule-Based | Intent |
|---|---|---|
| 장치 매칭 정확도 | 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.12ms | 0.06ms |
| P95 응답 시간 | 0.23ms | 0.11ms |
| 최대 응답 시간 | 408ms** | 0.54ms |
| 메모리 사용량 | 0.07MB | 0.06MB |
| 오답 수 | 7개 | 9개 |
- * Rule-Based 평균에는 Kiwi 형태소 분석기 첫 초기화(408ms) 가 포함돼 있다.
- ** 실제로는 한 번 로드 후에는 0.2ms 이하라, 실서비스에서는 이 숫자는 거의 신경 쓰지 않아도 된다.
숫자로만 보면,
- 정형 명령은 두 엔진 모두 100% 맞춘다.
- 비정형도 Rule이 100%, Intent가 96.7%라 꽤 선방한다.
- 진짜 문제는 엣지 케이스(60~65%)다.
- 속도와 메모리는 두 엔진 모두 RPi 기준으로도 완전히 여유 있다.
즉, "거실 불 켜줘" 같은 무난한 명령은 이미 기술적으로 끝났다. 흥미로운 부분은 이상한 입력, 오타, 애매한 말을 어디까지 봐 줄 것인가 쪽에 있다.
Rule-Based 엔진을 조금 더 가까이서 보면
Rule 엔진의 파이프라인을 한 줄로 요약하면 이렇다.
별칭 매칭 → 장소+장치타입 조합 → 문맥 힌트 → 동작 추출 → 후처리 → 숫자 파라미터 → 동작 유효성 검증각 단계는 다음과 같은 역할을 한다.
- 별칭 매칭
"거실 불"→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 컨텍스트를 별도 관리했다.
false_positive_contexts = {
"끄": ["시끄"],
"열": ["열심"],
"틀": ["틀리"],
}이런 자잘한 장치를 안 해두면, 테스트 숫자는 나름 괜찮게 찍히더라도 실제 사용감이 아주 안 좋아진다.
Intent 엔진의 관점 — "의도부터 태깅하고 장치를 찾자"
Intent 엔진은 조금 다른 흐름을 따른다.
텍스트
↓
장치 타입 감지 (에어컨/조명/선풍기/...)
↓
동작 감지 (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 엔진의 약점은 이렇다.
- 오타 처리 불가
- 존재하지 않는 장소/장치 조합에 대한 검증 부족
- 장치 또는 동작만 들어온 불완전한 입력
- 띄어쓰기도 안 된 비정상적인 입력
재미있는 건, 이런 오답 대부분의 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/이고, 구조는 대략 이렇게 잡았다.
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 # 이 글의 원본 리포트실제로 돌려보려면:
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개별 엔진을 장난감처럼 써 보려면 아래 정도 코드로 충분하다.
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 전체를 한 줄로 정리해 보자.
- 정형 명령은 이미 기술적으로 끝났다.
- Rule, Intent 둘 다 100% 정확도.
- 비정형도 Rule이 100%, Intent가 96.7%로 꽤 괜찮다.
- 진짜 문제는 엣지 케이스와 오타, 애매한 입력 처리다.
- 속도/메모리는 두 엔진 모두 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를 준비하고 있다면, 위 숫자들과 삽질 포인트들이 대략적인 레이더를 잡는 데 도움이 되었으면 한다.