티스토리 뷰
안녕하세요 Diana 입니다.
오늘은 WebRTC 글에 이어 화상 통화를 구현하기 전, 통화를 구현하기 위해 핵심 개념인 VoIP와 CallKit에 대해 알아보도록 하겠습니다.
우선 통화의 과정은 크게 아래와 같습니다.

PushKit은 앱을 깨우고 CallKit은 화면을 보여주며 WebRTC는 실제로 목소리를 전달합니다.
이제 각각의 개념에 대해 알아보도록 하겠습니다.
✅ VoIP(Voice over IP)
VoIP는 이름 그대로 IP 네트워크 위로 음성을 보내는 것을 의미합니다.
VoIP는 음성을 잘게 쪼개 데이터 패킷으로 만들어 인터넷으로 흘려보내는 패킷 교환 방식을 사용하며 카카오톡의 보이스톡, 페이스 타임, 디스코드 통화 등이 모두 이 VoIP 를 사용합니다.
iOS13 이후 부터는 VoIP와 PushKit은 거의 하나로 동작하여 VoIP Push 라고도 불리며 이 VoIP Push는 배터리 문제를 해결하기 위해 등장하였습니다.
과거 VoIP는 전화를 수신하기 위해 서버와 항상 연결을 유지해야 했습니다. 연결을 살려두기 위해 앱이 사용 중이 아닐때도 주기적으로 메세지를 주고받았고 이로 인해 기기가 자주 깨어나며 배터리 소모가 상당했습니다. 게다가 사용자가 앱을 종료해버리기라도 하면 전화를 받을 수 없었죠. 이는 상당한 배터리를 소모하였죠.
VoIP Push는 이 문제를 해결했습니다.
앱이 소켓을 열어두는 대신 전화가 올 때 서버가 특수 푸시를 쏘고 이 푸시는 일반 푸시와 달리 앱을 백그라운드에서 실제로 깨워 실행시켜줍니다.
대신 조건이 존재합니다. 이 푸시는 최우선순위를 차지하기 때문에 VoIP Push를 사용한 경우 반드시 통화를 시작해주어야 합니다.
iOS13 부터 이 조건은 필수가 되었고 통화를 시작할 목적이 아니라면 일반 Push를 사용할 것을 공식문서에서는 권장하고 있습니다.
✅ CallKit?
CallKit은 이름과 달리 "통화를 하는 프레임워크"가 아닙니다. 미디어 전송은 대부분 직접 구현해야 하며 CallKit이 하는 일은 내 앱의 통화를 시스템이 실제 통화로 인식하도록 만드는 것이죠.
CallKit 아키텍쳐의 핵심은 통신 방향이 클래스로 분리되어 있습니다.
| 방향 | 객체 | 역할 |
| 앱 -> 시스템(보고) | CXProvider | 통화 수신, 끊김 등 외부에서 일어난 일을 알린다. |
| 앱 -> 시스템(요청) | CXCallController | 사용자가 앱 UI에서 한 행동을 시스템에 요청(CXTransaction에 담아서) |
| 시스템 -> 앱 | CXProviderDelegate | 통화 수신, 끊김 등 -> 실제 작업은 내가 수행 |
사용자가 직접 앱의 화면에서 버튼을 누른 경우는 CXCallController로 시스템에 요청을 보내게 되고, 외부에서 푸시로 전화가 도착하는 경우에는 CXProvider을 통해 시스템에 보고 하게 됩니다. 그리고 둘다 결국 실제 작업은 CXProviderDelegate에서 한다는 공통점을 가지고 있습니다.

위에서도 언급했지만 주의해야 할 점은 VoIP 푸시를 사용하는 경우 CallKit에 대한 보고는 필수이며 푸시를 받은 뒤 reportNewIncomingCall을 호출하지 않으면 시스템에서 앱을 강제 종료 시키고 반복되는 경우 해당 앱에 VoIP 푸시 전달 자체를 중단합니다.
따라서 푸시 핸들러에서 네트워크를 통해 발신자를 체크하고 보고하는 등의 작업을 진행하면 안됩니다. 하더라도 일단 임시 정보로 보고하고 정보가 도착하는 경우 provider.reportCall(with:update:)로 갱신하는 순서여야 합니다.
CXProvider(시스템 -> 앱)
CXCallUpdate 인스턴스를 통해 속성(이름, 화상통화인지 음성통화 인지 등)을 정의할 수 있습니다.
시스템에서 일어난 일을 앱이 전달받기 위해 필요한 클래스로 사용자가 잠금 화면 수신 UI에서 수락을 누르는 경우 그 이벤트는 앱 UI가 아닌 시스템이서 발생한 것 입니다.
이때 CXProvider은 Delegate로 이를 받아 "이제 WebRTC 연결을 시작하겠다"를 판단합니다.
CXCallController(앱 -> 시스템)
반대로 앱 안에서 사용자가 발신 버튼을 누른 경우, 앱이 시스템에게 요청을 보냅니다.
여기서 앱은 시스템에 "명령이 아닌 요청(CSTransaction)"을 보냅니다.
시스템은 이를 거절할 수 있고 따라서 119와 같은 긴급전화 통화 중이라면 앱의 발신 요청은 거부됩니다.
통화 관리의 최종 결정권은 항상 시스템이 쥐고 있다는 게 CallKit의 핵심 원칙입니다.
이제 개념들을 확인했으니 구현으로 들어가보도록 하겠습니다.
✅ 구현 - VoIP
우선 Push를 보내야 하므로 VoIP 기능을 담당하는 VoIPManager을 구현해보도록 하겠습니다.
final class VoIPManager: NSObject {
static let shared = VoIPManager()
private var registry: PKPushRegistry?
private override init() {
super.init()
register()
}
func register() {
guard registry == nil else { return }
let r = PKPushRegistry(queue: .main)
r.delegate = self
r.desiredPushTypes = [.voIP]
registry = r
}
}
VoIPManager의 PKPushRegistry delegate는 weak로 VoIPManager을 일반 객체로 생성하게 되면 이후 해당 객체를 호출한 부분이 해제되었을 때 Delegate가 호출되지 않는 문제가 발생할 수 있습니다.
따라서 VoIPManager을 싱글톤으로 생성해주었고 PKPushRegistry를 통해 푸시를 등록해주며 푸시가 왔을 때 메인스레드에서 받을 것을 명시해줍니다.
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
print("VoIP Token = \(token)")
sendTokenToServer(token)
}
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
guard type == .voIP,
let uuidString = payload.dictionaryPayload["uuid"] as? String,
let uuid = UUID(uuidString: uuidString)
else {
completion()
return
}
let callerName = payload.dictionaryPayload["callerName"] as? String ?? "알 수 없음"
CallManager.shared.reportIncomingCall(uuid: uuid, from: callerName) { _ in
Task {
await CallManager.shared.connectSignaling(uuid: uuid)
completion()
}
}
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
guard type == .voIP else { return }
removeTokenFromServer()
}
이제 Delegate 처리를 해줍니다.
didUpdate는 desirePushType 설정, 토큰 변경 등의 경우에 호출됩니다. 여기서 받은 토큰을 서버에 올려줍니다.
didReceiveIncomingPushWith는 서버가 VoIP 푸시를 발송했을 때 호출되며 앱이 종료상태인 경우 iOS가 앱을 깨워 이 메서드를 호출합니다.
마지막으로 didInvalidatePushTokenFor의 경우 토큰이 만료된 경우 호출됩니다. 토큰이 만료되면 이후 서버에 있는 토큰 또한 삭제해 줍니다.
✅ 구현 - CallKit
이렇게 토큰 처리가 끝났으면 이제 CallKit을 사용한 통화 기능을 구현해주도록 하겠습니다.
final class CallManager: NSObject, ObservableObject {
@Published private(set) var currentCallUUID: UUID?
@Published private(set) var currentCallerName: String?
static let shared = CallManager()
private let provider: CXProvider
private let callController = CXCallController()
private var activeCalls: [UUID: String] = [:] // [UUID: ActiveCall]
override init() {
let configuration = CXProviderConfiguration()
configuration.supportsVideo = false
configuration.maximumCallGroups = 1
configuration.maximumCallsPerCallGroup = 1
configuration.supportedHandleTypes = [.phoneNumber, .generic]
configuration.includesCallsInRecents = true
provider = CXProvider(configuration: configuration)
super.init()
provider.setDelegate(self, queue: nil)
}
...
}
우선 CXProviderConfiguration()을 통해 통화 초기 설정을 진행해줍니다.
CXProviderConfiguration은 통화 UI와 각종 설정들을 진행해주는 명세서로 CXProvider을 만들 때 한번 생성합니다.
...
func startCall(to destination: String, video: Bool = false) {
let callUUID = UUID()
let handle = CXHandle(type: .generic, value: destination)
let startCallAction = CXStartCallAction(call: callUUID, handle: handle)
startCallAction.isVideo = video
activeCalls[callUUID] = destination // 나중에 종료하기 위해 필수
callController.requestTransaction(with: startCallAction) { [weak self] error in
guard let self else { return }
if let error = error as NSError? {
print("발신 실패 code=\(error.code) — \(error.localizedDescription)")
self.activeCalls[callUUID] = nil
} else {
self.currentCallUUID = callUUID
self.currentCallerName = destination
}
}
}
...
이후 통화 시작을 구현해줍니다.
CXHandler은 상대를 가리키는 식별자로 .generic 외에도 .emailAddress, .phoneNumber 등 상대를 식별할 수 있는 여러 Type들을 가지고 있습니다.
CXStartCallAction는 하나의 요청서로 callUUID가 지정하는 상대에게 통화 요청을 진행하겠다는 의미를 담고 있습니다.
CXTransaction은 요청서를 담는 봉투입니다. 현재는 요청이 하나이므로 따로 CXTransaction 처리는 해주지 않았습니다.
이렇게 완성된 봉투를 우리는 requestTransaction(with:)를 통해 시스템에 전달하게 됩니다.
다만 여기서 기억해야 할 점은 requestTransaction은 어디까지나 "요청"이라는 것입니다.
시스템이 요청을 승인하면 그 결과는 CXProviderDelegate의 perform CXStartCallAction으로 다시 앱에게 돌아오게 되고, 실제 발신 작업은 그곳에서 수행하게 됩니다. 즉 버튼을 누른 곳에서 바로 WebRTC를 시작하는 것이 아니라, 반드시 시스템의 허가를 한 바퀴 돌아서 시작한다는 것이죠.
이번엔 반대로 수신을 구현해보도록 하겠습니다.
...
func reportIncomingCall(uuid: UUID, from callerName: String, completion: @escaping (Error?) -> Void) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.localizedCallerName = callerName
update.hasVideo = false
provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in
guard let self else { return }
if error == nil {
self.activeCalls[uuid] = callerName
self.currentCallUUID = uuid
self.currentCallerName = callerName
}
completion(error)
}
}
...
VoIPManager의 didReceiveIncomingPushWith에서 호출했던 바로 그 메서드입니다.
CXCallUpdate는 수신 화면에 표시될 정보를 담는 객체로 발신자 이름, 화상 통화 여부 등을 설정할 수 있습니다. 그리고 reportNewIncomingCall을 호출하는 순간 시스템이 수신 UI(잠금 화면 전화 화면)를 띄워주게 됩니다.
앞서 언급했듯 이 보고는 푸시를 받은 즉시 이루어져야 하며, 발신자 정보가 아직 준비되지 않았다면 임시 정보로 먼저 보고한 뒤 provider.reportCall(with:update:)로 갱신해야 합니다.
통화 종료 요청도 마찬가지로 CXCallController를 통해 진행합니다.
...
func endCall(uuid: UUID) {
let endCallAction = CXEndCallAction(call: uuid)
callController.requestTransaction(with: endCallAction) { error in
if let error {
print("종료 실패 — \(error.localizedDescription)")
}
}
}
...
사용자가 앱 UI에서 종료 버튼을 누른 경우이므로 방향은 앱 -> 시스템, 즉 요청입니다. 이 요청 역시 승인되면 perform CXEndCallAction으로 돌아오게 됩니다.
✅ 구현 - CXProviderDelegate
이제 CallKit의 마지막 조각이자 실제 작업이 일어나는 곳, CXProviderDelegate를 구현해보도록 하겠습니다.
발신이든 수신이든 종료든, 모든 길은 결국 이곳으로 모입니다.
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// 시스템이 통화 상태를 초기화한 경우 (오류 등)
// 진행 중인 통화 리소스를 모두 정리한다.
activeCalls.removeAll()
currentCallUUID = nil
currentCallerName = nil
}
// 발신 요청이 승인된 경우
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
Task {
await connectSignaling(uuid: action.callUUID)
// 상대에게 벨이 울리기 시작했다고 시스템에 보고
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
action.fulfill()
}
}
// 사용자가 수신 UI에서 응답을 누른 경우
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
Task {
await answerSignaling(uuid: action.callUUID) // SDP answer 전송
action.fulfill()
}
}
// 통화가 종료된 경우 (거절, 종료 버튼, 상대방 종료 모두 포함)
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectWebRTC()
activeCalls[action.callUUID] = nil
currentCallUUID = nil
currentCallerName = nil
action.fulfill()
}
// 시스템이 오디오 세션을 준비해준 경우
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// 이 시점부터 WebRTC 오디오를 시작해야 한다.
startWebRTCAudio()
}
// 오디오 세션이 회수된 경우
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
stopWebRTCAudio()
}
}
각 메서드에서 주목해야 할 점들을 정리해보겠습니다.
먼저 action.fulfill() 입니다.
모든 perform 메서드는 작업이 끝나면 반드시 fulfill()(성공) 또는 fail()(실패)을 호출해 요청서에 도장을 찍어주어야 합니다. 도장을 찍지 않으면 시스템은 해당 요청이 아직 처리 중이라고 판단하고, 일정 시간이 지나면 타임아웃으로 통화가 실패하게 됩니다.
다음은 CXAnswerCallAction 입니다.
사용자가 잠금 화면에서 응답을 누른 순간 호출되며, 여기서 시그널링을 통해 SDP answer를 상대에게 전달하고 WebRTC 연결을 진행합니다. 즉 "수신 화면을 띄우는 것"과 "실제 연결을 시작하는 것" 사이의 다리를 이 메서드가 담당하는 것이죠.
마지막으로 가장 실수하기 쉬운 부분, didActivate audioSession 입니다.
CallKit 환경에서 오디오 세션의 주인은 앱이 아니라 시스템입니다. 시스템이 셀룰러 통화 등과의 우선순위를 조정한 뒤 오디오를 써도 된다며 넘겨주는 시점이 바로 didActivate이며, WebRTC의 오디오는 반드시 이 시점 이후에 시작해야 합니다.
만약 이를 무시하고 응답 직후에 바로 오디오를 시작하면, 소리가 나지 않거나 스피커/수화기 라우팅이 꼬이는 등 원인을 찾기 어려운 문제들을 만나게 됩니다. WebRTC 라이브러리를 사용하는 경우 audioSession의 useManualAudio 설정을 통해 오디오 시작 시점을 직접 제어할 수 있습니다.
✅ 정리
서버가 VoIP 푸시 발송 -> PushKit이 앱을 깨움 -> 즉시 reportNewIncomingCall로 시스템에 보고 -> 시스템이 수신 UI 표시 -> 사용자의 응답이 CXProviderDelegate로 전달 -> 시그널링과 WebRTC 연결 -> didActivate 이후 오디오 시작
PushKit은 앱을 깨우고, CallKit은 화면과 권한을 관리하며, WebRTC는 목소리를 전달합니다. 그리고 통화의 최종 결정권은 언제나 시스템이 쥐고 있다는 것, 이것이 iOS 통화 구현의 핵심이었습니다.
단순한 플로우에 비해 꽤나 여러 개념이 들어가 있어 이해하는데 시간이 조금 걸렸던 것 같습니다.
다음 글에서는 이 구조 위에 실제 시그널링을 연결하여 두 기기 간 통화를 완성해보도록 하겠습니다.
출처
https://developer.apple.com/documentation/callkit
https://developer.apple.com/documentation/callkit/making-and-receiving-voip-calls
https://www.kodeco.com/1276414-callkit-tutorial-for-ios
https://developer.apple.com/documentation/callkit/voip-calling-with-callkit
https://ios-development.tistory.com/873
https://medium.com/mindful-engineering/voice-over-internet-protocol-voip-801ee15c3722
'Swift' 카테고리의 다른 글
| Swift - WebRTC 구현(3) (0) | 2026.07.24 |
|---|---|
| Swift - WebRTC 구현(2) (0) | 2026.07.22 |
| Swift - WebRTC 구현(1) (0) | 2026.07.20 |
| Swift - WebRTC란? (0) | 2026.07.19 |
| Swift - API 호출 실패 시 재시도 (0) | 2026.07.07 |
- Total
- Today
- Yesterday
- AI
- 인공신경망
- 알고리즘
- 경사하강법
- opaque
- webrtc
- 동시성
- Tuist
- BoxedType
- Algorithm
- iphone
- opaquetype
- coredata
- CD/CI
- SwiftUI
- kakaomapssdk
- CacheLocality
- 스위프트
- Actor
- Concurrency
- swift
- Xcode
- ViewBuilder
- nonisolated
- GradientDescent
- 코어데이터
- tuist v4
- ios
- 역전파
- SocialLogin
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
