🌐 KO

πŸ“ž CallKit 완전정볡

Make VoIP apps feel native! Integrate call UI with the system using CallKit.

⭐ Difficulty: ⭐⭐⭐ ⏱️ Est. Time: 2-3h πŸ“‚ System & Network

✨ CallKit is?

CallKit is a framework that integrates VoIP (Voice over IP) apps into the iPhone's native phone system. It displays calls with the same UI as FaceTime and Phone, and allows answering calls from the lock screen. Apps like Slack, WhatsApp, and Zoom use CallKit.

πŸ“¦ Key Features

Feature Overview
βœ… μ‹œμŠ€ν…œ 톡화 UI 톡합
βœ… 잠금 ν™”λ©΄ 톡화 μ•Œλ¦Ό
βœ… 톡화 기둝 (졜근 톡화)
βœ… 차단 및 식별 (Caller ID)
βœ… PushKit 연동 (λ°±κ·ΈλΌμš΄λ“œ μ•Œλ¦Ό)
βœ… 톡화 λŒ€κΈ°, 톡화 μ „ν™˜
βœ… μŒμ†Œκ±°, μŠ€ν”Όμ»€ μ œμ–΄

πŸ“ž Basic Structure: CXProvider and 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 Integration

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 ν˜ΈμΆœν•˜μ—¬ μ„œλ²„μ— μ €μž₯
    }
}

🎯 Practical Example: Complete VoIP App

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 Guidelines

HIG Recommendations
βœ… 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. 톡화 μƒνƒœ 보고 λˆ„λ½

πŸ”§ Practical Tips

Real-World Patterns
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 Configuration

Info.plist
<!-- λ°±κ·ΈλΌμš΄λ“œ λͺ¨λ“œ -->
<key>UIBackgroundModes</key>
<array>
    <string>voip</string>
</array>

<!-- Entitlementsμ—μ„œ μ„€μ • ν•„μš” -->
- Push Notifications ν™œμ„±ν™”
- Background Modes > Voice over IP 체크

πŸ’‘ CallKit 핡심
βœ… μ‹œμŠ€ν…œ 톡화 UI와 μ™„λ²½ 톡합
βœ… PushKit으둜 λ°±κ·ΈλΌμš΄λ“œ μˆ˜μ‹ 
βœ… 잠금 ν™”λ©΄μ—μ„œλ„ 톡화 κ°€λŠ₯
βœ… 톡화 기둝 μžλ™ μ €μž₯
βœ… FaceTimeκ³Ό λ™μΌν•œ UX

πŸ“¦ Learning Resources

πŸ’»
GitHub Project
πŸ“–
Apple Official Docs
πŸŽ₯
WWDC 2016

πŸ“Ž Apple Official Resources

πŸ“˜ Documentation πŸ’» Sample Code 🎬 WWDC Sessions