πŸ“ž CallKit 완전정볡

VoIP 앱을 λ„€μ΄ν‹°λΈŒ μ „ν™”μ²˜λŸΌ! CallKit으둜 톡화 UIλ₯Ό μ‹œμŠ€ν…œμ— ν†΅ν•©ν•˜μ„Έμš”.

✨ 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