본문으로 건너뛰기

© 2026 Molayo

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

Next.js BFF를 통한 AmiVoice의 동기식 HTTP API 호출 — 인증, multipart 순서, 그리고 WebM의 함정

요약

Next.js를 사용하여 AmiVoice API를 호출하는 BFF(Backend for Frontend) 구현 방법을 다룹니다. API 키 보안을 위해 서버 측에서 API를 중계하는 구조와 multipart 데이터 전송 시 주의사항을 설명합니다.

핵심 포인트

  • API 키 유출 방지를 위한 Next.js API Route 기반 BFF 구현
  • AmiVoice API의 동기식 HTTP 인증 및 multipart 데이터 순서 준수
  • 브라우저 MediaRecorder의 WebM/Opus 포맷 처리 방식 이해
  • 순수 함수 매퍼를 활용한 JSON 데이터 재구성 및 테스트 전략

📝 원문은 Zenn에 일본어로 게시되었습니다. 이 글은 영어 버전입니다.
Canonical: https://zenn.dev/uya0526_design/articles/satellite1_amivoice-bff

📚 이 글은 저의 "낭독 속도 측정기 개발 로그" 시리즈 중 satellite article #1입니다. 전체적인 맥락은 메인 기사를 참조하세요.

이 글의 위치

낭독 속도 측정기 앱에서, 이 글은 브라우저에서 녹음된 오디오를 AmiVoice API로 전송하여 인식된 텍스트와 타임스탬프를 받아오는 부분을 다룹니다. 주제는 API 키를 브라우저에 노출하지 않고 외부 API를 호출하는 것 — 즉, **BFF (Backend for Frontend)**를 구현하는 것입니다.

메인 기사에서는 주요 내용만 다루었으므로, 여기서는 여러분이 직접 재현할 수 있는 수준까지 깊게 들어갑니다. 구체적으로 다음 네 가지를 다룹니다:

  • 브라우저에서 AmiVoice를 직접 호출하면 안 되는 이유 (왜 BFF가 필요한가)
  • AmiVoice의 동기식 HTTP 인증 (auth), 파라미터, 그리고 multipart 순서 (multipart order)
  • 브라우저의 MediaRecorder 출력물 (WebM/Opus)이 그대로 전달되는 이유
  • 순수 함수 매퍼 (pure-function mapper)를 사용하여 원시 JSON을 재구성하고 피스처 (fixtures)로 테스트하기

💡 저는 공개적으로 TypeScript를 배우고 있는 전직 Java 엔지니어이므로, 여기저기 Java와의 비교를 덧붙이곤 합니다.

BFF가 필요한 이유

AmiVoice API를 사용하려면 API 키가 필요합니다. 그리고 그 키는 절대로 브라우저 측 코드에 나타나서는 안 됩니다. 프론트엔드 JavaScript는 사용자가 완전히 조사할 수 있으므로, 거기에 키를 작성하면 즉시 유출됩니다.

따라서 키를 보유하는 중계기(relay)를 삽입합니다.

[Browser] ──audio Blob──▶ [Next.js API Route (BFF / 키 보유)] ──▶ [AmiVoice API]
 녹음 / 표시          audio 필드          u / d / a로 재구성        음성 → 텍스트

브라우저는 오직 저의 API Route (/api/recognize)만 호출하며, 해당 Route가 서버 측에서 키를 첨부하여 AmiVoice로 전달합니다. 키는 단순히 process.env에서 읽어올 뿐이며, 브라우저로 전달되는 번들(bundle)에 포함되지 않습니다.

Java 비교: 이는 Spring의 @RestControllerapplication.yml(환경 변수)에서 외부 API 키를 읽어와 클라이언트에게 노출하지 않고 전달하는 것과 같습니다. "비밀 키를 숨기고 외부 API를 중계하는 얇은 서블릿 (Servlet)"이라고 생각하면 됩니다. Next.js에서는 app/api/recognize/route.ts에 정의된 export async function POST@PostMapping에 해당합니다.

AmiVoice 동기식 HTTP API 명세 (Spec)

공식 매뉴얼을 바탕으로 구현하고 curl로 재차 확인하며 동기식 HTTP 인터페이스를 사용했습니다. 주요 사항은 다음과 같습니다:

항목상세 내용
엔드포인트 (Endpoint)POST https://acp-api.amivoice.com/v1/nolog/recognize (로그 미기록 버전)
...

여기서 저는 **세 가지 함정 (traps)**에 빠졌습니다. 순서대로 공유하겠습니다.

함정 ① — 인증 (Auth)은 헤더가 아니라 u 필드입니다

"REST API 인증"이라고 하면 보통 Authorization: Bearer ...를 떠올립니다. 하지만 AmiVoice의 동기식 HTTP는 다릅니다. API 키는 u라고 불리는 multipart 필드에 포함되어야 합니다. 처음에는 헤더에 넣었더니 401 스타일의 오류가 발생하여 여기서 막혔습니다.

함정 ② — 오디오 a는 _마지막_에 위치해야 합니다

multipart 파트들은 반드시 uda (audio) 순서로 구성되어야 하며, 오디오가 마지막 파트여야 합니다. 그 뒤에 다른 필드를 추가하면 오디오가 무시되는 현상이 발생했습니다. FormData에 append하는 순서가 직접적인 의미를 갖습니다.

함정 ③ — WebM/Opus는 있는 그대로 통과됩니다 (c 생략 가능)

브라우저의 MediaRecorder는 보통 audio/webm;codecs=opus를 출력합니다. 저는 "AmiVoice에 전달하기 전에 형식을 변환해야겠구나"라고 대비하고 있었지만, WebM + Opus는 컨테이너 내에 헤더를 포함하고 있으므로 동기식 HTTP에서는 오디오 형식 파라미터인 c를 생략할 수 있습니다 (curl로 확인 완료). 녹음된 Blob을 변환 없이 그대로 보낼 수 있습니다.

API 라우트 (BFF) 구현

함정들을 모두 고려하여 작성한 app/api/recognize/route.ts 코드입니다.

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

...

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

핵심 아이디어는 **"FormData의 2단계 처리"**입니다. 브라우저 → 내 Route(라우트)는 audio 필드로 데이터를 받고, Route → AmiVoice는 이를 u/d/a로 재구성합니다.

Java와의 비교: 이는 인바운드 DTO (Data Transfer Object)를 아웃바운드 외부 API를 위한 multipart form 형태로 재구성하는 것과 같은 형태입니다. "받는 형태와 보내는 형태가 서로 다르다"는 개념이 FormData 재구성(reshape)에 그대로 적용됩니다.

저는 이 Route가 AmiVoice의 원시 JSON (raw JSON)을 거의 수정하지 않은 상태로 반환하도록 설계했습니다. Route에서 포맷팅을 처리하는 대신, 다음 매퍼 (Mapper, 순수 함수)에게 맡겨 책임을 분리했습니다.

먼저 curl로 원시 JSON 확인하기

구현하기 전에 curl을 사용하여 엔드포인트에 요청을 보내 원시 응답 (raw response)의 형태를 확인하는 것이 결국 가장 빠른 방법임이 드러났습니다.

curl -X POST https://acp-api.amivoice.com/v1/nolog/recognize \
  -F u="$AMIVOICE_API_KEY" \
  -F d="-a-general" \
...

환경 변수($AMIVOICE_API_KEY)로부터 API 키를 전달하므로, 커맨드 히스토리나 코드에 실제 키를 하드코딩하지 않습니다. "키를 코드에서 분리하라"는 원칙은 curl 검증 단계부터 적용됩니다.

반환된 JSON은 대략 다음과 같습니다 (발췌됨; 값은 예시입니다):

{
  "results": [
    {
...

매퍼 (Mapper): 원시 JSON → 앱 타입 (순수 함수)

저는 원시 JSON을 앱 내부에서 사용하는 AmiVoiceResponse 타입으로 재구성합니다. 이 변환을 I/O가 없는 **순수 함수 (pure function)**로 만들어, 측정 로직(calculateMetrics) 외부에 레이어로 배치합니다.

interface AmiVoiceResponse {
  text: string;
  segments: { starttime: number; endtime: number }[];
...

여기에도 수정 사항이 하나 있었습니다. 처음 버전에서는 raw.result.tokens (단수)를 참조하여 아무것도 반환받지 못했습니다. 이를 raw.results[0].tokens (복수 배열)로 수정했습니다. 이는 먼저 curl로 원본 JSON을 확인했다면 방지할 수 있었던 실수였으며, "실제 데이터를 조기에 확보하는 것"이 얼마나 중요한지를 다시 한번 확인시켜 주었습니다.

🧭 설계 결정 (Design decision): 세그먼트(segments)는 "단어별(per word)" 또는 "발화별(per utterance)"로 구축할 수 있습니다. 저는 단어별 방식을 선택했는데, 단어 사이의 간격(침묵)을 정체율(stagnation rate)에 반영함으로써 유창성(fluency)을 더 세밀하게 측정할 수 있기 때문입니다.

피스처(Fixtures)를 이용한 테스트

매퍼(mapper)는 순수 함수(pure function)이므로 Vitest로 테스트하기가 매우 간단합니다. 핵심은 curl로 캡처한 실제 데이터를 피스처(fixture)로 저장하는 것입니다.

fixtures/
├── test_01.json   # 짧은 응답 (3개 토큰)
└── test_02.json   # 실제 curl 데이터 기반 (9개 토큰)
import { describe, test, expect } from "vitest";
import { mapAmiVoiceResponse } from "./mapAmiVoiceResponse";
import raw01 from "../../fixtures/test_01.json";
...

여기서 제가 의식했던 점은 과거 프로젝트에서 얻은 교훈입니다: "테스트 통과 ≠ 의도한 대로 동작함." 만약 .map()의 결과만을 다른 .map()과 비교한다면, 결국 매퍼와 동일한 로직으로 테스트를 작성하게 되어 의도를 검증할 수 없게 됩니다. 따라서 starttime: 1080과 같은 구체적인 값을 하드코딩하고 이를 단언(assert)함으로써, "정말로 올바른 값을 가져오고 있는가?"를 확실히 고정했습니다.

Java와의 비교: 피스처(Fixtures)는 JUnit 테스트 리소스(src/test/resources 아래의 JSON)와 유사하며, toHaveLength 또는 구체적인 값에 대한 단언(assert)은 assertEquals에 대응합니다. "운영 환경과 유사한 원본 JSON을 테스트에 고정(pinning)한다"는 개념은 그대로 이어집니다.

직접 구현한 부분 / AI에게 요청한 부분

이것은 AI 협업 개발이므로, 투명성을 위해 그 차이를 기술합니다.

직접 구현한 부분 / AI에게 요청한 부분

이것은 AI 협업 개발이므로, 투명성을 위해 그 차이를 기술합니다.

AreaDetail
My decisions / implementationBFF 구조 채택, u/d/a 순서, raw-JSON 반환 정책, 단어별 세그먼트 선택, fixture 내 구체 값 어설트(concrete-value asserts), 공식 매뉴얼 확인, curl 검증
...
AI가 기본적인 예제 코드(boilerplate examples)를 제공했지만, 공식 사양에 맞게 수정한 부분, 검증 과정, 그리고 수정 사항은 모두 제가 직접 처리했습니다.

마무리하며 (Wrapping Up)

이 글은 Next.js BFF를 통해 AmiVoice의 동기식 HTTP API를 호출했던 기록입니다. 여기서 얻은 교훈(takeaways)은 다음과 같습니다:

  1. API 키는 브라우저에 노출시키지 마세요. Next.js API Routes를 거쳐 전달하는 BFF를 사용하세요.
  2. 동기식 HTTP의 경우, 인증(auth)은 u 필드가 담당하고, 오디오 a마지막 부분이며, WebM/Opus 포맷을 사용하면 c 필드를 생략할 수 있습니다.
  3. raw JSON을 **순수 함수 매퍼(pure-function mapper)**로 재구성하고, 실제 curl 데이터가 포함된 fixture와 구체 값 어설트를 사용하여 테스트하세요.

제가 실수했던 대부분의 부분은 처음부터 raw 응답을 확인했다면 예방할 수 있었던 것들이었습니다. 외부 API를 다룰 때는, 구현하기 전에 일단 curl로 한 번 호출해보고 자신의 눈으로 raw JSON을 살펴보는 것이 가장 빠르다는 것을 알게 되었습니다.

자세한 개발 기록은 레포지토리의 LEARNING_LOG_Phase1_Step3.md에 있습니다.

다음번에는 인식된 결과(settled facts)를 Claude Haiku에 전달하여 한 줄 피드백을 생성하는

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0