티스토리 뷰

Swift

Swift - WebRTC 구현(2)

DevDiana 2026. 7. 22. 18:11

안녕하세요 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
링크
«   2026/08   »
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
글 보관함