본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 27. 11:55

.NET 데스크톱 앱에 Gemma 4 음성 인식 기능 추가하기: 살아남은 llama-server 사이드카

요약

.NET 10 기반 데스크톱 앱에 Gemma 4의 멀티모달 음성 인식 기능을 온디바이스로 통합하는 과정을 다룹니다. 다양한 런타임 경로를 검토한 끝에 llama-server를 사이드카 방식으로 채택하여 Windows 환경에서 GPU 가속과 교차 벤더 지원을 구현하는 아키텍처를 설명합니다.

핵심 포인트

  • Gemma 4의 Conformer 오디오 인코더를 활용한 온디바이스 음성 인식 구현
  • Windows 환경의 제약 사항(단일 설치, 교차 벤더 GPU 지원) 해결 과정
  • llama-server를 자식 프로세스로 활용하는 사이드카 아키텍처 채택
  • 기존 오디오 파이프라인을 유지하며 ISpeechRecognizer 인터페이스로 통합

2026년 4월, Google은 네이티브 오디오 경로를 갖춘 멀티모달 모델인 Gemma 4를 출시했습니다. 저는 제가 만든 .NET 10 받아쓰기 앱인 Parlotype에 Whisper와 함께 사용할 두 번째 음성 엔진으로 이를 추가하고 싶었습니다. 자식 프로세스로 llama.cpp의 llama-server를 선택하기 전까지 네 가지 런타임 경로가 탈락했습니다. 이 포스트에서는 탈락 과정, 살아남은 아키텍처, 변형 카탈로그, 그리고 벤치마크를 살펴봅니다.

Parlotype은 온디바이스 (on-device) 음성 인식을 기본으로 하는 Windows용 음성-텍스트 변환 데스크톱 앱입니다. 글로벌 핫키를 누르고, 말하고, 떼면 됩니다. 그러면 사용자가 타이핑하던 앱에 텍스트가 나타납니다. 이 포스트는 두 번째 온디바이스 엔진을 추가하는 것에 관한 것입니다. 클라우드 음성 제공업체는 별도의 선택 사항(opt-in) 트랙이며 여기서 다루는 주제가 아닙니다.

이 글은 동일한 주제에 대한 저의 Gemma 4 Challenge 제출물에 대한 긴 동반 문서입니다. 챌린지 포스트가 출시 결정과 함께 5가지 변형 모델을 둘러보는 투어였다면, 이 글은 런타임 선택과 그 아래의 아키텍처에 관한 것입니다.

제약 사항

뻔한 답변들이 왜 막다른 길이었는지 이해할 수 있도록 제약 사항을 먼저 명시하겠습니다.

  • 온디바이스 (On-device) 엔진. Gemma 4는 Whisper와 함께 또 다른 로컬 인식기로 추가되므로, 이 경로의 추론 (inference)은 사용자의 기기에 머뭅니다. 클라우드 제공업체는 별도의 선택 사항 (opt-in) 트랙이며 이 포스트의 범위 밖입니다.
  • Windows 데스크톱, 단일 최종 사용자 설치 프로그램. "먼저 Python을 설치하고, 그다음 WSL2를 설치하고, 그다음..." 같은 방식은 안 됩니다. 실제 사용자들은 그렇게 하지 않을 것입니다.
  • 교차 벤더 GPU (Cross-vendor GPU). AMD, Intel, NVIDIA를 지원하며 CPU 폴백 (fallback)이 가능해야 합니다. 앱을 특정 벤더에 종속시키는 것은 허용되지 않습니다.
  • 오디오 파이프라인이 이미 존재함. WASAPI 캡처 -> 16 kHz 모노 float[] -> Silero VAD -> 음성 세그먼트 -> 인식기 (recognizer) -> 텍스트 주입. 새로운 엔진은 파이프라인을 재설계하지 않고 기존 ISpeechRecognizer 인터페이스 뒤에 바로 끼워 맞춰져야 합니다.
  • 호스트 프로세스를 위한 .NET 10 및 Avalonia UI 12.

그다음은 트리거(trigger)입니다. Google은 Conformer 오디오 인코더(audio encoder)를 탑재한 Gemma 4 (E2B 및 E4B)를 출시했습니다. LibriSpeech-test-clean 데이터셋에 대해 보고된 단어 오류율(WER)은 4.17%로, 깨끗한 음성 환경에서는 더 큰 Whisper 변체들과 경쟁할 만한 수준입니다. 동일한 체크포인트(checkpoint)를 사용하여 나중에 텍스트 후처리(text post-processing)도 수행할 수 있습니다. 문제는 "Gemma 4를 추가해야 하는가"가 아니었습니다. 문제는 "Windows 환경의 .NET에서, 온디바이스(on-device) 기본 설정을 유지하는 또 다른 로컬 엔진으로서 어떻게 구현할 것인가"였습니다.

네 가지 런타임 막다른 길 (Dead-ends)

이 부분은 이 포스트에서 가장 많은 엔지니어링 노력이 들어갔으며, 기록할 가치가 가장 높은 부분입니다. 각 거절 사례에는 구체적인 이유가 있습니다.

막다른 길 1: onnxruntime-genai를 통한 네이티브 .NET 추론 (inference)

가장 먼저 고려할 만한 당연한 선택지였습니다. GenAI 확장이 포함된 ONNX Runtime은 이미 .NET에서 Phi-3 및 유사한 소형 모델들을 실행하고 있습니다. 만약 Gemma 4가 지원되었다면, 앱에는 새로운 ISpeechRecognizer 구현체만 추가되면 되었을 것입니다. 추가 프로세스도, 별도의 설치 프로그램도 필요 없었을 것입니다.

하지만 지원되지 않습니다. Gemma 4의 아키텍처는 레이어별 임베딩(per-layer embeddings), 가변 헤드 차원(variable head dimensions), 그리고 KV 캐시 공유(KV cache sharing)를 사용합니다. 이 중 어느 것도 이 글을 쓰는 시점의 onnxruntime-genai에서는 이해되지 않았습니다. 추적 이슈(Tracking issue): microsoft/onnxruntime-genai#2062.

레이어별 임베딩을 간단히 설명하자면, 각 트랜스포머(transformer) 레이어가 하나의 임베딩 행렬을 공유하는 대신 각자 고유의 임베딩 행렬을 갖는 것을 의미합니다. 가변 헤드 차원은 서로 다른 레이어의 어텐션 헤드(attention heads)가 서로 다른 크기를 가질 수 있음을 의미합니다. 표준 ONNX 익스포터(exporters)와 런타임(runtimes)은 이 두 가지 사항을 모두 가정하지 않습니다. ONNX Runtime이 근본적인 지원을 제공하기 전까지는 .NET 네이티브 경로는 존재하지 않습니다.

막다른 길 2: HuggingFace Transformers를 사용하는 Python 사이드카 (sidecar)

두 번째 시도는 작은 Python 사이드카를 만드는 것이었습니다. 로컬 FastAPI 서버를 실행하고, 127.0.0.1로 HTTP 통신을 하며, 4비트 양자화(4-bit quantization)를 위한 bitsandbytes와 함께 HF Transformers를 통해 전사(transcribe)하는 방식입니다. .NET 측에서는 임시 WAV 파일을 작성하고, 이를 POST로 전송한 뒤, JSON을 파싱하고 정리하는 과정을 거칩니다.

이 기능은 실제로 벤치마크 전용 도구(ADR-024)로 출시되었습니다. 데스크톱 앱에 연결된 적은 없었습니다. 세 가지 이유가 있습니다:

  1. 설치 과정에서 Python과 CUDA를 포함하게 됩니다. 이는 개발자가 아닌 일반 사용자들에게는 시작조차 할 수 없는 문제입니다.
  2. bitsandbytes는 Windows 지원이 제한적입니다. 사용자가 소비자용 GPU에서 Gemma 4를 저렴하게 사용할 수 있게 해주는 4-bit 경로를 이용하려면 WSL2나 Linux가 필요합니다.
  3. 벤치마크(Benchmark) 결과가 신뢰할 수 없었습니다.

세 번째 포인트는 깊이 살펴볼 가치가 있습니다. LibriSpeech-test-other 데이터셋에 대한 첫 번째 Gemma 4 벤치마크 결과는 96.94%의 WER(Word Error Rate, 단어 오류율)로 나타났습니다. 수 기가바이트를 차지해야 할 모델임에도 불구하고, 사이드카(Sidecar) 프로세스의 최대 호스트 RAM 사용량은 약 79 MB에 불과했습니다. 이 수치는 너무 형편없어서, 당연한 결론은 "Gemma 4가 나쁘다"가 아니라 "이 파이프라인(Pipeline)이 조용히 고장 나 있다"였습니다. 2주 후, 동일한 데이터셋과 동일한 기기에서 llama.cpp 경로를 사용했을 때 동일한 모델에 대해 13.15%의 WER을 기록했습니다.

여기서 얻는 교훈은 "Python이 나쁘다"가 아닙니다. 교훈은 모델 카드(Model Card)의 주장보다 당신이 배포하는 추론(Inference) 경로가 더 중요하다는 것이며, 이는 오직 당신의 스택(Stack)에서 직접 측정해 봄으로써만 알 수 있다는 것입니다.

이 고장 난 벤치마크는 llama-server를 찾아내게 만든 탐색의 계기가 되기도 했습니다.

막다른 길 3: LLamaSharp

LLamaSharp는 llama.cpp를 기반으로 한 네이티브 .NET P/Invoke 레이어입니다. 더 많은 제어가 가능하고, 별도의 프로세스가 필요 없으며, HTTP 경계도 없습니다. 이론적으로는 .NET 앱에 가장 적합한 방식입니다.

장애물은 빌드 결합(Build-coupling)이었습니다. LLamaSharp는 컴파일 시점에 특정 llama.cpp 빌드에 링크됩니다. 사용자의 백엔드(Backend)를 Vulkan에서 CUDA로 전환하려면 호스트 앱을 다시 빌드해야 합니다. 하나의 바이너리(Binary)에서 "AMD에서는 Vulkan을 사용하고, NVIDIA에서는 CUDA를 사용하라"는 식의 배포를 할 수 있는 좋은 방법이 없습니다. Gemma 4의 오디오 지원 또한 채팅 완성(Chat-completions) 경로보다 엔지니어링 측면에서 훨씬 더 많은 작업이 필요했습니다.

막다른 길 4: Ollama와 Lemonade

Ollama는 모든 옵션 중 가장 매끄러운 UX (User Experience)를 제공했을 것입니다. 하지만 당시에는 Gemma 오디오를 지원하지 않았습니다. 관련 이슈 추적: ollama/ollama#15333.

Lemonade는 Ryzen AI 하드웨어에서 강력한 성능을 발휘하지만, AMD 전용입니다. 벤더 간 호환성 (Cross-vendor)은 필수 요구 사항이었습니다.

llama-server인가

llama-server는 llama.cpp와 함께 제공되는 HTTP 서버입니다. 결정 날짜 (2026-05-09, ADR-025) 기준으로, 이는 Gemma 4 오디오를 지원하면서 안정적인 HTTP API를 갖춘 유일한 벤더 간 호환 Windows 네이티브 런타임 (Runtime)이었습니다.

구체적인 이유는 다음과 같습니다:

  • OpenAI와 호환되는 /v1/chat/completions 엔드포인트를 노출합니다. 오디오는 input_audio 콘텐츠 블록으로 입력됩니다. 이 구조는 문서화되어 있으며 안정적입니다.
  • 사전 빌드된 Vulkan 바이너리 (llama-bXXXX-bin-win-vulkan-x64, llama.cpp의 GitHub Releases 제공)는 단 한 번의 다운로드로 AMD, Intel, NVIDIA GPU에서 모두 작동합니다.
  • CUDA, Vulkan, CPU 및 기타 백엔드 (Backends)가 각각 별도의 아카이브로 제공됩니다. 여러 개를 병행하여 설치하고 전환할 수 있습니다.
  • Gemma 4 GGUF 가중치 (Weights)와 오디오 프로젝터 (mmproj)가 HuggingFace의 ggml-org에 게시되어 있습니다.

그 대가로 관리해야 할 추가 프로세스가 발생합니다. 콜드 스타트 (Cold start), 포트 충돌, 충돌 처리 (Crash handling), 업그레이드 중 파일 잠금 등이 그것입니다. 이 포스트의 나머지 대부분의 내용은 이를 어떻게 길들였는지에 관한 것입니다.

아키텍처 (Architecture)

두 개의 다이어그램이 있습니다. 첫 번째는 디스크에 무엇이 있는지와 누가 무엇을 다운로드하는지를 보여줍니다. 두 번째는 엔진별로 오디오 파이프라인 (Audio pipeline)이 어떻게 분기되는지를 보여줍니다.

최상위 통합

Top-level integration

다이어그램은 세 개의 계층으로 구성됩니다. 앱(.NET 호스트 프로세스), 디스크(%LOCALAPPDATA%/parlotype - 설치된 서버, 모델 및 프롬프트 저장용), 그리고 외부 소스(GGUF를 위한 HuggingFace, llama-server 빌드를 위한 GitHub Releases)입니다. 사이드카(sidecar)는 앱과 디스크 사이에 위치하는데, 이는 사이드카가 두 영역 모두에 걸쳐 있기 때문입니다. 즉, 앱에 의해 생성되지만, 그 바이너리와 가중치(weights)는 디스크에 존재합니다.

오디오 파이프라인 (Audio pipeline): Whisper와 Gemma 4의 병행 사용

Audio pipeline: Whisper and Gemma 4, side by side

가운데에 있는 다이아몬드 형태는 아키텍처의 핵심 축입니다. DelegatingSpeechRecognizer는 초기화 시점에 사용자의 SpeechEngine 설정을 읽고, 모든 호출을 WhisperSpeechRecognizer 또는 LlamaCppSpeechRecognizer 중 하나로 전달합니다. 오디오 파이프라인 자체는 어떤 엔진이 활성화되어 있는지 알지 못합니다. 동일한 캡처(capture), 동일한 VAD(Voice Activity Detection), 동일한 인젝터(injector)를 사용합니다. 오른쪽 브랜치는 프로세스 경계(process boundary)를 넘나드는데, 이것이 Gemma 4 경로를 사용하는 데 따르는 비용입니다.

언급할 만한 주요 타입은 다음과 같습니다:

  • SpeechEngine 열거형 (enum): Parlotype.Core에 정의되어 있으며 (Whisper 또는 Gemma4), SettingsKeys.SpeechEngine을 통해 영구 저장됩니다.
  • DelegatingSpeechRecognizer: ISpeechRecognizer 싱글톤(singleton)으로 등록됩니다. InitializeAsync 시점에 하위 인식기(underlying recognizer)를 선택합니다.
  • LlamaCppSpeechRecognizer: llama-server.exe 프로세스의 생명주기(lifecycle)를 관리합니다. 프로세스 생성(spawn), /health 폴링(poll), 전사(transcribe), 종료(terminate) 과정을 수행합니다.
  • JsonLlamaServerRegistry: manifest.json에 기록된 관리 대상 설치 항목을 추적합니다 (아래에서 설명).
  • IPromptTemplateRegistry: 호출마다 활성화된 전사 프롬프트(transcription prompt)를 조회합니다.

input_audio 콘텐츠 블록

대부분의 ".NET에서 llama.cpp 사용하기" 튜토리얼은 텍스트 전용 채팅만을 다룹니다. 오디오 경로는 상세히 보여줄 가치가 있습니다. 오디오는 input_audio 콘텐츠 블록 내에 base64로 인코딩된 WAV 블롭(blob) 형태로 전송됩니다:

// LlamaCppSpeechRecognizer.cs의 발췌
var body = new
{
...

stream = false는 의도적인 설정입니다. 에러 핸들링(Error handling)이 더 단순해지고, SSE(Server-Sent Events) 파서가 필요 없으며, 전사(Transcription)가 짧은 버스트 형태(클립당 30초 미만, 아래 트레이드오프 참조)로 이루어지기 때문입니다. 후처리(Post-processing)가 완료되어 더 긴 텍스트를 출력하게 된다면, 스트리밍(Streaming)을 도입할 가치가 충분할 것입니다.

트레이드오프 (Trade-offs)

제가 겪었던 문제들을 발생한 순서대로 정리했습니다.

모델 크기 (Model size). GGUF E4B Q4_K_M은 약 5.9 GiB입니다. BF16 변체(Variants)는 약 15 GiB에 달합니다. Gemma4ModelInfo 카탈로그 (ADR-029)는 5가지 변체를 큐레이션하고 있으며, ggml-org/gemma-4-E2B-it-GGUF에는 Q4_K_M 에셋이 게시되지 않았음을 명시적으로 기록하고 있습니다. 저는 수동 테스트 중 404 에러를 통해 이 사실을 알게 되었고, 이후 실제 파일 목록을 바탕으로 카탈로그를 다시 구축했습니다.

노이즈가 있는 오디오 (Noisy audio). LibriSpeech-test-other 데이터셋에서 CUDA를 사용한 Whisper LargeV3Turbo의 WER(Word Error Rate)은 11.48%를 기록했습니다. 가장 성능이 좋은 Gemma 4 변체(E2B-it-BF16)의 WER은 13.15%였습니다. 더 어려운 영어 분할 데이터셋에서 1.7포인트의 격차가 발생한 것입니다. Google 자체 평가에서도 Gemma 4는 회의 스타일의 노이즈 환경에서 더 뒤처지는 모습을 보였습니다 (AMI 데이터셋에서 Gemma는 약 41%의 WER을 기록한 반면, Whisper-large-v3는 약 16%를 기록했습니다). 솔직한 평가는 Gemma 4가 낭독형 음성(Read speech)에서는 경쟁력이 있지만, 노이즈와 오디오 중첩이 증가함에 따라 Whisper보다 성능이 더 빠르게 저하된다는 것입니다.

콜드 스타트 (Cold start). ADR-025에서는 llama-server의 콜드 스타트 시간을 3초에서 30초 사이로 예상했습니다. 저의 첫 번째 벤치마크 수치는 이 범위의 상한값(E2B-Q8_0의 경우 modelLoad에 21.3초 소요)을 확인해 주었습니다. 상시 실행되는 웜업 패스(Warm-up pass, ADR-031)를 추가한 후, 동일한 modelLoad 시간은 6.7초로 단축되었습니다. 원래 발생하던 비용의 대부분은 인식기(Recognizer)가 아니라 OS 페이지 캐시(Page cache)와 CUDA 드라이버 초기화(Init) 때문이었습니다. 웜업된 호스트(Warm host)에서의 실제 InitializeAsync 시간은 Gemma 4의 경우 약 6.7초에서 9.3초 사이이며, Whisper의 경우 약 0.7초에서 1.5초 사이입니다.

30초 클립 제한. Gemma 4 오디오는 요청당 30초로 제한됩니다. Parlotype의 VAD (Voice Activity Detection)가 이미 이보다 짧게 청크 (chunk)를 나누고 있었기에 큰 문제는 되지 않았지만, 이는 실질적인 아키텍처 상의 한계입니다.

E2B-Q8_0는 불안정합니다. 벤치마크 도중, gemma-4-E2B-it-Q8_0 모델이 간헐적으로 잘못된 `` 추론 토큰 (reasoning tokens)을 방출하여 llama-server의 채팅 템플릿 (chat-template) 파서 (parser)를 HTTP 500 오류와 함께 충돌시켰습니다. 첫 번째 50개 샘플 실행은 스트림 중간에 실패했습니다. 두 번째 실행은 성공했으나, 장황한 사고 텍스트 (thought-text) 유출로 인해 RTF (Real-Time Factor)가 비정상적으로 높았습니다 (다른 Gemma 양자화 모델들의 약 0.04와 비교했을 때 0.315 기록). 카탈로그에는 실험을 위해 E2B-Q8_0를 선택할 수 있도록 유지해 두었습니다. 기본값은 E4B Q4_K_M입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0