블로그 목록
Tutorial10분 읽기

RTM(Real-time Messaging)으로 실시간 채팅 시스템 구축

Agora RTM을 사용하여 확장 가능한 실시간 메시징 시스템을 구현하는 모범 사례를 공유합니다.

AgoraRTMChatMessaging

Agora RTM(Real-Time Messaging)은 실시간 텍스트 메시징시그널링을 위한 경량 SDK입니다. 화상 통화(RTC)와 함께 사용하면 채널 내 채팅, 손들기, 참가자 상태 등을 안정적으로 구현할 수 있습니다. 이 글에서는 RTM으로 실시간 채팅 시스템을 구축하는 방법과 모범 사례를 정리합니다.

RTM이란?

RTM은 Agora의 실시간 메시징 서비스로, 음성·영상이 아닌 데이터를 빠르게 주고받을 때 사용합니다.

  • 채널 메시지: 특정 채널을 구독한 모든 사용자에게 메시지 브로드캐스트
  • 시그널링: 화면 공유 시작/중지, 손들기, 호스트·시청자 역할 등 앱 단위 이벤트를 JSON으로 전달
  • 저지연: 전용 인프라로 짧은 지연 시간과 높은 도달률을 목표로 설계됨

RTC(화상·음성)와 RTM(텍스트·시그널링)은 별도 SDK·별도 토큰을 사용하며, 같은 Agora App ID 아래에서 채널명만 맞추면 함께 사용할 수 있습니다.

준비 사항

  • Agora 콘솔: 프로젝트의 App ID, App Certificate
  • RTM 토큰 서버: App Certificate는 서버에만 두고, 클라이언트에는 userId로 요청한 RTM 토큰만 내려주는 API를 구현합니다.
  • 패키지: agora-rtm-sdk (Web)

전체 흐름 요약

  1. RTM 클라이언트 생성new RTM(APP_ID, userId)
  2. 토큰 발급 → 백엔드에서 userId 기준 RTM 토큰 발급
  3. 로그인rtm.login({ token })
  4. 채널 구독rtm.subscribe(channelName) → 해당 채널 메시지 수신 가능
  5. 메시지 발송rtm.publish(channelName, message)
  6. 메시지 수신rtm.addEventListener("message", handler) 에서 event.publisher, event.message 처리
  7. 퇴장rtm.logout()

아래 코드는 프로젝트의 app/agora-demo/rtc/page.tsx에서 사용한 RTM 채팅 흐름을 참고한 예시입니다.

1. RTM 클라이언트 생성 및 로그인

RTM은 사용자 단위로 동작합니다. userId는 문자열로, RTC의 uid와 맞춰 두면 같은 사용자를 하나의 주체로 다루기 쉽습니다.

import AgoraRTM from "agora-rtm-sdk";

const { RTM } = AgoraRTM;
const rtm = new RTM(APP_ID, rtmUserId);

// 백엔드에서 RTM 전용 토큰 발급 (RTC 토큰과 별도)
const res = await fetch(
  `${RTM_TOKEN_API}?userId=${encodeURIComponent(rtmUserId)}`
);
const { token: rtmToken } = await res.json();

await rtm.login({ token: rtmToken });
  • RTC 채널 토큰(/api/token)과 RTM 사용자 토큰(/api/rtm-token)은 서로 다른 API에서 발급하는 구성을 권장합니다.

2. 채널 구독 및 메시지 수신

채팅을 하려면 채널을 구독해야 합니다. 구독한 채널로 들어오는 모든 메시지를 message 이벤트로 받을 수 있습니다.

await rtm.subscribe(channelName);

rtm.addEventListener("message", (event) => {
  const raw =
    typeof event.message === "string"
      ? event.message
      : new TextDecoder().decode(event.message);
  const publisher = event.publisher; // 발신자 userId

  // JSON 시그널링(화면 공유 등)과 일반 채팅 구분
  try {
    const d = JSON.parse(raw);
    if (d?.type === "screen") {
      // 화면 공유 시작/중지 시그널 처리
      setScreenShareUid(d.start ? Number(d.uid) : null);
      return;
    }
  } catch {
    // JSON이 아니면 일반 채팅 메시지
  }

  // 자신이 보낸 메시지는 발송 시 이미 UI에 넣었으므로 중복 표시 방지
  if (String(publisher) === String(rtmUserId)) return;

  setMessages((prev) => [
    ...prev,
    { uid: publisher, text: raw, isMe: false },
  ]);
});
  • 채널 메시지는 해당 채널을 구독한 모든 사용자에게 전달됩니다.
  • 메시지가 문자열 또는 Uint8Array로 올 수 있으므로, 바이너리일 때는 TextDecoder로 디코딩하는 것이 안전합니다.

3. 메시지 발송

채널에 텍스트를 보낼 때는 rtm.publish(channelName, message)를 사용합니다. 메시지는 문자열로 보내며, JSON 문자열을 사용하면 타입별 시그널링을 나눌 수 있습니다.

// 일반 채팅
await rtm.publish(channelName, "안녕하세요");

// 시그널링 예: 화면 공유 알림
await rtm.publish(
  channelName,
  JSON.stringify({ type: "screen", start: true, uid: localUid })
);
  • 발송 성공 후 자신의 메시지는 곧바로 로컬 UI에 추가하고, message 이벤트에서는 publisher === 자신인 경우는 스킵하면 중복 표시를 막을 수 있습니다.

4. 한글 IME 중복 전송 방지

한글처럼 **조합형 입력(IME)**을 쓰는 경우, 엔터를 치는 순간 조합 중인 글자가 짧은 간격으로 한 번 더 전송되는 경우가 있습니다. 이를 줄이려면 방금 보낸 메시지와 내용·시간을 비교해 짧은 간격의 짧은 문자열 중복을 걸러주는 방식이 유용합니다.

const lastSentRef = useRef({ text: "", at: 0 });

function sendMessage(text) {
  const now = Date.now();
  const { text: lastText, at: lastAt } = lastSentRef.current;
  const isLikelyImeDuplicate =
    now - lastAt < 300 &&
    lastText.length > 0 &&
    text.length <= 2 &&
    text.length < lastText.length &&
    lastText.endsWith(text);
  if (isLikelyImeDuplicate) return;

  lastSentRef.current = { text, at: now };
  rtm.publish(channelName, text).then(() => {
    // 로컬에 내 메시지 추가
  });
}
  • 300ms 이내, 2글자 이하, 이전 메시지 끝부분과 같은 경우 등만 필터해도 IME 중복이 상당 부분 줄어듭니다.

5. 시그널링 확장 (타입별 메시지)

채팅과 시그널링을 같은 채널에서 구분하려면 JSON으로 type 필드를 두고 처리하는 방식을 많이 씁니다.

type용도
(없음 또는 일반 텍스트)채팅 메시지
screen화면 공유 시작/중지 알림 (start, uid)
presence참가자 수·역할 (라이브 스트리밍에서 호스트/시청자 수 등)
signaling손들기, 질문 요청 등 (예: action: "raise_hand", fromUid)

예시:

// 수신 측
const d = JSON.parse(raw);
switch (d?.type) {
  case "screen":
    setScreenShareUid(d.start ? d.uid : null);
    break;
  case "presence":
    setPresenceMap((prev) => ({ ...prev, [d.uid]: { role: d.role } }));
    break;
  case "signaling":
    if (d.action === "raise_hand") addRaisedHand(d.fromUid);
    break;
  default:
    addChatMessage({ uid: event.publisher, text: raw });
}

6. 퇴장 시 정리

채널을 나갈 때는 RTM 로그아웃을 호출해 연결과 리소스를 정리합니다. RTC와 함께 쓰는 경우에는 RTC leave 및 트랙 closertm.logout()을 호출하면 됩니다.

await rtm.logout();

RTC와 함께 쓸 때 정리

구분RTCRTM
역할음성·영상 스트림텍스트·시그널링
토큰채널·uid 기준 RTC 토큰userId 기준 RTM 토큰
입장client.join(appId, channelName, token, uid)rtm.login({ token }) + rtm.subscribe(channelName)
퇴장client.leave(), 트랙 close()rtm.logout()
  • 같은 채널명을 사용하면, 화상 통화 방 하나에 RTC(영상/음성)와 RTM(채팅·시그널링)을 동시에 붙일 수 있습니다.
  • 실제 구현 예는 Agora Demo > RTC - Call (/agora-demo/rtc)에서 채팅 패널과 화면 공유 알림을 확인할 수 있고, Live Streaming 데모에서는 손들기·참가자 수(presence)까지 RTM으로 처리하고 있습니다.

참고

  • RTM 토큰은 RTC 토큰과 별도로 백엔드에서 발급해야 합니다.
  • 메시지 크기·빈도 제한은 Agora RTM 제한 사항을 참고하세요.
  • 자세한 API는 Agora RTM SDK 문서를 참고하면 됩니다.

RTC 화상 통화 구현은 Agora RTC를 활용한 실시간 화상 통화 구현하기 글을 참고하세요.

실제 데모

/agora-demo/rtc

© 2026 Frank Kim. All rights reserved.