본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 18. 21:54

AmiVoice 타임스탬프를 활용한 일본어 낭독 속도 측정 — STT Claude에 머물지 않는 코칭 앱

요약

AmiVoice API의 타임스탬프를 활용해 일본어 낭독 속도와 유창성을 측정하고, Claude Haiku로 피드백을 제공하는 웹 앱 개발 과정을 다룹니다. BFF 패턴을 통한 API 보안과 계산 로직과 AI 역할을 분리한 2단계 설계 방식을 설명합니다.

핵심 포인트

  • AmiVoice의 단어별 타임스탬프를 활용한 정밀한 낭독 속도 및 정체율 계산법
  • API 키 보안을 위한 BFF(Backend for Frontend) 아키텍처 구축
  • 계산은 코드가, 문구 생성은 Claude가 담당하는 역할 분리 설계
  • AI 협업 개발 환경에서의 주도적인 학습 및 검증 방법론

📝 Zenn에 일본어로 처음 게시되었습니다. 이 글은 영어 버전입니다.
Canonical: https://zenn.dev/uya0526_design/articles/main_article_reading-speed-meter

서론 — 내가 만든 것

나는 일본어 지문을 소리 내어 읽으면, 속도와 유창성을 측정하고 AI 코치가 한 줄의 피드백을 제공하는 웹 앱을 만들었습니다.

흐름은 간단합니다. 스크립트를 보면서 마이크에 대고 지문을 소리 내어 읽으면 (최대 10초) — 나는 일본 고전인 _헤이케 이야기(The Tale of the Heike)_와 _호조키(Hōjōki)_의 도입부를 준비했습니다 — 그리고 **"Measure"**를 누르면:

  1. AmiVoice API가 오디오를 인식하고,
  2. 코드에서 해당 결과로부터 **순수 말하기 속도 (분당 글자 수) 및 정체율 (stagnation rate)**을 계산하며,
  3. Claude Haiku가 "한 가지 칭찬 + 한 가지 개선점" 형식으로 코칭을 반환합니다.

(측정은 녹음 후 버튼을 눌러 시작됩니다 — 자동으로 실행되지 않습니다.)

이 글은 전체적인 그림, 디자인 결정, 그리고 재현 단계를 다루는 하나의 완결된 콘텐츠가 되는 것을 목표로 합니다.

💡 나의 여정에서 이 프로젝트의 위치

나는 TypeScript와 Python을 공개적으로 학습하고 있는 전직 Java 엔지니어입니다. 이번 프로젝트는 내가 "AI 협업 개발 (AI-collaborative development)"을 명확한 모드로 의도적으로 채택한 첫 번째 프로젝트였습니다. 글 전반에 걸쳐 Java와의 비교를 덧붙일 예정이며 — 비슷한 배경을 가진 분들에게 도움이 되기를 바랍니다.

이 글을 통해 얻을 수 있는 것

  • AmiVoice가 반환하는 **단어별 타임스탬프 (per-word timestamps)**를 완전히 활용하는 평가 로직을 설계하는 방법
  • API 키가 브라우저에 절대 도달하지 않도록 **BFF (Backend for Frontend)**를 구축하는 방법
  • 코드는 계산을 수행하고 Claude Haiku는 문구 작성만 담당하는 "2단계" 설계 방식
  • 직접 검증해야만 배울 수 있는 생생한 발견 사항 — 예: "최적화했다고 생각했지만 실제로는 작동하지 않았던 부분"

직접 재현해 보실 수 있도록 구체적인 엔드포인트(endpoints), 파라미터(parameters), 환경 변수(environment variables)를 포함했습니다.

나의 학습 스타일 (AI 투명성)

💡 학습 동반자 및 이 글이 작성된 방식

공개의 기준은 **"AI를 사용했는가"**가 아니라 **"내가 주도하고 있는가"**입니다.

영역소유자
기술 선택, 평가 알고리즘 설계, 아키텍처 결정, 코드 검증
...

다시 말해, 사고는 나의 것이고, 문구는 AI의 도움을 받았으며, 그 모든 것을 내가 검증합니다. 모든 설계 결정, 모든 임계값(threshold)의 근거, 그리고 "어디에서 막혔는지"에 대한 모든 내용은 저의 직접적인 정보입니다. 저는 저장소(repository)에 단계별 LEARNING_LOG를 유지하여 "직접 구현한 것"과 "AI에게 요청한 것"을 분리합니다.

독창성 — 왜 "STT → Claude"에서 멈추지 않는가?

음성 인식 (STT)과 생성형 AI를 결합하는 많은 글들이 "오디오를 전사(transcribe)한 다음, 그 텍스트를 Claude에 바로 전달한다"는 단계에서 멈춥니다. 그것도 작동은 하지만, AmiVoice의 가장 핵심적인 부분을 버리는 셈입니다.

AmiVoice는 단순한 전사 도구가 아닙니다. 각 개별 단어에 대해 다음과 같은 정보를 반환합니다:

필드 (Field)의미
written표면형 (한자 + 가나)
...

starttime / endtime을 사용하면 언제, 어떤 단어를, 얼마 동안 읽었는지 알 수 있습니다. 따라서 저의 접근 방식은 다음과 같습니다:

평가 로직(코드)에서 깊이를 확보한 다음, Claude Haiku를 통해 이를 인간이 이해할 수 있는 형태로 변환하는 것 — 즉, 2단계 설계입니다.

교육 및 음성학 분야에서 낭독 유창성(read-aloud fluency)은 전통적으로 **정확도(accuracy), 속도(speed), 표현력(expressiveness)**이라는 세 가지 축을 중심으로 논의됩니다. 이 앱(Phase 1)은 주로 **속도(speed)**를 구현하며, 속도의 반대 급부로서 자체 지표인 **정체율(stagnation rate, 일시 정지 비율)**을 추가합니다. 일시 정지(pauses)는 학술적으로 발화 속도(speech rate)의 일부로 취급되므로, 저는 정체를 속도와 인접한 지표로 설정했습니다.

축 (Axis)이 앱의 지표단계 (Phase)
속도 (Speed)순수 발화 속도 (chars/min) / 정체율 (자체 지표, 속도 인접)Phase 1 ✅
...

"시간 정보에 크게 의존하는 낭독 평가"는 비용은 낮으면서(단순 산술 연산) 진정으로 차별화되는, 보기 드문 최적의 지점(sweet spot)임이 드러났습니다.

아키텍처 — 브라우저에 API 키 노출 방지

AmiVoice와 Claude 모두 API 키(API keys)가 필요하며, 이 키들은 절대로 브라우저에 노출되어서는 안 됩니다. 따라서 키를 보유하는 얇은 중계층(BFF / proxy)을 삽입합니다.

[Browser]
  스크립트 표시 / 녹음 (getUserMedia → MediaRecorder → Blob)
        │  FormData(audio)
...

브라우저는 두 개의 외부 API를 직접 호출하지 않습니다. Next.js API Routes가 키를 보유하는 얇은 중계 역할을 수행합니다.

Java와의 비교: 이는 Spring의 @RestControllerapplication.yml에서 외부 API 키를 읽어 클라이언트에게 절대 보여주지 않고 호출을 중계하는 것과 같은 개념입니다.

저는 I/O를 포함하지 않는 순수 함수(pure function)인 calculateMetrics 내부에 모든 것을 유지하여, Vitest로 직접 테스트할 수 있도록 했습니다.

// 입력: AmiVoice 응답에서 재구성된 타입
interface AmiVoiceResponse {
  text: string;
...

두 가지 설계 포인트:

  • 글자 수(Character count)는 인식된 텍스트의 코드 포인트(code points)를 기준으로 합니다: [...text].length. 원문 스크립트를 기준으로 하면 낭독자가 앞부분을 건너뛸 때 속도가 과다 측정될 수 있으므로 이를 피했습니다.
  • Math.round를 사용하여 숫자 타입을 유지합니다. toFixed는 문자열을 반환하므로 사용하지 않습니다.

Java와의 비교: [...text].length는 서로게이트 쌍(surrogate pairs)을 고려한 글자 수이며, reducestream().mapToLong().sum()에 대응합니다. 0으로 나누기 방지(division-by-zero guards) 및 경계 테스트(boundary tests)는 JUnit에서 항상 작성하는 오류 경로(error-path) 및 경계값 분석(boundary-value analysis)과 정확히 일치합니다.

임계값(thresholds)을 솔직하게 결정하기

다음으로, labelMetrics는 숫자를 평가 레이블("약간 빠름" 등)로 변환합니다. 여기서 어려운 질문은 **분당 몇 글자를 "빠름"으로 간주할 것인가?**입니다.

솔직히 말씀드리면, "분당 N자 = 빠름/느림"에 대한 학술적인 임계값을 찾을 수 없었습니다. 그래서 속도의 경우 일반적인 경험칙(rule of thumb)인 뉴스 아나운서의 속도 ≈ 분당 300자에 의존했으며, 일반인 기준으로는 이 전문적인 수준을 "약간 빠름"의 _시작점_으로 의도적으로 설정했습니다. 정체율(stagnation rate)의 경우 수치적 표준이 전혀 없으므로, 저는 이를 솔직하게 **사내 휴리스틱(in-house heuristic)**으로 취급합니다.

속도 라벨 (Speed label)분당 글자 수 (Chars/min)
느림 (Slow)– 149
...

이 문구들의 이면에 담긴 논리 — 사내 기준(in-house placement) 설정, 왜 정체율(stagnation rate)을 사내 지표로 취급하는지, "물 흐르듯 유창하다(fluent as water off a board)"라는 표현의 문화적 맥락, 그리고 모라(mora)와의 관계 — 에 대해서는 별도의 부속 기사인 **"지표의 근거 (The rationale behind the metrics)" ({{DEVTO_SATELLITE_3_URL}})**에서 자세히 다룹니다. 여기서는 원칙 하나만 기억하세요. 근거가 없을 때는 그것을 과학인 것처럼 꾸미지 말고, 과정을 공개하고 선을 그으십시오.

⚠️ MVP의 한계 (솔직하게 밝힘): 현재는 스크립트와 인식된 텍스트를 대조하는 기능이 없습니다 (정확도(accuracy)는 README의 Phase 2 단계입니다). 또한 정체율(stagnation rate)은 일본어의 의미 있는 휴지(pause, ma)를 구분하지 못합니다. 후자는 README의 Phase 2에도 아직 기재되지 않은 연구 과제입니다. 두 사항 모두 앱의 푸터(footer)에 명시되어 있습니다.

AmiVoice 완전 활용하기

/api/recognize는 녹음된 Blob을 수신하여 AmiVoice의 **동기식 HTTP API (synchronous HTTP API)**로 전달합니다. 여기서 사람들이 흔히 실수하는 부분들이 있습니다. (전체 구현 및 심층 분석은 부속 기사 #1, **"AmiVoice 동기식 HTTP 구현 (Implementing AmiVoice synchronous HTTP)" ({{DEVTO_SATELLITE_1_URL}})**에 있습니다.)

const AMIVOICE_ENDPOINT = "https://acp-api.amivoice.com/v1/nolog/recognize";
const AMIVOICE_ENGINE = "-a-general"; // 일반 대화용 (general conversational)

...

(단순화된 코드입니다. 실제 코드에서는 AMIVOICE_API_KEY가 누락되면 500 에러를 반환하며, 빈 키를 AmiVoice로 전송하지 않습니다.)

공식 매뉴얼과 curl을 통해 확인한 주의 사항(Gotchas)은 다음과 같습니다:

  • 인증(Auth)은 Authorization 헤더가 아니라 멀티파트(multipart)의 u 필드를 사용합니다. 처음에는 헤더 인증이라고 가정했다가 여기서 막혔습니다.
  • 오디오 a는 반드시 멀티파트의 마지막 파트여야 합니다. 그 뒤에 다른 필드를 추가하면 무시됩니다.
  • WebM + Opus는 컨테이너에 헤더를 포함하므로, 동기식 HTTP에서 오디오 포맷 파라미터 c를 생략할 수 있습니다 (curl로 확인 완료). 브라우저의 MediaRecorder 출력값(audio/webm;codecs=opus)이 그대로 통과되었습니다.

저는 calculateMetrics로 전달하기 전에 순수 함수 매퍼(pure-function mapper)를 사용하여 원시 JSON을 AmiVoiceResponse로 재구성(reshape)합니다. text는 최상위 레벨에 있으며, segmentsresults[0].tokens에 있는 starttime / endtime을 기반으로 구축됩니다. — 그리고 맞습니다, 처음에는 result(단수형)라고 참조했다가 수정해야 했습니다.

Claude Haiku의 "2단계" 설계

피드백 생성을 위해 저는 분리에 매우 엄격합니다. 코드는 계산을 수행하고, Haiku는 오직 문구(wording)만 담당합니다. 데이터가 Haiku에 도달할 때쯤이면 그것은 이미 **확정된 사실(settled facts)**입니다. 예를 들어, 개발 중에 수행한 실제 측정(Heike 샘플 낭독)에서 labelMetrics를 실행한 결과는 "속도 = 322자/분, 라벨 = 약간 빠름, 정체 = 0%, 라벨 = 거의 없음"이었습니다. Haiku의 유일한 임무는 이를 따뜻한 문구로 번역하는 것입니다. 저는 소형 모델이 가장 잘하는 것, 즉 문구와 톤(tone)에 집중하게 하며, 모델에게 수치적 정밀함을 요구하지 않습니다.

const result = await client.messages.create({
  model: process.env.ANTHROPIC_MODEL!, // 예: claude-haiku-4-5
  max_tokens: 256,
...
  • system = 페르소나 및 기본 규칙 (정적인 코칭 페르소나)
  • messages role: "user" = 매번 변경되는 동적 데이터 (평가 JSON)

Java와의 비교: 고정된 system 프롬프트와 가변적인 user 페이로드를 사용하는 것은 서비스 레이어(Service layer, 계산 = 코드)와 프레젠테이션 레이어(presentation layer, 문구 = Haiku)를 분리하는 것과 같은 개념입니다.

작동하지 않았던 "최적화"

이것은 이번에 제가 직접 겪은 가장 큰 발견입니다. 저는 **프롬프트 캐싱 (prompt caching)**을 통해 비용을 절감하고자 정적 system 프롬프트에 cache_control을 추가했습니다. 심지어 손익분기점 계산까지 마친 후 "두 번 사용하면 이득이다"라는 결론을 내렸습니다.

하지만 전혀 작동하지 않았습니다. Claude Haiku 4.5의 최소 캐싱 가능 크기는 4,096 토큰이며, 제 system 프롬프트는 몇 백 토큰에 불과했습니다. 임계값(threshold)을 충족하지 못했기 때문에, cache_control을 작성했음에도 불구하고 아무것도 캐싱되지 않았습니다. (응답의 usage를 통해 확인 결과, cache_creation_input_tokenscache_read_input_tokens 모두 0이었습니다.)

"캐싱으로 최적화했다"라고 쓰는 것은 거짓이 될 것입니다. 그래서 저는 이 내용을 기사에 검증 과정: 도움이 될 것이라 생각하여 확인했으나, 조건에 부합하지 않음을 확인했고 결과적으로 도움이 되지 않았다는 식으로 남겨두고 있습니다. 단순히 "최적화했다"라고 무책임하게 주장하지 않는 것이 제가 생각하는 정직함의 일부입니다.

"프롬프트에 작성됨 ≠ 준수됨"

한 가지 더 있습니다. 약 10초 분량의 낭독을 수행했을 때, 문장 부호가 없는 인식된 텍스트가 생성되었습니다. 그런데 Haiku는 입력값에 없던 구체적인 팁을 덧붙였습니다: "문장 부호에서 숨을 쉬세요." 저는 프롬프트에 "문장 부호를 언급하지 마세요"라는 문구를 추가했지만, 그조차도 100% 보장되지는 않았습니다.

이는 과거 프로젝트에서 얻은 교훈과 같은 형태입니다: "통과한 테스트 ≠ 의도한 대로 동작함." 중요한 것은 무언가를 _지시했는지_가 아니라, 실제 데이터를 통해 그것이 준수되었는지 검증하는 것입니다. 궁극적으로 완전히 안정적인 출력을 원한다면, 개선의 초점을 코드로 옮겨 하나의 필드로 전달할 수 있습니다 (Phase 2 옵션).

최종 확정된 프롬프트, 전체 캐시 검증 상세 내용, 그리고 문장 부호 문제를 격리한 방법은 위성 문서 #2, "Claude Haiku 코칭 설계와 프롬프트 늪 (Claude Haiku coaching design and the prompt swamp)" ({{DEVTO_SATELLITE_2_URL}})에 있습니다.

재현 단계 (Reproduction Steps)

로컬에서 실행하기 위한 최소 단계:

git clone https://github.com/uya0526-design/reading-speed-meter
cd reading-speed-meter
npm install

프로젝트 루트에 .env.local 파일을 생성합니다 (절대 커밋하지 마세요):

AMIVOICE_API_KEY=your_amivoice_api_key
ANTHROPIC_API_KEY=your_anthropic_api_key
ANTHROPIC_MODEL=claude-haiku-4-5
npm run dev    # → http://localhost:3000
npm test       # Vitest (28개 테스트)
npm run build  # 프로덕션 빌드 확인

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0