실시간 통신(RTC)은 일반적으로 영상 및 오디오 경험과 연관되어 있습니다. 하지만 동일한 인프라를 활용하여 애플리케이션 로직을 구동하는 구조화된 데이터를 교환할 수도 있습니다.
많은 실시간 시스템에서 클라이언트 간 게임 상태를 동기화하려면 상태 동기화를 관리하기 위해 WebSockets나 백엔드 서비스와 같은 추가 계층이 필요합니다. 이 프로젝트에서는 Agora의 RTC SDK 내 데이터스트림 메시징을 사용하여 미디어 스트림과 함께 경량 게임 상태를 처리함으로써, 특정 시나리오에서 별도의 인프라 필요성을 줄입니다.
RTC 데이터 스트림 메시징은 프로덕션급 시그널링을 목적으로 하지 않는다는 점을 유의해야 합니다. 전송이 보장되거나 엄격한 순서가 보장되지 않기 때문에, 대화록이나 보조 상태와 같은 비중요 데이터에 가장 적합합니다. 확장 가능하고 신뢰할 수 있는 시그널링을 위해서는 시그널링 서비스와 같은 전용 메시징 계층을 사용해야 합니다.
이러한 제약 사항을 고려하여, 저는 의도적으로 이 구현을 최소한으로 유지했습니다. 아고라 웹 SDK, 기본 프론트엔드 기술, 그리고 백엔드 동작을 시뮬레이션하기 위한 경량 서버리스 함수를 사용합니다. 목표는 프로덕션에 바로 적용 가능한 아키텍처를 제시하는 것이 아니라, 이러한 기본 요소들이 어떻게 결합되어 기능적인 시스템을 구성할 수 있는지 보여주는 것입니다.
이 가이드는 Agora를 사용하여 다음 세 가지를 동시에 수행하는 2인용 배틀쉽 게임 구축 과정을 안내합니다:
- 플레이어 간의 영상/음성 통신
- 기존 WebRTC 연결을 통한 데이터스트림 메시지로 게임 상태 동기화
- 플레이어의 명령에 반응하는 (원할 경우!)
이 과정에서 이 접근 방식이 잘 작동하는 부분, 한계가 있는 부분, 그리고 어떻게 확장할 수 있는지를 강조합니다.
구축할 내용
다음과 같은 기능을 갖춘 브라우저 기반 배틀쉽 게임:
- 플레이어가 게임을 시작/참가/관전할 수 있음
- 두 플레이어가 게임을 하는 동안 서로의 모습과 목소리를 볼 수 있음
- 각 플레이어는 별도의 채널에서 대기 중인 자신만의 AI 에이전트를 보유함
- 플레이어는 AI 에이전트에게 좌표를 말로 전달하여 공격함 (“B4 공격”)
- 플레이어는 각자의 AI 에이전트에게 대신 선택해 달라고 요청할 수 있음 (“나 대신 선택해 줘!”)
- 마우스 클릭을 통해 게임을 진행할 수도 있으며, AI 에이전트가 플레이어의 마우스 입력에 반응합니다
- 게임 상태(공격, 명중, 빗나감)는 Agora의 데이터스트림 채널을 통해 동기화됩니다
- 관전자는 게임에 참여하여 실시간 게임 플레이를 지켜볼 수 있습니다
핵심 문제: 턴제 게임 상태 동기화
턴제 게임에서는 모든 플레이어가 다음 사항에 대해 합의해야 합니다:
- 누구의 차례인지
- 어떤 수를 뒀는지
- 보드가 어떻게 보이는지
- 게임이 언제 끝나는지
기본적인 접근 방식은 일정한 간격으로 서버를 폴링하는 것입니다.
더 효율적인 접근 방식: 웹소켓을 통해 서버가 업데이트를 푸시/풀하는 방식입니다.
여기서 사용할 재미있지만(반드시 올바른 방법은 아닐 수 있는!) 아고라(Agora)의 접근 방식: 이미 영상과 오디오를 위해 열려 있는 동일한 RTC 연결을 사용합니다.
아고라의 데이터스트림 메시징이 여기서 효과적인 이유
아고라 WebSDK 클라이언트의 sendStreamMessage
() API를 사용하면 기존 RTC 채널로 데이터 메시지를 브로드캐스트할 수 있습니다. 배틀쉽의 수(행, 열, 결과)의 경우 페이로드 크기가 일반적으로 작으며(약 50바이트), 메시지 전송 빈도가 낮다는 점을 고려할 때 적합합니다. 이 API는 멀티플레이어 게임 상태를 추적하는 서버나 서비스를 대체하지는 않지만, 최소한의 노력으로 상태를 중계하는 백엔드를 구현하는 효과를 낼 수 있게 해줍니다.
여기서 사용할 메시지 구조의 일부는 다음과 같습니다:
// Attack message sent when a player makes a move
const msgObj = {
type: "attack",
row: 3,
col: 7
};
client.sendStreamMessage(JSON.stringify(msgObj));
// Attack result sent back by the defender
const resultMsg = {
type: "attack_result",
row: 3,
col: 7,
isHit: true,
isGameOver: false
};
client.sendStreamMessage(JSON.stringify(resultMsg));각 플레이어의 메인 RTC 클라이언트는 다음 메시지를 수신 대기합니다:
client.on("stream-message", handleStreamMessage);function handleStreamMessage(uid, msgData) {
const decoder = new TextDecoder();
const msgStr = decoder.decode(msgData);
const msg = JSON.parse(msgStr);
switch (msg.type) {
case "attack":
handleAttack(msg);
break;
case "attack_result":
handleAttackResult(msg);
break;
case "ready":
handlePlayerReady();
break;
}
}페이로드가 포함된 메시지 유형이라는 이 패턴을 사용하면 간단한 RPC 스타일의 시스템을 구축할 수 있습니다. 이를 통해 전용 WebSocket 서버가 필요 없어집니다.
아키텍처: 멀티 채널 설계
이제부터가 흥미로운 부분입니다. 이 게임은 하나의 Agora 채널을 사용하지 않습니다. 게임당 세 개의 채널을 사용합니다:
- 메인 게임 채널 (
battleship_123456)
- 두 플레이어 모두 호스트로 참여
- 비디오/오디오/데이터 메시지가 이곳으로 전송됨
- 관람자는 ‘관람자’ 역할로 참여
2. 에이전트 A 채널 (battleship_123456_agenta)
- 플레이어 A는 자신의 차례가 되면 이곳에 오디오를 게시함
- 에이전트 A가 참여하여 청취
- 에이전트 A는 음성 TTS로 응답
- 플레이어 B는 이곳에 구독하지만, 에이전트 A의 오디오만 수신합니다. 플레이어 B는 이미 메인 게임 채널을 통해 플레이어 A의 오디오를 수신하고 있습니다.
3. 에이전트 B 채널 (battleship_123456_agentb)
- 플레이어 B는 이곳에 오디오를 게시합니다
- 에이전트 B가 참여하여 청취합니다
- 에이전트 B는 음성 명령으로 응답합니다
- 플레이어 A는 이곳에 구독하지만, 에이전트 B에게만 구독합니다. 플레이어 A는 이미 메인 게임 채널을 통해 플레이어 B의 오디오를 수신하고 있습니다.

왜 에이전트마다 별도의 채널이 필요한가요? 격리 때문입니다. 각 에이전트는 상대방의 음성 명령이 아닌, 자신의 플레이어 음성 명령만 들어야 합니다. 두 에이전트가 모두 메인 채널에 있다면 서로 간섭을 일으키고 잘못된 플레이어의 명령에 응답하게 될 것입니다.
코드: 여러 클라이언트 설정
// Main game client for video/audio/data
client = AgoraRTC.createClient({ mode: "live", codec: "vp8", role: "host" });
await client.join(AGORA_APP_ID, currentChannelName, null, "PlayerA");
await client.publish([localAudioTrack, localVideoTrack]);// Agent client for voice commands
agentClient = AgoraRTC.createClient({ mode: "live", codec: "vp8", role: "host" });
await agentClient.join(AGORA_APP_ID, currentAgentChannelName, null, "PlayerA");
await agentClient.publish(localAgentAudioTrack);각 클라이언트는 독립적입니다. 라이프사이클은 별도로 관리하고 이벤트도 따로 처리하지만, 동일한 사용자 컨텍스트를 공유합니다. 이는 통신 내용을 목적에 따라 구분할 수 있게 해주기 때문에 매우 유용합니다.
게임 루프: 공격 및 대응 흐름
플레이어 A가 공격할 때 어떤 일이 일어나는지 살펴보겠습니다:
1단계: 플레이어가 명령을 내림
// Player says "Attack B4" into their microphone
// Agent hears this via the agent channel
// Agent processes the transcript and extracts coordinatesThe agent’s transcript handler parses the spoken text:
function handleAgentStreamMessage(uid, msgData) {
const messageDataJson = JSON.parse(messageData);
if (messageDataJson.text.includes("Acknowledged")) {
// Agent confirmed valid coordinates
const match = messageDataJson.text.match(/([A-J])(1[0]|[1-9])/);
if (match) {
const [_, letter, number] = match;
const row = letter.charCodeAt(0) - 'A'.charCodeAt(0);
const col = parseInt(number) - 1;
attackCell(row, col, letter, number);
}
}
}2단계: 공격 메시지 전송됨
function attackCell(row, col) {
if (!isMyTurn || enemyBoardState[row][col] !== 0) {
showNotification("Invalid move!");
return;
} const msgObj = { type: "attack", row, col };
client.sendStreamMessage(JSON.stringify(msgObj));
// Immediately switch turns locally
isMyTurn = false;
updateStatus("Waiting for opponent...");
}Notice the optimistic turn switch. Player A assumes the message will arrive, so they disable their controls immediately. This prevents double-moves.
3단계: 상대가 수신하고 처리함
function handleAttack(msg) {
// Check if the attack hit a ship
const isHit = myShips[msg.row][msg.col];
myBoardState[msg.row][msg.col] = isHit ? 1 : 2;
renderMyBoard();
// Check for game over
const totalHits = myBoardState.flat().filter(cell => cell === 1).length;
const isGameOver = totalHits === TOTAL_SHIP_CELLS;
// Send result back
const resultMsg = {
type: "attack_result",
row: msg.row,
col: msg.col,
isHit,
isGameOver
};
client.sendStreamMessage(JSON.stringify(resultMsg));
if (!isGameOver) {
isMyTurn = true; // Now it's my turn
updateStatus("Your turn!");
}
}4단계: 최초 공격자가 결과를 수신함
function handleAttackResult(msg) {
// Update my view of the enemy's board
enemyBoardState[msg.row][msg.col] = msg.isHit ? 1 : 2;
renderEnemyBoard();
if (msg.isGameOver) {
showGameOver(true); // I won!
return;
}
// Result received, but it's opponent's turn now
isMyTurn = false;
updateStatus("Waiting for opponent...");
}이 4단계 프로세스를 통해 두 클라이언트 모두 동기화 상태를 유지할 수 있습니다. 핵심은 수비자가 ‘진실의 원천’이라는 점입니다. 플레이어 A가 공격을 보내지만, 공격이 적중했는지 여부는 플레이어 B가 결정합니다. 플레이어 A는 플레이어 B의 응답을 신뢰해야 합니다.
경합 조건 처리
두 플레이어가 동시에 공격 버튼을 클릭하면 어떻게 될까요?
문제점: 심판 역할을 하는 중앙 서버가 없으면, 잠금(locking)을 통해 경합 조건을 방지할 수 없습니다. 두 플레이어 모두 자신이 차례라고 생각할 수 있습니다.
해결책: 게임 시작 시 표준 턴 순서를 정하고, 메시지 순서를 신뢰합니다.
function startGame() {
bothPlayersReady = true;
// Player A always goes first
isMyTurn = isPlayerA;
updateStatus(isMyTurn ? "Your turn!" : "Waiting for opponent...");
}플레이어가 자신의 차례라고 생각하며 공격을 받았을 때, handleAttack 함수는 isMyTurn을 확인하지 않기 때문에 여전히 이를 올바르게 처리합니다. 단순히 반응할 뿐입니다. 이로 인해 게임은 결국 일관성을 유지하게 됩니다. 즉, 두 플레이어 모두 잠시 자신의 차례라고 생각하더라도 메시지 순서가 충돌을 해결해 줄 것입니다.
두 플레이어가 동시에 공격을 보낼 수 있을까요? 네, 가능합니다. 하지만 각 플레이어는 자신이 보낸 결과가 아닌, 수신한 결과에 따라 상대방의 보드만 업데이트하기 때문에 게임은 일관성을 유지합니다. 플레이어 A의 공격은 플레이어 B의 보드를 업데이트하고, 그 반대의 경우도 마찬가지입니다. 두 플레이어는 절대 동일한 데이터 구조를 건드리지 않습니다.
음성 에이전트 통합
AI 에이전트가 가장 인상적인 부분입니다. 각 플레이어는 다음과 같은 기능을 갖춘 음성 비서를 배정받습니다:
- “Attack [좌표]” 명령을 감지합니다
- 좌표를 검증합니다
- 음성 피드백을 통해 행동을 확인합니다
- “Choose for me”라고 요청하면 다음 수를 제안할 수 있습니다
에이전트 라이프사이클
에이전트는 플레이어가 게임에 참여했을 때 시작되지 않습니다. 두 플레이어 모두 함선 배치를 완료했을 때 시작됩니다:
function handlePlayerReady() {
otherPlayerReady = true;
if (!isPlacingShips) {
bothPlayersReady = true;
// NOW start the agents
if (isPlayerA) {
startAgent("AgentA", currentAgentChannelName, "AgentA", "PlayerA",
"You are acting as a commentator for a game of Battleship...",
"Welcome to Battleship Agora Player A!");
} else {
startAgent("AgentB", currentAgentChannelName, "AgentB", "PlayerB",
"You are acting as a commentator for a game of Battleship...",
"Welcome to Battleship Agora Player B!");
}
isMyTurn = isPlayerA;
updateStatus(isMyTurn ? "Your turn!" : "Waiting for opponent...");
}
}왜 기다려야 할까요? 에이전트가 선박 배치 과정에서 명령을 엿듣는 것을 원치 않기 때문입니다. 에이전트가 배치 지시를 실수로 공격 명령으로 오해할 수도 있습니다.
에이전트 람다 함수
에이전트는 브라우저에서 직접 트리거되지 않습니다. 우리는 완전한 ConvoAI 에이전트를 시작하는 데 필요한 API 키를 보유한 서버리스 함수를 호출할 뿐입니다. 이 서버리스 함수 자체는 단순히 Agora의 ConvoAI RESTful API 호출입니다:
async function startAgent(name, chan, uid, remoteUid, prompt, message) {
const url = <LAMBDA FUNCTION URL OR API GATEWAY URL>
const reqBody = {
agentname: name,
channel: chan,
agentuid: uid,
agentrtmuid: chan,
rtmflag: true,
remoteuid: remoteUid,
prompt: prompt,
message: message
};
const resp = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(reqBody)
});
const data = await resp.json();
myAgentsId = data.agent_id; // Save for later cleanup
}이 Lambda 함수는 다음과 같은 작업을 수행하는 에이전트를 시작합니다:
- 지정된 Agora 채널에 참여
- 플레이어의 오디오를 구독
- 수신 오디오에 대해 음성-텍스트 변환(ASR)을 실행(Agora의 ARES 서비스를 사용하지만, 지원되는 모든 ASR을 사용할 수 있음)
- GPT-4o-mini를 통해 명령어 처리(지원되는 모든 모델을 사용할 수 있음)
- 텍스트-음성 변환(TTS) 응답 생성(여기서는 Azure 사용)
- TTS 오디오 및 user/assistant.transcript 데이터스트림 메시지를 채널로 다시 전송합니다
에이전트 메시지 프로토콜
Agora ConvoAI 음성 에이전트는 Agora의 RTM(실시간 메시징) 또는 RTC 데이터스트림 메시지를 통해 트랜스크립트를 다시 보낼 수 있습니다. 여기서는 WebSDK만 사용하고 다른 것은 사용하지 않겠다는 목표를 달성하기 위해 데이터스트림 방식을 선택했습니다. 메시지는 크기 제한을 처리하기 위해 청크 단위로 도착합니다:
function handleAgentStreamMessage(uid, msgData) {
let [messageId, messagePart, messageChunks, messageData] =
new TextDecoder().decode(msgData).split("|");
messageData = atob(messageData); // Base64 decode
// Reconstruct chunked messages
messagesMap.set(messageId,
messagesMap.get(messageId) ?
messagesMap.get(messageId) + messageData :
messageData
);
// Wait for all chunks
if (parseInt(messagePart) === parseInt(messageChunks)) {
const fullMessage = messagesMap.get(messageId);
messagesMap.delete(messageId);
processAgentMessage(fullMessage);
}
}이 청크화 프로토콜은 대용량 대화록을 처리합니다. 각 청크에는 다음이 포함됩니다:
messageId: 이 메시지의 고유 IDmessagePart: 해당 청크 번호 (1, 2, 3...)messageChunks: 총 청크 수messageData: Base64로 인코딩된 페이로드
음성 명령 분석
전체 대화록을 확보한 후, 좌표를 추출합니다:
if (messageDataJson.text.includes("Acknowledged")) {
// Agent uses "Acknowledged" prefix for valid commands
const match = messageDataJson.text.match(/([A-J])(1[0]|[1-9])/);
if (match) {
const [_, letter, number] = match;
const row = letter.charCodeAt(0) - 'A'.charCodeAt(0);
const col = parseInt(number) - 1;
attackCell(row, col, letter, number);
} else {
showNotification("Invalid coordinates! Try again.");
}
}정규 표현식 /([A-J])(1[0]|[1-9])/은 다음을 일치시킵니다:
- A-J 범위의 문자 (행)
- 1–10 범위의 숫자 (열, “10”에 대해서는 특별 처리 적용)
이 구문 분석은 에이전트가 아닌 클라이언트 측에서 수행됩니다. 에이전트의 역할은 유효한 공격 명령(“B4에 대한 발사를 확인함”)을 수신했는지 확인하는 것입니다. 그런 다음 클라이언트가 좌표를 추출하여 공격을 실행합니다.
볼륨 제어: 어떤 에이전트의 소리를 들어야 할까요?
두 에이전트 모두 말을 하고 있지만, 사용자는 자신의 에이전트 소리만 선명하게 들을 수 있어야 합니다:
// Player A's agent channel
await agentClient.join(AGORA_APP_ID, currentAgentChannelName, null, "PlayerA");
await agentClient.publish(localAgentAudioTrack);
localAgentAudioTrack.setVolume(100); // Full volume for Player A// Player B's agent channel
await agentClient.join(AGORA_APP_ID, currentAgentChannelName, null, "PlayerB");
await agentClient.publish(localAgentAudioTrack);
localAgentAudioTrack.setVolume(0); // Muted for Player B during opponent's turn볼륨은 회전 각도에 따라 조절됩니다:
function handleAttack(msg) {
// Opponent attacked me, now it's my turn
isMyTurn = true;
localAgentAudioTrack.setVolume(100); // Unmute my agent
}function attackCell(row, col) {
// I'm attacking, switching to opponent's turn
isMyTurn = false;
localAgentAudioTrack.setVolume(0); // Mute my agent
}이를 통해 자연스러운 턴 순서가 형성될 뿐만 아니라, 무엇보다도 한 사용자의 에이전트가 다른 사용자의 음성 입력을 듣지 못하게 됩니다. 이렇게 하면 게임 중에도 두 플레이어가 서로 대화할 수 있으며, 턴이 아닌 플레이어의 에이전트가 반응하는 일은 없습니다. 여러분의 에이전트는 자신의 턴이 왔을 때만 듣고 말하며, 그 외에는 침묵을 지킵니다.
관전 모드: 관전자 시점
이 게임은 게임 도중에 참여하여 두 보드를 모두 관전할 수 있는 관전자를 지원합니다. 이는 말처럼 간단하지 않습니다.
관전자로 참여하기
async function joinAsAudience(chName) {
// Create main client as audience role
client = AgoraRTC.createClient({ mode: "live", codec: "vp8", role: "host" });
await client.join(AGORA_APP_ID, chName, null, audienceId);
// Join both agent channels to hear both agents
agentClient = AgoraRTC.createClient({ mode: "live", codec: "vp8", role: "audience" });
await agentClient.join(AGORA_APP_ID, currentAgentAChannelName, null, audienceId);
audienceSpecialClient = AgoraRTC.createClient({ mode: "live", codec: "vp8", role: "audience" });
await audienceSpecialClient.join(AGORA_APP_ID, currentAgentBChannelName, null, audienceId);
// Send join notification so players can send board state
const msgObj = { type: "audience-joined" };
client.sendStreamMessage(JSON.stringify(msgObj));
// Switch to audience role (no publishing)
await client.setClientRole("audience");
}청중은 세 가지 클라이언트가 필요합니다:
- 메인 채널 클라이언트 (게임 메시지 및 영상용)
- 에이전트 A 채널 클라이언트 (에이전트 A의 음성 수신용)
- 에이전트 B 채널 클라이언트 (에이전트 B의 음성 수신용)
청중은 에이전트 채널에 “청중(audience)” 역할로 참여하며, 이는 구독은 가능하지만 게시(broadcast)는 불가능함을 의미합니다.
보드 상태 동기화
청중이 참여하면 audience-joined메시지를 전송합니다. 두 플레이어는 이에 응답하여 현재 보드 상태를 브로드캐스팅합니다:
function handleAudienceJoined() {
if (client) {
const boardState = {
type: "board-state",
isPlayerA: isPlayerA,
board: myBoardState,
ships: myShips
};
client.sendStreamMessage(JSON.stringify(boardState));
}
}청중은 보드 상태를 수신하고 이를 렌더링합니다:
function handleAudienceStreamMessage(uid, msgData) {
const msg = JSON.parse(msgStr);
if (msg.type === "board-state") {
if (msg.isPlayerA) {
myBoardState = msg.board;
myShips = msg.ships;
renderMyBoard();
} else {
enemyBoardState = msg.board;
myShips2 = msg.ships;
renderMyBoard2();
}
}
}이 접근 방식이 효과적인 이유는 다음과 같습니다:
- 관전자는 게임 상태를 변경하지 않고, 단지 이를 렌더링할 뿐입니다
- 플레이어는
board-state메시지를 통해 모든 수를 관전자에게 실시간으로 전달합니다 - 늦게 참여한 관전자는 초기
audience-joined핸드셰이크를 통해 게임 상황을 파악할 수 있습니다
함선 배치: 게임 시작 전 상태
게임이 시작되기 전에, 플레이어는 자신의 보드에 함선을 배치합니다. 이 단계에는 다음과 같은 규칙이 적용됩니다:
function startShipPlacement() {
isPlacingShips = true;
currentShipType = 0;
currentShipOrientation = 'horizontal';
// Enable click handlers on player's own board
const cells = myBoardEl.querySelectorAll("div");
cells.forEach((cell, idx) => {
const row = Math.floor(idx / 10);
const col = idx % 10;
cell.onclick = () => placeShip(row, col);
cell.onmouseover = () => showPlacementPreview(row, col, true);
cell.onmouseout = () => showPlacementPreview(row, col, false);
});
}함선은 순차적으로 배치됩니다. 즉, 순양함 등으로 넘어가기 전에 모든 구축함을 먼저 배치해야 합니다. 이렇게 하면 UI 로직이 단순화됩니다:
function placeShip(row, col) {
if (!canPlaceShip(row, col)) {
showNotification("Can't place ship here!");
return;
}
const ship = SHIPS[currentShipType];
// Mark cells as occupied
for (let i = 0; i < ship.length; i++) {
const r = currentShipOrientation === 'horizontal' ? row : row + i;
const c = currentShipOrientation === 'horizontal' ? col + i : col;
myShips[r][c] = true;
}
SHIPS[currentShipType].count--;
if (SHIPS[currentShipType].count === 0) {
currentShipType++; // Move to next ship type
}
shipsToPlace--;
if (shipsToPlace === 0) {
finishPlacement();
}
}준비 상태 동기화
플레이어가 배치 작업을 완료하면 ready 메시지를 전송합니다:
function finishPlacement() {
isPlacingShips = false;
const readyMsg = { type: "ready" };
client.sendStreamMessage(JSON.stringify(readyMsg));
if (otherPlayerReady) {
startGame(); // Both ready, begin!
} else {
updateStatus("Waiting for opponent to finish placing ships...");
}
}두 플레이어 모두 준비가 완료되어야 게임이 시작됩니다. 이를 통해 한 플레이어가 함선을 배치하는 도중에 공격을 받는 상황을 방지할 수 있습니다.
오류 처리 및 예외 상황
재연결
플레이어의 연결이 끊어지면, Agora의 SDK가 대부분의 복잡한 작업을 처리합니다:
client.on("connection-state-change", (curState, prevState, reason) => {
console.log(`Connection state: ${prevState} -> ${curState} (${reason})`);
if (curState === "DISCONNECTED") {
updateStatus("Connection lost. Attempting to reconnect...");
} else if (curState === "CONNECTED") {
updateStatus("Reconnected!");
// Re-sync game state by requesting board updates
const msgObj = { type: "audience-joined" }; // Reuse audience logic
client.sendStreamMessage(JSON.stringify(msgObj));
}
});현재 코드에는 구현되어 있지 않지만, 실제 운영 환경에서는 추가할 가치가 있습니다.
메시지 전달 보장
Agora의 데이터 스트림 채널은 UDP 전송 방식을 사용하며, 이는 메시지 전달이나 순서를 보장하지 않습니다. 안정성이 필요한 애플리케이션은 애플리케이션 계층에서 확인(acknowledgment) 로직을 구현해야 합니다. 메시지가 손실되거나 순서가 뒤바뀔 수 있습니다. 턴제 게임의 경우, 다음과 같은 이유로 대체로 문제가 되지 않습니다.
- 공격은 결과로 확인됩니다 — 플레이어 B가 플레이어 A의 공격을 전혀 수신하지 못하면, 플레이어 A는 결과를 받지 못하며 무언가 잘못되었음을 알게 됩니다
- 게임 상태는 빈번하게(매 턴마다) 동기화됩니다
- 최악의 경우는 한 턴이 누락되는 것이지, 완전한 비동기화가 아닙니다
전달 보장이 필요하다면 다음을 고려하십시오:
- 메시지에 시퀀스 번호 추가
- ACK/NACK 프로토콜 구현
- 데이터 메시지 대신 Agora RTM(실시간 메시징) 사용
무효한 수
게임은 클라이언트와 서버 양쪽에서 수를 검증합니다(두 플레이어 모두 검증자 역할을 한다는 점을 기억하십시오):
function attackCell(row, col) {
if (!isMyTurn) {
showNotification("Not your turn!");
agentSpeak(myAgentsId, "Wait your turn!");
return;
}
if (enemyBoardState[row][col] !== 0) {
showNotification("Cell already attacked!");
agentSpeak(myAgentsId, "That area is already decimated!");
return;
}
// Move is valid, proceed
const msgObj = { type: "attack", row, col };
client.sendStreamMessage(JSON.stringify(msgObj));
}플레이어 A가 (클라이언트 코드를 수정하여) 이러한 검사를 우회하더라도, 플레이어 B의 handleAttack 함수는 자신의 보드 상태를 기준으로 해당 수를 올바르게 처리할 것입니다. 플레이어 A는 함선이 없는 위치에 강제로 명중시킬 수 없습니다.
성능 고려 사항
메시지 크기 제한
Agora의 RTC SDK의 sendStreamMessage() API는 메시지당 최대 1KB의 페이로드와 초당 최대 30개의 메시지 전송 속도를 지원합니다. 이 제한 범위 내에서 사용하는 것이 좋습니다.
// Good: ~50 bytes
{ type: "attack", row: 3, col: 7 }// Bad: ~3KB
{
type: "attack",
row: 3,
col: 7,
attackerId: "player_a_12345",
timestamp: "2024-01-15T10:30:00.000Z",
metadata: { ... },
fullBoardState: [...] // Don't send unnecessary data
}필요한 데이터만 전송하세요. 보드 상태는 100셀 × 1바이트 = 100바이트입니다. 선박 위치도 마찬가지입니다. 여유 공간이 충분합니다.
렌더링 성능
메시지가 올 때마다 보드를 업데이트하면 화면이 깜빡일 수 있습니다:
function renderMyBoard() {
const cells = myBoardEl.querySelectorAll("div");
cells.forEach((cell, idx) => {
const row = Math.floor(idx / 10);
const col = idx % 10;
const val = myBoardState[row][col];
const hasShip = myShips[row][col];
// Only update cell className if it changed
const newClass = `h-7 w-7 border border-gray-600 ${
val === 0 ? (hasShip ? "bg-yellow-500" : "bg-gray-700") :
val === 1 ? "bg-red-500" : "bg-blue-500"
}`;
if (cell.className !== newClass) {
cell.className = newClass;
}
});
}이 방식은 업데이트 전에 className이 변경되었는지 확인하여 불필요한 레이아웃 재구성을 방지합니다. 10×10 크기의 보드에는 과도한 조치일 수 있지만, 더 큰 규모의 게임에서는 좋은 습관입니다.
에이전트 응답 지연 시간
음성 명령은 여러 처리 단계에 걸쳐 측정 가능한 지연 시간을 유발합니다. 다음은 대표적인 추정치이며, 실제 값은 네트워크 상태, ASR 제공업체의 성능, 모델 응답 시간에 따라 달라집니다:
1. 플레이어가 말함 — 발화 시간 (~1–2초)
2. 오디오가 에이전트로 전송됨 (~50–100ms)
3. 음성-텍스트 변환 처리 (~300–500ms, 제공업체에 따라 다름)
4. LLM이 명령 처리 (~500ms–2초, 모델에 따라 다름)
5. 텍스트 음성 변환(TTS) 생성 (~300–500ms)
6. 오디오가 클라이언트로 다시 전송됨 (~50–100ms)
이 단계들은 순차적으로 진행되므로, 전체 지연 시간은 모든 단계에 걸쳐 누적됩니다. 개발자는 사용자 경험에 미치는 영향에 대한 결론을 내리기 전에 자체 배포 환경에서 지연 시간을 측정해야 합니다.
지연 시간을 줄이려면 다음 사항을 고려하십시오:
· 지연 시간이 짧은 ASR 제공업체를 사용하십시오. Deepgram과 같은 서비스는 배치 ASR보다 처리 시간이 훨씬 짧은 스트리밍 트랜스크립션 기능을 제공합니다.
· TTS 출력을 스트리밍하십시오. 전체 오디오 클립이 생성될 때까지 기다리는 대신, TTS 오디오가 생성되는 대로 점진적으로 스트리밍하고 재생하십시오.
· 프롬프트의 길이를 줄이십시오. 더 짧고 직접적인 시스템 프롬프트는 LLM 측의 토큰 처리 시간을 단축합니다.
· 더 빠른 모델을 선택하십시오. 사용 사례에 GPT-4 수준의 추론이 필요하지 않다면, 더 작은 모델이나 디스틸레이션 모델을 사용하면 추론 지연 시간을 크게 줄일 수 있습니다.
마우스 클릭 입력은 병렬 상호작용 방법으로 계속 사용할 수 있습니다. 음성 제어는 상호작용 모델의 확장 기능일 뿐, 필수 요소는 아닙니다.
이 패턴 확장하기
이 아키텍처는 모든 턴제 게임에 적용 가능합니다:
체스
- 수:
{ type: “move”, from: “e2”, to: “e4” } - 상태: 64개의 칸, 말 위치
- 에이전트: “폰을 e4로 이동” → 유효성 검사 후 실행
포커
- 수:
{ type: ‘bet’, amount: 50 },{ type: “fold” } - 상태: 카드, 팟, 플레이어 칩
- 에이전트: “50을 레이즈하겠다” → 베팅 로직 처리
틱택토
- 수:
{ type: “mark”, row: 1, col: 1 } - 상태: 9개의 칸
- 에이전트: “오른쪽 아래” → 좌표로 변환
이 패턴은 다음 조건을 충족하는 모든 게임에 확장 가능합니다:
- 수(Moves)가 작음 (<1KB)
- 수행 빈도가 낮음 (<10회/초)
- 클라이언트가 수를 독립적으로 검증할 수 있음
부정행위는 어떻게 될까요?
이 아키텍처는 클라이언트를 신뢰합니다. 플레이어 A는 자바스크립트를 수정하여 다음과 같은 행동을 할 수 있습니다:
- 모든 공격이 명중했다고 주장하기
- 플레이어 B의 배 위치를 확인하기
- 연속으로 여러 번 공격하기
캐주얼 게임의 경우, 이러한 접근 방식이 허용될 수 있습니다. 경쟁적인 플레이를 위해서는 서버 심판이 필요합니다:
// Pseudocode for server-validated moves
async function attackCell(row, col) {
const response = await fetch("/api/validate-move", {
method: "POST",
body: JSON.stringify({ gameId, playerId, row, col })
});
const { valid, result } = await response.json();
if (valid) {
// Broadcast move via Agora
client.sendStreamMessage(JSON.stringify({
type: "attack",
row,
col,
serverSignature: result.signature // Proof server validated this
}));
}
}서버는 데이터의 신뢰할 수 있는 원본 역할을 하며, 클라이언트는 단순히 결과를 렌더링합니다. Agora는 여전히 실시간 통신을 처리하지만, 게임 로직은 서버 측으로 이동합니다.
배포 체크리스트
실제 서비스에 배포하기 전에 다음 사항을 확인하세요:
- Agora App ID: AGORA_APP_ID 변수를 자신의 ID로 교체하세요
- 토큰 인증: 현재 코드는 null 토큰을 사용합니다(개발용)
- Lambda 엔드포인트: 에이전트 함수 URL을 업데이트하세요
- CORS 헤더: Lambda 함수가 귀하의 도메인을 허용하도록 설정하세요
- 속도 제한: 메시지 스팸을 방지하세요(Agora에 내장된 제한 기능이 있지만)
- 오류 경계: 모든 Agora 호출 주위에 try-catch를 추가하세요
- 로깅: 게임 이벤트에 대한 분석 기능을 구현하세요
- 모바일 지원: 터치 컨트롤과 모바일 브라우저를 테스트하세요
토큰 인증
프로덕션 환경에서는 서버 측에서 토큰을 생성하세요:
// Backend endpoint
app.post("/api/get-token", (req, res) => {
const { channelName, uid } = req.body;
const token = RtcTokenBuilder.buildTokenWithUid(
AGORA_APP_ID,
AGORA_APP_CERTIFICATE,
channelName,
uid,
RtcRole.PUBLISHER,
Math.floor(Date.now() / 1000) + 3600 // 1 hour expiry
);
res.json({ token });
});// Frontend
const response = await fetch("/api/get-token", {
method: "POST",
body: JSON.stringify({ channelName, uid })
});
const { token } = await response.json();
await client.join(AGORA_APP_ID, channelName, token, uid);클라이언트 코드에서 앱 인증서를 절대 노출하지 마십시오.
흔히 저지르는 실수
1. 구독 해지 잊기
정리할 때는 모든 추적 기능을 중지하십시오:
async function leaveChannel() {
if (localAudioTrack) {
localAudioTrack.close();
localAudioTrack = null;
}
if (localVideoTrack) {
localVideoTrack.close();
localVideoTrack = null;
}
if (remoteAudioTrack) {
remoteAudioTrack.stop();
remoteAudioTrack = null;
}
await client.leave();
client = null;
}이 점을 간과하면 메모리 누수가 발생하고 카메라/마이크가 계속 활성화된 상태로 유지됩니다.
2. 사용자가 게시한 이벤트 처리 누락
원격 사용자를 구독하지 않으면 해당 사용자의 영상을 볼 수 없습니다:
client.on("user-published", async (user, mediaType) => {
await client.subscribe(user, mediaType);
if (mediaType === "video") {
user.videoTrack.play("remoteVideo");
} else if (mediaType === "audio") {
user.audioTrack.play();
}
});한 가지 브라우저로만 테스트할 때는 이 점을 잊기 쉽습니다. 항상 서로 다른 두 가지 브라우저나 기기로 테스트하세요.
3. 가입 전 메시지 전송
// Wrong: client not joined yet
client = AgoraRTC.createClient({...});
client.sendStreamMessage("hello"); // Error!// Right: wait for join
client = AgoraRTC.createClient({...});
await client.join(...);
client.sendStreamMessage("hello"); // Works메시지를 전송하기 전에는 항상 await client.join()을 호출해야 합니다.
4. Try-Catch 없이 메시지 파싱하기
// Dangerous
function handleStreamMessage(uid, msgData) {
const msg = JSON.parse(new TextDecoder().decode(msgData));
// What if msgData is corrupted?
}
// Safe
function handleStreamMessage(uid, msgData) {
try {
const msgStr = new TextDecoder().decode(msgData);
const msg = JSON.parse(msgStr);
// Process msg
} catch (e) {
console.error("Invalid message:", e);
}
}메시지를 사용하기 전에 항상 메시지 구조를 검증하십시오.
결론: 이 패턴을 사용할 때
다음과 같은 경우 게임에 Agora의 데이터 메시징을 사용하십시오:
- ✅ 이미 영상/음성 통신이 필요한 경우
- ✅ 이동 횟수가 드문 경우(플레이어당 초당 10회 미만)
- ✅ 플레이어들이 어차피 같은 채널에 있는 경우
- ✅ 백엔드 인프라를 최소화하고 싶은 경우
다음과 같은 경우에는 사용하지 마세요:
- ❌ ACK를 통한 메시지 전달 보장이 필요한 경우
- ❌ 게임 상태가 매우 큰 경우(업데이트당 10KB 초과)
- ❌ 서버가 최종 결정권을 가지는 검증(server-authoritative validation)이 필요한 경우
- ❌ 이동 속도가 네트워크 지연 시간을 초과하는 경우
이 배틀쉽(Battleship) 구현 예시는 실시간 통신, 경량 상태 동기화, 음성 상호작용이 단일 시스템 내에서 어떻게 함께 작동할 수 있는지 보여줍니다. 이 접근 방식은 체스, 카드 게임, 퀴즈와 같이 플레이어가 공유 상태와 실시간 상호작용을 모두 필요로 하는 다른 턴제 사용 사례에도 적용할 수 있습니다.
더 중요한 점은, 빈도가 낮고 비(非)중요한 업데이트의 경우 기존 RTC 연결을 확장하여 통신과 기본적인 상태 동기화를 모두 처리할 수 있음을 보여준다는 것입니다. 이를 통해 단일 실시간 레이어 위에 시스템을 구축할 수 있어 아키텍처의 복잡성을 줄이고, 초기 단계나 실험적 구현 시 추가 서비스가 필요하지 않게 됩니다.
진정한 이점은 무엇일까요? 바로 하나의 기능(화상 통화)을 구축하는 것만으로 멀티플레이어 게임 동기화 기능을 무료로 얻을 수 있다는 점입니다.
추가 자료
샘플 코드 저장소
이 배틀쉽 게임의 전체 소스 코드: BattleshipAgora


