WebRTC 기반 영상 통화 Flutter 앱 개발하기

woody.ahn·2024년 7월 1일
0
post-thumbnail

Flutter를 사용해 WebRTC 기반의 영상 통화 앱을 만들어보려고 합니다. 평소에 Flutter에 관심이 있었고, 회사에서 이미 React Native를 사용 중이어서 새로운 기술을 시도해보고 싶었죠. 이번 프로젝트는 간단한 연결과 종료 기능만 구현된 샘플 앱 수준입니다.

개발 환경 구축

먼저, Flutter 개발 환경 구축을 해야 합니다. 이 부분은 구글에 이미 많은 자료들이 있으니 자세한 설명보다는 생각나는 것들만 간단히 정리해 보겠습니다.

  1. Flutter 설치
    • Flutter SDK를 다운로드하여 설치합니다.
    • 환경 변수를 설정합니다.
  2. Android Studio 설치
    • Android Studio를 다운로드하여 설치합니다.
    • Flutter 및 Dart 플러그인을 설치합니다.
  3. Android Studio에서 Flutter 프로젝트 생성
    • Android Studio를 실행하고 새로운 Flutter 프로젝트를 생성합니다.

만약 iOS 앱까지 빌드하시려면 추가로 다음이 필요합니다:

  1. Xcode 설치
  2. Xcode에서 프로젝트의 Signing 설정
    • Xcode에서 프로젝트를 열고, iOS 앱의 Signing & Capabilities에서 적절한 팀과 프로비저닝 프로파일을 설정합니다.

Flutter가 제대로 설치되었는지는 flutter doctor 명령어로 확인할 수 있습니다. 아래는 flutter doctor 실행 예시입니다:

> flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[] Flutter (Channel stable, 3.22.2, on macOS 14.5 23F79 darwin-arm64, locale ko-KR)
[] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[] Xcode - develop for iOS and macOS (Xcode 15.4)
[] Chrome - develop for the web
[] Android Studio (version 2024.1)
[] VS Code (version 1.90.2)
[] Connected device (4 available)
[] Network resources

• No issues found!

flutter doctor를 통해 환경 설정이 올바르게 되었는지 확인할 수 있으며, 만약 문제가 있다면 여기서 표시된 정보를 통해 문제를 해결할 수 있습니다.

Signaling Server

WebRTC를 사용한 영상 통화 앱을 만들려면 앱 이외에 시그널링 서버가 필요합니다. 시그널링 서버를 통해 SDP(Session Description Protocol) 교환 과정을 통해 미디어 연결이 가능해집니다. 시그널링 서버는 WebRTC 피어 간의 초기 연결을 설정하는 데 필요한 정보를 교환하는 역할을 합니다.

  • SDP 교환: 연결을 설정하기 위해 클라이언트 간에 세션 설명을 교환합니다.
  • ICE Candidate 교환: NAT 방화벽을 통과할 수 있도록 ICE(Interactive Connectivity Establishment) 후보를 교환합니다.

SDP, ICE Candidate에 대한 설명과 이것들이 서로 교환되어야 하는지에 대해서 이미 많은 분들이 정리해두었으니 생략합니다. 이 글은 Flutter 앱을 만드는 것에 집중하겠습니다.

SDP와 ICE Candidate를 교환하는 방식에 대해서는 정해진 것이 없습니다. HTTP를 사용해도 되고, WebSocket을 사용해도 됩니다. Socket.IO나 Redis를 사용할 수도 있습니다. 앱이 SDP와 ICE Candidate를 주고받을 수 있기만 하면 됩니다. 이 프로젝트에서는 Socket.IO를 사용했습니다.

시그널링 서버 코드는 repo를 참고하시면 됩니다.

매우 간단하게 구현되어 있습니다. 같은 room ID로 두 명이 join을 하면 한쪽에 start를 보내서 SDP, ICE Candidate 교환이 시작되도록 되어 있습니다.
당연한 이야기지만 시그널링 서버는 앱에서 접근할 수 있는 서버에 배포되어 있어야 합니다.

Flutter Code

이제부터 Flutter 코드 설명입니다. 기본적으로 WebRTC를 사용하려면 아래와 같은 과정이 필요합니다.

  1. 로컬 스트림 생성
  2. 피어 연결(PeerConnection) 생성
  3. 시그널링 서버를 통한 SDP, ICE Candidate 교환
  4. Video Renderer
  5. 종료

1. 로컬 스트림 생성

영상 통화를 위해서 카메라와 마이크의 권한을 설정해야 영상과 음성을 사용할 수 있습니다.

Android
android/app/src/main/AndroidManifest.xml 파일에 다음 권한을 추가합니다.

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

iOS
ios/Runner/Info.plist 파일에 다음 권한 설명을 추가합니다.

<key>NSCameraUsageDescription</key>
<string>We need access to the camera for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need access to the microphone for audio calls</string>

위와 같이 각 플랫폼의 권한 설정을 해주고, 카메라와 마이크로부터 영상과 음성을 얻어옵니다.

아래는 권한을 요청하고 로컬 스트림을 생성하는 코드입니다.

// 카메라 권한상태를 조회하고 요청
Future<PermissionStatus> _checkCameraPermission() async {
  var status = await Permission.camera.status;
  if (status.isDenied) {
    status = await Permission.camera.request();
  }
  return status;
}

// 마이크의 권한 상태를 조회하고 요청
Future<PermissionStatus> _checkMicrophonePermission() async {
  var status = await Permission.microphone.status;
  if (status.isDenied) {
    status = await Permission.microphone.request();
  }
  return status;
}

// 로컬스트림 생성
_createLocalStream() async {
  var cameraStatus = await _checkCameraPermission();
  var micStatus = await _checkMicrophonePermission();

  if (cameraStatus.isGranted && micStatus.isGranted) {
  	// 오디오와 비디오 설정
    final Map<String, dynamic> mediaConstraints = {
      'audio': true,
      'video': {
        'facingMode': 'user', // 전면 카메라 사용
        'mandatory': {
          'minWidth': '640',
          'minHeight': '320',
        },
      },
    };
    
    // 로컬스트림 생성
    MediaStream stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
    _localStream = stream;
    
    // 로컬 비디오 렌더러에 생성된 로컬스트림을 연결
    _localRenderer.srcObject = stream;

	// 생성된 로컬 스트림의 모든 트랙을 RTCPeerConnection에 추가하여 원격 피어와 공유
    _localStream!.getTracks().forEach((track) {
      _peerConnection!.addTrack(track, _localStream!);
    });

	// 스트림이 설정된 후 UI 업데이트
    setState(() {});
  }
}

2. 피어 연결(PeerConnection) 생성

상대방과 연결을 위해 WebRTC의 RTCPeerConnection 객체를 생성하고 필요한 이벤트 리스너를 설정합니다.

_createPeerConnection() async {
  Map<String, dynamic> configuration = {
    "iceServers": [
      {
        "urls": ["stun:stun.l.google.com:19302"]
      },
    ],
    'sdpSemantics': 'unified-plan',
  };

  // 새로운 RTCPeerConnection 객체를 생성
  final pc = await createPeerConnection(configuration);

  // 새로운 ICE candidate 발견될 때 호출되는 콜백 함수.
  pc.onIceCandidate = (candidate) {
  	// 발견된 ICE 후보를 시그널링 서버로 전송하여 상대방에게 전달
    socket!.emit('ice_candidate', {
      'candidate': candidate.candidate,
      'sdpMid': candidate.sdpMid,
      'sdpMLineIndex': candidate.sdpMLineIndex,
    });
  };

  // 원격 피어에서 새로운 미디어 스트림 트랙을 수신할 때 호출되는 콜백 함수
  pc.onTrack = (event) {
  	// 수신한 스트림을 `_remoteRenderer`에 설정하여 원격 비디오를 표시
    _remoteRenderer.srcObject = event.streams[0];
    setState(() {});
  };

  _peerConnection = pc;
}

이 코드는 WebRTC를 사용하여 피어 간의 미디어 스트림을 교환하기 위한 기본 설정을 다룹니다. RTCPeerConnection 객체는 WebRTC를 통해 미디어 데이터를 주고받는 데 핵심적인 역할을 합니다.

3. 시그널링 서버를 통한 SDP, ICE Candidate 교환

위에서 설명한 것 처럼 Socket.IO를 사용해서 SDP, ICE Candidate를 교환합니다.

void _connectSocket() {
  const url = SIGNALING_SERVER_URL;
  socket = IO.io(url, <String, dynamic>{
    'transports': ['websocket'],
    'autoConnect': false,
  });
  socket!.connect();
  
  // 시그널링 서버와 연결되면 이름과 room id를 join 메시지로 전달
  socket!.on('connect', (_) {
    print('connected to signaling server');
    socket!.emit('join', {'name': widget.name, 'room': widget.roomId});
  });

  // start 메시지 수신
  socket!.on("start", (_) async {
    // offer SDP를 생성하고 시그널링 서버로 전달
    _createOffer();
  });

  // offer 메시지 수신 
  socket!.on('offer', (data) async {
    // 상대방의 offer SDP를 set하고, answer SDP 생성
    await _peerConnection!.setRemoteDescription(
      RTCSessionDescription(data['sdp'], data['type']),
    );
    _createAnswer();
  });

  // answer 메시지 수신
  socket!.on('answer', (data) async {
    // 상대방의 answer SDP를 set
    await _peerConnection!.setRemoteDescription(
      RTCSessionDescription(data['sdp'], data['type']),
    );
  });

  // ice_candidate 메시지 수신
  socket!.on('ice_candidate', (data) async {
    // 상대방의 ice candidate를 추가
    await _peerConnection!.addCandidate(
      RTCIceCandidate(
        data['candidate'],
        data['sdpMid'],
        data['sdpMLineIndex'],
      ),
    );
  });

  socket!.on('disconnect', (_) {
    print('disconnected from signaling server');
  });
}

// offer SDP를 생성하고, 시그널링 서버로 offer 메시지 전달
void _createOffer() async {
  RTCSessionDescription description = await _peerConnection!
      .createOffer({'offerToReceiveVideo': 1, 'offerToReceiveAudio': 1});
  await _peerConnection!.setLocalDescription(description);
  socket!.emit('offer', {
    'sdp': description.sdp,
    'type': description.type,
    'room': widget.roomId,
  });
}

// answer SDP를 생성하고, 시그널링 서버로 answer 메시지 전달
void _createAnswer() async {
  RTCSessionDescription description = await _peerConnection!
      .createAnswer({'offerToReceiveVideo': 1, 'offerToReceiveAudio': 1});
  await _peerConnection!.setLocalDescription(description);
  socket!.emit('answer', {
    'sdp': description.sdp,
    'type': description.type,
    'room': widget.roomId,
  });
}

4. Video Renderer

영상 통화 애플리케이션에서 비디오를 표시하기 위해서는 RTCVideoRenderer를 사용해야 합니다.
먼저, RTCVideoRenderer 객체를 초기화해야 합니다. 로컬 및 원격 비디오 스트림을 위한 두 개의 렌더러를 설정합니다.

final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();


void initState() {
  super.initState();
  
  await _localRenderer.initialize();
  await _remoteRenderer.initialize();
}

로컬 비디오 렌더링

// 로컬 스트림 생성
MediaStream stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);

// 로컬 비디오 렌더러에 생성된 로컬 스트림을 연결
_localRenderer.srcObject = stream;

// 스트림이 설정된 후 UI 업데이트
setState(() {});

원격 비디오 렌더링

pc.onTrack = (event) {
	_remoteRenderer.srcObject = event.streams[0];
	setState(() {});
};

5. 종료

통화가 종료될 때 로컬스트림RTCPeerConnection을 꼭 정리해주어야 합니다.

void _stopCall() {
  // 로컬 스트림의 모든 트랙을 정지
  _localStream?.getTracks().forEach((track) => track.stop());
  _localStream?.dispose();
  
  // peerconnection 종료
  _peerConnection?.close();
}

앱 테스트하기

이제 두 개의 폰을 준비하고 Android Studio나 Xcode에서 빌드를 완료하고 실행하면 아래 왼쪽 이미지와 같은 화면이 보입니다. 이름에는 아무거나 넣고, 방 번호에 같은 숫자를 넣어준 후에 통화 참여를 누르면 👍

마치며

React-Native에서는 로컬영상의 radius가 안 먹히는 문제가 있던데, Flutter에서는 문제 없이 잘 되네요.
전체 코드는 repo 에 올려 두었습니다.

profile
developer

2개의 댓글

comment-user-thumbnail
2025년 4월 29일

덕분에 잘 배우고 갑니다!!

답글 달기
comment-user-thumbnail
2025년 5월 30일

좋은 강좌 감사드립니다^^

답글 달기