블로그 목록
Telephony30분 읽기

iOS VoIP Push 구성 — APNs, PushKit, CallKit

일반 APNs 알림과 PushKit VoIP push의 token·topic·payload·처리 수명주기를 구분합니다. PushKit callback에서 CallKit 통화를 보고하고 RTC 연결을 시작하는 흐름, server-side push 인증, Apple 정책상 주의사항을 공식 문서 기준으로 설명합니다. 관리형 알림 연동과 직접 구축의 책임 범위도 비교합니다.

iOSPushKitAPNsVoIPCallKitAgora ChatPKPushRegistrydevice tokenSwift
목차(57개 항목)
  1. 0. 핵심 명제 — VoIP 푸시는 APNs 위의 "특수 모드"
  2. 1. APNs 기초 — 푸시는 어떻게 동작하나
    1. 푸시 이전 시대
    2. 푸시의 발명
    3. "APNs는 서버, PushKit은 도구함" — 위치의 차이
    4. Device Token — 디바이스의 "주소"
  3. 2. 일반 푸시 vs VoIP 푸시 — 5가지 분리 항목
    1. 일반 APNs 푸시의 한계
    2. 통화는 그러면 안 됨
    3. Apple의 해결책: PushKit (= VoIP 푸시)
    4. 분리 항목 5종 매트릭스
    5. 같은 앱이 두 토큰을 동시에 가진다
  4. 3. APNs provider 인증 — 토큰 방식과 인증서 방식
    1. 인증서가 왜 필요한가
    2. 두 가지 인증 방식
    3. Apple이 분리한 이유
    4. 서버 측 발송 차이 (Node.js apn 라이브러리 예시)
    5. .p12 vs .p8 — 두 가지 인증 자격증명
    6. 비교
    7. `.p8` 발급 절차
  5. 4. PushKit 등록 코드 — 한 줄씩 분해
    1. 등록 함수
  6. 5. 토큰 수신 콜백 — 누가 호출하나
    1. 이 함수는 **iOS가** 호출함
    2. 함수 이름의 Swift 컨벤션
    3. 파라미터 의미
    4. `pushCredentials.token`이란
  7. 6. 토큰 라이프사이클 — 발급/캐시/갱신
    1. 토큰은 언제 발급되나
    2. `didUpdate`에서 현재 값을 서버에 반영
    3. 멀티 디바이스 토큰 관리
    4. 토큰 무효화 콜백
  8. 7. VoIP 푸시 페이로드 형식
    1. 일반 푸시 페이로드
    2. VoIP 푸시 페이로드
    3. 콜백에서 받기 + CallKit 띄우기
  9. 8. CallKit + PushKit + Agora RTC 통합 — 단계별
    1. 8-1. CallKit Provider 설정
    2. 8-2. 사용자가 "받기" 눌렀을 때 — 정확한 순서
    3. 8-3. CallKit 컨텍스트에서의 Restriction 패턴
    4. 8-4. 발신 흐름 — 세 단계 라이프사이클
    5. 8-5. 그 외 주요 콜백
  10. 9. 자체 구현 vs Agora Chat 매니지드 푸시
    1. 자체 구현 시 해야 할 일들
    2. Agora Chat의 매니지드 푸시
    3. Agora Chat 매니지드 푸시의 실제 범위
    4. 의사결정 매트릭스
    5. SA 입장의 정리
  11. 10. 한 줄 결론 + 구현 체크리스트
    1. PushKit 등록
    2. VoIP 푸시 수신
    3. 인증서 / 서버
    4. CallKit 통합
    5. 엣지 케이스
  12. 한 장으로 머릿속 정리
  13. 관련 글
  14. 참고 자료

"VoIP 푸시 토큰이랑 일반 APNs 토큰이 같은 거 아닌가요?", "인증서 두 개 발급받으라는데 왜요?", "통화용 푸시는 우리가 직접 서버 만들어야 해요, 아니면 Agora가 해주나요?" — RTC 도입 고객이 자주 던지는 질문 3종 세트입니다.

#35에서 CallKit + AVAudioSession + Agora ADM의 4-레이어를 다뤘다면, 이 글은 그 위에 푸시 인프라를 올리는 작업을 정리합니다. APNs 기초부터, 일반 푸시 vs VoIP 푸시의 행정적 분리, PushKit 코드 한 줄씩, 자체 구현 vs Agora Chat 매니지드 옵션까지.


0. 핵심 명제 — VoIP 푸시는 APNs 위의 "특수 모드"

PushKit은 별도 푸시 시스템이 아니다. APNs라는 같은 인프라 위에서 동작하는 특수한 모드일 뿐이고, 그 모드를 다루는 iOS 측 프레임워크가 PushKit이다.

흔한 오해와 정정:

오해정확한 이해
PushKit이 푸시를 보낸다APNs가 보낸다. PushKit은 받는 쪽 프레임워크
VoIP 푸시는 별도 Apple 푸시망이다같은 APNs. 토큰·topic·push type과 수신 API가 다름
일반 토큰으로 VoIP 푸시 가능❌ 거부됨. 별도 토큰 필요
VoIP 푸시는 반드시 별도 인증서가 필요토큰 기반 .p8 키는 권한이 부여된 여러 APNs topic에 사용할 수 있음

이 분리가 왜 존재하는가가 이 글의 절반입니다.


1. APNs 기초 — 푸시는 어떻게 동작하나

푸시 이전 시대

옛날 앱은 자기가 켜져 있을 때만 새 정보를 가져올 수 있었습니다. 백그라운드에 들어간 앱이 메시지를 알려면 1분마다 서버에 "새거 있어?"라고 물어봐야 함 → 배터리 폭망.

푸시의 발명

해결책: 서버가 디바이스에 먼저 말 거는 시스템.

문제: 서버가 수억 대 아이폰의 IP를 어떻게 알지? 그리고 아이폰 IP는 자주 바뀌는데?

→ Apple이 중간에 우체국 역할. 이게 APNs(Apple Push Notification service).

[앱 서버] ──→ [Apple APNs] ──→ [사용자 아이폰]

iOS는 APNs와의 지속 연결을 시스템 수준에서 관리합니다. 앱 서버는 APNs provider API에 요청하고, APNs가 해당 앱·기기의 토큰으로 알림을 라우팅합니다.

"APNs는 서버, PushKit은 도구함" — 위치의 차이

이 둘이 같은 거라고 헷갈리는 경우가 많아서 한 번 정리.

[Apple 데이터센터 (구름 위)]
└─ APNs 서버 ← 실제 푸시를 라우팅하는 서버 인프라

[당신의 아이폰 (손에 든 기기)]
└─ iOS 운영체제
   ├─ APNs 클라이언트 ← APNs 연결을 관리하는 시스템 구성요소
   ├─ UIKit              ← 화면/버튼 그리는 프레임워크
   ├─ Foundation         ← String, Array, Date 등 기본 타입
   ├─ AVFoundation       ← 오디오/비디오
   ├─ PushKit            ← VoIP 푸시 도착 시 앱 코드를 호출해주는 도우미
   ├─ CallKit            ← 통화 UI 그리는 도우미
   └─ ...수백 개의 프레임워크

iOS 내부 프레임워크들은 아이폰 저장공간에 파일로 깔려 있습니다 (/System/Library/Frameworks/PushKit.framework/). 앱이 import PushKit하면 그 파일에 있는 코드를 갖다 씁니다.

그래서 "PushKit은 iOS 안에 있다" =

  • APNs 서버는 Apple 데이터센터에 (멀리)
  • PushKit 프레임워크는 내 폰의 iOS 안에 (가까이)
  • 둘이 협업해서 VoIP 푸시 한 번이 동작

PushKit은 서버가 아닙니다. Apple 클라우드에 있는 게 아닙니다. 내 아이폰 저장공간 안에 코드 파일로 존재하는 도구함입니다. CallKit, AVFoundation도 마찬가지.

Device Token — 디바이스의 "주소"

서버가 누구한테 보낼지 알려면 식별자가 필요합니다.

앱 첫 실행:
  앱 → iOS:    "푸시 받을 거야, 등록해줘"
  iOS → APNs:  "이 앱+이 디바이스+이 환경 토큰 발급해줘"
  APNs → iOS:  "ABC123..."
  iOS → 앱:    "토큰 ABC123 받아"
  앱 → 서버:   "내 토큰 ABC123 저장해놔"

푸시 보낼 때:
  서버 → APNs: "ABC123한테 '새 메시지' 보내줘"
  APNs → 그 디바이스로 라우팅

토큰은 특정 앱 + 특정 디바이스 + 특정 환경의 조합 식별자. 같은 앱이라도 다른 폰이면 다른 토큰, 같은 폰이라도 다른 앱이면 다른 토큰.


2. 일반 푸시 vs VoIP 푸시 — 5가지 분리 항목

일반 APNs 푸시의 한계

메시징 앱이 백그라운드일 때 메시지 도착:

APNs 푸시 도착 → 알림 배너 표시 → 사용자가 탭 → 그제서야 앱 실행

사용자가 직접 탭해야 앱이 깨어남. iOS가 배터리 보호 차원에서 강제하는 동작.

통화는 그러면 안 됨

전화는 즉시 통화 화면이 떠야 함. "탭하세요" 알림 보고 누르고 있을 시간이 없음 — 발신자는 지금 벨을 듣고 있음.

Apple의 해결책: PushKit (= VoIP 푸시)

VoIP 푸시 도착 → 사용자 개입 없이 앱 자동 실행 → 통화 화면 즉시 표시

일반 푸시는 알림만 띄우고 끝. VoIP 푸시는 앱 코드를 즉시 실행시킴. 차원이 다른 권한.

분리 항목 5종 매트릭스

APNs는 두 흐름을 token·topic·push type과 수신 API로 구분합니다.

항목일반 푸시VoIP 푸시
처리 목적사용자 알림·백그라운드 갱신수신 VoIP 통화 보고
provider 인증.p8 토큰 또는 TLS 인증서.p8 토큰 또는 VoIP topic 권한이 있는 TLS 인증서
토큰APNs device tokenVoIP push token (별도)
Topic 헤더com.example.appcom.example.app.voip (.voip 접미사)
pushType 헤더alert 또는 backgroundvoip
도착 시 처리iOS가 알림 자동 표시앱 코드 즉시 깨움 → PushKit 콜백
규정 위반 시APNs 정책과 백그라운드 실행 제한 적용미보고 VoIP push는 앱 종료·향후 전달 중단 가능

이 5개 중 하나라도 어긋나면 푸시 거부됩니다. 서버 인프라를 짤 때 두 채널을 명확히 분리해야 함.

같은 앱이 두 토큰을 동시에 가진다

당연히 가능. 보통의 메시징/통화 앱이 그렇습니다.

메시징 앱:
├─ APNs device token: "4a9c..." ← 메시지 알림용
└─ VoIP push token:    "8d2b..." ← 보이스톡 수신용

서버 DB:
┌─────────┬─────────────────┬─────────────────┐
│ user_id │ apns_token      │ voip_token      │
├─────────┼─────────────────┼─────────────────┤
│ vita    │ 4a9c...         │ 8d2b...         │
│ alice   │ 8d2b...         │ 7f1e...         │
└─────────┴─────────────────┴─────────────────┘

발급 순서는 무관. 앱 시작 시 둘 다 등록하면 각자 별도 콜백으로 발급됨.


3. APNs provider 인증 — 토큰 방식과 인증서 방식

인증서가 왜 필요한가

서버가 APNs에 푸시를 보낼 때 APNs는 "너 진짜 이 앱 만든 사람 맞아?"를 검증해야 함. 누구나 보낼 수 있으면 스팸 천국.

→ Apple Developer 콘솔에서 인증서 발급 → 서버는 그 인증서로 서명 → APNs가 검증 → 통과 시 발송.

두 가지 인증 방식

방식자격 증명범위
토큰 기반APNs signing key (.p8) + JWT키에 허용된 팀의 여러 topic에 사용 가능
인증서 기반앱/topic별 TLS 인증서와 개인키 (.p12로 내보내기도 함)인증서 extension에 표시된 topic으로 제한

VoIP 여부는 자격 증명 파일 확장자가 아니라 device token, apns-topic의 .voip 접미사, apns-push-type: voip, 그리고 자격 증명에 허용된 topic의 조합으로 결정됩니다.

Apple이 분리한 이유

VoIP 푸시는 앱을 백그라운드에서 강제 실행시키는 강력한 권한. 일반 푸시와 섞어두면:

  • 앱들이 모든 알림을 VoIP로 보내서 배터리 학살
  • 통화 아닌 용도(메시지, 광고)에 남용

→ APNs는 topic과 push type을 구분하고, 앱은 VoIP push를 실제 수신 통화 보고에만 사용해야 합니다.

서버 측 발송 차이 (Node.js apn 라이브러리 예시)

일반 APNs 푸시:

notification.topic    = 'com.example.myapp';        // bundle ID 그대로
notification.pushType = 'alert';
notification.payload  = { aps: { alert: '새 메시지' } };
provider.send(notification, apnsToken);             // APNs provider 자격 증명으로 인증

VoIP 푸시:

notification.topic    = 'com.example.myapp.voip';   // ← .voip 접미사 필수
notification.pushType = 'voip';                     // ← voip 명시 필수
notification.payload  = { /* 자유 형식 */ };
provider.send(notification, voipToken);             // 동일 키 또는 허용된 인증서로 인증
항목일반 푸시VoIP 푸시
Topiccom.example.appcom.example.app.voip
pushType 헤더alert / backgroundvoip
provider 인증.p8 키 또는 topic 허용 인증서.p8 키 또는 VoIP topic 허용 인증서
토큰APNs device tokenVoIP push token
도착 처리iOS가 알림 표시앱 코드 깨움 → PushKit 콜백

.p12 vs .p8 — 두 가지 인증 자격증명

Apple은 푸시 인증 자격증명을 두 형식으로 제공합니다.

Option A: .p12 인증서 (전통 방식)

파일명: VoIPServices.p12
내용:   바이너리 (X.509 인증서 + 개인키)
크기:   약 4~10KB
유효기간: 발급일로부터 1년 (만료 시 갱신 필수)

Option B: .p8 인증키 (신형, 권장)

파일명: AuthKey_ABC123XYZ.p8
내용:   ASCII 텍스트 (PEM 형식)
크기:   약 250바이트
유효기간: 만료 없음

.p8 텍스트로 열면:

-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...
-----END PRIVATE KEY-----

비교

항목.p12 (VoIP Services Certificate).p8 (Auth Key)
만료1년마다 갱신만료 없음
인증 방식TLS 클라이언트 인증서JWT 토큰 (HTTP/2)
적용 범위인증서 extension의 topic발급 시 선택한 팀의 앱 또는 제한된 topic
dev/prod 분리인증서 종류에 따라 확인같은 키로 development/production APNs 연결에 사용 가능
VoIP 푸시✅✅ (apns-push-type: voip 헤더로 구분)

직접 APNs provider를 운영한다면 .p8은 인증서 만료 갱신을 줄여 줍니다. 다만 키가 유출되면 여러 topic에 영향이 갈 수 있으므로 Key ID·Team ID와 함께 비밀 저장소에서 관리하고, JWT의 발급 시각을 갱신해야 합니다. 제3자 서비스는 업로드 형식을 별도로 제한할 수 있으므로 해당 서비스의 현재 공식 문서를 확인합니다.

.p8 발급 절차

1. https://developer.apple.com/account 로그인
2. "Certificates, Identifiers & Profiles" 클릭
3. 좌측 메뉴 "Keys" 선택
4. 우측 상단 "+" 버튼
5. Key Name 입력 (예: "MyApp Push Key")
6. "Apple Push Notifications service (APNs)" 체크
7. Continue → Register
8. Download 버튼 클릭 (한 번만 가능)
9. AuthKey_XXXXXXXXXX.p8 파일 받음

중요: 키는 Apple 계정에 저장되지 않으므로 안전한 곳에 백업. 다시 다운로드 불가. Download 버튼이 비활성화되어 있다면 이미 다운받았다는 의미.

JWT 서명 시 함께 필요한 정보:

항목어디서 확인
Key ID (10자리)Keys 페이지에서 키 클릭 시 표시
Team ID (10자리)Apple Developer 계정 우측 상단
Bundle ID앱 프로젝트 설정

4. PushKit 등록 코드 — 한 줄씩 분해

등록 함수

final class PushManager: NSObject, PKPushRegistryDelegate {
    private let pushRegistry = PKPushRegistry(queue: .main)

    override init() {
        super.init()
        pushRegistry.delegate = self
        pushRegistry.desiredPushTypes = Set([.voIP])
    }
}

PKPushRegistry는 지역 변수로 버리지 말고 앱이 실행되는 동안 강한 참조로 유지합니다. Apple의 예제도 delegate를 지정한 registry를 장기 보관합니다.

PKPushRegistry(queue: .main)

PushKit 시스템과 대화하는 창구 객체. "내가 VoIP 푸시 받을 거야"라고 등록하는 사무소.

queue: .main → 푸시 도착 알림을 메인 스레드(UI 스레드)에서 받음. CallKit 띄우기는 UI 작업이라 메인 스레드가 자연스러움.

pushRegistry.delegate = self

여기가 senior가 놓치기 쉬운 델리게이트 패턴의 핵심.

문제: PKPushRegistry는 시스템 객체. 토큰이 발급되거나 푸시가 도착하면 누구에게 알릴지 모름.

해결: "이런 일 생기면 이 객체한테 말 걸어"라고 미리 정해둠 = delegate.

PKPushRegistry (시스템) ── delegate ──→ 내 객체 (PKPushRegistryDelegate 채택)
                                          │
                                  ┌───────┴───────┐
                                  ▼               ▼
                       didUpdate 콜백        didReceiveIncomingPush 콜백
                       (토큰 발급)           (푸시 도착)

PKPushRegistryDelegate 프로토콜을 채택하고 정해진 함수들을 구현하면 iOS가 알아서 호출.

pushRegistry.desiredPushTypes = Set([.voIP])

PushKit은 원래 여러 푸시 타입을 처리하도록 설계됐으나(과거 워치 컴플리케이션 등), 현재 사실상 .voIP만 의미 있음.

desired type을 설정하면 시스템이 등록을 처리하고, 자격 증명이 갱신될 때 delegate 콜백을 호출합니다.


5. 토큰 수신 콜백 — 누가 호출하나

func pushRegistry(_ registry: PKPushRegistry,
                  didUpdate pushCredentials: PKPushCredentials,
                  for type: PKPushType) {
    let bytes = [UInt8](pushCredentials.token)
    uploadToServer(bytes)
}

이 함수는 iOS가 호출함

내가 호출하는 게 아닙니다. desiredPushTypes = [.voIP] 설정 후 iOS가 알아서:

  1. APNs에 VoIP 토큰 요청
  2. 토큰 수신
  3. 등록된 delegate의 이 함수를 자동 호출

콜백 패턴 — "토큰 발급되는 시점은 모르겠지만, 발급되면 이 함수 실행시켜줘"라고 미리 함수를 만들어두는 것.

함수 이름의 Swift 컨벤션

pushRegistry(_:didUpdate:for:)
   │           │           │
[알리는 객체]  [일어난 일]  [부가 정보]

자연어로 읽으면: "pushRegistry가 (특정 type을 위한) credentials를 업데이트했다"

파라미터 의미

파라미터의미
registry: PKPushRegistry어떤 registry에서 발생 (보통 한 개라 무시)
pushCredentials: PKPushCredentials토큰 정보 묶음
type: PKPushType어떤 타입의 푸시 토큰인지 (.voIP)

pushCredentials.token이란

VoIP 푸시 디바이스 토큰 = 디바이스 주소.

타입은 Data (바이트 묶음). 사람이 읽으라고 만든 게 아니라 컴퓨터가 식별자로 쓰라고 만든 것이라 그냥 바이트 덩어리.

[UInt8](pushCredentials.token)은 Data를 UInt8 배열(0~255 부호 없는 정수 배열)로 변환. 보통은:

  1. Data → 16진수 문자열 ("4a9cff...")
  2. 그 문자열을 HTTP POST 본문에 담아 서버 전송

서버는 받은 토큰을 (사용자, 토큰) 페어로 DB에 저장.


6. 토큰 라이프사이클 — 발급/캐시/갱신

토큰은 언제 발급되나

짧은 답: registry가 등록된 뒤 시스템이 현재 자격 증명을 delegate로 전달하며, 토큰 값은 바뀔 수 있다고 가정해야 합니다.

긴 답:

앱 첫 실행:
  앱 → iOS:    "등록해줘"
  iOS → APNs:  "토큰 발급"
  APNs → iOS:  "ABC123..."  ← 새 발급
  iOS → 앱:    didUpdate 콜백

앱 두 번째 실행:
  앱 → iOS:    "등록해줘"
  iOS:         "이미 ABC123 캐시되어 있음"
  iOS → 앱:    didUpdate 콜백으로 같은 값 전달

토큰 변경 시점과 값을 앱이 예측해서는 안 됨

didUpdate에서 현재 값을 서버에 반영

콜백이 오면 현재 토큰을 서버에 upsert합니다. Apple은 토큰을 앱에 캐시해 재사용하지 말고 시스템이 전달한 값을 사용하라고 안내합니다.

func pushRegistry(_ registry: PKPushRegistry, didUpdate ..., for type: PKPushType) {
    // 전달된 현재 값을 서버에 idempotent upsert
    uploadToServer(token)
}

멀티 디바이스 토큰 관리

한 사용자가 아이폰 + 아이패드 + 안드로이드를 동시에 쓰는 경우가 흔합니다. 그래서 보통은 (user_id, device_id) 조합으로 관리:

user_devices 테이블:
┌─────────┬───────────────┬───────────┬──────────┬──────────────┐
│ user_id │ device_id     │ token     │ platform │ token_type   │
├─────────┼───────────────┼───────────┼──────────┼──────────────┤
│ vita    │ iphone-uuid-A │ 4a9c...   │ ios      │ voip         │
│ vita    │ iphone-uuid-A │ 9f8e...   │ ios      │ apns         │
│ vita    │ ipad-uuid-B   │ 7d2b...   │ ios      │ voip         │
│ vita    │ android-id-C  │ 8e1f...   │ android  │ fcm          │
└─────────┴───────────────┴───────────┴──────────┴──────────────┘

설계 포인트:

  • 한 디바이스가 APNs 토큰 + VoIP 토큰을 둘 다 가질 수 있음 → token_type으로 구분
  • vita에게 전화 걸 때 → vita의 모든 device 행을 조회 → 각 디바이스에 푸시 발송
  • 어느 기기에서든 받으면 통화 시작, 다른 기기에서는 "다른 곳에서 받음" 처리
  • 여러 기기에서 수신할 수 있는 제품은 동일한 중복 수신·응답 정리 정책이 필요

토큰 무효화 콜백

func pushRegistry(_ registry: PKPushRegistry,
                  didInvalidatePushTokenFor type: PKPushType) {
    // 토큰이 더 이상 유효하지 않음 → 서버 DB에서 삭제
    deleteFromServer()
}

이 콜백 받으면 서버 DB에서 그 토큰을 지워야 함. 구토큰으로 푸시 보내봐야 헛수고.


7. VoIP 푸시 페이로드 형식

일반 푸시 페이로드

{
  "aps": {
    "alert": "새 메시지가 도착했습니다",
    "badge": 1,
    "sound": "default"
  }
}

aps 안에 시스템이 표시할 정보를 넣음 → iOS가 자동으로 알림 배너 표시.

VoIP 푸시 페이로드

{
  "aps": {},
  "caller_id": "alice",
  "caller_name": "앨리스",
  "call_id": "call_123",
  "call_uuid": "F47AC10B-58CC-4372-A567-0E02B2C3D479"
}

특징:

  • aps는 거의 비어있어도 됨 — iOS가 알림을 자동 표시 안 하니까
  • 개발자가 정한 키를 추가할 수 있지만 민감한 RTC 토큰이나 개인정보는 싣지 않음
  • 이 페이로드 전체가 didReceiveIncomingPushWith 콜백의 payload.dictionaryPayload로 들어옴

콜백에서 받기 + CallKit 띄우기

func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void) {
    
    let dict = payload.dictionaryPayload
    
    let callerName  = dict["caller_name"] as? String ?? "알 수 없음"
    let callId      = dict["call_id"] as? String ?? ""
    let callUuid    = UUID(uuidString: dict["call_uuid"] as? String ?? "") ?? UUID()
    
    // CallKit에 통화 띄우기 — 반드시 이 콜백 안에서!
    let update = CXCallUpdate()
    update.remoteHandle = CXHandle(type: .generic, value: callerName)
    
    provider.reportNewIncomingCall(with: callUuid, update: update) { error in
        // 표시 완료
    }
    
    completion()  // 시스템에 처리 끝났다고 알림 (필수)
}

iOS 13+ 결정적 룰: VoIP push를 받으면 reportNewIncomingCall로 통화를 보고해야 합니다. 보고하지 않으면 시스템이 앱을 종료할 수 있고, 반복하면 향후 VoIP push 전달을 중단할 수 있습니다.


8. CallKit + PushKit + Agora RTC 통합 — 단계별

여기서 #35의 4-레이어가 실제 코드로 만나는 모습.

8-1. CallKit Provider 설정

class CallController: NSObject {
    let controller = CXCallController()
    let provider: CXProvider
    let rtcEngine: AgoraRtcEngineKit
    
    override init() {
        provider = CXProvider(configuration: Self.providerConfiguration)
        super.init()
        provider.setDelegate(self, queue: nil)
    }
    
    private static var providerConfiguration: CXProviderConfiguration {
        let config = CXProviderConfiguration(localizedName: "ExampleCall")
        config.supportsVideo = false
        config.maximumCallsPerCallGroup = 1
        config.maximumCallGroups = 1
        config.supportedHandleTypes = [.generic]
        config.iconTemplateImageData = UIImage(named: "callkit_logo")?.pngData()
        config.ringtoneSound = "ring5.mp3"
        // config.includesCallsInRecents = false  // 시스템 통화기록 노출 여부
        return config
    }
}

8-2. 사용자가 "받기" 눌렀을 때 — 정확한 순서

extension CallController: CXProviderDelegate {
    
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // 서버에 수락을 알리고 RTC 연결을 준비한다.
        signaling.accept(callUUID: action.callUUID)
        prepareRtcChannelIfNeeded()
        action.fulfill()
    }
    
    // 시스템이 AVAudioSession 활성화 완료 → RTC 오디오 시작/재개
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        resumeRtcAudio()
    }
    
    // 시스템이 세션 비활성화 → RTC 오디오 중지/일시 정지
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        pauseRtcAudio()
    }
    
    // ⑥ 끊기
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        rtcEngine.leaveChannel(nil)
        
        action.fulfill()
    }
}

8-3. CallKit 컨텍스트에서의 Restriction 패턴

setAudioSessionOperationRestriction은 SDK의 AVAudioSession 조작을 제한하는 공개 API지만, CallKit을 쓴다고 항상 .all이 정답인 것은 아닙니다.

책임 모델선택 기준
SDK가 세션 구성기본 제한을 유지하고 CallKit 활성화 콜백과 SDK 오디오 API를 연결
앱이 category/mode/options 구성현재 SDK가 지원하면 .configureSession처럼 필요한 조작만 제한
앱이 전체 수명 주기 관리.all을 쓰는 대신 활성화·비활성화·route 변경을 앱이 모두 구현

이유:

제한 범위가 넓을수록 SDK 대신 앱이 책임질 코드가 늘어납니다. 사용하는 Agora SDK 버전의 API 레퍼런스와 CallKit 샘플을 기준으로 가장 좁은 제한을 선택하고, 통화 시작·종료·인터럽션·블루투스 route를 회귀 테스트합니다.

8-4. 발신 흐름 — 세 단계 라이프사이클

발신은 수신과 다르게 세 가지 시점을 CallKit에 명시적으로 보고해야 통화 시간 카운트가 정확해집니다.

[사용자가 앱 안에서 "전화걸기" 탭]
    ↓
① 내 앱 → CallKit: "발신 의도"
   - CXStartCallAction 생성 + CXCallController.request(transaction:)
    ↓
② CallKit → 내 앱: "OK, 발신 진행해"
   - CXProviderDelegate의 perform(CXStartCallAction) 콜백
    ↓
③ 그 안에서 실제 발신 작업
   - Agora 채널 조인
   - 백엔드에 알림 → 서버가 상대 디바이스에 VoIP 푸시 발송
   - reportOutgoingCall(with:startedConnectingAt:) ← "연결 시도 중"
    ↓
④ 상대 폰에서 VoIP 푸시 수신 → reportNewIncomingCall (수신측)
    ↓
⑤ 상대가 받기 누름 → 시그널링 채널로 "answered" 신호 도달
   - reportOutgoingCall(with:connectedAt:)        ← "연결 완료"
   - 여기서부터 CallKit이 통화 시간 카운트 시작
    ↓
⑥ 통화 진행
    ↓
⑦ 끊기 → CXEndCallAction

① 발신 시작 요청:

func startOutgoingCall(to calleeId: String) {
    let callUUID = UUID()
    let handle = CXHandle(type: .generic, value: calleeId)
    
    let startCallAction = CXStartCallAction(call: callUUID, handle: handle)
    startCallAction.isVideo = false
    
    let transaction = CXTransaction(action: startCallAction)
    callController.request(transaction) { error in
        if let error = error {
            print("발신 실패: \(error)")
        }
        // 성공해도 여기서는 할 일 없음 — 실제 처리는 델리게이트에서
    }
}

② 델리게이트 콜백 — 실제 발신 작업:

func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
    // CallKit이 발신을 승인. 이제 실제 발신 작업 수행.
    
    // 앱과 SDK가 합의한 AudioSession 정책 적용
    configureAudioSession()
    
    // Agora 채널 조인
    rtcEngine.joinChannel(byToken: token,
                          channelId: action.handle.value,
                          info: nil, uid: 0)
    
    // 백엔드에 "이 사람한테 통화 시작" 알림
    // → 서버가 상대 디바이스에 VoIP 푸시 발송
    backend.notifyCallStart(callee: action.handle.value,
                            callUUID: action.callUUID)
    
    // CallKit에 "연결 시도 중" 보고
    provider.reportOutgoingCall(with: action.callUUID,
                                startedConnectingAt: nil)
    
    action.fulfill()
}

③ 상대방이 받았을 때:

func onCalleeAnswered(callUUID: UUID) {
    // 시그널링 채널(RTM/RTC 콜백 등)에서 "상대 응답" 알림 도착 시
    provider.reportOutgoingCall(with: callUUID, connectedAt: nil)
    // → CallKit이 통화 시간 카운트 시작
    // → 시스템 통화 화면이 "연결됨" 상태로 전환
}

보고 누락 시 영향:

누락결과
startedConnectingAt 미보고CallKit UI가 "연결 시도 중" 표시 안 됨
connectedAt 미보고통화 시간 카운트 시작 안 됨, 통화 기록의 duration이 0
CXEndCallAction 미보고통화가 끝나도 시스템 UI가 잠금 화면에 남아있음

8-5. 그 외 주요 콜백

콜백처리
CXSetMutedCallActionagoraKit.muteLocalAudioStream(action.isMuted)
CXSetHeldCallAction통화 보류(hold)
CXAction.timedOut위 액션이 시간 내 fulfill 안 되면 호출 → 정리/롤백

CallKit 액션은 제한 시간 안에 fulfill() 또는 fail()로 끝내야 합니다. 네트워크 작업을 무한정 기다리지 말고 앱 상태를 정리할 수 있는 실패 경로를 둡니다.


9. 자체 구현 vs Agora Chat 매니지드 푸시

자체 구현 시 해야 할 일들

  1. provider 자격 증명 관리 — .p8 signing key 또는 TLS 인증서, topic 권한과 교체 정책
  2. 푸시 발송 서버 — APNs HTTP/2 연결 풀, JWT 토큰 갱신, 재시도, 큐
  3. 토큰 라이프사이클 — 디바이스별 토큰 DB, 환경별 분리(dev/prod), 갱신 추적
  4. Android FCM — 멀티플랫폼이면 별도 인프라
  5. 운영 모니터링 — 발송 성공률, 인증서 만료 알람

Agora Chat의 매니지드 푸시

현재 공개된 Agora Chat iOS 오프라인 푸시 문서는 일반 APNs device token 등록과 메시지 알림 흐름을 설명합니다.

// Agora Chat SDK가 토큰을 등록받음 (정확한 API)
[[AgoraChatClient sharedClient] bindDeviceToken:deviceToken];

// Swift:
AgoraChatClient.sharedClient().bindDeviceToken(deviceToken)

API명 주의: 흔히 registerForRemoteNotifications로 잘못 알려져 있는데, 그건 Apple UIKit 메서드. Agora Chat의 정확한 API는 bindDeviceToken:.

Agora Chat 매니지드 푸시의 실제 범위

푸시 종류Agora Chat이 매니지드?
메시지 알림 (일반 APNs)✅ 콘솔에 APNs 자격 증명 등록 → SDK token binding
VoIP 푸시 (콜 초대)공개 iOS 오프라인 푸시 가이드에서 PushKit 통화 흐름을 제공하지 않음
Signaling/RTM 콜 초대 VoIP 푸시제품 문서에 별도 기능이 없다면 앱 백엔드에서 APNs VoIP provider 구현 필요

의사결정 매트릭스

측면자체 구현Agora Chat 매니지드 (메시지)
초기 개발 시간APNs provider·토큰 DB·CallKit 구현 필요SDK/콘솔 통합 중심
백엔드 인프라필요불필요
인증서 관리직접 관리콘솔 업로드 1회
iOS/Android 통합따로 구현단일 API
발송 성공률 모니터링직접 구축대시보드 제공
운영 비용인프라 + 인력Agora 사용료
커스터마이징 자유도provider 정책을 직접 설계제품이 제공하는 설정 범위
VoIP 푸시 처리포함별도 인프라 필요

SA 입장의 정리

고객이 "VoIP 푸시 어떻게 해요?" 물어보면 두 가지를 깔끔히 분리해서 안내:

  1. 메시지 알림 푸시: Agora Chat 통합 시 매니지드로 해결 가능
  2. VoIP 통화 푸시: 현재 사용 중인 Agora 제품의 공식 문서에 PushKit provider 기능이 없다면 자체 APNs provider + PushKit + CallKit 통합 필요

이 두 트랙을 합쳐 풀스택 통화 앱을 만들 때, 보통:

  • 메시지/채팅: Agora Chat (매니지드 푸시 포함)
  • 통화 시그널링: Agora Signaling/RTM
  • 통화 미디어: Agora RTC
  • 통화 VoIP 푸시 인프라: 자체 구축 (PushKit + 백엔드 + 인증서)

이 4가지를 묶어 설계해야 합니다.


10. 한 줄 결론 + 구현 체크리스트

PushKit은 APNs VoIP push를 받는 iOS 프레임워크다. 일반 알림과 device token·topic·push type·수신 API가 다르지만, provider 인증은 .p8 키 또는 허용된 TLS 인증서를 사용할 수 있다.

PushKit 등록

□ PKPushRegistry를 앱 시작 직후 등록
□ desiredPushTypes = Set([.voIP])
□ delegate = self (PKPushRegistryDelegate 채택)
□ didUpdate 콜백에서 토큰을 매번 서버에 upload (upsert)
□ didInvalidatePushTokenFor에서 서버 DB 정리

VoIP 푸시 수신

□ didReceiveIncomingPushWith 콜백 안에서 즉시 reportNewIncomingCall (iOS 13+ 필수)
□ payload.dictionaryPayload에서 caller_id / channel_id / call_uuid 파싱
□ completion() 반드시 호출
□ reportNewIncomingCall 실패 시에도 completion 호출

인증서 / 서버

□ APNs provider 인증 방식 선택: `.p8` 토큰 또는 TLS 인증서
□ 자격 증명에 VoIP topic 권한이 있는지 확인
□ 서버: topic = "...app.voip" (.voip 접미사)
□ 서버: pushType 헤더 = "voip"
□ 인증서 방식이면 만료 알람, 토큰 방식이면 키 보관·JWT 갱신 정책 설정

CallKit 통합

□ CXProviderConfiguration의 supportsVideo / handleTypes 서비스에 맞게
□ CXAnswerCallAction에서 서버 수락·RTC 준비 후 fulfill/fail
□ didActivate에서 RTC 오디오 시작 또는 재개
□ CXEndCallAction에서 leaveChannel과 통화 상태 정리
□ 모든 CXAction 콜백에서 timeout 안에 fulfill
□ didDeactivate에서 RTC 오디오 일시 정지 또는 중지

엣지 케이스

□ 앱이 죽어있는 상태에서 VoIP 푸시 수신 시 정상 동작
□ 통화 중 다른 통화 들어왔을 때 동작 정의
□ 블루투스 헤드셋 / 스피커 / earpiece 라우팅 (#35 §Case 6 참고)
□ 시스템 알람 / 시스템 전화 인터럽션 처리 (#35 §Case 1, 2 참고)
□ 통화 종료 후 ADM 미복구 처리 (#35 §Case 5 참고)

한 장으로 머릿속 정리

┌─────────────────────────────────────────────────────────┐
│              Apple APNs (서버 인프라)                    │
│     ┌──────────────────┬──────────────────┐             │
│     │   일반 푸시 채널   │   VoIP 푸시 채널  │             │
│     │  (일반 topic)     │  (VoIP topic)    │             │
│     │  topic: bundleId  │ topic: ...voip   │             │
│     │  pushType: alert  │ pushType: voip   │             │
│     └────────┬─────────┴─────────┬────────┘             │
└──────────────┼───────────────────┼──────────────────────┘
               │                   │
               ▼                   ▼
        [APNs 토큰 발급]      [VoIP 토큰 발급]
               │                   │
       ┌───────┴───────┐   ┌───────┴───────┐
       ▼               ▼   ▼               ▼
   [발급 시]       [도착 시]    [발급 시]    [도착 시]
   AppDelegate가   iOS가 알림   PKPushRegistry  PKPushRegistry
   토큰 받음       자동 표시    가 토큰 받음    가 콜백 실행
                  (탭하면 앱)                  (즉시 앱 깸)
                                                   │
                                                   ▼
                                         CallKit reportNewIncomingCall
                                                   │
                                                   ▼
                                         사용자 "받기" → 액션 처리
                                                   │
                                                   ▼
                                         didActivate → RTC 오디오 시작

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.