[OpenVidu] Spring, React로 구현하기
Computer Science/WebRTC

[OpenVidu] Spring, React로 구현하기

728x90

개요

WebRTC를 써야 하는 프로젝트를 진행하게 되어 WebRTC에 대해 알아보다가, WebRTC를 기반으로 하는 오픈 소스 플랫폼인 OpenVidu에 대해 알게 되었다. 비교적 구현하기 쉽다고 알려져 있어 OpenVidu로 WebRTC를 구현하기로 했다.

 

우선 백은 spring, 프론트는 react로 구현하며

나는 백 부분을 맡았다.

 

나는 백 부분을 맡게 되어 처음 OpenVidu에 대해 공부할 때 OpenVidu에서 제공해 주는 튜토리얼 코드를 java 버전만 열어봤고, 생각보다 코드가 별로 없었다.

'엥? 이게 다라고? 이러면 끝난다고?' 라고 처음에는 생각했다.

 

근데 백 부분만 열어보고는 도저히 흐름이 이해가 되지 않았다.

백에서는 프론트에서 요청이 오면 세션을 생성해 주고 세션에 접속할 수 있는 토큰을 발급해 주고 끝난다.

근데 토큰 형식이 웹 소켓에 접속할 때 사용하는 토큰 형식이었다.

처음에 OpenVidu를 설렁설렁 공부하고 봤을 때는, '아니 웹 소켓도 안열어주고 그냥 토큰만 덜렁 준다고? 내가 따로 웹소켓 설정을 해줘야 하는 건가?!' 싶어서 구글링 열심히 해봤지만 OpenVidu 사용하는 사람 중에 웹 소켓 따로 열어주는 사람은 없는 것 같았다.

그리고 GPT를 열심히 괴롭혀 봤는데, 역시 우리 거짓말쟁이 GPT는 웹 소켓 따로 설정해줘야 한다고 했고 그 때문에 헤매느라 시간을 더 낭비했다.

 

왜 프론트 코드를 열어볼 생각을 안 했는지....

하루 종일 머리 싸매고 있다가 밤에 react 튜토리얼 코드를 열어보자마자 실행 방식이 이해가 되는 것 같았다.

 

여기까지 와서야 공식문서 제대로 볼 생각을 했고.....

공식 문서와 구글링으로 공부한 내용을 기록하고자 한다.

 

 

 

OpenVidu란?

OpenVidu는 WebRTC(Real-Time Communication)을 기반으로 하는 오픈 소스 플랫폼으로, 실시간 비디오 및 오디오 통신을 쉽게 구현할 수 있도록 도와주는 미디어 서버이다. OpenVidu 애플리케이션은 일반적으로 Application Server와 Application Client, 두 가지 주요 구성 요소로 구성된다.

 

Application Server

Application Server는 주로 OpenVidu API와 직접 상호작용하며, 애플리케이션의 비즈니스 로직을 담당한다. 일반적으로 웹 서버 또는 백엔드 서버로 구현된다.

 

역할

  • OpenVidu 플랫폼과의 상호 작용 : Application Server는 OpenVidu 서버와 통신하여 세션 관리, 토큰 생성 및 제어와 같은 영상 및 오디오 통신을 관리하는 데 사용되는 OpenVidu API를 호출한다.
  • 사용자 관리 : 애플리케이션 사용자들의 세션 생성, 연결, 종료, 등록 및 인증과 같은 사용자 관리 기능을 담당한다.
  • 이벤트 처리 : OpenVidu에서 발생하는 이벤트를 처리하고, 필요에 따라 애플리케이션 클라이언트에게 브로드캐스팅하여 실시간으로 상호작용할 수 있도록 도와준다.

 

 

Application Client

Application Client는 일반적으로 웹 브라우저나 모바일 앱과 같은 클라이언트 측 기술을 사용하여 사용자와 상호작용한다. 이 클라이언트는 사용자의 브라우저 또는 디바이스에서 실행된다.

 

역할

  • 사용자 미디어 스트림 관리 : Application Client는 사용자의 웹캠과 마이크로폰 스트림을 생성하고 OpenVidu 서버로 전송한다. 또한 다른 사용자들로부터 받은 스트림을 화면에 표시하여 실시간으로 비디오 및 오디오 통신을 수행한다.
  • UI/UX 구현 : 사용자 인터페이스와 사용자 경험을 구현하여 사용자들이 비디오 통화를 시작하고 관리할 수 있도록 도와준다.

 

 

요약

Application Server는 OpenVidu API와 상호작용하며 사용자 관리와 비즈니스 로직을 처리한다. 반면에 Application Client는 사용자의 브라우저 또는 모바일 앱에서 실행되며 미디어 스트림 관리와 사용자 인터페이스를 담당한다. 이 두 구성 요소가 함께 작동하여 실시간 비디오 및 오디오 통신을 구현하고 제공한다.

 

 

 

OpenVidu 워크 플로우

💡제가 이해한 대로 작성한 거라 정확하지 않을 수 있습니다.

  1. 프론트에서 백으로 customSessionId(방 이름)를 보내주며 sessionId(세션의 pk)를 요청한다.
  2. 백은 전달받은 customSessionId를 이용해 openvidu에서 세션을 생성하고 프론트에게 생성된 세션의 sessionId를 보내준다.
  3. 프론트는 백에게 받은 sessionId에 대해 해당 세션에 접속하기 위해 필요한 token을 요청한다.
  4. 백은 sessionId에 해당하는 세션에 접속할 수 있는 token을 생성해 프론트에게 보내준다.
  5. 프론트는 전달받은 token을 이용해 해당 세션에 접속한다.

 

728x90

 

OpenVidu 예제

💡로컬에서의 기준입니다.

 

 

포트 정보

OpenVidu 4443
Spring 5000
React 3000

 

 

OpenVidu 배포

  1. 도커 설치
  2. docker run -p 4443:4443 --rm -e OPENVIDU_SECRET=MY_SECRET openvidu/openvidu-dev:2.28.0
    4443 포트에서 연다. (포트 수정 시 백엔드, 프론트엔드 OpenVidu 접속 포트 설정도 바꿔야 한다.)
    OPENVIDU_SECRETMY_SECRET으로 설정했다. (변경한다면 백엔드 OPENVIDU_SECRET 설정도 수정해주어야 한다.)
    2.28.0 버전으로 배포. (백엔드, 프론트엔드 라이브러리도 버전 맞추기)

 

 

Application Server - 백엔드(Spring)

  1. git clone https://github.com/OpenVidu/openvidu-tutorials.git -b v2.28.0으로 예시 코드 다운
  2. openvidu-tutorials/openvidu-basic-java 프로젝트 열기
  3. 필요한 코드 수정

해당 프로젝트는 maven 프로젝트라 OpenVidu dependency가 다음과 같다.

pom.xml

<!-- xml -->
<dependency>
	<groupId>io.openvidu</groupId>
	<artifactId>openvidu-java-client</artifactId>
	<version>2.28.0</version>
</dependency>

 

gradle 프로젝트일 경우 다음을 추가해 주면 된다.

build.gradle

// WebRTC - Openvidu
implementation group: 'io.openvidu', name: 'openvidu-java-client', version: '2.28.0'

 

src/main/resources/application.properties

# Spring 서버 띄울 포트 번호
server.port: 5000
server.ssl.enabled: false

# OpenVidu 서버에 접속하기 위한 url (마지막에 '/' 빼먹지 않게 주의)
OPENVIDU_URL: http://localhost:4443/
# OpenVidu 서버에 접속하기 위한 secret key
OPENVIDU_SECRET: MY_SECRET

 

src/main/java/io/openvidu/basic/java/Controller.java

package io.openvidu.basic.java;

import java.util.Map;

import javax.annotation.PostConstruct;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import io.openvidu.java.client.Connection;
import io.openvidu.java.client.ConnectionProperties;
import io.openvidu.java.client.OpenVidu;
import io.openvidu.java.client.OpenViduHttpException;
import io.openvidu.java.client.OpenViduJavaClientException;
import io.openvidu.java.client.Session;
import io.openvidu.java.client.SessionProperties;

@CrossOrigin(origins = "*") // CORS 설정
@RestController
public class Controller {

	@Value("${OPENVIDU_URL}") // application.properties 파일에 있는 값을 가져온다.
	private String OPENVIDU_URL;

	@Value("${OPENVIDU_SECRET}") // application.properties 파일에 있는 값을 가져온다.
	private String OPENVIDU_SECRET;

	private OpenVidu openvidu;

	@PostConstruct
	public void init() {
		this.openvidu = new OpenVidu(OPENVIDU_URL, OPENVIDU_SECRET); // OpenVidu 생성
	}

	/**
	 * @param params The Session properties -> customSessionId 들어있음.
	 * @return The Session ID -> sessionId String으로 리턴
	 */
	@PostMapping("/api/sessions") // 세션 요청할 url
	public ResponseEntity<String> initializeSession(@RequestBody(required = false) Map<String, Object> params)throws OpenViduJavaClientException, OpenViduHttpException {
		SessionProperties properties = SessionProperties.fromJson(params).build();
		Session session = openvidu.createSession(properties);
		return new ResponseEntity<>(session.getSessionId(), HttpStatus.OK);
	}

	/**
	 * @param sessionId The Session in which to create the Connection -> 토큰을 받고 싶은 세션의 sessionId
	 * @param params    The Connection properties
	 * @return The Token associated to the Connection -> 토큰을 String으로 리턴 (ex. "ws://localhost:4443?sessionId=ses_E92GgfwzZ8&token=tok_Yan9N57WxqX4GdHx")
	 */
	@PostMapping("/api/sessions/{sessionId}/connections") // 토큰 요청할 url
	public ResponseEntity<String> createConnection(@PathVariable("sessionId") String sessionId,
			@RequestBody(required = false) Map<String, Object> params)
			throws OpenViduJavaClientException, OpenViduHttpException {
		Session session = openvidu.getActiveSession(sessionId);
		if (session == null) {
			return new ResponseEntity<>(HttpStatus.NOT_FOUND);
		}
		ConnectionProperties properties = ConnectionProperties.fromJson(params).build();
		Connection connection = session.createConnection(properties);
		return new ResponseEntity<>(connection.getToken(), HttpStatus.OK);
	}

}

 

4. 서버 실행

 

 

Application Client - 프론트엔드(React)

  1. git clone https://github.com/OpenVidu/openvidu-tutorials.git -b v2.28.0으로 예시 코드 다운 → 백엔드와 동일
  2. openvidu-tutorials/openvidu-react 프로젝트 열기
  3. 필요한 코드 수정 (src/App.js)

 

line 8

// openvidu의 sessionId와 token을 받아올 prefix 주소 -> 백엔드 요청 주소 -> Application server url
// 마지막 '/' 빼먹지 않게 주의
const APPLICATION_SERVER_URL = process.env.NODE_ENV = 'http://localhost:5000/';

 

line 15 ~ 22

this.state = {
    mySessionId: '', // 방 pk
    myUserName: '', // 로그인된 사용자 닉네임
    session: undefined,
    mainStreamManager: undefined,  // Main video of the page. Will be the 'publisher' or one of the 'subscribers'
    publisher: undefined,
    subscribers: [],
};

 

line 76 ~ 165 (수정 사항은 없음, 주석 별로 흐름 파악하면 좋을 듯. 4번이 서버에서 sessionId와 token을 받아오는 부분.)

joinSession() {
    // --- 1) Get an OpenVidu object ---

    this.OV = new OpenVidu();

    // --- 2) Init a session ---

    this.setState(
        {
            session: this.OV.initSession(),
        },
        () => {
            var mySession = this.state.session;

            // --- 3) Specify the actions when events take place in the session ---

            // On every new Stream received...
            mySession.on('streamCreated', (event) => {
                // Subscribe to the Stream to receive it. Second parameter is undefined
                // so OpenVidu doesn't create an HTML video by its own
                var subscriber = mySession.subscribe(event.stream, undefined);
                var subscribers = this.state.subscribers;
                subscribers.push(subscriber);

                // Update the state with the new subscribers
                this.setState({
                    subscribers: subscribers,
                });
            });

            // On every Stream destroyed...
            mySession.on('streamDestroyed', (event) => {

                // Remove the stream from 'subscribers' array
                this.deleteSubscriber(event.stream.streamManager);
            });

            // On every asynchronous exception...
            mySession.on('exception', (exception) => {
                console.warn(exception);
            });

            // --- 4) Connect to the session with a valid user token ---

            // Get a token from the OpenVidu deployment
            this.getToken().then((token) => {
                // First param is the token got from the OpenVidu deployment. Second param can be retrieved by every user on event
                // 'streamCreated' (property Stream.connection.data), and will be appended to DOM as the user's nickname
                mySession.connect(token, { clientData: this.state.myUserName })
                    .then(async () => {

                        // --- 5) Get your own camera stream ---

                        // Init a publisher passing undefined as targetElement (we don't want OpenVidu to insert a video
                        // element: we will manage it on our own) and with the desired properties
                        let publisher = await this.OV.initPublisherAsync(undefined, {
                            audioSource: undefined, // The source of audio. If undefined default microphone
                            videoSource: undefined, // The source of video. If undefined default webcam
                            publishAudio: true, // Whether you want to start publishing with your audio unmuted or not
                            publishVideo: true, // Whether you want to start publishing with your video enabled or not
                            resolution: '640x480', // The resolution of your video
                            frameRate: 30, // The frame rate of your video
                            insertMode: 'APPEND', // How the video is inserted in the target element 'video-container'
                            mirror: false, // Whether to mirror your local video or not
                        });

                        // --- 6) Publish your stream ---

                        mySession.publish(publisher);

                        // Obtain the current video device in use
                        var devices = await this.OV.getDevices();
                        var videoDevices = devices.filter(device => device.kind === 'videoinput');
                        var currentVideoDeviceId = publisher.stream.getMediaStream().getVideoTracks()[0].getSettings().deviceId;
                        var currentVideoDevice = videoDevices.find(device => device.deviceId === currentVideoDeviceId);

                        // Set the main video in the page to display our webcam and store our Publisher
                        this.setState({
                            currentVideoDevice: currentVideoDevice,
                            mainStreamManager: publisher,
                            publisher: publisher,
                        });
                    })
                    .catch((error) => {
                        console.log('There was an error connecting to the session:', error.code, error.message);
                    });
            });
        },
    );
}

 

line 224 ~ 312 (화면 렌더링 부분. 여기서 화면 구성하면 될 듯.)

render() {
    const mySessionId = this.state.mySessionId;
    const myUserName = this.state.myUserName;

    return (
        <div className="container">
            {this.state.session === undefined ? (
                <div id="join">
                    <div id="img-div">
                        <img src="resources/images/openvidu_grey_bg_transp_cropped.png" alt="OpenVidu logo" />
                    </div>
                    <div id="join-dialog" className="jumbotron vertical-center">
                        <h1> Join a video session </h1>
                        <form className="form-group" onSubmit={this.joinSession}>
                            <p>
                                <label>Participant: </label>
                                <input
                                    className="form-control"
                                    type="text"
                                    id="userName"
                                    value={myUserName}
                                    onChange={this.handleChangeUserName}
                                    required
                                />
                            </p>
                            <p>
                                <label> Session: </label>
                                <input
                                    className="form-control"
                                    type="text"
                                    id="sessionId"
                                    value={mySessionId}
                                    onChange={this.handleChangeSessionId}
                                    required
                                />
                            </p>
                            <p className="text-center">
                                <input className="btn btn-lg btn-success" name="commit" type="submit" value="JOIN" />
                            </p>
                        </form>
                    </div>
                </div>
            ) : null}

            {this.state.session !== undefined ? (
                <div id="session">
                    <div id="session-header">
                        <h1 id="session-title">{mySessionId}</h1>
                        <input
                            className="btn btn-large btn-danger"
                            type="button"
                            id="buttonLeaveSession"
                            onClick={this.leaveSession}
                            value="Leave session"
                        />
                        <input
                            className="btn btn-large btn-success"
                            type="button"
                            id="buttonSwitchCamera"
                            onClick={this.switchCamera}
                            value="Switch Camera"
                        />
                    </div>

                    {this.state.mainStreamManager !== undefined ? (
                        <div id="main-video" className="col-md-6">
                            <UserVideoComponent streamManager={this.state.mainStreamManager} />

                        </div>
                    ) : null}
                    <div id="video-container" className="col-md-6">
                        {this.state.publisher !== undefined ? (
                            <div className="stream-container col-md-6 col-xs-6" onClick={() => this.handleMainVideoStream(this.state.publisher)}>
                                <UserVideoComponent
                                    streamManager={this.state.publisher} />
                            </div>
                        ) : null}
                        {this.state.subscribers.map((sub, i) => (
                            <div key={sub.id} className="stream-container col-md-6 col-xs-6" onClick={() => this.handleMainVideoStream(sub)}>
                                <span>{sub.id}</span>
                                <UserVideoComponent streamManager={sub} />
                            </div>
                        ))}
                    </div>
                </div>
            ) : null}
        </div>
    );
}

 

line 315 ~ 347 (주석 읽어보는 거 추천)

백에 요청 보내는 함수 부분

joinSession() 4번에서 호출하는 함수

sessionId와 token 요청 url 변경되면 여기서 변경하면 됨.

/**
 * --------------------------------------------
 * GETTING A TOKEN FROM YOUR APPLICATION SERVER
 * --------------------------------------------
 * The methods below request the creation of a Session and a Token to
 * your application server. This keeps your OpenVidu deployment secure.
 *
 * In this sample code, there is no user control at all. Anybody could
 * access your application server endpoints! In a real production
 * environment, your application server must identify the user to allow
 * access to the endpoints.
 *
 * Visit https://docs.openvidu.io/en/stable/application-server to learn
 * more about the integration of OpenVidu in your application server.
 */
async getToken() {
    const sessionId = await this.createSession(this.state.mySessionId);
    return await this.createToken(sessionId);
}

async createSession(sessionId) {
    const response = await axios.post(APPLICATION_SERVER_URL + 'api/sessions', { customSessionId: sessionId }, {
        headers: { 'Content-Type': 'application/json', },
    });
    console.log('createSession');
    return response.data; // The sessionId
}

async createToken(sessionId) {
    const response = await axios.post(APPLICATION_SERVER_URL + 'api/sessions/' + sessionId + '/connections', {}, {
        headers: { 'Content-Type': 'application/json', },
    });
    return response.data; // The token
}

 

4. npm install로 필요한 라이브러리 다운 받기

5. npm start로 서버 시작
    원래 node.js 18 버전대 사용했는데 버전 안 맞아서 실행 안됨.

    nvm으로 16 버전으로 설정 후 실행했음. (17도 안됨...)

 

 

마무리

도커, 백, 프론트 각각이 서버에 잘 올라가면 서로 소통하며 방이 잘 생성되는 것을 확인할 수 있다.

근데 OpenVidu의 진짜 시작은 ec2 서버에 올리는 것....

막상 끝나고 보면 쉬운데 한 번 엉키면 어디가 잘못 돼서 안되는지 알기 어렵다.

다른 블로그 보니 ec2에 제일 먼저 OpenVidu를 올리는 것을 추천한다. (진짜로.... 꼬이는 거 없이 가장 쉽게 올릴 수 있는 방법이지 않을까 싶다.)

 

728x90