사용자가 말하면 AI가 음성으로 답하는 실시간 에이전트, 막상 만들려고 하면 트랜스크립트가 깨져서 나오는 순간 막막해집니다. 이 글은 Agora Conversational AI로 음성 AI를 구축하는 전 과정을 따라갑니다. LLM과 TTS 키를 서버에만 두는 토큰 설계부터, RTC로 받은 트랜스크립트를 여러 프레임으로 쪼개진 채 도착하는 청크에서 다시 조립하는 방법, RTM 텍스트 전송, 그리고 에이전트 상태 표시와 마이크 권한 타이밍 같은 실전 노하우까지 코드로 정리했습니다.
하나의 토큰에 RTC 권한(채널 참여, 오디오/비디오 퍼블리시)과 RTM 권한(로그인)이 함께 포함됩니다. RTM으로 사용자 텍스트를 전송하려면 이 토큰이 RTC와 RTM 권한을 모두 담고 있어야 하며, 동시에 join 페이로드에 properties.advanced_features.enable_rtm: true가 설정되어야 RTM 텍스트 경로가 활성화됩니다(아래 ②, 4단계 참고).
주의: API 인증에 쓰는 Customer ID/Secret과 properties.token에 넣는 RTC(v007) 토큰은 별개입니다. 전자는 어떤 계정이 API를 호출하는지 인증하고, 후자는 에이전트가 RTC/RTM 채널에 참여할 권한을 부여합니다. Authorization: agora token=... 형식을 join 호출에 쓰면 401로 실패합니다.
주의: JSON.parse(raw) 직접 파싱은 동작하지 않습니다. 반드시 |로 분리 → 청크 누적 → atob() → JSON.parse() 순서로 처리해야 합니다.
4단계: RTM 텍스트 메시지 전송
RTM은 텍스트 전송 전용입니다. 수신은 RTC stream-message를 통해 에이전트가 에코해줍니다.
전제 조건: 이 경로가 동작하려면 (1) join 페이로드에 advanced_features.enable_rtm: true가 있어야 하고, (2) rtm.login()에 넘기는 토큰이 RTM 권한을 포함해야 합니다(1단계 ① 토큰 생성 참고). 둘 중 하나라도 빠지면 메시지가 에이전트에 전달되지 않습니다.
// 볼륨을 100ms 간격으로 폴링해 talking 상태 감지const volumeInterval = setInterval(() => {
const volume = remoteAudioTrack.getVolumeLevel();
if (volume > 0) setAgentState("talking");
elsesetAgentState("listening");
}, 100);
// ⚠️ 반드시 정리: 메모리 누수 방지return() =>clearInterval(volumeInterval);
2. 트랜스크립트 실시간 업데이트 (스트리밍 느낌 주기)
LLM 응답은 한 번에 오지 않고 isFinal: false → isFinal: true 순서로 옵니다. 중간 상태를 표시하면 사용자는 AI가 "생각하고 있다"는 느낌을 받습니다.
// turn_id 기준으로 메시지를 in-place 업데이트setMessages(prev => {
const existing = prev.find(m => m.id === turnId);
if (existing) {
// 기존 메시지 텍스트 업데이트 (append하지 않음)return prev.map(m =>
m.id === turnId ? { ...m, text: transcript, isFinal } : m
);
}
return [...prev, { id: turnId, role, text: transcript, isFinal }];
});
isFinal: false인 메시지는 바운싱 점(...)과 낮은 opacity로 표시해 "아직 말하는 중"임을 나타냅니다.
3. 마이크 권한 요청 타이밍
마이크 권한은 사용자가 직접 Start 버튼을 누른 직후 요청해야 합니다. 페이지 로드 시 자동 요청하면 브라우저가 차단하거나 사용자가 거부할 가능성이 높습니다.
// ✅ 올바른 방식: 버튼 클릭 핸들러 내부에서 요청asyncfunctionhandleStartCall() {
// 권한 요청이 createMicrophoneAudioTrack() 내부에서 자동 발생const audioTrack = awaitAgoraRTC.createMicrophoneAudioTrack({
AEC: true, // 에코 제거 (스피커 소리가 마이크로 다시 들어가는 것 방지)ANS: true, // 노이즈 억제 (키보드 소리, 주변 소음 제거)AGC: true, // 자동 게인 조절 (목소리가 너무 작거나 클 때 자동 조절)
});
}
// ❌ 피해야 할 방식: 페이지 로드 시 미리 요청useEffect(() => {
navigator.mediaDevices.getUserMedia({ audio: true }); // 사용자 경험 저하
}, []);
4. 연결 중 UI 비활성화
에이전트가 연결되기 전에 설정을 변경하거나 메시지를 보내는 것을 막아야 합니다.
// 텍스트 입력: 연결된 상태에서만 노출
{isConnected && (
<divclassName="text-input-area"><inputplaceholder="메시지 입력..." /><button>전송</button></div>
)}
// System Prompt / 인사말 설정: 연결 중에는 비활성화
<button disabled={isConnected} onClick={openSettings}>
설정
</button>
에이전트는 일정 시간(idle_timeout) 동안 채널에 사용자가 없으면 자동 종료됩니다. 사용자에게 미리 알려주지 않으면 "갑자기 끊겼다"는 인상을 줍니다.
[NEEDS VERIFICATION] 기본 idle_timeout 값과 단위는 API 버전/모드에 따라 다릅니다. 일반 모드에서는 초(예: 30초) 단위로, OpenAI Realtime 연동 시에는 밀리초 단위로 해석되는 등 차이가 있으므로, 운영 전 Start a conversational AI agent REST API 문서에서 현재 기본값과 단위를 반드시 확인하세요. 또한 idle_timeout은 remote_rtc_uids에 지정된 사용자가 모두 채널을 떠난 뒤부터 카운트됩니다.
// user-left 이벤트 = 에이전트가 채널에서 나갔음
rtcClient.on("user-left", (user) => {
if (String(user.uid) === AGENT_UID) {
setAgentState("disconnected");
showToast("대화가 종료되었습니다. (일정 시간 동안 대화가 없었습니다)");
}
});
timeout 시간은 start-agent 페이로드에서 조절:
idle_timeout: 300, // 5분으로 연장
7. 리소스 정리 (메모리 누수 방지)
컴포넌트 언마운트 또는 연결 종료 시 반드시 모든 리소스를 해제합니다.
asyncfunctionleaveChannel() {
// 1. 타이머 정리clearInterval(volumeIntervalRef.current);
clearTimeout(deferredAudioFallbackRef.current);
// 2. RTM 로그아웃await rtmClient.logout();
// 3. 마이크 트랙 닫기 (하드웨어 해제)
localAudioTrack.close(); // 이걸 빠뜨리면 탭을 닫을 때까지 마이크 표시등이 켜진 채로 남음// 4. RTC 채널 퇴장await rtcClient.leave();
// 5. 상태 초기화setMessages([]);
setIsConnected(false);
}
8. SDK 동적 import (SSR 안전)
Agora SDK는 window, navigator 등 브라우저 API에 의존합니다. Next.js 같은 SSR 환경에서 파일 상단에 import하면 서버에서 크래시가 발생합니다.
// ❌ 파일 상단 정적 import — SSR에서 크래시importAgoraRTCfrom"agora-rtc-sdk-ng";
// ✅ async 함수 내부에서 동적 importasyncfunctionjoinChannel() {
constAgoraRTC = (awaitimport("agora-rtc-sdk-ng")).default;
const { default: AgoraRTM } = awaitimport("agora-rtm");
// ...
}
관련 글
#2 Agora RTC 실시간 화상통화 구현 — ConvoAI의 음성 채널은 결국 RTC 위에서 동작한다. join/publish/subscribe 기초가 그대로 적용된다.
#3 RTM 실시간 채팅 시스템 구축 — 이 글의 "텍스트 전송 전용 RTM"을 제대로 이해하려면 RTM 로그인·publish 모델을 먼저 알아야 한다.