Socket.IO를 사용하여 실시간 코드 스트리밍 앱을 빌드하고 Azure에 호스트

Microsoft Word의 공동 생성 기능과 같은 실시간 환경을 구축하는 것은 어려울 일일 수 있습니다.

Socket.IO는 사용법이 간단한 API를 통해 클라이언트와 서버 간의 실시간 통신에 적합한 라이브러리라는 것을 입증했습니다. 그러나 Socket.IO의 연결 크기 조정이 어렵다고 호소하는 Socket.IO 사용자들의 보고서가 자주 보입니다. Web PubSub for Socket.IO를 사용하면 개발자는 더 이상 영구 연결 관리에 대해 걱정할 필요가 없습니다.

개요

이 문서에서는 코더가 코딩 활동을 대상 그룹에 스트리밍할 수 있는 앱을 빌드하는 방법을 보여줍니다. 다음을 사용하여 이 애플리케이션을 빌드합니다.

  • Monaco Editor - Visual Studio Code를 지원하는 코드 편집기
  • Express - Node.js 웹 프레임워크
  • Socket.IO 라이브러리가 실시간 통신을 위해 제공하는 API
  • Web PubSub for Socket.IO를 사용하는 호스트 Socket.IO 연결

완성된 앱

코드 편집기 사용자는 완성된 앱을 사용하여 사람들이 입력을 볼 수 있는 웹 링크를 공유할 수 있습니다.

Screenshot of the finished code-streaming app.

약 15분 동안 프로시저에 집중하고 쉽게 이해할 수 있도록 이 문서에서는 두 가지 사용자 역할과 두 역할이 편집기에서 할 수 있는 작업을 정의합니다.

  • 온라인 편집기에 입력할 수 있고 콘텐츠가 스트리밍되는 작성자
  • 작성자가 입력한 실시간 콘텐츠를 수신할 수 있지만 편집할 수는 없는 뷰어

아키텍처

Item 목적 이점
Socket.IO 라이브러리 백 엔드 애플리케이션과 클라이언트 간에 대기 시간이 짧은 양방향 데이터 교환 메커니즘 제공 대부분의 실시간 통신 시나리오를 해결하는 손쉬운 API
Web PubSub for Socket.IO Socket.IO 클라이언트와 WebSocket 또는 폴링 기반 영구 연결을 호스트 100,000개의 동시 연결 지원. 간단한 애플리케이션 아키텍처

Diagram that shows how the Web PubSub for Socket.IO service connects clients with a server.

필수 조건

이 문서의 단계를 수행하려면 다음이 필요합니다.

Web PubSub for Socket.IO 리소스 만들기

다음과 같이 Azure CLI를 사용하여 리소스를 만듭니다.

az webpubsub create -n <resource-name> \
                    -l <resource-location> \
                    -g <resource-group> \
                    --kind SocketIO \
                    --sku Free_F1

연결 문자열 가져오기

연결 문자열을 사용하면 Web PubSub for Socket.IO에 연결할 수 있습니다.

다음 명령을 실행합니다. 이 문서의 뒷부분에서 애플리케이션을 실행할 때 필요하므로 반환된 연결 문자열을 어딘가에 보관해 두세요.

az webpubsub key show -n <resource-name> \ 
                      -g <resource-group> \ 
                      --query primaryKey \
                      -o tsv

애플리케이션의 서버 쪽 코드 작성

먼저 서버 쪽부터 작업하여 애플리케이션의 코드를 작성합니다.

HTTP 서버 빌드

  1. Node.js 프로젝트 만들기

    mkdir codestream
    cd codestream
    npm init
    
  2. 다음과 같이 서버 SDK 및 Express를 설치합니다.

    npm install @azure/web-pubsub-socket.io
    npm install express
    
  3. 필요한 패키지를 가져오고 정적 파일을 처리하는 HTTP 서버를 만듭니다.

    /*server.js*/
    
    // Import required packages
    const express = require('express');
    const path = require('path');
    
    // Create an HTTP server based on Express
    const app = express();
    const server = require('http').createServer(app);
    
    app.use(express.static(path.join(__dirname, 'public')));
    
  4. /negotiate 엔드포인트를 정의합니다. 작성자 클라이언트가 먼저 이 엔드포인트에 도달합니다. 이 엔드포인트는 HTTP 응답을 반환합니다. 응답에는 클라이언트가 영구 연결을 설정하는 데 사용해야 하는 엔드포인트가 포함되어 있습니다. 또한 클라이언트에 할당된 room 값을 반환합니다.

    /*server.js*/
    app.get('/negotiate', async (req, res) => {
        res.json({
            url: endpoint
            room_id: Math.random().toString(36).slice(2, 7),
        });
    });
    
    // Make the Socket.IO server listen on port 3000
    io.httpServer.listen(3000, () => {
        console.log('Visit http://localhost:%d', 3000);
    });
    

Web PubSub for Socket.IO 서버 만들기

  1. 다음과 같이 Web PubSub for Socket.IO SDK를 가져오고 옵션을 정의합니다.

    /*server.js*/
    const { useAzureSocketIO } = require("@azure/web-pubsub-socket.io");
    
    const wpsOptions = {
        hub: "codestream",
        connectionString: process.argv[2]
    }
    
  2. 다음과 같이 Web PubSub for Socket.IO 서버를 만듭니다.

    /*server.js*/
    
    const io = require("socket.io")();
    useAzureSocketIO(io, wpsOptions);
    

이 Socket.IO 설명서에 설명된 대로 두 단계는 Socket.IO 서버를 만드는 일반적인 방법과 약간 다릅니다. 이 두 단계를 수행하면 서버 쪽 코드가 영구 연결 관리를 Azure 서비스에 오프로드할 수 있습니다. Azure 서비스의 도움으로 애플리케이션 서버는 가벼운 HTTP 서버로만 작동합니다.

비즈니스 논리 구현

Web PubSub에 호스트되는 Socket.IO 서버를 만들었으므로, 이제 Socket.IO의 API를 사용하여 클라이언트와 서버가 통신하는 방법을 정의할 수 있습니다. 이 프로세스를 비즈니스 논리 구현이라고 합니다.

  1. 클라이언트가 연결되면 애플리케이션 서버는 클라이언트에 login 사용자 지정 이벤트를 보내서 로그인되었음을 알립니다.

    /*server.js*/
    io.on('connection', socket => {
        socket.emit("login");
    });
    
  2. 각 클라이언트는 joinRoomsendToRoom 이벤트를 내보내고 서버는 이 이벤트에 응답할 수 있습니다. 서버가 클라이언트에서 조인하려는 room_id 값을 가져오면 Socket.IO의 API에서 socket.join을 사용하여 대상 클라이언트를 지정된 방에 조인합니다.

    /*server.js*/
    socket.on('joinRoom', async (message) => {
        const room_id = message["room_id"];
        await socket.join(room_id);
    });
    
  3. 클라이언트가 조인되면 서버는 message 이벤트를 전송하여 클라이언트에 성공했다고 알립니다. 클라이언트가 ackJoinRoom 형식의 message 이벤트를 받으면 클라이언트는 서버에 최신 편집기 상태를 보내라고 요청할 수 있습니다.

    /*server.js*/
    socket.on('joinRoom', async (message) => {
        // ...
        socket.emit("message", {
            type: "ackJoinRoom", 
            success: true 
        })
    });
    
    /*client.js*/
    socket.on("message", (message) => {
        let data = message;
        if (data.type === 'ackJoinRoom' && data.success) {
            sendToRoom(socket, `${room_id}-control`, { data: 'sync'});
        }
        // ... 
    });
    
  4. 클라이언트가 sendToRoom 이벤트를 서버에 보내면 서버는 코드 편집기 상태 변경 내용을 지정된 방으로 브로드캐스트합니다. 이제 방에 있는 모든 클라이언트가 최신 업데이트를 받을 수 있습니다.

    socket.on('sendToRoom', (message) => {
        const room_id = message["room_id"]
        const data = message["data"]
    
        socket.broadcast.to(room_id).emit("message", {
            type: "editorMessage",
            data: data
        });
    });
    

애플리케이션의 클라이언트 쪽 코드 작성

서버 쪽 프로시저가 완료되었으므로, 이제 클라이언트 쪽에서 작업하면 됩니다.

초기 설정

서버와 통신할 Socket.IO 클라이언트를 만들어야 합니다. 문제는 클라이언트가 영구 연결을 설정해야 하는 서버입니다. Web PubSub for Socket.IO를 사용하므로, 서버는 Azure 서비스입니다. 앞에서 클라이언트에 Web PubSub for Socket.IO에 대한 엔드포인트를 제공하는 /negotiate 경로를 정의했습니다.

/*client.js*/

async function initialize(url) {
    let data = await fetch(url).json()

    updateStreamId(data.room_id);

    let editor = createEditor(...); // Create an editor component

    var socket = io(data.url, {
        path: "/clients/socketio/hubs/codestream",
    });

    return [socket, editor, data.room_id];
}

initialize(url) 함수는 다음과 같은 몇 가지 설정 작업을 함께 구성합니다.

  • HTTP 서버에서 Azure 서비스에 대한 엔드포인트 가져오기
  • Monaco Editor 인스턴스 만들기
  • Web PubSub for Socket.IO와의 영구 연결 설정

작성자 클라이언트

앞서 언급했듯이, 클라이언트 쪽에는 작성자와 뷰어라는 두 가지 사용자 역할이 있습니다. 작성자가 입력하는 모든 내용이 뷰어의 화면으로 스트리밍됩니다.

  1. 다음과 같이 Web PubSub for Socket.IO에 대한 엔드포인트와 room_id 값을 가져옵니다.

    /*client.js*/
    
    let [socket, editor, room_id] = await initialize('/negotiate');
    
  2. 작성자 클라이언트가 서버에 연결되면 서버는 작성자에게 login 이벤트를 보냅니다. 작성자는 서버에 지정된 방에 직접 조인하도록 요청하여 응답할 수 있습니다. 200밀리초마다 작성자 클라이언트는 최신 편집기 상태를 방으로 보냅니다. flush 함수는 보내기 논리를 구성합니다.

    /*client.js*/
    
    socket.on("login", () => {
        updateStatus('Connected');
        joinRoom(socket, `${room_id}`);
        setInterval(() => flush(), 200);
        // Update editor content
        // ...
    });
    
  3. 작성자가 아무 것도 편집하지 않으면 flush() 함수는 아무 작업도 하지 않고 그냥 반환합니다. 무언가를 편집하면 편집기 상태 변경 내용이 방으로 전송됩니다.

    /*client.js*/
    
    function flush() {
        // No changes from editor need to be flushed
        if (changes.length === 0) return;
    
        // Broadcast the changes made to editor content
        sendToRoom(socket, room_id, {
            type: 'delta',
            changes: changes
            version: version++,
        });
    
        changes = [];
        content = editor.getValue();
    }
    
  4. 새 뷰어 클라이언트가 연결되면 뷰어는 편집기의 최신 완료 상태를 가져와야 합니다. 이를 위해 sync 데이터가 포함된 메시지가 작성자 클라이언트로 전송됩니다. 이 메시지는 작성자 클라이언트에 완료 편집기 상태를 보내라고 요청합니다.

    /*client.js*/
    
    socket.on("message", (message) => {
        let data = message.data;
        if (data.data === 'sync') {
            // Broadcast the full content of the editor to the room
            sendToRoom(socket, room_id, {
                type: 'full',
                content: content
                version: version,
            });
        }
    });
    

뷰어 클라이언트

  1. 작성자 클라이언트와 마찬가지로 뷰어 클라이언트는 initialize()를 통해 Socket.IO 클라이언트를 만듭니다. 뷰어 클라이언트가 연결되고 서버로부터 login 이벤트를 받으면 뷰어 클라이언트는 지정된 방에 직접 조인하라고 서버에 요청합니다. room_id 쿼리는 이 방을 지정합니다.

    /*client.js*/
    
    let [socket, editor] = await initialize(`/register?room_id=${room_id}`)
    socket.on("login", () => {
        updateStatus('Connected');
        joinRoom(socket, `${room_id}`);
    });
    
  2. 뷰어 클라이언트가 서버로부터 message 이벤트를 수신하고 데이터 형식이 ackJoinRoom이면 뷰어 클라이언트는 완료 편집기 상태를 보내라고 방의 작성자 클라이언트에게 요청합니다.

    /*client.js*/
    
    socket.on("message", (message) => {
        let data = message;
        // Ensures the viewer client is connected
        if (data.type === 'ackJoinRoom' && data.success) { 
            sendToRoom(socket, `${id}`, { data: 'sync'});
        } 
        else //...
    });
    
  3. 데이터 형식이 editorMessage이면 뷰어 클라이언트는 실제 내용에 따라 편집기를 업데이트합니다.

    /*client.js*/
    
    socket.on("message", (message) => {
        ...
        else 
            if (data.type === 'editorMessage') {
            switch (data.data.type) {
                case 'delta':
                    // ... Let editor component update its status
                    break;
                case 'full':
                    // ... Let editor component update its status
                    break;
            }
        }
    });
    
  4. 다음과 같이 Socket.IO의 API를 사용하여 joinRoom()sendToRoom()을 구현합니다.

    /*client.js*/
    
    function joinRoom(socket, room_id) {
        socket.emit("joinRoom", {
            room_id: room_id,
        });
    }
    
    function sendToRoom(socket, room_id, data) {
        socket.emit("sendToRoom", {
            room_id: room_id,
            data: data
        });
    }
    

애플리케이션 실행

리포지토리 찾기

이전 섹션에서는 뷰어와 작성자 간의 편집기 상태 동기화와 관련된 핵심 논리를 설명했습니다. 전체 코드는 예제 리포지토리에서 찾을 수 있습니다.

리포지토리 복제

리포지토리를 복제하고 npm install 명령을 실행하여 프로젝트 종속성을 설치할 수 있습니다.

서버 시작하기

node server.js <web-pubsub-connection-string>

이것은 이전 단계에서 받은 연결 문자열입니다.

실시간 코드 편집기에서 재생

브라우저 탭에서 http://localhost:3000을 엽니다. 첫 번째 웹 페이지에 URL이 표시된 채로 다른 탭을 엽니다.

첫 번째 탭에서 코드를 작성하는 경우 입력하는 내용이 다른 탭에 실시간으로 반영되는 것을 볼 수 있습니다. Web PubSub for Socket.IO를 사용하면 클라우드에서 메시지를 쉽게 전달할 수 있습니다. express 서버는 정적 index.html 파일 및 /negotiate 엔드포인트만 제공합니다.