📞 CallKit 완전정복
VoIP 앱을 네이티브 전화처럼! CallKit으로 통화 UI를 시스템에 통합하세요.
⭐ 난이도: ⭐⭐⭐
⏱️ 예상 시간: 2-3h
📂 System & Network
✨ CallKit이란?
CallKit은 VoIP(Voice over IP) 앱을 iPhone의 네이티브 통화 시스템에 통합하는 프레임워크입니다. FaceTime, 전화 앱과 동일한 UI로 통화를 표시하고, 잠금 화면에서도 통화를 받을 수 있습니다. Slack, WhatsApp, Zoom 등이 CallKit을 사용합니다.
📦 주요 기능
기능 개요
✅ 시스템 통화 UI 통합
✅ 잠금 화면 통화 알림
✅ 통화 기록 (최근 통화)
✅ 차단 및 식별 (Caller ID)
✅ PushKit 연동 (백그라운드 알림)
✅ 통화 대기, 통화 전환
✅ 음소거, 스피커 제어📞 기본 구조: CXProvider와 CXCallController
CallManager.swift
import CallKit import AVFoundation class CallManager: NSObject { static let shared = CallManager() // CXProvider: 시스템에서 앱으로 들어오는 이벤트 처리 private let provider: CXProvider // CXCallController: 앱에서 시스템으로 액션 요청 private let callController = CXCallController() private override init() { // Provider 설정 let config = CXProviderConfiguration() config.localizedName = "MyVoIP" config.supportsVideo = true config.maximumCallGroups = 1 config.maximumCallsPerCallGroup = 1 config.supportedHandleTypes = [.phoneNumber, .generic] // 벨소리 설정 config.ringtoneSound = "ringtone.caf" provider = CXProvider(configuration: config) super.init() provider.setDelegate(self, queue: nil) } } // MARK: - Provider Delegate extension CallManager: CXProviderDelegate { // 오디오 세션 설정 (필수) func providerDidReset(_ provider: CXProvider) { // 모든 통화 종료 print("Provider reset") } // 통화 시작 func provider(_ provider: CXProvider, perform action: CXStartCallAction) { // 통화 시작 로직 configureAudioSession() action.fulfill() } // 통화 응답 func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { // 통화 응답 로직 configureAudioSession() action.fulfill() } // 통화 종료 func provider(_ provider: CXProvider, perform action: CXEndCallAction) { // 통화 종료 로직 action.fulfill() } // 음소거 func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { action.fulfill() } // 대기 (Hold) func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { action.fulfill() } // 오디오 세션 활성화 func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { // VoIP 오디오 시작 print("Audio session activated") } func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playAndRecord, mode: .voiceChat) try session.setActive(true) } catch { print("Audio session error: \(error)") } } }
📲 수신 전화 표시하기
IncomingCall.swift
import CallKit extension CallManager { // 수신 전화 알림 func reportIncomingCall( uuid: UUID, handle: String, hasVideo: Bool = false, completion: ((Error?) -> Void)? = nil ) { // CXCallUpdate로 통화 정보 설정 let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .phoneNumber, value: handle) update.hasVideo = hasVideo update.localizedCallerName = handle // 시스템에 통화 알림 provider.reportNewIncomingCall(with: uuid, update: update) { error in if let error = error { print("Failed to report call: \(error.localizedDescription)") } else { print("Incoming call reported") } completion?(error) } } } // 사용 예시 let callUUID = UUID() CallManager.shared.reportIncomingCall( uuid: callUUID, handle: "010-1234-5678", hasVideo: false )
📤 발신 전화 시작하기
OutgoingCall.swift
import CallKit extension CallManager { // 발신 전화 시작 func startCall(handle: String, videoEnabled: Bool = false) { let uuid = UUID() // Handle 생성 let handle = CXHandle(type: .phoneNumber, value: handle) // Start Call Action let startCallAction = CXStartCallAction(call: uuid, handle: handle) startCallAction.isVideo = videoEnabled // Transaction 생성 및 요청 let transaction = CXTransaction(action: startCallAction) callController.request(transaction) { error in if let error = error { print("Start call failed: \(error.localizedDescription)") } else { print("Start call succeeded") } } } // 통화 종료 func endCall(uuid: UUID) { let endCallAction = CXEndCallAction(call: uuid) let transaction = CXTransaction(action: endCallAction) callController.request(transaction) { error in if let error = error { print("End call failed: \(error.localizedDescription)") } else { print("Call ended") } } } } // 사용 예시 CallManager.shared.startCall(handle: "010-9876-5432", videoEnabled: false)
📱 SwiftUI 통합
CallView.swift
import SwiftUI import CallKit struct CallView: View { @State private var phoneNumber = "" @State private var isCallActive = false @State private var currentCallUUID: UUID? var body: some View { VStack(spacing: 20) { Text("VoIP 통화") .font(.largeTitle) .bold() if isCallActive { // 통화 중 UI VStack(spacing: 20) { Image(systemName: "phone.fill") .font(.system(size: 60)) .foregroundStyle(.green) Text("통화 중...") .font(.title2) Text(phoneNumber) .font(.title3) .foregroundStyle(.secondary) Button(action: endCall) { HStack { Image(systemName: "phone.down.fill") Text("통화 종료") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(.red) } } else { // 다이얼 UI TextField("전화번호", text: $phoneNumber) .textFieldStyle(.roundedBorder) .keyboardType(.phonePad) Button(action: makeCall) { HStack { Image(systemName: "phone.fill") Text("전화 걸기") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(phoneNumber.isEmpty) Button("수신 테스트") { simulateIncomingCall() } .buttonStyle(.bordered) } } .padding() } func makeCall() { currentCallUUID = UUID() CallManager.shared.startCall(handle: phoneNumber) isCallActive = true } func endCall() { if let uuid = currentCallUUID { CallManager.shared.endCall(uuid: uuid) } isCallActive = false currentCallUUID = nil } func simulateIncomingCall() { let uuid = UUID() CallManager.shared.reportIncomingCall( uuid: uuid, handle: "010-1234-5678" ) } }
🔔 PushKit 연동 (백그라운드 알림)
PushKitManager.swift
import PushKit import CallKit class PushKitManager: NSObject, PKPushRegistryDelegate { static let shared = PushKitManager() private let pushRegistry = PKPushRegistry(queue: .main) func registerForVoIPPushes() { pushRegistry.delegate = self pushRegistry.desiredPushTypes = [.voIP] } // Push Token 받음 func pushRegistry( _ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType ) { let token = credentials.token.map { String(format: "%02x", $0) }.joined() print("VoIP Push Token: \(token)") // 서버로 토큰 전송 sendTokenToServer(token) } // Push 수신 (통화 알림) func pushRegistry( _ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void ) { guard type == .voIP else { return } // Payload에서 통화 정보 추출 let callerID = payload.dictionaryPayload["caller_id"] as? String ?? "Unknown" let hasVideo = payload.dictionaryPayload["has_video"] as? Bool ?? false // CallKit으로 수신 전화 표시 let callUUID = UUID() CallManager.shared.reportIncomingCall( uuid: callUUID, handle: callerID, hasVideo: hasVideo ) { error in completion() } } func sendTokenToServer(_ token: String) { // API 호출하여 서버에 저장 } }
🎯 실전 예제: 완전한 VoIP 앱
VoIPApp.swift
import SwiftUI @main struct VoIPApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { CallView() } } } class AppDelegate: NSObject, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { // PushKit 등록 PushKitManager.shared.registerForVoIPPushes() // CallManager 초기화 _ = CallManager.shared return true } }
🔧 통화 상태 업데이트
CallUpdate.swift
import CallKit extension CallManager { // 통화 연결됨 func reportCallConnected(uuid: UUID) { provider.reportOutgoingCall(with: uuid, connectedAt: Date()) } // 통화 시작 실패 func reportCallFailed(uuid: UUID) { provider.reportCall(with: uuid, endedAt: Date(), reason: .failed) } // 상대방이 거부 func reportCallRemoteEnded(uuid: UUID) { provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) } // 통화 정보 업데이트 func updateCall(uuid: UUID, localizedName: String) { let update = CXCallUpdate() update.localizedCallerName = localizedName provider.reportCall(with: uuid, updated: update) } } // 사용 예시 let callUUID = UUID() // 통화 연결 성공 CallManager.shared.reportCallConnected(uuid: callUUID) // 5초 후 통화 종료 DispatchQueue.main.asyncAfter(deadline: .now() + 5) { CallManager.shared.reportCallRemoteEnded(uuid: callUUID) }
📋 Caller ID 및 차단 확장
CallDirectoryExtension.swift
import CallKit // Call Directory Extension (별도 타겟) class CallDirectoryHandler: CXCallDirectoryProvider { // 차단 번호 추가 override func beginRequest(with context: CXCallDirectoryExtensionContext) { do { // 차단할 번호 목록 let blockedNumbers: [CXCallDirectoryPhoneNumber] = [ 82101234567, // 국가코드 포함 82109876543 ] for number in blockedNumbers.sorted() { context.addBlockingEntry(withNextSequentialPhoneNumber: number) } // 발신자 표시 정보 let identifications: [(CXCallDirectoryPhoneNumber, String)] = [ (82101111111, "홍길동"), (82102222222, "배달 서비스") ] for (number, label) in identifications.sorted(by: { $0.0 < $1.0 }) { context.addIdentificationEntry( withNextSequentialPhoneNumber: number, label: label ) } context.completeRequest() } catch { context.cancelRequest(withError: error) } } }
💡 HIG 가이드라인
HIG 권장사항
✅ DO
1. 정확한 발신자 정보 표시
- 이름, 번호를 명확하게
- localizedCallerName 사용
2. 오디오 세션 올바르게 설정
- .playAndRecord + .voiceChat
- provider didActivate 에서 오디오 시작
3. 통화 상태 즉시 업데이트
- 연결, 종료 상태를 정확하게 보고
- reportCallConnected, reportCallEnded
4. PushKit 필수 사용
- 백그라운드에서도 통화 수신
- VoIP Push만 사용
❌ DON'T
1. 일반 Push Notification 사용 금지
2. CallKit 없이 VoIP 앱 제작 불가
3. 통화 아닌 용도로 PushKit 사용
4. 통화 상태 보고 누락🔧 실무 활용 팁
실전 패턴
import CallKit // 1. 통화 관찰자 (Observer) class CallObserver { let callObserver = CXCallObserver() init() { callObserver.setDelegate(self, queue: .main) } } extension CallObserver: CXCallObserverDelegate { func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) { print("Call changed: \(call.uuid)") if call.isOnHold { print("통화 대기 중") } if call.hasEnded { print("통화 종료됨") } } } // 2. 음소거 상태 토글 func toggleMute(callUUID: UUID, isMuted: Bool) { let muteAction = CXSetMutedCallAction(call: callUUID, muted: isMuted) let transaction = CXTransaction(action: muteAction) CXCallController().request(transaction) { error in if let error = error { print("Mute failed: \(error)") } } } // 3. 다중 액션 (통화 전환) func swapCalls(currentUUID: UUID, incomingUUID: UUID) { let holdAction = CXSetHeldCallAction(call: currentUUID, onHold: true) let answerAction = CXAnswerCallAction(call: incomingUUID) let transaction = CXTransaction(actions: [holdAction, answerAction]) CXCallController().request(transaction) { error in if let error = error { print("Swap failed: \(error)") } } }
🔐 Info.plist 및 Entitlements 설정
Info.plist
<!-- 백그라운드 모드 --> <key>UIBackgroundModes</key> <array> <string>voip</string> </array> <!-- Entitlements에서 설정 필요 --> - Push Notifications 활성화 - Background Modes > Voice over IP 체크
💡 CallKit 핵심
✅ 시스템 통화 UI와 완벽 통합
✅ PushKit으로 백그라운드 수신
✅ 잠금 화면에서도 통화 가능
✅ 통화 기록 자동 저장
✅ FaceTime과 동일한 UX