🇺🇸 EN

📞 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

📦 학습 자료

💻
GitHub 프로젝트
📖
Apple 공식 문서
🎥
WWDC 2016

📎 Apple 공식 자료

📘 공식 문서 💻 샘플 코드 🎬 WWDC 세션