노그미 개발기(온디바이스 AI 녹음·전사·요약)
회고

노그미 개발기(온디바이스 AI 녹음·전사·요약)

2026-07-041수정

회의가 끝나면 늘 같은 후회를 했다. "아까 그거 뭐라고 했더라."

녹음은 남았지만 다시 듣는 일은 없었다. 두 시간짜리 녹음을 처음부터 들을 여유가 어디 있나. 받아쓰기 앱은 많았지만 대부분 녹음 파일을 서버로 올렸고, 사내 회의나 인터뷰를 남의 서버에 통째로 넘기는 게 늘 마음에 걸렸다.

그래서 직접 만들었다. 녹음부터 전사, 요약까지 전부 기기 안에서 끝나는 앱. 이름은 노그미(Nogmi)다. 이 글은 노그미가 어떤 앱인지, 왜 온디바이스를 고집했는지, 그리고 만들면서 부딪힌 삽질들을 정리한 기록이다.

노그미 — 녹음부터 요약까지 전부 기기 안에서

노그미가 하는 일

노그미는 애플 생태계 전용 녹음·전사·요약 앱이다. iPhone·iPad·Mac·Apple Watch가 하나의 코드베이스로 묶여 있고, 녹음한 파일은 iCloud Drive로 모든 기기에 자동 동기화된다.

핵심 흐름은 단순하다.

  1. 아무 기기에서나 원탭으로 녹음한다.
  2. 녹음이 끝나면 온디바이스 AI가 발화 언어를 자동 감지해 전사한다.
  3. 회의록·강의·인터뷰 등 목적에 맞는 모드로 AI가 요약한다.

녹음 목록 — 회의도 강의도 한 번의 탭으로

녹음 하나를 열면 재생 컨트롤 아래에 전사요약 탭이 나온다. 전사는 타임스탬프가 붙은 세그먼트로 쌓이고, 각 줄을 탭하면 그 지점부터 재생된다.

타임스탬프가 붙은 온디바이스 전사

요약은 회의록·강의·일반·브레인스토밍·인터뷰 다섯 모드 중에 고른다. 회의록 모드는 핵심 결정사항과 액션 아이템을, 강의 모드는 라벨 없는 개요를 뽑는다. 하단에 어떤 모델로 요약했는지가 그대로 찍힌다 — gemma-4-4b-it-4bit.

5가지 모드로 AI가 요약

왜 전부 기기 안에서였나

가장 큰 이유는 프라이버시다. 회의·인터뷰·강의 녹음은 대부분 남에게 보이면 안 되는 내용이다. 이걸 요약하려고 외부 API에 올리는 순간, 녹음의 통제권은 내 손을 떠난다.

두 번째는 오프라인이다. 지하 회의실, 비행기, 신호 없는 산속에서도 녹음은 계속돼야 한다. 서버에 의존하면 신호가 끊긴 순간 전사와 요약이 멈춘다.

세 번째는 비용이다. 개인 프로젝트에서 사용자마다 STT·LLM API를 붙이면 서버 비용이 사용량에 비례해 늘어난다. 온디바이스는 이 비용이 0이다. 모델을 한 번 내려받으면 그 뒤로는 전기 값만 든다.

그래서 처음부터 규칙을 하나 못 박았다. 녹음 데이터는 기기 밖으로 나가지 않는다. iCloud 동기화조차 애플의 개인 컨테이너를 쓸 뿐, 내 서버는 존재하지 않는다.

MLX를 버리고 Gemma 4 하나로 통일한 이유

처음 설계는 전사와 요약에 서로 다른 스택을 썼다. 전사는 WhisperKit, 요약은 MLX LLM(Qwen·Llama). 나름 합리적이라 생각했다. 전사는 전사 전용 모델이, 요약은 범용 LLM이 잘할 테니까.

문제는 통합 과정에서 터졌다. Gemma 4가 오디오 입력을 직접 받는다는 이야기를 듣고 "전사도 LLM 하나로 되지 않을까" 싶어 MLX 경로를 팠는데, 앱이 쓰던 MLX 스택에는 오디오 입력 경로 자체가 없었다. 텍스트와 비전만 지원하고, 오디오 가중치는 로딩 단계에서 필터링돼 버렸다. iOS에서 MLX로 음성을 받아쓰는 건 불가능했다.

한참 헤매다 실마리를 애플이 아니라 구글 쪽에서 찾았다. AI Edge Gallery 앱에서 iPhone이 실제로 오디오를 전사하는 걸 직접 확인했고, 그 경로가 MLX가 아니라 LiteRT-LM이라는 걸 알아냈다.

// LiteRT-LM: 오디오 파일과 지시문을 한 메시지로 넣는다
let message = Message(contents: [
    .audioFile(path: wavURL.path),
    .text("Transcribe this recording."),
])
let response = try await conversation.sendMessage(message)

여기서 큰 결정을 했다. WhisperKit도, MLX도 전부 걷어내고 Gemma 4(LiteRT) 단일 모델로 통일한다. 전사도 Gemma 4가 직접 하고, 요약도 같은 모델의 텍스트 생성으로 한다. 녹음을 30초 이하 창으로 잘라 각 창을 전사하고, 창의 시간대를 그대로 세그먼트 타임스탬프로 붙였다.

의존성 트리가 극적으로 가벼워졌다. WhisperKit·MLX·swift-huggingface·swift-transformers가 전부 사라지고, 사용자는 모델 하나만 내려받으면 전사와 요약을 모두 할 수 있게 됐다. 기기 메모리에 따라 E2B(가벼움)와 E4B(정확함)를 자동으로 골라준다.

참고로 README에 아직 남아 있는 WhisperKit·MLX 설명은 이 피벗 이전의 흔적이다. 코드는 이미 Gemma 4 단일 모델로 넘어왔다.

GPU 백엔드는 항상 실패했다

모델을 붙이고 실기기에서 돌리자마자 첫 벽에 부딪혔다. iPhone 17 Pro에서 엔진 생성이 통째로 실패했다.

LiteRT 엔진 생성 실패(GPU/GPU): Failed to create engine

원인은 LiteRT iOS 바이너리의 GPU 델리게이트 이슈였다(공식 저장소에도 같은 리포트가 올라와 있었다). 내가 고칠 수 있는 층이 아니었다. 그래서 엔진 로딩을 단계적 폴백으로 짰다. GPU/GPU로 시도하고, 실패하면 GPU/CPU-audio, 그것도 안 되면 CPU/CPU로 내려간다.

덕분에 앱은 어떤 기기에서도 죽지 않게 됐지만, 대가가 있었다. 항상 느린 CPU 경로로만 돌아갔다. 그리고 이 느림이 다음 사건의 불씨가 됐다.

17분짜리 녹음이 14%에서 멈췄다

가장 오래 붙잡은 버그다. 17분 녹음을 전사하면 항상 14% 근처에서 멈췄다. 처음엔 메모리 부족(OOM)을 의심했지만, 계측을 붙여 보니 가용 메모리는 시종 3.9GB로 안정적이었다. OOM이 아니었다.

진짜 원인은 두 가지가 겹친 것이었다.

  • 특정 오디오 창의 디코드가 폭주적으로 느렸다. 정상 창은 2~4초에 끝나는데, 어떤 한 창은 31초에서 심하면 13분까지 걸렸다. 출력이 길어서가 아니라(런어웨이가 아니라) 순수하게 디코드가 느린 거였다.
  • 그 정체 동안 백그라운드 시간이 만료되면서 작업이 중단됐고, 재시작하면 같은 창에서 또 막혀 무한 정체에 빠졌다.

해결은 세 겹으로 쌓았다.

첫째, 창별 75초 워치독을 넣었다. 한 창이 75초를 넘기면 네이티브 디코드를 취소하고 그 창을 건너뛴다. 이것만으로 진행률이 14%에서 88%까지 뚫렸다.

// 정체된 창은 버리고 다음 창으로 — 전체가 막히지 않게
let watchdog = Task {
    try await Task.sleep(for: .seconds(75))
    conversation.cancel()          // 네이티브 디코드 취소
}

둘째, 전사 중에는 자동 화면잠금을 끈다(isIdleTimerDisabled). 화면이 잠기면 백그라운드로 밀려 중단되던 걸 막았다.

셋째, 연속으로 엔진 생성이 3번 실패하면 작업을 중단하도록 했다. 이걸 안 하면 엔진이 계속 실패하는데도 "전사 완료(0세그먼트)"로 잘못 끝나 빈 전사가 저장됐다. 데이터 손실이었다. 이제는 실패로 처리하고 체크포인트를 보존한다.

이 세 개를 다 얹은 뒤에야 17분 녹음이 처음부터 끝까지 완주했다.

요약 품질의 진짜 병목은 프롬프트가 아니었다

요약이 자꾸 핵심을 빠뜨렸다. 당연히 프롬프트 탓이라 생각하고 시스템 지시문을 몇 번이나 다시 썼다. 별 소용이 없었다.

35분짜리 강의 녹음으로 하네스를 만들어 레퍼런스(Whisper 전사 + 대형 모델 요약)와 비교하고 나서야 범인을 찾았다. 프롬프트가 아니라 입력 클램프였다.

기존 코드는 전사가 길면 앞 5,000자와 뒤 5,000자만 남기고 중간을 버렸다. 그런데 하필 그 버려지는 구간에 핵심이 몰려 있었다. 강의로 치면 중반부 본론이 통째로 사라진 셈이다. 모델은 받은 적도 없는 내용을 요약할 수 없었다.

또 하나 반직관적인 발견 — 소형 모델은 단일 패스가 다중 패스보다 낫다. map-reduce나 refine 방식은 합성 단계에서 2~4B 모델이 무손실 재작성을 못 하고 오히려 항목을 압축해 떨궜다. 전체 전사를 한 번에 넣는 단일 패스가 커버리지를 43%에서 84%로 끌어올렸다.

정리하면 이렇게 바꿨다.

항목이전이후
입력 처리앞뒤 5k만, 중간 버림22,000자 이하 단일 패스
긴 전사다중 패스초과 시에만 map-reduce
후처리없음빈 섹션·라벨 에코·환각 정제

소형 온디바이스 모델을 다룰 때 프롬프트보다 입력을 온전히 넣는 것이 더 중요하다는 걸 이때 배웠다.

맥은 또 다른 나라였다

크로스플랫폼이라 하면 코드 한 벌로 다 되는 줄 알았다. 절반은 맞고 절반은 틀렸다. UI는 SwiftUI 크로스플랫폼 가드가 잘 돼 있어 몇 군데만 손보면 됐다.

Mac에서도 그대로, 넓은 화면에서 목록과 요약을 한눈에

문제는 모델 런타임이었다. LiteRT-LM xcframework에는 Mac Catalyst 슬라이스가 없었다. iOS 앱을 "Designed for iPad"로 맥에 올리는 것까진 되지만, Catalyst로 빌드하면 링크가 깨졌다. 하는 수 없이 맥은 별도의 네이티브 macOS 타깃(NogmiMac)으로 분리하고, LiteRT도 맥 전용 네이티브 바이너리를 따로 물렸다.

바이너리 버전까지 갈라졌다. 이게 특히 헷갈리는 함정이다.

  • iOS = LiteRT v0.12.0 고정 (상위 버전은 래퍼와 심볼이 안 맞아 빌드 실패)
  • macOS = LiteRT v0.13.1 (v0.12.0은 요약에서 음절 분리·<pad>·무한 반복 등 토큰화가 깨졌다)

같은 앱인데 플랫폼마다 다른 바이너리, 다른 버전, 다른 빌드 번호를 관리하게 됐다. "한 코드베이스"라는 말은 UI 층에서만 사실이었다.

온디바이스 모델은 출력을 믿으면 안 된다

마지막으로, 소형 온디바이스 모델을 실제 제품에 쓰면서 얻은 교훈 하나. 모델 출력을 그대로 믿고 화면에 뿌리면 안 된다. 소형 모델은 특유의 고질병이 있다.

  • 무음 창을 만나면 "I'm not sure what you mean" 같은 대화형 환각을 뱉는다.
  • <pad> 같은 특수 토큰, 캐럿(^^), LaTeX 조각이 섞여 나온다.
  • 같은 줄을 반복하거나, 고유명사를 엉뚱한 영어로 바꿔 버린다.

그래서 엔진 출력에 정제 계층(sanitize)을 뒀다. 특수 토큰과 노이즈를 걷어내고, 줄 단위 반복을 차단하되 줄바꿈은 보존한다. 무음 창의 대화형 환각은 아예 걸러 낸다. 이 방어층 없이는 온디바이스 요약을 사용자에게 내놓을 수 없었다.

끝맺음

노그미를 만들며 얻은 건 "온디바이스 AI는 낭만이 아니라 엔지니어링"이라는 감각이다. 서버에 던지면 끝날 일을, GPU 폴백·워치독·입력 클램프·출력 정제 같은 방어를 겹겹이 쌓아야 겨우 제품이 됐다.

그래도 방향은 후회하지 않는다. 신호 없는 회의실에서 녹음이 끝나자마자 요약이 뜨고, 그 어떤 데이터도 기기 밖으로 나가지 않는다는 사실 — 이 두 가지가 처음 목표 그대로다.

남은 숙제도 분명하다. GPU 백엔드가 실패하는 원인을 잡으면 전사 속도가 크게 오를 것이고, 재개가 체크포인트 창이 아니라 창 0부터 다시 시작하는 낭비도 손봐야 한다. 온디바이스 AI 앱을 만드는 사람에게 이 기록이 삽질 한 번을 줄여 주면 좋겠다.

노그미 개발기(온디바이스 AI 녹음·전사·요약)