코로나19 팬데믹 이후 화상 통화는 일상에서 없어서는 안 될 필수 기능이 되었습니다. 채팅 앱, 음성 통화, 화상 통화 같은 기능 덕분에 우리는 친구와 가족과 계속 연결될 수 있었습니다.
이번 튜토리얼에서는 Twilio의 프로그래머블 비디오(Video) API를 활용해 React Native 앱에 화상 통화 기능을 직접 구현하는 방법을 알아보겠습니다.
과정 자체는 매우 간단합니다. 화상 회의 방(Room)을 생성하고 다른 사용자를 그 방에 초대하면 됩니다. 이를 위해서는 카메라와 마이크 접근 권한이 필요하므로, 테스트 시에는 실제 스마트폰 기기를 사용해야 합니다.
Twilio API에 접근하기 위해 사용할 핵심 패키지는 react-native-twilio-video-webrtc입니다.
준비 사항
- Twilio 계정
- 테스트용 iOS 또는 Android 실기기 최소 2대
- React Native 개발 환경 설정 완료
그럼 시작해 보겠습니다!
Twilio API 키 발급받는 방법
Twilio API 키를 발급받으려면 먼저 Twilio 계정이 필요합니다. 해당 URL에 접속해 계정을 생성한 뒤, 아래 스크린샷에 표시된 위치로 이동하세요.

액세스 토큰을 처리할 서버 구축하기
액세스 토큰을 발급받으려면 새로운 Node 서버 프로젝트를 만들어야 합니다. 필요한 패키지를 아래 명령어로 설치하세요.
yarn add dotenv express ngrok nodemon twilio다음으로 환경 변수 파일인 .env에 Twilio 인증 정보를 추가합니다.
PORT=3000
ACCOUNT_SID=AC5ceb0847c50c91b143ce07
API_KEY_SID=SKa173c10de99a26fd86969b
API_KEY_SECRET=Czv7IjNIZJis8s7jb5FePi이제 API 엔드포인트를 생성해야 합니다. 먼저 필요한 패키지를 임포트하고, 액세스 토큰을 얻기 위한 객체 인스턴스를 생성합니다.
import 'dotenv/config';
import express from 'express';
import twilio from 'twilio';
import ngrok from 'ngrok';
const AccessToken = twilio.jwt.AccessToken;
const VideoGrant = AccessToken.VideoGrant;
const app = express();여기서 Express 인스턴스가 제공하는 get 메서드를 사용해, 액세스 토큰을 응답으로 반환하는 엔드포인트 함수를 작성합니다.
함수 내부에서는 Twilio 인증 정보로 새 인스턴스를 생성하고, 모바일 기기의 회원가입 화면에서 전달받은 사용자 이름을 identity 속성으로 지정합니다.
마지막으로 사용자에게 비디오 사용 권한을 부여한 뒤, JWT 토큰을 기기로 반환하면 됩니다. 전체 코드는 아래와 같습니다.
app.get('/getToken', (req, res) => {
if (!req.query || !req.query.userName) {
return res.status(400).send('Username parameter is required');
}
const accessToken = new AccessToken(
process.env.ACCOUNT_SID,
process.env.API_KEY_SID,
process.env.API_KEY_SECRET,
);
// Set the Identity of this token
accessToken.identity = req.query.userName;
// Grant access to Video
var grant = new VideoGrant();
accessToken.addGrant(grant);
// Serialize the token as a JWT
var jwt = accessToken.toJwt();
return res.send(jwt);
});
또한 외부에서 쉽게 접근할 수 있도록 생성한 엔드포인트 API를 인터넷에 노출시켜야 합니다. 이를 위해 아래 코드를 사용합니다.
app.listen(process.env.PORT, () =>
console.log(`Server listening on port ${process.env.PORT}!`),
);
ngrok.connect(process.env.PORT).then((url) => {
console.log(`Server forwarded to public url ${url}`);
});
마지막으로 아래 스크린샷과 같이 서버를 실행하면 됩니다.

이제 액세스 토큰을 반환하는 API 엔드포인트가 성공적으로 완성되었습니다.
React Native 프로젝트 설정하기
React Native 프로젝트에서는 필요한 패키지를 수동으로 설치하고, Android와 iOS 두 플랫폼 모두에서 카메라 및 마이크 접근 권한을 설정해야 합니다.
먼저 프로젝트 터미널에서 아래 명령어를 실행해 react-navigation과 react-native-twilio-video-webrtc 등 필요한 패키지를 설치합니다.
yarn add @react-navigation/native @react-navigation/stack react-native-reanimated react-native-gesture-handler react-native-screens react-native-safe-area-context @react-native-community/masked-view react-native-dotenv react-native-permissions <https://github.com/blackuy/react-native-twilio-video-webrtc>
iOS 설정
iOS의 경우 패키지를 수동으로 설정해야 합니다. 먼저 Podfile에서 iOS 타깃을 11로 상향해야 합니다. Twilio의 네이티브 Video SDK가 iOS 11.0 이상만 지원하기 때문입니다.
platform :ios, '11.0'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
Podfile에는 아래 코드와 같이 권한 요청 설정을 추가합니다.
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera.podspec"
pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone.podspec"
그다음 info.plist 파일을 열어 카메라와 마이크 접근 권한을 요청하는 코드를 추가합니다.
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>We require your permission to access the camera while in a video call</string>
<key>NSMicrophoneUsageDescription</key>
<string>We require your permission to access the microphone while in a video call</string>
이제 iOS 설정은 모두 끝났습니다.
Android 설정
우선 ./android/settings.gradle 파일에 아래 한 줄을 추가합니다.
project(':react-native-twilio-video-webrtc').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-twilio-video-webrtc/android')
그다음 ./android/app/build.gradle 파일에 패키지 implementation 코드를 추가합니다.
implementation project(':react-native-twilio-video-webrtc')
마지막으로 MainApplication.java 파일에도 아래 코드를 임포트합니다.
import com.twiliorn.library.TwilioPackage;
그리고 아래 코드를 통해 패키지를 활성화합니다.
@Override
protected List getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List packages = new PackageList(this).getPackages();
// add the following code
packages.add(new TwilioPackage());
return packages;
}
방 등록(Register Room) 화면 만들기
이제 화상 통화 앱에서 사용자가 특정 방에 접속할 수 있도록 해주는 "방 등록(Register Room)" 화면을 만들겠습니다.
먼저 아래 코드와 같이 필요한 패키지들을 임포트합니다.
import React, {useState, useRef, useEffect, useContext} from 'react';
import {
StyleSheet,
View,
Text,
StatusBar,
TouchableOpacity,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Dimensions,
} from 'react-native';
import {
TwilioVideoLocalView,
TwilioVideoParticipantView,
TwilioVideo,
} from 'react-native-twilio-video-webrtc';
import {NavigationContainer} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
- react-navigation: 등록 화면과 화상 통화 화면 간의 네비게이션을 담당합니다.
- react-native-permissions: 카메라와 마이크 접근 권한을 처리합니다.
- react-native-twilio-video-webrtc: Twilio의 프로그래머블 비디오 통화 API에 접근할 수 있게 해줍니다.
인스턴스 및 변수 초기화
먼저 react-navigation을 위한 인스턴스를 생성합니다. 그런 다음 여러 화면에 상태를 분배하기 위해 state와 컨텍스트 변수를 초기화합니다.
const Stack = createStackNavigator();
const initialState = {
isAudioEnabled: true,
status: 'disconnected',
participants: new Map(),
videoTracks: new Map(),
userName: '',
roomName: '',
token: '',
};
const AppContext = React.createContext(initialState);
const dimensions = Dimensions.get('window');
네비게이션 부트스트랩
App.js 파일에서 네비게이션 컨테이너 스택을 생성합니다. Stack 컴포넌트를 사용해 Context를 통해 모든 화면에 상태를 분배합니다.
export default () => {
const [props, setProps] = useState(initialState);
return (
<>
<StatusBar barStyle="dark-content" />
<AppContext.Provider value={{props, setProps}}>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Video Call" component={VideoCallScreen} />
</Stack.Navigator>
</NavigationContainer>
</AppContext.Provider>
</>
);
};
등록(Register) 화면 구현하기
등록 화면에는 사용자의 정보를 입력받고 화상 통화 방에 참여할 수 있게 해주는 모달 폼이 포함됩니다.
먼저 register.js 파일에서 컨텍스트로부터 props를 가져옵니다.
import React, {useState, useRef, useEffect, useContext} from 'react';
import {
checkMultiple,
request,
requestMultiple,
PERMISSIONS,
RESULTS,
} from 'react-native-permissions';
const RegisterScreen = ({navigation}) => {
const {props, setProps} = useContext(AppContext);
다음으로 카메라와 마이크 권한을 처리하는 함수를 작성합니다.
const _checkPermissions = (callback) => {
const iosPermissions = [PERMISSIONS.IOS.CAMERA, PERMISSIONS.IOS.MICROPHONE];
const androidPermissions = [
PERMISSIONS.ANDROID.CAMERA,
PERMISSIONS.ANDROID.RECORD_AUDIO,
];
checkMultiple(
Platform.OS === 'ios' ? iosPermissions : androidPermissions,
).then((statuses) => {
const [CAMERA, AUDIO] =
Platform.OS === 'ios' ? iosPermissions : androidPermissions;
if (
statuses[CAMERA] === RESULTS.UNAVAILABLE ||
statuses[AUDIO] === RESULTS.UNAVAILABLE
) {
Alert.alert(
'Error',
'Hardware to support video calls is not available',
);
} else if (
statuses[CAMERA] === RESULTS.BLOCKED ||
statuses[AUDIO] === RESULTS.BLOCKED
) {
Alert.alert(
'Error',
'Permission to access hardware was blocked, please grant manually',
);
} else {
if (
statuses[CAMERA] === RESULTS.DENIED &&
statuses[AUDIO] === RESULTS.DENIED
) {
requestMultiple(
Platform.OS === 'ios' ? iosPermissions : androidPermissions,
).then((newStatuses) => {
if (
newStatuses[CAMERA] === RESULTS.GRANTED &&
newStatuses[AUDIO] === RESULTS.GRANTED
) {
callback && callback();
} else {
Alert.alert('Error', 'One of the permissions was not granted');
}
});
} else if (
statuses[CAMERA] === RESULTS.DENIED ||
statuses[AUDIO] === RESULTS.DENIED
) {
request(statuses[CAMERA] === RESULTS.DENIED ? CAMERA : AUDIO).then(
(result) => {
if (result === RESULTS.GRANTED) {
callback && callback();
} else {
Alert.alert('Error', 'Permission not granted');
}
},
);
} else if (
statuses[CAMERA] === RESULTS.GRANTED ||
statuses[AUDIO] === RESULTS.GRANTED
) {
callback && callback();
}
}
});
};
그다음 앱이 실행될 때마다 이 권한 확인 함수가 호출되도록 useEffect 훅 내부에서 호출합니다.
useEffect(() => {
_checkPermissions();
}, []);
마지막으로 방 이름과 사용자 이름을 입력받는 간단한 폼을 만듭니다. 입력값은 서버로 전송되어 Twilio API에 등록됩니다.
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}>
<ScrollView contentContainerStyle={styles.container}>
<View style={styles.form}>
<View style={styles.formGroup}>
<Text style={styles.text}>User Name</Text>
<TextInput
style={styles.textInput}
autoCapitalize="none"
value={props.userName}
onChangeText={(text) => setProps({...props, userName: text})}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.text}>Room Name</Text>
<TextInput
style={styles.textInput}
autoCapitalize="none"
value={props.roomName}
onChangeText={(text) => setProps({...props, roomName: text})}
/>
</View>
<View style={styles.formGroup}>
<TouchableOpacity
disabled={false}
style={styles.button}
onPress={() => {
_checkPermissions(() => {
fetch(`https://ae7a722dc260.ngrok.io/getToken?userName=${props.userName}`)
.then((response) => {
if (response.ok) {
response.text().then((jwt) => {
setProps({...props, token: jwt});
navigation.navigate('Video Call');
return true;
});
} else {
response.text().then((error) => {
Alert.alert(error);
});
}
})
.catch((error) => {
console.log('error', error);
Alert.alert('API not available');
});
});
}}>
<Text style={styles.buttonText}>Connect to Video Call</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
실행하면 에뮬레이터 스크린샷과 같은 결과를 볼 수 있습니다.

위 화면에서 방 등록 화면의 모달 폼을 통해 방 이름과 사용자 이름을 입력하고, 서버에서 실행 중인 Twilio API에 등록할 수 있습니다.
화상 통화(Video Call) 화면 만들기
화상 통화 화면에는 두 개의 창이 있습니다. 하나는 내 카메라 화면을, 다른 하나는 상대방 카메라 화면을 표시합니다.
먼저 상태를 받아들이도록 컨텍스트를 초기화하고, useRef 훅으로 참조 변수를 생성해 상태에 접근합니다.
const VideoCallScreen = ({navigation}) => {
const twilioVideo = useRef(null);
const {props, setProps} = useContext(AppContext);
다음으로 twilioVideo 객체의 connect 메서드에 방 이름과 액세스 토큰을 전달하여 연결을 초기화합니다.
useEffect(() => {
twilioVideo.current.connect({
roomName: props.roomName,
accessToken: props.token,
});
setProps({...props, status: 'connecting'});
return () => {
_onEndButtonPress();
};
}, []);
이제 화상 통화 화면의 메인 바디 템플릿을 작성합니다. 조건부 렌더링을 사용해 연결이Established되고 스트리밍이 시작되었을 때만 참가자의 카메라 화면을 보여줍니다.
{(props.status === 'connected' || props.status === 'connecting') && (
<View style={styles.callWrapper}>
{props.status === 'connected' && (
<View style={styles.grid}>
{Array.from(props.videoTracks, ([trackSid, trackIdentifier]) => (
<TwilioVideoParticipantView
style={styles.remoteVideo}
key={trackSid}
trackIdentifier={trackIdentifier}
/>
))}
</View>
)}
</View>
)}
다음으로 통화 종료, 음소거, 전면/후면 카메라 전환 같은 통화 중 기능을 제어하는 함수들을 만듭니다.
const _onEndButtonPress = () => {
twilioVideo.current.disconnect();
setProps(initialState);
};
const _onMuteButtonPress = () => {
twilioVideo.current
.setLocalAudioEnabled(!props.isAudioEnabled)
.then((isEnabled) => setProps({...props, isAudioEnabled: isEnabled}));
};
const _onFlipButtonPress = () => {
twilioVideo.current.flipCamera();
};
여기서는 twilioVideo 인스턴스가 제공하는 disconnect, setLocalAudioEnabled, flipCamera 메서드를 사용해 필요한 통화 중 기능들을 트리거했습니다.
이제 이 함수들을 호출할 버튼들을 렌더링합니다.
<View style={styles.optionsContainer}>
<TouchableOpacity style={styles.button} onPress={_onEndButtonPress}>
<Text style={styles.buttonText}>End</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={_onMuteButtonPress}>
<Text style={styles.buttonText}>
{props.isAudioEnabled ? 'Mute' : 'Unmute'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={_onFlipButtonPress}>
<Text style={styles.buttonText}>Flip</Text>
</TouchableOpacity>
</View>
마지막 단계는 모든 화상 통화 이벤트를 처리하고 관찰하도록 구성된 TwilioVideo 컴포넌트를 추가하는 것입니다.
<TwilioVideo
ref={twilioVideo}
onRoomDidConnect={() => {
setProps({...props, status: 'connected'});
}}
onRoomDidDisconnect={() => {
setProps({...props, status: 'disconnected'});
navigation.goBack();
}}
onRoomDidFailToConnect={(error) => {
Alert.alert('Error', error.error);
setProps({...props, status: 'disconnected'});
navigation.goBack();
}}
onParticipantAddedVideoTrack={({participant, track}) => {
if (track.enabled) {
setProps({
...props,
videoTracks: new Map([
...props.videoTracks,
[
track.trackSid,
{
participantSid: participant.sid,
videoTrackSid: track.trackSid,
},
],
]),
});
}
}}
onParticipantRemovedVideoTrack={({track}) => {
const videoTracks = props.videoTracks;
videoTracks.delete(track.trackSid);
setProps({...props, videoTracks});
}}
/>
같은 방에 있는 사용자 간에 정상적으로 연결이 수립되면 아래와 같은 결과를 얻을 수 있습니다.

위 스크린샷은 한 방에서 두 참가자 간에 진행되는 화상 통화를 보여줍니다.
이것으로 React Native 앱에 화상 통화 기능을 성공적으로 구현했습니다.
마무리
이 튜토리얼은 React Native 앱에 Twilio의 프로그래머블 비디오 통화 API를 활용해 화상 통화 기능을 구축하는 방법을 다루는 입문자 수준의 학습 자료였습니다.
React Native 파트뿐만 아니라 별도의 Node 서버 프로젝트에서 전체 API를 구현하는 과정까지 함께 살펴보았습니다.
다음 단계로는 익명 통화 시작이나 다수 참가자 화상 회의 방 같은 고급 기능을 추가해 볼 수 있습니다.
기능 아이디어나 완성도 높은 화상 통화 앱이 필요하다면, 강력한 기능을 갖춘 Video Chat 앱을 제공하는 instamobile.io를 확인해 보세요.
그럼 다음에 또 만나요. 즐거운 코딩 되세요!