티스토리 뷰
안녕하세요 Diana 입니다.
오늘은 WebRTC 구현을 이어나가보도록 하겠습니다.
앞선 글의 WebRTCClient 코드에서는 SDP를 생성하여 이를 등록한 뒤 이를 어디에도 올리지 않고 끝냈습니다.
이번 글에서는 생성된 SDP와 ICE 후보들을 Firestore에 올려 상대가 읽을 수 있도록 통신 부분을 구현해주도록 하겠습니다.
✅ DataChannel 생성
우선 WebRTC를 위해 필요한 객체는 아래와 같습니다.
- MediaStream: 카메라와 마이크 등의 데이터 스트림 접근
- RTCPeerConnection: 암호화 및 대역폭 관리 및 오디오, 비디오 연결
- RTCDataChannel: 일반적인 데이터의 P2P 통신
현재 offer가 만든 SDP 텍스트와 수집된 ICE Candidates 들은 전부 폰 메모리 안에 있습니다.
이를 이제 FirebaseStore에 올려보도록 하겠습니다.
final class SignalingClient {
private let roomRef: DocumentReference
private var listeners: [ListenerRegistration] = []
init(roomId: String) {
roomRef = Firestore.firestore().collection("rooms").document(roomId)
}
// 서버에서 offer을 탐색하여 여부에 따라 caller, callee 를 구분
func join(completion: @escaping (PeerRole, String?) -> Void) {}
// 서버의 offer 파라미터 업데이트
func upload(offer sdp: String) {}
// 서버의 answer 파라미터 업데이트
func upload(answer sdp: String) {}
// 서버의 파라미터가 Generate 되었을 때
func upload(candidate: RTCIceCandidate, from role: PeerRole) {}
// caller 전용으로 answer가 채워지길 기다림
func observeAnswer(_ handler: @escaping (String) -> Void) {}
// 상대 candidate collection 구독
func observeRemoteCandidates(myRole: PeerRole,handler: @escaping (RTCIceCandidate) -> Void) {}
func stopListening() {}
func reset(completion: @escaping () -> Void) {}
}
Firebase 관련 기능은 SignalingClient에 구현하였습니다.
초기화 단계에서는 collection을 생성해 준 뒤 값의 생성과 업데이트를 위한 함수들을 생성해줍니다.
SignalingClient의 내부는 정말 말그대로 FirebaseStore에 대한 생성, 업데이트, 삭제 기능 만을 담당하므로 세부 구현은 제외하였습니다.
다음은 WebRTCClient 에 대해 알아보도록 하겠습니다.
protocol WebRTCClientDelegate: AnyObject {
func webRTCClient(_ client: WebRTCClient, didGenerate candidate: RTCIceCandidate)
func webRTCClient(_ client: WebRTCClient, didChange state: RTCIceConnectionState)
func webRTCClientDidOpenDataChannel(_ client: WebRTCClient)
}
우선 생성된 SDP 등을 FirebaseStore에 저장하던 해야하기 때문에 콜백으로 결과 값을 받아와주기 위해 Delegate를 생성해줍니다.
final class WebRTCClient: NSObject {
weak var delegate: WebRTCClientDelegate?
// static let + 클로저 = 지연생성 + 단 한번만 + 스레드 안전 (여러 스레드에서 동시에 접근 해도 한번만 실행돼 런타임 보장)
private static let factory: RTCPeerConnectionFactory = {
// 이전과 동일
}()
...
// remote description이 설정되기 전, 도착한 후보 보관
private var pendingCandidates: [RTCIceCandidate] = []
private var hasRemoteDescription = false
override init() {
... // 이전과 동일
}
// caller 호출, callee는 didOpen으로 받는다
func createDataChannel() {
let channel = peerConnection.dataChannel(forLabel: "main", configuration: RTCDataChannelConfiguration())
channel?.delegate = self
dataChannel = channel
}
// Caller
func makeOffer(_ completion: @escaping (RTCSessionDescription) -> Void) {
let constraint = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
peerConnection.offer(for: constraint) { [weak self] sdp, error in
guard let self, let sdp else {
print("offer 실패: \(String(describing: error))")
return
}
self.peerConnection.setLocalDescription(sdp) { _ in completion(sdp) }
}
}
// Callee
func makeAnswer(_ completion: @escaping (RTCSessionDescription) -> Void) {
let constraint = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
peerConnection.answer(for: constraint) { [weak self] sdp, error in
guard let self, let sdp else {
print("answer 실패: \(String(describing: error))"); return
}
self.peerConnection.setLocalDescription(sdp) { _ in completion(sdp) }
}
}
func setRemoteDescription(_ sdp: RTCSessionDescription, completion: @escaping () -> Void) {
guard !hasRemoteDescription else { return }
peerConnection.setRemoteDescription(sdp) { [weak self] error in
guard let self else { return }
if let error {
print("remote SDP 실패: \(error)")
return
}
self.hasRemoteDescription = true
self.pendingCandidates.forEach { self.peerConnection.add($0) { _ in } }
self.pendingCandidates.removeAll()
completion()
}
}
func add(_ candidate: RTCIceCandidate) {
guard hasRemoteDescription else {
pendingCandidates.append(candidate)
return
}
peerConnection.add(candidate) { _ in }
}
func close() {
dataChannel?.close()
peerConnection.close()
}
}
그리고 핵심이 되는 WebRTCClient를 구현해줍니다.
이 부분은 Caller와 Callee의 관점에서 설명이 필요합니다.
두 개의 디바이스 A와 B가 있다고 가정하며 설명해보도록 하겠습니다.
디바이스 A (Caller)
1. DataChannel을 생성합니다
2. Constraint를 생성 한 뒤 peerConnection.offer을 통해 offer SDP를 생성합니다
3. setLocalDesciption(offer) 을 통해 A의 ICE 수집을 시작합니다
4. 이렇게 생성된 offer을 Firestore에 업로드 합니다
디바이스 B (Callee)
5. setRemoteDescription(offer)을 통해 B의 remote 칸을 채웁니다. 추가로 쌓인 A의 Candidate 를 Flush 합니다.
6. Constraint를 생성 한 뒤 peerConnection.answer을 통해 answer SDP를 생성합니다.
7. setLocalDescription(answer)을 통해 B의 ICE 수집을 시작합니다.
8. answer을 Firestore에 업로드 합니다.
디바이스 A (Caller)
9. 다시 디바이스 A로 넘어와 listener로 answer을 수신하여 setRemoteDescription(answer)을 통해 A의 remote 칸을 채우며 쌓인 B의 Candidate를 Flush 합니다.

구현한 것을 돌려보면 위와 같은 화면을 확인할 수 있습니다.
Collection의 이름을 동일하게 입력한 뒤 입장을 누르면 Caller은 Offer가 비어있음을 확인하고 스스로의 Role이 Caller 임을 인지합니다.
이후 Callee가 올때까지 대기합니다.
이후 Callee가 입장하는 경우 Callee는 이미 Offer가 있음을 확인하고 Listener을 붙여 Answer을 채운 뒤 서버에 올립니다.
Caller와 Callee의 동작과 각각의 개념을 이해하는데 꽤나 시간이 걸린 것 같습니다만 이제 연결에 성공했고 정상적으로 잘 동작하는 것을 확인했습니다.
다음으로는 채팅을 구현해보도록 하겠습니다.
'Swift' 카테고리의 다른 글
| Swift - VoIP Push(PushKit) & CallKit (0) | 2026.07.27 |
|---|---|
| Swift - WebRTC 구현(3) (0) | 2026.07.24 |
| Swift - WebRTC 구현(1) (0) | 2026.07.20 |
| Swift - WebRTC란? (0) | 2026.07.19 |
| Swift - API 호출 실패 시 재시도 (0) | 2026.07.07 |
- Total
- Today
- Yesterday
- GradientDescent
- SocialLogin
- AI
- ViewBuilder
- Xcode
- nonisolated
- opaque
- 인공신경망
- 스위프트
- opaquetype
- 알고리즘
- 역전파
- 코어데이터
- kakaomapssdk
- SwiftUI
- Tuist
- CacheLocality
- iphone
- Actor
- 경사하강법
- Concurrency
- webrtc
- Algorithm
- tuist v4
- coredata
- BoxedType
- swift
- 동시성
- ios
- CD/CI
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
