티스토리 뷰
오늘은 앞에서 배운 WebRTC의 개념을 활용해 채팅, 파일전송 그리고 화상 통화 등의 간단한 기능들을 구현해보려고 합니다.
시작하기에 앞서 저는 이 세 기능을 따로 만드는 것이 아닌 하나의 연결 위에 얹는다는 관점으로 진행할 예정입니다.
WebRTC의 연결을 하나 뚫어 놓으면 그 안에는 임의의 데이터를 전송하는 데이터 채널과 음성, 영상 전용인 미디어 트랙이 생성됩니다. 채팅과 파일 전송 등의 기능은 데이터 채널을 사용하고 화상 통화 등의 기능은 미디어 트랙을 거치게 되죠.

즉, 연결의 기반이 되는 노란색 칸을 제대로 만들 필요가 있습니다.
✅ 프로젝트 세팅
우선 시작에 앞서 SPM으로 WebRTC 패키지를 추가합니다.
그리고 Info.plist에는 NSCameraUsageDescription과 NSMicrophoneUsageDescription을 미리 추가해줍니다.

✅ Firebase Database 연결
저는 시그널링 채널 설정을 위해 Firebase를 사용할 것 입니다.
정석적인 시그널링의 방법은 아니지만 Peer 값을 Firebase 의 알람을 통해 전달해줌으로써 시그널링과 같이 작동할 수 있습니다.
우선 FirebaseStore을 연결한 뒤 정상적으로 동작하는지 간단하게 테스트 해주겠습니다.
Firebase Database에 rooms라는 이름의 DB Collection을 생성해줍니다.

그리고 앱으로 돌아와 간단한 기능을 구현해줍니다.
ChannelTest는 FirebaseStore과의 연결을 담당하며 Collection에 값을 Send 하면 자동으로 구독 중인 상황이면 값이 업데이트 됩니다.
import FirebaseFirestore
import Combine
@MainActor
final class ChannelTest: ObservableObject {
@Published var received = "-"
@Published var isListening = false
private let db = Firestore.firestore()
private var listener: ListenerRegistration?
private let roomId = "test-room"
func startListening() {
listener = db.collection("rooms").document(roomId)
.addSnapshotListener { [weak self] snapshot, error in
if let error {
print("수신 오류 \(error)")
return
}
guard let text = snapshot?.data()?["ping"] as? String else { return }
self?.received = text
}
isListening = true
}
func send() {
let text = "\(Int.random(in: 0...100))"
db.collection("rooms").document(roomId)
.setData(["ping": text], merge: true)
}
func stop() {
listener?.remove()
listener = nil
isListening = false
}
}

두 개의 디바이스에서 코드를 돌렸을 때 위와 같은 화면이 나타나게 되며 한쪽에서 데이터를 Send 하면 구독중인 다른 쪽에서도 정상적으로 값의 업데이트가 이루어지는 것을 확인했습니다.
✅ 두번째 과정
이제 WebRTC 구현을 진행해보도록 하겠습니다.
우리는 보통 "내 폰의 주소"라고 하면 하나를 떠올립니다. 하지만 실제로 내 폰의 주소는 상대가 어디서 나에게 오느냐에 따라 통하는 주소가 다릅니다.
예를 들어보겠습니다.
같은 와이파이에 있는 사람에게 제 주소는 192.168.0.10 입니다. 그런데 이 주소를 LTE를 사용하는 친구에게 알려주면 아무런 소용이 없습니다. 친구 입장에서 192.168.0.10은 자기 집 공유기 아래의 어느 기기를 가리키기 때문입니다.
따라서 WebRTC는 정답 하나를 고르려고 하지 않고 가능한 주소를 전부 목록으로 만들어 상대에게 넘깁니다.
목록을 만들기 위해서는 총 세 곳에 물어봐야 합니다.
1. 운영체제에게: "내 네트워크 인터페이스는 뭐가 있어?" 라고 물어보게 되며 와이파이, LTE, VPN이 다 잡히면 각각이 별개의 host 후보가 됩니다.
2. STUN 서버에게: 내 폰은 자기 공인 IP를 스스로 알 수 없기 때문에 밖에 있는 누군가가 내가 어떻게 보이는지 알려주어야 합니다. 폰이 STUN 서버에 패킷을 하나 보내면, 서버는 내 주소에 대한 정보를 답해줍니다.
3. TURN 서버에게
이번 과정에서는 이 후보들이 제대로 오는지를 확인하려고 합니다.
import WebRTC
final class WebRTCClient: NSObject {
// static let + 클로저 = 지연생성 + 단 한번만 + 스레드 안전 (여러 스레드에서 동시에 접근 해도 한번만 실행돼 런타임 보장)
private static let factory: RTCPeerConnectionFactory = {
RTCInitializeSSL()
return RTCPeerConnectionFactory(encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory())
}()
private let peerConnection: RTCPeerConnection // 연결을 끊었다 다시 걸기 위해서는 만드는 것이 정석
private var dataChannel: RTCDataChannel?
override init() {
let config = RTCConfiguration()
config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.1.google.com:19302"])]
config.sdpSemantics = .unifiedPlan
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
guard let pc = WebRTCClient.factory.peerConnection(with: config, constraints: constraints, delegate: nil) else {
fatalError("PeerConnection 생성 실패")
}
peerConnection = pc
super.init()
peerConnection.delegate = self
}
func gatherTest() {
dataChannel = peerConnection.dataChannel(forLabel: "main", configuration: RTCDataChannelConfiguration())
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
peerConnection.offer(for: constraints) { [weak self] sdp, error in
guard let sdp else {
print("offer 실패: \(String(describing: error))")
return
}
print("======offer 생성됨======")
print(sdp.sdp)
self?.peerConnection.setLocalDescription(sdp) { _ in
print("====== local description 설정 완료 ======")
}
}
}
}
extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ pc: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
print("후보 ▶ \(candidate.sdp)") // 상대에게 보낼때는 sdpMid와 sdpMLineIndex 모두 보내야 함
}
func peerConnection(_ pc: RTCPeerConnection, didChange state: RTCIceGatheringState) {
let name = ["new", "gathering", "complete"][Int(state.rawValue)] // 이후 변경되야 함
print("수집 상태: \(name)")
}
func peerConnection(_ pc: RTCPeerConnection, didChange state: RTCSignalingState) {}
func peerConnection(_ pc: RTCPeerConnection, didChange state: RTCIceConnectionState) {}
func peerConnection(_ pc: RTCPeerConnection, didAdd stream: RTCMediaStream) {}
func peerConnection(_ pc: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
func peerConnectionShouldNegotiate(_ pc: RTCPeerConnection) {}
func peerConnection(_ pc: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
func peerConnection(_ pc: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
}
위 코드를 살펴보면 우선 RTCInitializeSSL()을 통해 암호화 라이브러리를 초기화합니다. WebRTC는 미디어를 무조건 암호화하여 보내기 때문에 필수이며 앱 생애 주기에 한번만 호출되야 하므로 Factory 초기화 클로저 안에 넣어주었습니다.
encoder / decoder Factory는 어떤 영상 코덱을 쓸 수 있는지를 정하며 이후 화상통화 할때 사용하는 부분입니다.
이후 RTCConfiguration을 통해 연결을 설정하는데 iceServer의 urlStrings가 배열인 이유는 서버 하나가 여러 주소를 가질 수 있기 때문이며(TCP용, UDP용 등) 하나의 RTCIceServer 안에 묶이면 자격 증명을 공유하게 됩니다.
sdpSementics는 SDP를 어떤 방법으로 쓸지 정하며 Plan B와 Unified Plan 두 가지가 있습니다.
현재는 Unified Plane이 표준으로 쓰이고 있으며 API들은 이걸 전제로 동작합니다.
gatherTest()에 대해 알아보면 dataChannel은 이후 연결이 되었을 때 데이터를 주고받을 통로에 해당되며 forLabel은 채널이 여러 개일 때 구분하는 용도이며 이후 callee 쪽의 didOpen에서 같은 라벨로 받게 됩니다.
Constraint의 경우 이후 자신이 받을 데이터에 대한 정보로 지금은 nil로 설정하여 "기본 값으로 설정하겠다"라는 의미입니다.
데이터 채널만 사용할 경우 nil로 충분하지만 이후 수정할 예정이므로 넘어가도록 하겠습니다.
Offer은 초안을 작성하는 것으로 offer(for:)이 호출되면 WebRTC가 내부 상태를 보고 SDP 텍스트를 생성합니다.
RTCDataChannelConfiguration()의 기본 값은 순서보장 + 재전송 보장입니다.
TCP처럼 동작한다고 생각하면 되고 나중의 파일 전송 시 이 성질이 그대로 필요합니다.
offer 생성은 비동기로 기기가 지원하는 코덱을 조사하고 암호키를 준비하는 등의 시간이 걸립니다. 따라서 완료 클로저를 받고 이 클로저는 메인이 아닌 WebRTC 내부 스레드에서 호출됩니다.
하지만 offer을 만든 것 만으로는 아무일도 일어나지 않습니다. setLocalDescription으로 구성을 확정해야 비로소 ICE 후보 수집이 시작되죠.
그리고 아래는 Delegate 코드입니다.
이렇게 작성된 코드의 결과물은 아래와 같습니다.
======offer 생성됨======
v=0
o=- 6461656519104927157 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=extmap-allow-mixed
a=msid-semantic: WMS
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:Jr6K
a=ice-pwd:/3b5jbcprCp8LqM0aTXT7T2P
a=ice-options:trickle renomination
a=fingerprint:sha-256 14:EE:38:D9:A0:60:1A:42:4E:B0:B1:26:DD:71:1F:B3:0F:6D:4C:2B:F5:F5:26:82:13:A7:D0:EF:33:2D:4C:F4
a=setup:actpass
a=mid:0
a=sctp-port:5000
a=max-message-size:262144
====== local description 설정 완료 ======
수집 상태: gathering
후보 ▶ candidate:1869971030 1 udp 2122260223 169.254.33.154 56948 typ host generation 0 ufrag Jr6K network-id 1 network-cost 10
후보 ▶ candidate:4212945418 1 udp 2122063615 192.0.0.2 58621 typ host generation 0 ufrag Jr6K network-id 9 network-cost 900
후보 ▶ candidate:4215329689 1 udp 2121670399 192.0.0.6 63549 typ host generation 0 ufrag Jr6K network-id 3 network-cost 50
후보 ▶ candidate:27756728 1 udp 2122197247 2001:e60:8361:bc30:43f:2d08:b85e:857 55320 typ host generation 0 ufrag Jr6K network-id 10 network-cost 900
후보 ▶ candidate:349315609 1 udp 2122003199 fd74:6572:6d6e:7573:c:17ab:20a1:fb78 63658 typ host generation 0 ufrag Jr6K network-id 4 network-cost 50
후보 ▶ candidate:2888092708 1 udp 2122131711 2001:e60:58f6:77bf:8c48:2630:e007:538 55578 typ host generation 0 ufrag Jr6K network-id 11 network-cost 900
후보 ▶ candidate:42655541 1 udp 2121937663 fd74:6572:6d6e:7573:d:17ab:20a1:fb78 56903 typ host generation 0 ufrag Jr6K network-id 5 network-cost 50
후보 ▶ candidate:537873035 1 udp 2121872127 fd83:af9e:230f::1 65502 typ host generation 0 ufrag Jr6K network-id 12 network-cost 50
후보 ▶ candidate:297442510 1 tcp 1518280447 169.254.33.154 52761 typ host tcptype passive generation 0 ufrag Jr6K network-id 1 network-cost 10
후보 ▶ candidate:2245234834 1 tcp 1518083839 192.0.0.2 52762 typ host tcptype passive generation 0 ufrag Jr6K network-id 9 network-cost 900
후보 ▶ candidate:2240753921 1 tcp 1517690623 192.0.0.6 52763 typ host tcptype passive generation 0 ufrag Jr6K network-id 3 network-cost 50
후보 ▶ candidate:2137549344 1 tcp 1518217471 2001:e60:8361:bc30:43f:2d08:b85e:857 52764 typ host tcptype passive generation 0 ufrag Jr6K network-id 10 network-cost 900
후보 ▶ candidate:1780334721 1 tcp 1518023423 fd74:6572:6d6e:7573:c:17ab:20a1:fb78 52765 typ host tcptype passive generation 0 ufrag Jr6K network-id 4 network-cost 50
후보 ▶ candidate:3538628284 1 tcp 1518151935 2001:e60:58f6:77bf:8c48:2630:e007:538 52766 typ host tcptype passive generation 0 ufrag Jr6K network-id 11 network-cost 900
후보 ▶ candidate:2084912557 1 tcp 1517957887 fd74:6572:6d6e:7573:d:17ab:20a1:fb78 52767 typ host tcptype passive generation 0 ufrag Jr6K network-id 5 network-cost 50
후보 ▶ candidate:1589684243 1 tcp 1517892351 fd83:af9e:230f::1 52768 typ host tcptype passive generation 0 ufrag Jr6K network-id 12 network-cost 50
수집 상태: complete
위에서 살펴봐야 하는 내용은 두 가지 입니다.
- typ host 후보: 내 로컬 IP로 이게 나오면 SDK가 정상적으로 링크되어 PeerConnection이 살아있다는 의미입니다.
- typ srflx 후보: STUN 서버가 알려준 내 공인 IP로 앞서 설명했듯이 밖에서 내가 어떻게 보이는지에 대한 결과를 찍어줍니다.
이렇게 SDP 명세서와 ICE 후보 목록은 생성이 되었습니다.
이젠 이걸 상대에게 전달하는 코드를 작성해보겠습니다.
'Swift' 카테고리의 다른 글
| Swift - WebRTC 구현(3) (0) | 2026.07.24 |
|---|---|
| Swift - WebRTC 구현(2) (0) | 2026.07.22 |
| Swift - WebRTC란? (0) | 2026.07.19 |
| Swift - API 호출 실패 시 재시도 (0) | 2026.07.07 |
| Swift - nonisolated 란 (0) | 2026.06.30 |
- Total
- Today
- Yesterday
- 코어데이터
- coredata
- ViewBuilder
- Algorithm
- AI
- BoxedType
- iphone
- opaque
- Concurrency
- SwiftUI
- Xcode
- nonisolated
- CacheLocality
- 알고리즘
- SocialLogin
- CD/CI
- Tuist
- ios
- 경사하강법
- kakaomapssdk
- Actor
- tuist v4
- 역전파
- swift
- GradientDescent
- 동시성
- 스위프트
- 인공신경망
- webrtc
- opaquetype
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
