블로그 목록
실시간 통신20분 읽기

전화 음성에서 웹 자막까지 7 — Agora STT·Signaling 자막과 UID·토큰·말풍선

RTC Source·Agent·Signaling Viewer의 식별자와 토큰 권한을 나눕니다. UID·relay 관련 과거 설명을 실제 MESSAGE 시험으로 정정하고 3초·60초·final 자막 정책을 연습합니다.

PBX 입문Agora STTSignalingAgoraSTTUIDToken
목차(29개 항목)
  1. 0. 결론부터 — 음성은 RTC, 자막은 Signaling으로 이동한다
    1. 이번 편의 안전한 실습 범위
  2. 1. 개념 하나 — UID는 이름표지만 이름표가 붙는 공간이 다르다
    1. 실습 1 — 질문을 시스템별로 분류하기
    2. 흔한 오류
  3. 2. 개념 하나 — 토큰은 권한 증명이고, 메시지 전송은 동작이다
    1. 실습 2 — 시작 코드를 동작 단위로 읽기
    2. 결과 해석
    3. 흔한 오류
  4. 3. 개념 하나 — 같은 채널 문자열은 연결 단서이지 같은 채널 객체가 아니다
    1. 실습 3 — 63자 경계를 계산하기
    2. 결과 해석
  5. 4. 개념 하나 — Signaling MESSAGE는 방송 채널이고 USER는 개별 사용자 경로다
    1. 구체적인 예 — 전화 한 통의 자막을 브라우저 두 개에서 보기
    2. 실습 4 — 증거가 말할 수 있는 범위 표시하기
    3. 깨끗한 smoke와 라우팅 probe를 섞지 않는다
  6. 5. 개념 하나 — partial은 덧붙이지 않고 같은 turn의 최신 상태로 교체한다
    1. 실습 5 — 세 이벤트를 손으로 reduce하기
    2. 흔한 오류
  7. 6. 개념 하나 — final은 문장 확정이고 말풍선 종료는 UI 정책이다
    1. 실습 6 — 종료 시각 계산하기
    2. 누적 turn이 60초 경계를 넘는 경우
    3. 말풍선 상태 읽기
  8. 7. 전체 오프라인 실습 — 코드와 증거만으로 데이터 흐름 설명하기
    1. 기대 결과와 해석
  9. 8. 실제 화면을 사용할 때의 주의점
  10. 9. 자주 틀리는 설명을 바로잡기
    1. 추가 확인 질문
  11. 10. 직접 실행하기 전 체크리스트

전화 음성이 PCM으로 바뀌어 Agora RTC 채널에 들어왔다고 끝이 아닙니다. 사람이 읽는 자막이 되려면 음성을 인식하는 Agora STT Agent와, 그 결과를 웹으로 전달하는 Agora Signaling이 더 필요합니다.

코드·JSON 조회에는 별도 원본 프로젝트가 필요합니다. 블로그 저장소에는 pbx_gateway/ 소스와 증거 파일이 포함돼 있지 않습니다. 원본이 없다면 파일 조회를 건너뛰고 본문 예시와 계산을 따라가세요. 실습 준비 조건을 먼저 확인하세요.

이번 글에서는 이 마지막 구간을 초보자의 눈높이에서 읽습니다. 실제 인증값을 사용하거나 Agent를 실행하지 않고, 저장소의 코드와 검증 JSON만으로 다음 질문에 답합니다.

  • RTC UID와 Signaling UID는 왜 따로 있는가?
  • 토큰을 만들면 자막이 전송되는가?
  • partial, final, turn_id는 화면에서 어떻게 처리되는가?
  • 과거의 “Source UID로 로그인해야 한다”, “USER 메시지만 온다”, “relay가 필수다”라는 설명은 왜 정정됐는가?

이 글은 시리즈의 7편입니다. 전체 지도는 #58 전화 음성 자막 시스템 입문, 바로 앞의 PCM 전달은 #63 PCM을 Agora RTC로 전달하고 녹음 증거 읽기, 계층별 장애 분석은 #65 전화 자막 장애를 증거로 좁히는 법에서 이어집니다.


0. 결론부터 — 음성은 RTC, 자막은 Signaling으로 이동한다

현재 구현의 기본 경로는 다음과 같습니다.

전화 음성
  → Asterisk와 Python Gateway
  → PCM
  → Agora RTC: Source UID 70001이 음성 publish
  → Agora STT Agent UID 70011이 음성 인식
  → Agora Signaling의 같은 이름 MESSAGE 채널에 자막 publish
  → Viewer Signaling UID 99999가 subscribe
  → 웹 말풍선

여기서 가장 중요한 사실은 RTC와 Signaling이 서로 다른 서비스라는 점입니다.

구분이 시스템에서 하는 일기본 식별자
RTC실시간 음성을 운반Source 70001, Agent 70011
Signaling자막 JSON을 전달Agent 70011, Viewer 99999
Agora STT REST APIAgent 실행 인스턴스를 시작·종료runtime Agent ID

RTC 채널과 Signaling 채널에 같은 문자열을 쓰더라도 두 채널이 하나로 합쳐지는 것은 아닙니다. 웹은 RTC에 들어가지 않고 Signaling에 로그인한 뒤 메시지 채널을 별도로 구독합니다. Agora의 Signaling 2.x도 메시지 수신을 Pub/Sub 모델의 subscribe로 설명합니다. 플랫폼 탭을 바꿔 같은 개념을 확인할 수 있습니다. Agora Signaling 2.x migration guide

본문에서는 서비스 이름을 Agora Signaling으로 표기합니다. 코드의 RTM, RtmTokenBuilder, enable_rtm, data_channel: "rtm" 등은 실제 SDK·API 식별자이므로 그대로 둡니다.

이번 편의 안전한 실습 범위

이번 실습은 다음 읽기와 계산만 합니다.

허용: TypeScript 코드 읽기, JSON 증거 읽기, UID 표 만들기, 시간 계산
제외: .env 읽기, 토큰 발급, API 호출, Agent 시작, VM 접속, 실통화

즉 이 글에서 “검증됐다”는 말은 기존 증거 파일이 보여 주는 범위만 뜻합니다. 독자가 새 실통화를 완료했다는 뜻이 아닙니다.


1. 개념 하나 — UID는 이름표지만 이름표가 붙는 공간이 다르다

전화번호, RTC UID, Signaling UID, Agent ID가 모두 숫자나 문자열이라 처음에는 같은 종류로 보입니다. 그러나 각 시스템이 관리하는 이름 공간이 다릅니다.

값이름누가 사용하나무엇을 식별하나
1001SIP 계정Linphone, Asterisk전화기의 등록·인증 계정
7000PBX 내선Asterisk dialplan전화를 걸 목적지
70001Source RTC UIDGateway, Agora RTC전화 음성을 게시하는 RTC 사용자
70011Agent RTC/Signaling UIDAgora STT Agent음성을 듣고 자막을 게시하는 Agent 사용자
99999Viewer Signaling UID브라우저, Agora Signaling자막 채널을 구독하는 웹 사용자
A44... 같은 값runtime Agent IDAgora STT REST API이번에 실행한 Agent 작업 인스턴스
SSRCRTP 헤더RTP 송수신기한 RTP 패킷 흐름의 출처

7000으로 전화한다고 RTC UID가 자동으로 7000이 되지 않습니다. 70001과 70011이 10 차이인 것도 프로토콜 규칙이 아니라 현재 구현에서 알아보기 쉽게 정한 값입니다.

실습 1 — 질문을 시스템별로 분류하기

다음 질문의 빈칸을 채워 보세요.

1. 전화기가 누구로 등록했나?        → (        )
2. 어느 전화 목적지로 발신했나?     → (        )
3. RTC에서 누가 음성을 publish하나? → (        )
4. 누가 음성을 인식하나?            → (        )
5. 웹은 누구로 Signaling 로그인하나?      → (        )

기대 결과는 1001, 7000, 70001, 70011, 99999입니다.

이 표를 만들 수 있으면 “UID가 다르니 연결 실패”라는 성급한 결론을 피할 수 있습니다. 먼저 어떤 서비스의 UID인지 물어야 합니다.

흔한 오류

  • 7000과 70001을 같은 번호의 다른 표기라고 생각한다.
  • RTC UID와 Signaling UID가 숫자로 같아야 같은 사용자라고 생각한다.
  • runtime Agent ID를 RTC 입장 UID라고 생각한다.
  • SSRC를 애플리케이션 사용자 UID로 사용한다.

2. 개념 하나 — 토큰은 권한 증명이고, 메시지 전송은 동작이다

토큰을 출입증에 비유할 수 있습니다. 출입증을 발급받았다고 사람이 회의실에 들어가 발표까지 마친 것은 아닙니다.

전체 경로에는 Gateway용 RTC 토큰, Agent용 RTC·Signaling 복합 토큰, Viewer용 Signaling 토큰이 있습니다. 웹에서 시작할 때 요청하는 것은 Agent와 Viewer 토큰이며, Gateway 토큰은 별도 실행 준비 과정에서 생성합니다.

대상코드의 생성 방식묶이는 값역할
GatewayRtcTokenBuilder.buildTokenWithUid(...)RTC 채널, Source UID 70001Gateway의 RTC 음성 게시 권한
Agora STT AgentbuildTokenWithRtm(...)채널, Agent UIDAgent의 RTC publish와 Signaling 사용 권한
Viewer 웹RtmTokenBuilder.buildToken(...)Viewer UIDViewer의 Signaling 로그인 권한

Agent 토큰에 RTC와 Signaling 권한이 함께 들어 있어도 이 함수가 자막을 보내는 것은 아닙니다. 실제 게시 동작은 enable_rtm: true, data_channel: "rtm"으로 시작된 Agora STT Agent가 담당합니다.

Viewer도 다음 세 단계가 모두 필요합니다.

1. Viewer UID 99999용 Signaling 토큰 발급
2. new RTM(appId, "99999") 후 그 토큰으로 login
3. 원하는 메시지 채널 subscribe

Viewer 토큰의 사용자와 실제 로그인 UID는 일치해야 합니다. 반면 “어느 채널의 자막을 받을지”는 로그인 뒤 subscribe(channel)가 결정합니다. 토큰 생성 API를 호출하는 것 자체는 메시지 수신도, 채널 구독도 아닙니다.

Agora도 토큰을 사용자의 신원과 권한을 인증하는 짧은 수명의 키로 설명하며, App Certificate를 클라이언트에 넣지 않고 서버에서 발급하는 흐름을 권장합니다. Agora token server guide

실습 2 — 시작 코드를 동작 단위로 읽기

실제 페이지의 시작 순서를 축약하면 다음과 같습니다. 아래 코드는 개념 설명용 축약본입니다.

const [viewerToken, agentToken] = await Promise.all([
  getViewerRtmToken(viewerUid),
  getAgentRtcRtmToken(channel, agentUid),
]);

const rtm = new RTM(appId, viewerUid);
rtm.addEventListener("message", onMessage);

await rtm.login({ token: viewerToken });
await rtm.subscribe(channel, { withMessage: true });

const { agent_id } = await startV2vAgent({
  channel,
  token: agentToken,
  remote_rtc_uids: [sourceUid],
});

다음 순서를 종이에 적어 보세요.

토큰 발급 → listener 등록 → Signaling 로그인 → Signaling subscribe → Agent 시작

기대 결과는 “subscribe가 Agent 시작보다 먼저 끝난다”입니다. 이 순서는 Agent가 시작 직후 게시하는 메시지를 웹이 놓칠 가능성을 줄입니다.

결과 해석

  • 토큰 발급 성공: 인증 재료가 준비됐다는 뜻입니다.
  • Signaling 로그인 성공: Viewer 신원으로 Signaling 서비스에 연결했다는 뜻입니다.
  • subscribe 성공: 특정 채널 메시지를 받을 준비가 됐다는 뜻입니다.
  • agent_id 응답: Agora STT가 시작 요청을 수락했다는 뜻입니다.
  • 첫 유효 자막 수신: Gateway → RTC → Agora STT ASR → Signaling → 브라우저 경로가 실제 데이터로 이어졌다는 뜻입니다.

따라서 agent_id만 표시된 화면을 “자막 성공”이라고 보고하면 안 됩니다.

흔한 오류

  • App Certificate를 브라우저 코드에 넣는다.
  • Viewer UID용 토큰을 받아 다른 UID로 로그인한다.
  • Signaling 로그인만 하고 채널을 구독하지 않는다.
  • Agent부터 시작하고 나중에 listener와 subscribe를 붙인다.
  • 토큰 생성 함수를 메시지 송신 함수라고 설명한다.

3. 개념 하나 — 같은 채널 문자열은 연결 단서이지 같은 채널 객체가 아니다

현재 설정은 RTC와 Signaling에 같은 채널 문자열을 사용합니다.

RTC namespace                     Signaling namespace
pbx-gateway-test-20260928         pbx-gateway-test-20260928
  70001 ──audio──▶ 70011            70011 ──caption──▶ 99999

이름이 같아서 운영자가 한 통화 경로를 추적하기는 쉽습니다. 하지만 RTC 입장만으로 Signaling 구독이 자동 생성되지는 않습니다.

현재 Agora STT 요청의 핵심 필드는 다음과 같습니다.

{
  preset: "v2vt_base",
  properties: {
    channel,
    token,
    agent_rtc_uid: "70011",
    agent_rtm_uid: "70011",
    remote_rtc_uids: ["70001"],
    idle_timeout: 300,
    advanced_features: { enable_rtm: true },
    parameters: { data_channel: "rtm" },
    asr: { language: "ko-KR" },
    translation: { language: "ko-KR" },
    tts: { enable: false },
  },
}

이것은 실제 session-config.ts 구조를 읽기 쉽게 줄인 것입니다. idle_timeout: 300은 Agent 설정이고, 뒤에서 설명할 웹 말풍선의 3초 inactivity와 다른 타이머입니다.

실습 3 — 63자 경계를 계산하기

현재 웹과 Agent 토큰 API의 정규식은 채널 이름을 최대 64자로 허용합니다. 그러나 Gateway와 토큰 파일 생성기는 63자까지만 허용합니다. 통합 경로의 안전한 상한은 더 좁은 쪽인 63자입니다.

const channel = "pbx-" + "a".repeat(60);
console.log(channel.length);

기대 출력은 64입니다. 웹 입력 검증은 통과할 수 있지만 전체 PBX 통합 경로에서는 실패할 수 있는 값입니다. "a".repeat(59)로 바꾸면 전체 길이는 63입니다.

결과 해석

한 컴포넌트의 입력 검증 통과가 전체 시스템의 호환성을 보장하지 않습니다. 여러 컴포넌트의 제약이 다르면 교집합을 계약으로 삼아야 합니다.

4. 개념 하나 — Signaling MESSAGE는 방송 채널이고 USER는 개별 사용자 경로다

과거 설명에는 세 가지 잘못된 결론이 있었습니다.

  1. 웹은 Source RTC UID와 같은 UID로 Signaling 로그인해야 한다.
  2. Agora STT 자막은 USER 직접 메시지로만 온다.
  3. 여러 Viewer에게 보내려면 별도 relay가 필수다.

내부 시험 기록 요약에는 이 설명과 다른 결과가 보고돼 있습니다. 아래 수치는 기존 초안에 인용된 내부 관찰값입니다. 원본 JSON과 실행 버전은 이 블로그에 공개돼 있지 않아 이번 편집에서 독립적으로 대조하지 못했습니다. 재현 가능한 공개 검증 결과나 서비스 보장값으로 사용하지 마세요.

probe 역할UID
Source RTC70003
Agora STT Agent RTC/Signaling70013
비교 수신자 ASignaling 70003
독립 Viewer BSignaling 99999

두 수신자가 같은 이름의 Signaling 메시지 채널을 구독하자 각각 다음 결과를 받았습니다.

수신자 70003: Agent 메시지 269건, final 18건
수신자 99999: Agent 메시지 269건, final 18건
channelType: MESSAGE
publisher: 70013
relayUsed: false
identicalMessages: true

이 내부 관찰 요약이 보고하는 동작은 다음과 같습니다.

자막을 보는 브라우저는 음성을 보내는 Gateway와 다른 ID를 사용할 수 있습니다. 브라우저는 자신의 ID로 발급된 Signaling 토큰으로 로그인하고, Agora STT가 자막을 게시하는 채널을 구독합니다.

여기서 일치해야 하는 것은 브라우저의 로그인 UID와 토큰을 발급할 때 지정한 UID입니다. Gateway의 RTC UID를 브라우저 로그인에 복사하라는 뜻이 아닙니다. UID는 “누가 접속하는가”, 구독 채널은 “어느 자막을 받는가”를 구분합니다.

구체적인 예 — 전화 한 통의 자막을 브라우저 두 개에서 보기

아래는 위 내부 관찰을 이해하기 위한 설명용 구성입니다. 채널 이름 phone-caption-demo와 발화는 예시이며, 새로 실행한 시험 결과가 아닙니다. 모든 참여자는 같은 Agora 프로젝트를 사용하고, 토큰은 유효하며 필요한 접근 권한이 있다고 가정합니다.

참여자사용하는 ID하는 일
전화 음성을 보내는 GatewayRTC UID 70003RTC 채널 phone-caption-demo에 음성 게시
Agora STT AgentAgent UID 70013Source 70003의 음성을 인식하고 Signaling 채널 phone-caption-demo에 자막 게시
브라우저 ASignaling UID 7000370003용 Signaling 토큰으로 로그인하고 자막 채널 구독
브라우저 BSignaling UID 9999999999용 Signaling 토큰으로 로그인하고 같은 자막 채널 구독
전화에서 "안녕하세요"라고 말함
  → Gateway (RTC UID 70003)가 음성을 보냄
  → Agora STT Agent (UID 70013)가 자막을 만듦
  → Signaling MESSAGE 채널: phone-caption-demo
      ├─ 브라우저 A (Signaling UID 70003): "안녕하세요"
      └─ 브라우저 B (Signaling UID 99999): "안녕하세요"

브라우저 A의 ID가 Gateway와 같은 것은 이 비교 구성의 선택일 뿐, 자막 수신의 필수 조건이 아닙니다. 브라우저 B는 다른 ID를 쓰지만 동일한 자막 게시 채널을 구독하므로 같은 자막 메시지를 받는 구성입니다. 여기서 “같은 자막”은 Agent가 게시한 같은 메시지를 뜻하며, 두 화면에 정확히 같은 시각에 표시된다는 보장은 아닙니다.

브라우저 B의 설정을 한 줄씩 풀면 다음과 같습니다. 아래는 API 요청 본문이 아닌 설정 설명입니다.

Agora 프로젝트: Gateway·Agent와 같은 프로젝트
내 Signaling 로그인 UID: 99999
내 로그인 토큰: 서버에서 UID 99999용으로 발급한 Signaling 토큰
내가 구독할 Signaling MESSAGE 채널: phone-caption-demo
자막 속 음성의 출처: Source RTC UID 70003

99999는 자막을 받는 나의 ID이고, 70003은 자막으로 바뀌는 음성의 출처입니다. 시청자 ID가 달라져도 이 예제의 음성 출처는 그대로입니다. 현재 웹의 메시지 검증에서도 시청자 UID가 아닌 설정된 Source UID와 Agent UID를 기준으로 자막 출처를 확인합니다.

브라우저 B 설정해석
로그인 99999 + 99999용 Signaling 토큰 + 올바른 채널 구독이 예제에서 의도한 자막 수신 구성
로그인 99999 + 70003용 Signaling 토큰로그인 신원과 토큰의 UID가 불일치
로그인 99999 + 99999용 토큰 + 다른 채널 구독로그인했어도 이 전화의 자막을 받는 구성이 아님
로그인만 하고 채널을 구독하지 않음로그인 성공만으로 이 채널의 자막 수신을 확인할 수 없음

RTC 채널과 Signaling 채널에 같은 이름을 쓴 것은 이 프로젝트의 구성입니다. 두 서비스의 채널이 자동으로 연결되는 것은 아니며, Agent의 음성 구독·자막 게시와 브라우저의 자막 구독이 각각 설정돼 있어야 합니다.

파서가 호환성을 위해 USER 타입도 받아들이는 것은 “현재 Agora STT가 USER로 보낸다”는 증거가 아닙니다. 구현이 허용하는 입력 범위와 실제 관찰값을 구분해야 합니다.

실습 4 — 증거가 말할 수 있는 범위 표시하기

위 내부 관찰 요약을 전제로 아래 문장을 기록상 관찰됨, 확인 범위 밖, 기록과 맞지 않음으로 분류해 보세요. 원본 증거의 독립 검증과는 별개의 읽기 연습입니다.

A. 독립 Viewer 99999도 같은 MESSAGE 자막을 받았다.
B. relay가 없으면 여러 Viewer가 절대 받을 수 없다.
C. 100명의 Viewer가 동시에 안정적으로 받을 수 있다.
D. 이 probe에서 음질과 전체 지연이 모두 합격했다.

기대 결과는 다음과 같습니다.

문장판정이유
A기록상 관찰됨두 UID가 각각 269건, final 18건을 수신
B기록과 맞지 않음relayUsed: false인데 두 수신자가 수신
C확인 범위 밖수신자 두 명 비교이며 100명 부하 시험이 아님
D확인 범위 밖해당 실행에 sink_errors=131, live_drops=94가 존재

특히 269건은 269명의 사용자나 269개의 완성 문장을 뜻하지 않습니다. partial 갱신을 포함한 Agent 메시지 수입니다. 확정 메시지는 18건입니다.

깨끗한 smoke와 라우팅 probe를 섞지 않는다

기본 UID를 쓴 별도의 fixture smoke는 Source 70001, Agent 70011에서 Gateway 3,049프레임 입력·출력, sink_errors=0, live_drops=0, 브라우저 자막 138건, final turn 9건을 기록했습니다. 그러나 이것도 새 Linphone 실통화가 아니라 저장된 한국어 fixture를 주입한 시험입니다.

반면 UID 분리 probe는 라우팅 정정에는 유효하지만 오디오 오류가 있었습니다. 좋은 검증 보고서는 두 시험을 합쳐 “모든 것이 완벽했다”고 만들지 않습니다.

5. 개념 하나 — partial은 덧붙이지 않고 같은 turn의 최신 상태로 교체한다

음성 인식은 말이 끝날 때까지 문장을 여러 번 수정합니다.

turn 42, partial: "안"
turn 42, partial: "안녕"
turn 42, final:   "안녕하세요"

세 문자열을 이어 붙이면 안안녕안녕하세요가 됩니다. 그래서 같은 turn_id는 최신 텍스트로 교체합니다.

메시지 파서는 자막으로 인정하기 전에 다음 조건을 확인합니다.

  • 현재 MESSAGE 채널이 맞는가?
  • publisher가 Agent UID인가?
  • JSON이며 object === "user.transcription"인가?
  • stream_id가 기대한 Source UID인가?
  • turn_id, 비어 있지 않은 text, boolean final이 있는가?

통과하면 자막 key를 ${sourceUid}:${turnId}로 만듭니다. 서로 다른 Source가 우연히 같은 turn ID를 사용해도 충돌하지 않게 하려는 조합입니다.

실습 5 — 세 이벤트를 손으로 reduce하기

const messages = [
  { turn_id: 42, text: "안", final: false },
  { turn_id: 42, text: "안녕", final: false },
  { turn_id: 42, text: "안녕하세요", final: true },
];

const turns = new Map();
for (const message of messages) {
  turns.set(message.turn_id, message);
}
console.log([...turns.values()]);

기대 결과는 turn_id: 42, text: "안녕하세요", final: true인 항목 하나입니다.

실제 구현은 여기에 더해 다음 방어 규칙을 적용합니다.

  • 완전히 같은 중복은 상태와 타이머를 바꾸지 않는다.
  • 이미 final인 turn에 늦게 온 partial은 무시한다.
  • 닫힌 turn key를 최근 1,000개까지 기억해 뒤늦은 중복을 막는다.
  • 화면 말풍선은 최근 100개까지만 보관한다.

흔한 오류

  • partial을 모두 이어 붙인다.
  • final: true를 전체 대화 종료로 해석한다.
  • turn_id만 key로 써서 여러 Source의 turn을 충돌시킨다.
  • publisher와 stream_id를 검증하지 않고 모든 JSON을 자막으로 표시한다.

6. 개념 하나 — final은 문장 확정이고 말풍선 종료는 UI 정책이다

현재 웹은 세 가지 서로 다른 경계를 사용합니다.

경계의미
final: true해당 ASR turn의 텍스트 확정
마지막 유효 자막 후 3초현재 말풍선을 닫는 inactivity 정책
말풍선 생성 후 60초계속 갱신돼도 강제로 나누는 hard cap

말풍선 종료 시각은 다음 계산입니다.

closeAt = Math.min(
  lastMeaningfulUpdate + 3_000,
  bubbleCreatedAt + 60_000,
);

3초는 마이크의 실제 무음을 감지한 결과가 아닙니다. VAD도 아닙니다. 마지막으로 유효한 Signaling 자막을 받은 뒤 흐른 시간입니다. 네트워크나 ASR 지연으로 메시지가 늦어져도 이 타이머에는 영향을 줍니다.

final도 말풍선을 즉시 닫지 않습니다. 한 문장이 final이 된 뒤 3초 안에 다음 turn이 오면 같은 말풍선에 이어질 수 있습니다.

실습 6 — 종료 시각 계산하기

시간 단위를 초로 단순화해 계산해 봅니다.

말풍선 생성: 0초
마지막 유효 갱신: 12초

inactivity 후보 = 12 + 3 = 15초
hard cap 후보   = 0 + 60 = 60초
closeAt         = min(15, 60) = 15초

계속 말해서 마지막 갱신이 59초라면 어떻게 될까요?

inactivity 후보 = 59 + 3 = 62초
hard cap 후보   = 0 + 60 = 60초
closeAt         = 60초

기대 결과는 60초에서 말풍선이 나뉘는 것입니다.

누적 turn이 60초 경계를 넘는 경우

ASR이 같은 turn_id의 전체 누적 텍스트를 계속 보낼 수 있습니다. 이 turn이 60초 경계를 넘으면 새 말풍선에 전체 문장을 다시 넣는 단순 구현은 앞부분을 중복 표시합니다.

현재 repartitionTurn()은 이전 말풍선에 이미 배치한 길이와 새 누적 텍스트를 비교해 기존 부분과 continuation 부분을 다시 나눕니다. 더 짧은 final 교정이 뒤늦게 와도 최신 텍스트를 기준으로 재분배해 중복과 손실을 줄입니다.

말풍선 상태 읽기

상태뜻
recognizing하나 이상의 segment가 partial
settling현재 segment가 모두 final이지만 3초 후속 turn 대기 중
final말풍선이 닫혔고 모든 segment가 final
closed말풍선은 닫혔지만 일부 segment가 partial 상태로 남음

7. 전체 오프라인 실습 — 코드와 증거만으로 데이터 흐름 설명하기

다음 다섯 파일을 순서대로 읽는다고 가정합니다. 실제 비밀값 파일은 열지 않습니다.

session-config.ts
  → Agent가 누구의 RTC 음성을 들을지와 Signaling 사용 설정 확인

pbx-stt-token/route.ts, rtm-token/route.ts
  → Agent와 Viewer 토큰의 대상 확인

page.tsx
  → 로그인, subscribe, Agent 시작 순서 확인

captions.ts
  → MESSAGE 검증, turn upsert, 3초/60초 grouping 확인

v2v-rtm-uid-probe.json
  → 실제 관찰된 UID, MESSAGE, 수신 건수와 오류 범위 확인

과제는 아래 문장을 완성하는 것입니다.

“Gateway는 RTC UID ___로 음성을 게시한다. Agent ___는 remote_rtc_uids로 그 음성을 구독하고, 같은 이름의 Signaling MESSAGE 채널에 자막을 게시한다. 웹은 독립 Signaling UID ___로 로그인하고 그 채널을 subscribe한다. 같은 turn의 partial은 ___하고, 말풍선은 ___ 또는 ___에 닫힌다.”

기대 답은 70001, 70011, 99999, 최신 텍스트로 교체, 마지막 유효 자막 후 3초, 생성 후 60초입니다.

기대 결과와 해석

이 문장을 설명할 수 있으면 데이터 평면을 다음처럼 분리할 수 있습니다.

음성 성공: RTC에 Source 오디오가 도달
Agent 제어 성공: join 응답에서 runtime agent_id 획득
자막 라우팅 성공: 올바른 MESSAGE 채널에서 유효 payload 수신
UI 성공: turn 갱신과 말풍선 경계가 정책대로 표시

네 단계는 서로 대체할 수 없습니다. Agent가 RUNNING이어도 음성이 없을 수 있고, Signaling이 연결돼도 잘못된 채널을 구독할 수 있으며, 자막 JSON이 도착해도 파서가 publisher나 Source 불일치로 무시할 수 있습니다.


8. 실제 화면을 사용할 때의 주의점

현재 /agora-demo/pbx-stt 페이지는 단순 Viewer가 아닙니다. 페이지 하나가 다음을 모두 수행하는 진단 도구입니다.

Viewer Signaling 로그인
  + Signaling 채널 subscribe
  + Agora STT Agent 시작
  + 종료 시 Agent leave 시도

따라서 부하 시험을 한다며 현재 페이지 100개를 열어 각각 자막 시작을 누르면 안 됩니다. 그것은 Viewer 100명을 붙이는 시험이 아니라 Agent까지 100개 시작하려는 다른 시험이 됩니다. Viewer fan-out 부하를 검증하려면 Agent 하나와 독립 Viewer 여러 개를 분리한 전용 도구와 성공 기준이 필요합니다.

종료 버튼도 “정리 요청을 시도했다”와 “서버의 모든 연결이 확실히 정리됐다”를 구분해야 합니다. 현재 cleanup은 unsubscribe와 logout을 함께 시도하지만 각 실패가 항상 화면 오류로 전달된다고 보장할 수 없고, 브라우저 종료 시 keepalive leave도 완료가 보장되지는 않습니다.


9. 자주 틀리는 설명을 바로잡기

틀리기 쉬운 설명정확한 설명
Source RTC UID와 같은 UID로 Signaling 로그인해야 한다Viewer 토큰과 로그인 UID가 서로 일치하고 올바른 채널을 subscribe하면 된다
Agora STT 자막은 USER 직접 메시지만 온다실제 UID 분리 probe에서는 MESSAGE 채널 이벤트가 관찰됐다
여러 Viewer에는 relay가 필수다두 독립 UID가 relay 없이 같은 MESSAGE 채널 자막을 받았다. 대규모 fan-out 한계는 별도 검증 대상이다
269건 수신은 269명 부하 시험이다partial을 포함한 메시지 269건이며 수신자는 두 UID였다
final이 오면 말풍선을 즉시 닫는다final은 turn 확정이고 말풍선은 3초 inactivity 또는 60초 hard cap으로 닫힌다
3초는 음성 VAD가 감지한 무음이다마지막 유효 Signaling 자막 이후의 UI 타이머다
토큰을 만들면 메시지가 전송된다토큰은 권한 증명이다. 로그인, subscribe, Agent 게시 동작이 별도로 필요하다
Agent 시작 성공이면 전체 경로 성공이다첫 유효 자막을 받아야 데이터 경로까지 확인된다
페이지 100개는 Viewer 100명 시험이다현재 각 페이지가 Agent도 시작하므로 그런 사용은 올바른 Viewer 부하 시험이 아니다

추가 확인 질문

  1. 왜 Viewer UID와 Agent UID가 같으면 안 되나요?
    한 채널 안의 서로 다른 주체를 충돌 없이 구분하고, 토큰과 이벤트 publisher 검증을 명확히 하기 위해 현재 입력 검증에서 막습니다.

  2. 왜 subscribe를 Agent 시작 전에 하나요?
    초기 자막 메시지를 받을 준비를 먼저 끝내기 위해서입니다.

  3. 왜 stream_id도 검사하나요?
    같은 채널에서 기대한 Source의 자막만 표시하기 위해서입니다.

  4. 왜 final인데 settling 상태가 있나요?
    한 turn이 확정된 뒤에도 짧은 시간 안에 다음 turn이 이어질 수 있어 같은 말풍선으로 묶기 위해서입니다.

  5. UID 분리 probe로 무엇을 말할 수 없나요?
    100명 부하, 깨끗한 음질, end-to-end 지연 합격은 말할 수 없습니다.


10. 직접 실행하기 전 체크리스트

이번 블로그 작성 시점에는 새 패킷 캡처와 새 실통화를 실행하지 않았습니다. 이후 실제 실습을 할 때도 단계별 성공 기준을 분리하세요.

  • 채널 이름은 통합 경로 기준 63자 이하인가?
  • Source, Agent, Viewer UID가 어떤 서비스의 식별자인지 표로 적었는가?
  • Viewer 토큰의 사용자와 Signaling 로그인 UID가 일치하는가?
  • Signaling subscribe가 Agent 시작 전에 완료되는가?
  • agent_id 수신과 첫 유효 자막 수신을 다른 성공 단계로 기록했는가?
  • event의 publisher, channel type/name, stream_id, turn_id, final을 확인했는가?
  • 메시지 수, final 수, Viewer 수를 서로 다른 지표로 기록했는가?
  • sink_errors, live_drops가 있으면 라우팅 성공을 음질 성공으로 확대하지 않았는가?
  • 3초 inactivity를 VAD나 실제 무음 감지라고 부르지 않았는가?
  • 현재 진단 페이지를 여러 개 열어 Viewer 부하 시험으로 사용하지 않았는가?

다음 글 #65 전화 자막 장애를 증거로 좁히는 법에서는 “Agent는 실행 중인데 자막이 없다” 같은 증상을 SIP, RTP, PCM, RTC, Agora STT, Signaling, UI 계층으로 나누어 진단합니다.

© 2026 Frank Kim. All rights reserved.