11 October 2020

TL;DR

이전 포스팅에서 이어진다.

현재까지 진행 코드 : https://github.com/neocjmix/WebRTC/tree/p2p-화상채팅-구현-(1)

이번 포스팅에서는 WebRTC의 기술적 내용보다는 AWS 설정 과정 주를 이룬다. 필요한 사전지식의 범위를 최소화 하기 위해서 별도의 framework이나 AWS CloudFormation등을 이용하지 않고 웹 콘솔 설정 화면을 캡쳐해 보여줄 것이다. 하지만 서버/클라이언트의 로직과 구현방식에도 다소 변화가 있기 때문에 AWS의 API Gateway, lambda, S3에 대해 지식이 있거나, 각종 삽질과정을 보기원하지 않는다면 요약 챕터만 읽어도 무방하다.

구현목표

현재까지 앱의 구성을 보면 한개의 Node.js 기반 WAS에서 Socket.io를 통해 서로 통신하는 한개의 signaling 엔드포인트와 한개의 정적 html 페이지로 이루어져 있다. 이 Node.js 앱을 통째로 AWS EC2에 올려도 별 문제 없이 작동 하겠지만, 이 튜토리얼에서는 serverless 환경으로 구성해보고자 한다. 목표하는 구성은 다음과 같다.

내용 as-is to-be
WebSocket Socket.io AWS API Gateway WebSocket API
Signaling Server Node.js ‘http’ AWS lambda
frontend Node.js ‘node-static’ AWS S3

또한 로컬 네트웍이 아닌 인터넷 환경에서 새롭게 발생하는 문제들을 해결하고, 화상통화를 할 상대방을 식별하는 간단한 방법을 구현하여 실제 동작 가능한 레벨로 로직을 향상시킬 것이다.

준비할것

  • AWS 계정 : AWS웹사이트 에서 가입.
    프리티어로 시작하면 왠만한 기능은 적당한 트래픽 한도 내에서 12개월동안 무료이다.

AWS S3

이전 포스팅에서도 그랬지만, 일단 눈에 보이는 거부터 해보자.

(특별한 설정은 필요없기때문에 S3 콘솔에 익숙하지 않은 경우에만 아래 섹션을 클릭해 참조하면 되겠다.)

이전 프로젝트의 client 디렉토리의 파일들을 S3 스토리지에 올려서 https 프로토콜로 서빙한다. (자세히 보려면 클릭)
  1. AWS S3 콘솔 에 접속해서 Create Bucket 버튼을 클릭해 버킷을 하나 만든다. step1

  2. Bucket Name과 Region을 정하고 Next를 클릭한다. step2

  3. 이 버킷에 올리는 모든 파일을 호스팅 할 예정이므로 모든 접근을 열어준다. step3 이후 기본값으로 Next를 계속 클릭해 버킷을 생성한다.

  4. 버킷에 들어간다. step4

  5. Upload를 클릭한다. step5 나타나는 모달에 /client 이하 세개의 파일들을 Drag & Drop step6 이후 Next를 계속 클릭해 업로드를 마무리한다.

  6. 모든 파일을 선택해 Actions → Make Public step7

  7. index.html을 클릭해서 Overview를 확인하면 public URL을 확인할 수 있다. step8 클릭하면 클라이언트 앱을 브라우저에 띄운다.

S3를 통해서 client App을 호스팅하고 브라우저에 띄워보면 HTTP 주소로 특별한 경고 메시지 없이 정상적으로 잘 접속되는 것을 확인할 수 있다. 하지만 백엔드에서 Socket.io를 서빙하고 있지 않으므로 정상작동하지 않으며 콘솔의 에러메시지를 확인하면 sendMessage, onMessage를 구현하고 있는 message.js에서 socket.io를 로딩할 수 없다는 에러를 확인할 수 있다.

구현목표 를 다시 확인해보면 Socket.io를 AWS API Gateway WebSocket API로 변경할 예정이므로 다음챕터에서 WebSocket endpoint를 AWS에 구현한 다음 돌아와서 client 코드를 수정해야 한다.

AWS API Gateway - WebSocket API

이제 Socket.io와 http모듈을 통해서 구현했던 signaling 서버를 AWS API Gateway와 AWS Lambda 조합으로 재구성한다. AWS API Gateway는 AWS의 여러가지 서비스를 외부에서 작동하기 위한 endpoint를 만들 수 있는 서비스이다. REST API뿐만 아니라 웹소켓 API도 생성할 수 있다.

먼저 WebSocket endpoint를 AWS API Gateway를 통해서 구성한다. (자세히 보려면 클릭)
  1. AWS API Gateway에 접속해서 Create API 버튼을 클릭해 새로운 endpoint 구성을 시작한다.
    (API를 생성한 적이 없다면 건너뛰고 다음 단계로 진행) step1

  2. 여러 API type중 WebSocket API 를 선택한다. step2

  3. 임의의 API 이름을 지정하고 Next를 클릭한다. step3

  4. 이전 화면에서 route selection expression에 설정했던 $request.body.action 값에 따라 여러 integration으로 routing을 할 수 있는데 여기서는 모든 요청을 받아줄 단 한개의 integration으로 구성할 것이기 때문에 $default route만 추가하고 Next를 클릭한다.
    step4

  5. 실제 요청을 가지고 비즈니스 로직을 처리할 integration을 설정해야 한다. 원래 Lambda function과 integration하는 것이 목표인데, 아직 Lambda function은 채 만들지 않았다. 일단 되는대로 Mock으로 진행. step6

  6. 배포 스테이지를 정하는 단계이다. 개발 스테이지를 따로 만든다는지 그런걸 생각할 단계는 아니다. 기본값으로 진행. step7 Next를 클릭하면 API 가 생성된다.

  7. 일단 배포를 하고 보자. Actions → Deploy API step11

  8. Dashboard 에 들어가 보면 step8 WebSocket endpoint가 생성되어 있다. step9

API 테스트

여기까지 진행했다면 내용을 떠나서 URL이 생성되었으니 WebSocket 연결은 가능할 것같다. 브라우저에서 새탭을 열고(aws 가 열려있는 탭에서 하면 에러가 난다.) 개발자 도구 콘솔창에 입력해보자.

// 아래 세줄을 한번에 붙여넣는다.
const ws = new WebSocket("wss://{apiId}.execute-api.{region}.amazonaws.com/{stage}"); // 생성된 WebSocket URL
ws.onopen = () => console.log("connected.");
ws.onmessage = e => console.log(e.data);
// connected.

ws.send('') //대충 아무 내용이나 보내보자.
// {"message": "Internal server error", "connectionId":"UP98SdpMIE0CJPw=", "requestId":"UP9_GH07oE0Ft9w="}

위 코드는 Socket.io를 이용하지 않고 브라우저 기본 API를 이용해서 WebSocket 메시지를 주고받는 코드이다. 나중에 활용되니 눈여겨봐두자.

wscat라는 CLI 툴을 이용해서 테스트할 수도 있다.

> npm install -g wscat
> wscat -c wss://{apiId}.execute-api.{region}.amazonaws.com/{stage} ## 생성된 WebSocket URL
Connected (press CTRL+C to quit)
> {"action":"foobar"}
< {"message": "Internal server error", "connectionId":"UPtw1ejFoE0CFAQ=", "requestId":"UPtxGFJ0oE0FRYA="}

제대로 된 integration이나 response설정을 하지 않았기 때문에 error가 발생할 것이다. 이제 Lambda 함수를 생성해서 integration 연결을 해보자.

AWS Lambda

AWS Lambda는 AWS serverless 아키텍쳐의 핵심으로 내부 서비스나 API Gateway를 통해 호출되어 일정한 비즈니스 로직을 수행할 수 있다. Lambda function은 매번 새로운 인스턴스로 실행되기 때문에 상태를 유지하는것이 불가능하며, 이는 Stateful 프로토콜인 WebSocket과는 배치되는 일면이 있어 보이지만, WebSocket API의 connectionId를 사용한 접속자 구분 기능과 DynamoDB등 persistent서비스와의 연동을 통해서 stateful한 비즈니스 로직을 구성할 수 있다.

이번 챕터에서는 일단 기본 function을 생성해서 API Gateway와 연동하고 브라우저에서 connection을 이루어 메시지를 주고받는것 까지만 한다.

이를 위해서

새 Lambda function을 작성하고 WebSocket API 의 $default route integration에 설정한 후 integration response를 추가하고 API를 배포한다 (자세히 보려면 클릭)
  1. AWS API Lambda 에 접속해서 Create function 버튼을 클릭해 새로운 함수를 추가한다. step1

  2. 일단 Hello World만 할거니까 특별한 설정을 하지 않는다. 함수 이름을 넣고 Create function, step2 기본 구현 그대로 두고 함수생성을 마친다. 아마 아래와 같은 코드가 생성될 것이다.
    // AWS Lambda function scratch Hello World example 
    exports.handler = async (event) => {
     // TODO implement
     const response = {
         statusCode: 200,
         body: JSON.stringify('Hello from Lambda!'),
     };
     return response;
    };
    
  3. API Gateway 로 돌아가서 아까 만든 WebSocket API $default 라우트를 선택한뒤 현재 MOCK으로 설정되어 있는 Integration Request를 클릭 step3

  4. Lambda Function을 선택하고 방금 작성한 function을 고른후 Save. (API와 lambda의 Region이 서로 다르다면 번거로운 추가 설정이 필요하니 확인해보고 같은 Region으로 다시 만든다.) step4

  5. integration에서 리턴한 결과를 client에게 돌려주려면 integration Response를 추가해줘야만 한다. 그렇지 않으면 비즈니스 로직은 실행이 되지만 클라이언트는 아무런 응답을 받을 수 없다. step5

  6. 마지막으로 변경사항을 적용하기 위해서는 API를 반드시 재배포해야한다. step6

여기까지 진행한 후에 위 API 테스트 에서 실행한 절차를 다시 수행해보자. Hello from Lambda! 라는 ws 응답을 확인할 수 있을 것이다.

ConnectionId 가져오기

지난 포스팅에서 완성한 signaling서버 예제에서는 구현을 간단히 하기 위해서 모든 메시지를 broadcasting하였다. 이것은 서버 로직을 극단적으로 줄여주긴 했지만 결과적으로는 실제 사용에 큰 제약이 있는 시그널링 로직이었다. 게다가 socket.io의 broadcasting은 room 기능을 이용해서 구현되는데, 현재 접속한 사용자들의 목록을 유지하기 위해서 메모리에 state유지가 가능한 서버가 필요하다.

stateless한 Lambda function에서 broadcasting을 구현하기 위해서는 WebSocket API의 $connect $disconnect route를 이용하여 각 connection의 세션 정보를 DynamoDB등 persistent서비스에 저장하는 로직이 필요하다. WebSocket API가 integration된 Lambda function의 event 인자에 connectionId를 넘겨주므로 이를 이용하여 다음과 같은 구조로 구현할 수 있겠다.

(참고) Broadcasting을 위한 로직 구성

flowchart LR subgraph Sender C0(connection0) end subgraph Recievers C1("connection1 (id1)") C2("connection2 (id2)") C3("connection3... (id3...)") end subgraph "WebSocket API/$connect" L1("Lambda1") L2("Lambda1") L3("Lambda1") end P[(Persistence)] subgraph "WebSocket API/$message" L0("Lambda0") end C1 -->|Id1| L1 -->|Id1| P C2 -->|Id2| L2 -->|Id2| P C3 -->|Id3...| L3 -->|Id3...| P C0 -->|message| L0 --> Recievers L0 --> Recievers L0 --> Recievers L0 --> Recievers L0 --> Recievers L0 --> Recievers P -.->|"[id1, id2, id3, ...]"| L0
Reciever들이 자신의 connectionID를 persistence서비스에 저장해두면
Sender에서 broadcasting 요청이 있을때 저장되어 있던 모든 connectionID에게 메시지를 전달하는 식이다.

하지만 이 연재에서 구현하려고 하는것은 1:1 통신이고 지정된 상대방에게만 메시지를 전달하면 된다. connectionId가 제공되는 Lambda 환경에서는 브로드캐스팅보다 훨씬 간단하게 구현가능하다. 이걸 어떻게 하는지는 나중에 구현할때 보고 일단 자신의 connectionId를 받아와 보자.

먼저 Lambda function 코드를 다음과 같이 수정하고 deploy 버튼을 누른다.

Lambda function

exports.handler = async (event) => {
  return {
    statusCode: 200,
    body: JSON.stringify({ connectionId : event.requestContext.connectionId }), // connectionId를 되돌려준다.
  };
};

그다음 socket.io로 되어있는 message.js를 새로운 API를 사용하는 WebSocket 코드로 재작성 해보자. API 테스트에서 사용했던 브라우저 WebSocket API를 이용해서 작성한다.

index.html

...
<h2>내 화면</h2>
<input type="text" readonly id="localConnectionId" /> <!--내 ConnectionId를 표시할 input-->
<video id="localVideo" autoplay width="480px" playsinline></video>
<h2>상대방 화면</h2>
<video id="remoteVideo" autoplay width="480px" playsinline></video>
<button id="call">📞</button>
<!--삭제 <script src="/socket.io/socket.io.js"></script>-->
<script src="script.js" type="module"></script>
...

message.js

코드가 깨지지 않도록 기존 함수들의 interface만 유지하고 구현부는 일단 삭제한다.
getConnectionId 함수를 추가한다.

// WebSocket API
const WEB_SOCKET_API_URL = 'wss://{apiId}.execute-api.{region}.amazonaws.com/{stage}';
const socket = new WebSocket(WEB_SOCKET_API_URL); 

// 기존 함수들 인터페이스 유지
export const sendMessage = (type, payload) => {/* 나중에 구현예정 */};
export const onMessage = (type, callback) => socket.addEventListener('message', e => {/* 나중에 구현예정 */});

export const waitForConnected = new Promise(resolve => socket.addEventListener('open', resolve));

export const getConnectionId = async () => {
  await waitForConnected;
  socket.send('');
  return new Promise(resolve => {
    socket.addEventListener('message', e => resolve(JSON.parse(e.data).connectionId), {once: true});
  });
};

script.js

맨 처음에 다음 내용 추가

import {onMessage, sendMessage, getConnectionId} from "./message.js"; // getConnectionId import 추가
getConnectionId().then(connectionId => document.getElementById('localConnectionId').value = connectionId);

수정된 세 파일을 S3에 업로드 하고 Make Public을 실행 해준다. index.html의 URL로 접속해보면 새로 추가된 input에 자신의 connectionId가 표시될 것이다.
(웹 소켓 연결 에러가 날 경우 WEB_SOCKET_API_URL을 제대로 지정해주었는지 Gateway Dashboard를 보고 확인해보자.)

이 connectionId가 signal 서버에서 인식하는 현재 클라이언트의 주소라고 할 수 있고, 두 클라이언트 간에 이 ID정보를 서로 교환하면 signaling server를 통해서 서로 1:1 메시지를 주소받을 수 있다.

@Connection API

여태까진 클라이언트발 메시지와 이에 대한 응답만이 구현되었다. 한쪽의 메시지를 다른쪽에게 전달하려면 server to client 메시지도 구현해야 한다. 이전에 생성한 WebSocket API의 Dashboard를 다시한번 봐보자. step10 Connection URL 항목에 있는 URL은 @connections API 를 호출할 수 있는 API 주소이다. 이 URL에 다음과 같이 요청을 보내면 특정한 ConnectionId를 통해 연결된 client에게 메시지를 전달할 수 있다.

POST https://{api-id}.execute-api.{region}.amazonaws.com/{stage}/@connections/{connection_id}

위의 API reference를 보면 POST 이외에도 다른 method를 통해서 연결을 끊거나 상태를 조회하는 등의 동작을 수행할 수 있는것을 확인할 수 있다.

Lambda 함수에서는 AWS SDK를 이용하면 좀더 편리하다.

event => {
    const AWS = require('aws-sdk');
    new AWS
      .ApiGatewayManagementApi({ endpoint: `${event.requestContext.domainName}/${event.requestContext.stage}` })
      .postToConnection({ ConnectionId: connectionId, Data: '...'})
    }

AWS WebSocket을 통한 양방향 메세징으로 signaling 구현

이제 두 클라이언트 간에 connectionId만 있으면 서로 메세지를 교환할 수 있도록 구현할 재료가 준비되었다. 실제 구현을 해보자.

구현할 WebSocket API

  • ws 요청 :
    // stringified JSON
    { 
      connectionId?: string, // 메시지를 전달할 상대방의 Connection Id, 
      message?: string, // 메시지내용
    }
    
  • ws 응답 :
    // stringified JSON
    { 
      ConnectionId : string // 자기자신의 Connection Id를 돌려준다.
    }
    

Lambda function

const AWS = require('aws-sdk');

exports.handler = async (event) => {
  const from = event.requestContext.connectionId;
  const endpoint = `${event.requestContext.domainName}/${event.requestContext.stage}`;

  // 요청 body JSON에 connectionId가 있으면 메시지 전달
  if(event.body){
    const {connectionId, message} = JSON.parse(event.body);
    if(connectionId){
      await new AWS
        .ApiGatewayManagementApi({ endpoint })
        .postToConnection({
          ConnectionId: connectionId,
          Data: JSON.stringify({ message, from }),
        })
        .promise(); 
    }
  }

  // 요청 connectionId를 되돌려줌
  return {
    statusCode: 200,
    body: JSON.stringify({ ConnectionId : from }),
  };
};

위와 같이 작성하고 deploy 한다.

S3

message.js

구현예정이었던 메세징 부분을 구현한다. 메시지를 특정 peer에게 보내는 방식으로 바뀌었기 때문에 각 메소드의 파라미터에 상대방의 connectionId정보가 추가되어야 한다.

/*...*/

// 메시지를 보낼 상태의 connectionId를 추가로 받는다.
export const sendMessage = async (connectionId, type, payload) => {
  await waitForConnected;
  socket.send(JSON.stringify({
    connectionId,
    message: {
      type,
      payload
    }
  }));
};

export const onMessage = (type, callback) => socket.addEventListener('message', e => {
  const {message, from} = JSON.parse(e.data);
  if(message == null) return;
  if(message.type === type) callback(message.payload, from); // 메시지를 보내온 상대의 connectionId도 넘겨준다.
});

/*...*/

index.html

메시지를 보낼 상대방의 ConnectionId를 입력받을 input을 추가한다.

...
<h2>내 화면</h2>
<input type="text" readonly id="localConnectionId" />
<video id="localVideo" autoplay width="480px" playsinline></video>
<h2>상대방 화면</h2>
<input type="text" id="remoteConnectionId" /> <!--이 부분 추가-->
<video id="remoteVideo" autoplay width="480px" playsinline></video>
<button id="call">📞</button>
<script src="script.js" type="module"></script>
...

script.js 위 message.js의 sendMessage, onMessage 함수의 변경된 파라미터에 맞춰서 호출함수도 수정해준다.

// 파일 상단 어딘가에
const remoteConnectionId = document.getElementById('remoteConnectionId');

/*...*/

// SDP offer메시지 보내는 부분
const sendSdpOffer = async () => {
  const rtcSessionDescriptionInit = await rtcPeerConnection.createOffer();
  await rtcPeerConnection.setLocalDescription(rtcSessionDescriptionInit);
  // sendMessage 호출의 첫번째 인자로 사용자로부터 input에 입력받은 remoteConnectionId를 추가
  sendMessage(remoteConnectionId.value, 'SDP', rtcSessionDescriptionInit)
};

/* ...(중요!) 여기에 적혀있지 않아도, sendMessage 호출에 remoteConnectionId.value를 추가해야 한다... */

// SDP offer 메시지 받는 부분
onMessage('SDP', async (descriptionInit, from) => { // callback 실행 인자에 상대방의 connectionId 정보가 추가되었다.
  const rtcSessionDescription = new RTCSessionDescription(descriptionInit);
  await rtcPeerConnection.setRemoteDescription(rtcSessionDescription);
  if (descriptionInit.type === 'offer') {
    // offer를 받는 입장에서는 remoteId를 입력할 input이 비어 있다.
    // offer를 보내온 상대방의 id가 remoteId 이므로 이것으로 input 및 변수를 채우고 이후 통신에 사용한다.
    remoteConnectionId.value = from;
    callButton.disabled = true
    await sendSdpAnswer();
  }
})

거의 다 되었다. 이제 S3 bucket의 index.html을 두 브라우저에 각각 띄운 후 한쪽의 localConnectionId를 다른쪽 remoteConnectionId에 넣고 call 버튼을 클릭해보자.

…아무 일도 일어나지 않는다.

개발자 도구의 네트워크 탭을 확인해보자.

step1

internal server error Lambda 함수 실행시 뭔가 문제가 생긴듯 하다.

Lambda 함수 에러로그를 확인하러 가보자.

  1. step2
  2. step3
  3. step4

Lambda 함수가 @connections API를 실행할 권한이 없는 모양이다. 일단 요것만 해결하면 될 거 같다.

AWS 서비스간 권한설정

AWS 내부 서비스에서 다른 서비스를 호출하기 위해서는 권한 설정이 필요하다. 다음 절차를 통해서 Lambda 함수가 API Gateway 기능을 사용할 수 있도록 설정해주어야 한다.

람다 함수에 API Gateway 권한 추가 (자세히 보려면 클릭)
  1. step1

  2. step2

  3. 전체 권한을 허용해도 되지만 아무래도 구체적으로 제한하는게 바람직하다. 위의 에러 메시지를 잘 보면 실행이 필요한 서비스, 작업, 리소스를 알 수 있다.

      //            v서비스     v작업                          v리소스
      ...to perform execute-api:ManageConnections on resource: arn:aws:execute-api:......
    

    리소스는 @connection/{connectionId}@connection/* 같이 하위패스까지는 입력이 안되므로 production/POST/* 형식으로만 입력하였다. step3

위와 같이 권한까지 마저 설정하고 다시 두개의 브라우저에 앱을 띄워서 한쪽의 connectionId를 다른쪽에 입력하고 call을 하면 제대로 작동하는 것을 볼 수 있다.

지난 포스팅에서 앱을 제대로 작동시키기 위한 여러가지 제약사항이 있었는데 이제 그중 상당수가 해결이 되었다. 임시로만든 브로드캐스팅 방식이 아니라 제대로 상대방을 지정해서 통신할 수 있고, 동시에 여러 명이 접속해도 상대방의 connectionId만 잘 지정하면 문제없이 동작한다. 그리고 제대로 된 public URL을 가지고 있고 TLS를 통해서 서빙되므로 보안경고를 우회하느라 애 쓸 필요 없이 정상적으로 접속된다.

아직도 남은 숙제가 있다

하지만 과연 이대로 온전히 서비스가 가능한 것일까? 아직 WebRTC의 길은 험난하다.
만약 서로 다른 통신망에 접속한 기기간에 화상통화를 시도한다면 과연 정상적으로 동작할 수 있을까!

이런 상황을 만들기 위해서 가장 간단한 방법은 와이파이망에 접속된 PC에 화면 하나를 띄우고, LTE등 모바일 네트워크에 접속된 모바일 기기에서도 접속해서 화상통화를 시도해 보는 것이다. 이렇게 하면 다음과 같은 에러메시지가 뜬다.

Failed to execute 'addIceCandidate' on 'RTCPeerConnection': Error processing ICE candidate

ICE candidate를 처리하지 못했다는 말인데, 서로의 peer에 접근할 수 있는 제대로된 경로를 찾지 못했다는 얘기가 되겠다. STUN이라 불리는 특수한 서버를 이용해서 이걸 해결하는 방법, 그리고 p2p 연결이 불가능한 네트워크 상황일 때 TURN이라 불리는 중계 서버를 통해서 미디어를 전송하는 방법에 대해서 다음 포스팅에서 이어갈 예정이다.

요약

Github branch 에서도 코드를 확인할 수 있다.

AWS API Gateway

설정 요약

  • 유형 : WebSocket API
  • 경로 :
    • $default
      • 통합요청: LAMBDA_PROXY → Lambda function → 통합응답

AWS Lambda

설정 요약

  • 권한
    • 인라인 정책 추가
      • JSON
           {
              "Version": "2012-10-17", // [!주의] 현재 날짜가 아니라 고정값임
              "Statement": [
                  {
                      "Sid": "{임의의 고유한 ID}",
                      "Effect": "Allow",
                      "Action": "execute-api:ManageConnections",
                      "Resource": "arn:aws:execute-api:{REGION}:{ID}:{API_ID}/{STAGE}/POST/*"
                  }
              ]
           }
        

Code

const AWS = require('aws-sdk');

exports.handler = async (event) => {
  const from = event.requestContext.connectionId;
  const endpoint = `${event.requestContext.domainName}/${event.requestContext.stage}`;
  
  // 요청 body JSON에 connectionId가 있으면 메시지 전달
  if(event.body){    
    const {connectionId, message} = JSON.parse(event.body);
    await new AWS
      .ApiGatewayManagementApi({ endpoint })
      .postToConnection({
        ConnectionId: connectionId,
        Data: JSON.stringify({ message, from }),
      })
      .promise();
  }

  // 요청 connectionId를 되돌려줌
  return {
    statusCode: 200,
    body: JSON.stringify({ connectionId : from }),
  };
};

AWS S3

index.html

<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>WebRTC</title>
</head>
<body>
    <h1>화상통화</h1>
    
    <h2>내 화면</h2>
    <input type="text" readonly id="localConnectionId" /> <!--내 ConnectionId-->
    <video id="localVideo" autoplay width="480px" playsinline></video>
    
    <h2>상대방 화면</h2>
    <input type="text" id="remoteConnectionId" /> <!--상대방 ConnectionId-->
    <video id="remoteVideo" autoplay width="480px" playsinline></video>
    <button id="call">📞</button>
    
    <script src="script.js" type="module"></script>
</body>
</html>

message.js

// AWS API Gateway 대시보드에 표시된 URL
const WEB_SOCKET_API_URL = `wss://${API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE}/`;
const socket = new WebSocket(WEB_SOCKET_API_URL);

export const waitForConnected = new Promise(resolve => socket.addEventListener('open', resolve));

export const sendMessage = async (connectionId, type, payload) => {
  await waitForConnected;
  socket.send(JSON.stringify({
    connectionId,
    message: {
      type,
      payload
    }
  }));
};

export const onMessage = (type, callback) => socket.addEventListener('message', e => {
  const {message, from} = JSON.parse(e.data);
  if(message == null) return;
  if(message.type === type) callback(message.payload, from); // 메시지, 상대방의 connectionId를 callback으로 넘겨준다.
});

export const getConnectionId = async () => {
  await waitForConnected;
  
  //아무 메시지나 보내서 응답에 있는 connectionId를 promise로 넘겨준다.
  socket.send('');
  return new Promise(resolve => {
    socket.addEventListener('message', e => resolve(JSON.parse(e.data).connectionId), {once: true});
  });
};

script.js

import {getConnectionId, onMessage, sendMessage} from "./message.js";

const localConnectionId = document.getElementById('localConnectionId');
const callButton = document.getElementById('call');
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const remoteConnectionId = document.getElementById('remoteConnectionId');
const rtcPeerConnection = new RTCPeerConnection();

// 아래 세 메소드는 WebSocket을 통해서 통신하므로 
// remoteConnectionId input에 상대방의 connectionID가 입력되어 있어야 한다.  
const sendSdpOffer = async () => {
  const rtcSessionDescriptionInit = await rtcPeerConnection.createOffer();
  await rtcPeerConnection.setLocalDescription(rtcSessionDescriptionInit);
  sendMessage(remoteConnectionId.value, 'SDP', rtcSessionDescriptionInit)
};

const sendSdpAnswer = async () => {
  const rtcSessionDescriptionInit = await rtcPeerConnection.createAnswer();
  await rtcPeerConnection.setLocalDescription(rtcSessionDescriptionInit);
  sendMessage(remoteConnectionId.value, 'SDP', rtcSessionDescriptionInit);
};

const sendIceCandidate = candidate => {
  sendMessage(remoteConnectionId.value, 'ICE', candidate);
};

// 자신의 connectionId를 받아서 화면에 표시
getConnectionId().then(connectionId => localConnectionId.value = connectionId);

// 웹캠 화면 가져와 화면에 표시
navigator.mediaDevices
  .getUserMedia({video: true, audio: false})
  .then(mediaStream => {
    localVideo.srcObject = mediaStream;
    mediaStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track));
  });

// SDP offer/answer 교환
rtcPeerConnection.addEventListener('negotiationneeded', () => callButton.disabled = false)
callButton.addEventListener('click', sendSdpOffer)
onMessage('SDP', async (descriptionInit, from) => {
  const rtcSessionDescription = new RTCSessionDescription(descriptionInit);
  await rtcPeerConnection.setRemoteDescription(rtcSessionDescription);
  if (descriptionInit.type === 'offer') {
    remoteConnectionId.value = from;
    callButton.disabled = true
    await sendSdpAnswer();
  }
})


// ICE 후보 교환
rtcPeerConnection.addEventListener('icecandidate', e => e.candidate == null || sendIceCandidate(e.candidate));
onMessage('ICE', candidateInit => rtcPeerConnection.addIceCandidate(new RTCIceCandidate(candidateInit)))

// 상대방 화면 표시
rtcPeerConnection.addEventListener('track', e => remoteVideo.srcObject = new MediaStream([e.track]));

남은 과제

다음 포스팅에서 이어짐

  • 서로 다른 네트워크 상의 peer끼리 위치를 식별하는 방법 : STUN
  • p2p 연결이 불가능한 상황에서 데이터를 중계하는 방법 : TURN


blog comments powered by Disqus
처음으로