
회의 중에 Claude가 “다음에 물어봐야 할 것”을 제안해 주는 회의록 앱을 만들었습니다
요약
실시간 음성 변환과 Claude의 LLM을 결합하여 회의 중 실시간 피드백을 제공하는 AI 회의록 앱 'AI-Giziroku' 개발 사례를 소개합니다. 화자 분리(Diarization) 기술과 역할별 맞춤형 제안 기능을 구현하는 기술적 과정을 다룹니다.
핵심 포인트
- 실시간 음성 인식(AmiVoice)과 Claude를 연동한 Web 앱 구현
- 회의 중 영업, 엔지니어 등 역할별 맞춤형 질문 제안 기능
- 음성 데이터에서의 화자 분리(Diarization) 기술 적용
- Next.js 기반의 실시간 인터랙티브 UI 설계
상담이나 회의의 발언을 실시간으로 텍스트로 변환(Transcription)하고, 그 자리에서 Claude가 「다음에 확인해야 할 것」을 제안하며, 종료 시 회의록까지 자동으로 생성하는 Web 앱 AI-Giziroku를 만들었습니다.
이 앱의 가장 큰 특징은 회의 중에 실시간으로 “다음 수”를 제안해 주는 역할별 피드백입니다. 상담 도중에 「영업」, 「엔지니어」 등 여러 전문적인 시점이 동석하여 귓속말을 해주는 듯한 느낌으로, 회의록을 나중에 만들기만 하는 도구와는 결정적으로 다른 점입니다.
이 기사에서는 구현 배경부터 음성 처리, 화자 분리 (Diarization), LLM 연동과 같은 요소 기술의 해설, 그리고 기능별 처리 플로우까지 정리합니다. 기술적인 도전 과제로는 「하나의 믹스된 음성에서 어떻게 여러 화자를 분리할 것인가」, 프로덕트의 주인공으로는 「회의 중에 효과적인 역할별 피드백」, 이 두 가지를 축으로 작성하겠습니다.
소스 코드는 GitHub에 공개되어 있습니다. 함께 확인해 주세요.
먼저, 실제로 동작하는 모습부터 봐주세요.
완성 이미지
녹음 화면의 전체상 (메인)
말한 발언이 왼쪽에 흐르고, 오른쪽에 Claude의 제안이 나오는——라는 앱의 메인 화면. 기사의 썸네일/OGP도 이를 상정하고 있습니다.

여러 화자가 색상으로 구분되어 분리됨
본 기사의 주인공. 하나의 시스템 음성에서 상대방이 여러 명으로 나뉘어 있는 화면.

회의 중에 나오는 역할별 피드백

종료 후 자동으로 생성되는 회의록

목록·작성 화면


그럼, 왜 이것을 만들었는지, 어떻게 구현했는지를 차례대로 해설하겠습니다.
왜 만들었는가
상담이나 회의에서는 「말했다, 안 했다」가 나중에 문제가 되기 쉽고, 회의록 작성도 은근히 번거롭습니다. 기존의 텍스트 변환 도구는 많이 있지만, 제가 원했던 것은 다음 3가지를 그 자리에서 동시에 수행하는 것이었습니다.
- 자신과 상대방의 발언을 실시간으로 텍스트로 변환한다
- 대화의 흐름을 읽고 「확인 누락」, 「다음에 물어봐야 할 질문」을 회의 중에 제안한다
- 종료되면 회의록을 자동으로 정리한다
특히 2번이 핵심인데, 회의록은 회의가 끝난 뒤에 만드는 것이지만, 제안은 회의 중에야말로 가치가 있습니다. 그래서 AmiVoice (음성 인식)와 Claude (LLM)를 조합하여, Next.js 기반의 하나의 앱으로 통합했습니다.
기능의 전체상
- 🌟 역할별 피드백 (주인공): 회의 중에 Claude가 영업/엔지니어 등의 여러 관점에서 「다음에 물어봐야 할 것」을 실시간 제안
- 회의 생성: 제목, 회의 목적 (메타 정보), 피드백을 요청할 역할 (영업/엔지니어 등)을 설정
- 실시간 텍스트 변환: 자신의 마이크와 상대방 측 (온라인 회의의 시스템 음성)을 별도 계통으로 입력
- 여러 화자의 자동 분리: 상대방 측에 여러 명이 있어도 화자 분리 (Diarization)를 통해 「상대방 1, 상대방 2...」로 분리. 오류는 수동 수정 가능
- 회의록 자동 생성: 종료 시 백그라운드에서 Markdown 회의록을 생성 (회의 중의 제안 메모도 반영)
기술 스택
| 영역 | 채택 기술 |
|---|---|
| 프레임워크 | Next.js 16 (App Router) / React 19 |
| ... |
아키텍처
전체 구성은 다음과 같습니다. 음성 인식은 실시간성을 우선하여, 서버를 거치지 않고 브라우저에서 직접 AmiVoice의 WebSocket으로 접속합니다. 확정된 발언 텍스트만 자체 API를 통해 DB에 저장하며, LLM 호출 (제안·회의록)은 서버 측에서 수행합니다.
왜 서버를 거치지 않는가? 이유는 두 가지입니다. 하나는 왕복 지연 시간(Latency)의 단축 (실시간성)입니다. 다른 하나는, Next.js의 Route Handler가 WebSocket을 지원하지 않기 때문입니다. AmiVoice의 APP_KEY는 폐쇄적 운영 (사내 LAN / VPN 전제)으로 브라우저에 배포하는 결단을 내렸습니다. 공개 운영을 하려면 단기 토큰을 반환하는 방식으로 교체해야 합니다.
음성 입력 메커니즘
2개 계통으로 「누구의 목소리인가」를 구분
초기 설계에서는 화자의 구분을 음원으로 수행했습니다.
-
자신의 목소리 =
getUserMedia()로 취득한 마이크 입력 →speaker = self -
상대방의 목소리 =
getDisplayMedia()로 취득한 화면 공유 시스템 음성 (루프백) →speaker = partner
온라인 회의에서는 상대방의 목소리가 스피커에서 나오므로 시스템 음성으로 포착할 수 있습니다. Chrome의 사양상 getDisplayMedia는 video가 필수이므로, 취득 후 video 트랙은 즉시 폐기하고 audio 트랙만 사용합니다.
export async function acquirePartnerStream(): Promise<MediaStream> {
const display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
display.getVideoTracks().forEach((t) => t.stop()); // video는 불필요
...
AudioWorklet로 PCM 변환
AmiVoice의 실시간 API는 PCM 계열 포맷을 받습니다. 따라서 Web Audio API의 AudioWorklet을 사용하여, 마이크/루프백의 Float32 샘플을 16kHz / 16bit / 모노럴 / 리틀 엔디언 (Little Endian) PCM으로 변환합니다. ScriptProcessorNode는 권장되지 않으며(deprecated) 메인 스레드 부하가 높기 때문에 피했습니다.
worklet 측의 포인트:
- 입력 샘플을 16kHz로 다운샘플링 (선형 보간)
Int16Array로 스케일링 (clamped * 0x7fff)- 20ms (320 샘플)를 1 프레임으로 하여, 100ms (5 프레임)를 모아서 메인 스레드로 전송 (Transferable을 사용하여 복사를 방지)
// public/worklets/pcm-worklet.js (발췌)
emitSample(sample) {
const clamped = Math.max(-1, Math.min(1, sample));
...
메인 스레드에서는 받은 PCM의 맨 앞에 1바이트 0x70 (ASCII의 p)를 붙여 WebSocket으로 바이너리 전송합니다. 이것이 AmiVoice의 음성 데이터 프레임 형식입니다.
AmiVoice WebSocket 프로토콜
AmiVoice의 실시간 API는 텍스트 프레임으로 명령을, 바이너리 프레임으로 음성을 주고받습니다.
| 종류 | 내용 |
|---|---|
| 시작 명령 | s LSB16K -a-general authorization=<KEY> resultUpdatedInterval=400 |
| 음성 전송 | 바이너리 맨 앞 1바이트에 p (0x70) + PCM |
| 종료 | 텍스트 e |
s 수신 | 시작 ACK (code가 비어 있지 않으면 에러) |
U 수신 | 중간 결과 (미확정) |
A 수신 | 확정 발화 (최종 결과) |
e 수신 | 종료 ACK |
LSB16K는 「16kHz / 16bit / LE PCM」을 나타내는 단일 토큰입니다. 중간 결과(U)는 화면의 마지막 줄에 덮어쓰기하여 표시하고, 확정 발화(A)만을 DB에 저장합니다.
하나의 믹스 음성에서 여러 화자 분리하기
당초 설계의 한계
「음원으로 화자를 나누는」 방식에는 치명적인 허점이 있었습니다. 온라인 회의에서 상대방 측에 3명이 있더라도, 모두가 하나의 루프백 음성으로 믹스되어 전달된다는 점입니다. 즉, 상대방 측에 몇 명이 있든 partner로 묶일 수밖에 없습니다. 이는 음원으로 나누는 방식의 구조적인 한계입니다.
해결책: AmiVoice의 화자 다이아라이제이션 (Speaker Diarization)
조사 결과, AmiVoice의 실시간 WebSocket API는 **화자 다이아라이제이션 (Speaker Diarization, 화자 분리)**을 지원하고 있었습니다. 시작 명령에 segmenterProperties를 추가하는 것만으로 활성화할 수 있으며, 인식 결과 토큰마다 speaker0 / speaker1 ... 과 같은 라벨이 붙습니다 (최대 20명 화자).
그래서 상대방(partner)의 WebSocket 세션에 대해서만 화자 분리 (Diarization)를 활성화했습니다. 제 마이크는 단일 화자이므로 필요하지 않습니다.
let startCmd = `s LSB16K ${ENGINE} authorization=${APP_KEY} resultUpdatedInterval=400`;
if (diarize) {
// 값에 공백을 포함하므로 AmiVoice의 서식에 따라 큰따옴표로 감쌉니다
...
확정 발화(A)의 JSON은 results[].tokens[].label에 화자 라벨을 가집니다. 한 발화 안에 여러 라벨이 섞여 있을 수도 있기 때문에, 최빈 라벨 (Most frequent label) 을 채택하여 해당 발화의 화자로 지정합니다.
function extractFinal(payload: unknown): { text: string; label?: string } {
const p = (payload ?? {}) as AmiVoiceFinal;
const counts = new Map<string, number>();
...
화자 키 (Speaker Key) 설계
앱 내부에서는 "음원 (source)"과 "화자 키 (speaker key)"를 분리하여 다루기로 했습니다.
source:self/partner… 어떤 인식 파이프라인인지 (상태 표시나 중간 결과 분류에 사용)speaker key: 최종적으로 발화에 부여할 화자 식별자- 자신 →
self - 상대방 → 화자 분리 라벨을 변환한
partner-0/partner-1…
- 자신 →
라벨 변환과 표시 이름·색상 지정은 lib/speakers.ts에 집약했습니다. 표시 이름은 설정되지 않았을 경우 自分 (나) / 相手1 (상대1) / 相手2 (상대2) … 와 같은 기본 이름을 반환합니다.
export function diarizerLabelToKey(label?: string | null): string {
const m = label ? /^speaker(\d+)$/.exec(label) : null;
return m ? `partner-${m[1]}` : "partner-0";
...
데이터 흐름으로 보면 다음과 같습니다.

자동 분리의 약점을 감도 조절과 수동 수정으로 보완하기
하나의 혼합 음성으로부터 추정하는 것이기에 화자 분리가 만능은 아닙니다. 목소리 톤이 비슷하거나 동시 발화가 많으면 정밀도가 떨어집니다. 실제로 테스트했을 때 "세 번째 사람이 나타나지 않음 (다른 사람이 동일 인물로 취급됨)" 이라거나 "한 사람이 여러 명으로 나뉨" 같은 현상도 있었습니다. 이는 UX(사용자 경험)로 흡수하기로 했습니다.
감도 조절 슬라이더
화자 분리의 diarizerAlpha는 "새로운 화자가 얼마나 쉽게 나타날지"를 제어하는 파라미터입니다. 값이 클수록 새로운 화자가 더 쉽게 나타납니다 (AmiVoice 기본값은 1e-10). 이를 녹음 화면의 슬라이더를 통해 α = 1e^{exp} 형태로 조절할 수 있도록 했습니다. 설정은 localStorage에 저장되어 다음번에도 이어집니다.
- 같은 사람이 다른 사람으로 너무 많이 나뉨 → "적게"로 조절
- 다른 사람이 같은 사람으로 취급되어 인원수가 부족함 → "많게"로 조절
주의할 점: 녹음 중에 감도를 변경하면 화면 공유 다이얼로그가 다시 표시됨
diarizerAlpha는 WebSocket의 시작 명령(Start Command)에서 결정되기 때문에, 변경하려면 재연결이 필요합니다. 하지만 단순히 "상대방 인식을 중단하고 재개"하면, getDisplayMedia가 다시 실행되어 회의 중에 화면 공유 선택 다이얼로그가 다시 나타나게 됩니다. 이는 치명적으로 방해가 됩니다.
해결책은 "스트림 획득"과 "인식 세션 시작"을 분리하는 것이었습니다. 이미 획득한 MediaStream을 유지하고, 인식 세션(WebSocket + AudioWorklet)만 다시 연결합니다. stop 시에 스트림의 트랙을 중단하지 않는 keepStreamOnStop 플래그를 준비하여, 재적용 시에는 스트림을 재사용합니다.
const reapplyPartner = useCallback(async () => {
const h = partnerHandleRef.current;
if (!h) return;
...
수동 수정
그럼에도 오분리(誤分離)는 남을 수 있으므로, 마지막에는 사람이 수정할 수 있도록 했습니다.
화자명 변경: 입력란에서 표시 이름을 변경하면 모든 발언에 반영 (PATCH /api/meetings/[id]를 통해 speakerLabels를 업데이트)
발언별 화자 교체: 각 발언의 셀렉트 박스에서 다른 화자를 선택. + 새로운 화자로 새로운 partner-N도 발행 가능 (PATCH /api/transcripts/[id])
이러한 UI 구성 요소(화자 배지, 이름 편집, 교체 셀렉트 박스)는 공통화하여, 녹음 화면과 회의 종료 후의 상세 화면 양쪽 모두에서 사용할 수 있도록 했습니다.

기능별 플로우
회의 시작부터 종료까지
회의 중 제공되는 역할별 피드백 (이 앱의 주인공)
여러 화자를 분리하는 것은 "정확하게 기록하기" 위한 기능이지만, 이 앱에서 가장 가치가 있다고 생각하는 점은 회의 중에 Claude가 "다음 단계"를 제안해 준다는 것입니다. 회의록은 회의가 끝난 뒤에도 만들 수 있지만, 제안은 그 자리에서 나오지 않으면 의미가 없습니다. 이 부분을 정성스럽게 구현했습니다.
무엇이 좋은가
상담 중에는 머릿속이 "다음에 무엇을 물어봐야 할까", "조건을 확인하는 것을 잊지는 않았나"로 가득 차게 됩니다. 이를 여러 전문적인 관점에서 동시에 보조해 주는 것이 역할별 피드백입니다.
영업: 클로징(Closing), 관계 구축, 제안의 설득력 관점
엔지니어: 기술적 실현 가능성, 공수, 리스크 관점
경영/PM/마케팅/디자이너: 각자의 전문적인 관점
회의 자리에 "영업 선배"나 "기술 상담 상대"가 동석하여 실시간으로 귓속말을 해주는 이미지입니다. 녹음 화면에서 "피드백 가져오기"를 누르면, 선택한 역할별로 탭 전환을 통해 제안이 표시됩니다.

원리: 역할의 "관점"을 시스템 프롬프트(System Prompt)에 주입하기
역할은 lib/roles.ts에 id / label / perspective(해당 직종 특유의 관점)로 정의해 두고, 선택된 역할의 perspective를 시스템 프롬프트에 삽입합니다. 출력 포맷도 역할별 블록으로 고정하여, 화면 측에서 탭으로 파싱하기 쉽게 만들었습니다.
// lib/roles.ts (발췌) — 선택된 역할의 관점을 불렛 포인트로 주입
const roleList = roles.map((r) => `- ${r.label}: ${r.perspective}`).join("\n");
return `당신은 상담 및 회의에 동석하는 유능한 어시스턴트입니다.
...
역할을 하나도 선택하지 않으면, 기존 형식(역할에 의존하지 않는)의 범용 제안으로 폴백(Fallback)합니다. 또한 회의 생성 시 입력한 **회의 목적(메타 정보)**도 contextSection()을 통해 프롬프트에 삽입하여, 엉뚱한 제안을 줄이고 있습니다.
실시간 성능을 위한 고안
최근 로그만 전달: 매번 전체 문장을 보내면 느려지고 비용이 많이 들기 때문에, recentLimit(기본 40개 발언)을 설정하여 최근 대화로 범위를 좁혀 보냅니다. 회의가 길어져도 제안의 응답성이 떨어지지 않습니다.
프롬프트 캐싱 (Prompt Caching): 시스템 프롬프트에 cache_control: { type: "ephemeral" }를 부여합니다. 회의 중에는 동일한 시스템 프롬프트로 여러 번 제안을 가져오기 때문에, 캐시가 적용되어 레이턴시(Latency)와 비용을 줄일 수 있습니다.
화자명 반영: 프롬프트에 전달하는 대화 로그는 화자 키를 표시 이름으로 변환하므로, "상대방 1이 예산에 대해 언급"하는 것과 같이 누구의 발언인지를 고려한 제안이 됩니다.
가져온 제안은 ClaudeFeedback으로서 이력에 남기며, 회의 종료 시의 회의록 생성에도 "회의 중 메모"로서 전달합니다. 회의 중의 깨달음이 최종 결과물에도 이어지도록 설계했습니다.
회의록의 백그라운드 생성
회의록 생성은 시간이 걸리기 때문에 사용자를 기다리게 하지 않는 설계로 만들었습니다. Next.js의 after()를 사용하여, 응답(202)을 보낸 후에 생성을 실행합니다. 상태는 Meeting.summaryStatus (processing / done / error)로 관리하며, 상세 화면은 폴링(Polling)을 통해 완료를 감지하여 자동으로 표시합니다.
// app/api/claude/summary/route.ts (발췌)
await prisma.meeting.update({ where: { id }, data: { summaryStatus: "processing" } });
after(async () => {
...
프롬프트(Prompt)에 전달할 대화 로그는 화자 키(Speaker Key)를 표시 이름으로 변환한 후 정형화합니다. 이를 통해 회의록이나 제안에서도 "상대방 1" 또는 이름이 변경된 이름이 사용됩니다.
function transcriptsToText(transcripts, labels) {
return transcripts.map((t) => `${speakerName(t.speakerType, labels)}: ${t.text}`).join("\n");
}
데이터 모델
화자와 관련하여 추가된 것은 Meeting의 speakerLabels입니다.
(화자 키 → 표시 이름의 JSON 문자열)입니다. 발언의 화자는 Transcript.speakerType에 self / partner-N으로 저장됩니다.
화자 이름을 별도 테이블로 만들지 않고 Meeting의 JSON 열로 만든 이유는, SQLite + Prisma의 경량 운용으로도 충분했고, "회의 단위의 화자 이름 맵"이라는 성격이 JSON과 궁합이 좋았기 때문입니다. 발언의 화자 변경은 speakerType의 UPDATE로, 이름 변경은 speakerLabels의 UPDATE로 완결됩니다.
설계 판단과 배움
- 음원 기반 분리는 빠르지만 얕다: 자신/상대방의 2진법적 구분이라면 음원으로 충분합니다. 하지만 "상대방이 여러 명"이 되는 순간 한계에 부딪힙니다. 처음부터 "화자는 몇 명이라도 늘어날 수 있다"는 전제로 키 설계를 했어야 했습니다 (중간에
"self" | "partner"타입을 문자열 키로 다시 만들었습니다). - 자동 + 수동의 하이브리드가 현실적인 해답: 하나의 믹싱된 음성에서 분리하는 것은 원리적으로 한계가 있습니다. 완벽을 목표로 하기보다, 감도 조절과 수동 수정을 통해 사람이 고칠 수 있는 동선을 마련하는 것이 더 실용적입니다.
- 실시간 UX의 디테일: "재연결될 때마다 화면 공유 다이얼로그가 뜨는" 것과 같은 디테일이 경험을 해칩니다. 스트림(Stream)과 인식 세션의 라이프사이클(Lifecycle)을 분리하여 이를 회피했습니다.
- 기다리게 하지 않는 LLM 연동: 무거운 생성 작업은
after()를 통해 백그라운드(Background)화하고, 상태를 상태 열(Status Column)로 관리하면 UX가 안정됩니다.
요약
이 앱의 주인공은 회의 중에 실시간으로 효과를 발휘하는 역할별 피드백입니다. 역할의 "관점"을 시스템 프롬프트(System Prompt)에 주입하고, 최근 로그만 전달하며, 프롬프트 캐시(Prompt Cache)로 응답성을 확보함으로써, 상담 중에 여러 전문적인 관점이 귓속말을 해주는 듯한 경험을 구현했습니다.
그리고 그 제안을 뒷받침하는 "정확한 기록"을 위해, "나와 상대방"밖에 구분할 수 없었던 전사(Transcription)를 AmiVoice의 화자 다이아라이제이션(Speaker Diarization)을 통해 다중 화자 대응으로 만들었고, 자동 분리의 약점을 감도 슬라이더와 수동 수정으로 보완했습니다. 브라우저에서 직접 WebSocket으로 실시간 인식하고, LLM 연동은 서버 측에서 백그라운드 처리하는 역할 분담으로 정착되었습니다.
마찬가지로 "회의 중에 LLM의 보조를 받고 싶다", "온라인 회의의 시스템 음성을 분석하고 싶다", "실시간 전사 + LLM을 구현하고 싶다"는 분들에게 참고가 되기를 바랍니다.
소스 코드는 GitHub에 공개되어 있습니다.
Discussion

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