블로그 목록
Telephony30분 읽기

VoIP 푸시 인프라 완전 가이드 — APNs · PushKit · CallKit · Agora 통합 구현

"통화용 푸시 토큰은 일반 알림 토큰이랑 같은 거 아닌가요? 인증서는 왜 두 개나 받으라는 거죠?" RTC를 도입하는 고객이 가장 자주 던지는 질문입니다. 이 글은 PushKit이 별도 시스템이 아니라 APNs 위에서 도는 특수 모드라는 핵심을 짚으면서, 일반 푸시와 VoIP 푸시가 인증서, 토큰, 페이로드, 처리 방식까지 왜 갈라지는지 풀어냅니다. PushKit 등록부터 CallKit과 Agora RTC를 묶는 통합 코드, 그리고 자체 구축과 Agora Chat 매니지드 중 무엇을 골라야 하는지까지 실무 기준으로 정리했습니다.

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. 인증서의 2-Track 분리
    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 푸시는 별도 서버 인프라다같은 APNs. 다만 인증서/토큰/페이로드 형식이 분리됨
일반 토큰으로 VoIP 푸시 가능❌ 거부됨. 별도 토큰 필요
인증서 하나로 두 종류 푸시 가능❌ APNs 인증서 ≠ VoIP Services 인증서

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


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

푸시 이전 시대

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

푸시의 발명

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

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

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

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

Apple은 모든 아이폰과 항상 TCP 연결을 유지합니다 (시스템 레벨, 앱 무관). 이 연결 위에서 모든 푸시가 흐릅니다. 앱 서버는 Apple한테만 보내면 되고, Apple이 알아서 해당 아이폰에 라우팅.

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

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

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

[당신의 아이폰 (손에 든 기기)]
└─ iOS 운영체제
   ├─ APNs 클라이언트 ← APNs 서버와 항상 TCP 연결 유지하는 시스템 데몬
   ├─ 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 푸시의 한계

ChatMoa가 백그라운드일 때 메시지 도착:

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

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

통화는 그러면 안 됨

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

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

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

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

분리 항목 5종 매트릭스

Apple은 두 채널을 행정적으로 완전히 분리합니다.

항목일반 푸시VoIP 푸시
권한알림 표시만앱 코드 실행
인증서APNs 인증서VoIP Services 인증서 (별도 발급)
토큰APNs device tokenVoIP push token (별도)
Topic 헤더com.example.appcom.example.app.voip (.voip 접미사)
pushType 헤더alert 또는 backgroundvoip
도착 시 처리iOS가 알림 자동 표시앱 코드 즉시 깨움 → PushKit 콜백
남용 시별일 없음iOS가 권한 박탈

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

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

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

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

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

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


3. 인증서의 2-Track 분리

인증서가 왜 필요한가

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

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

두 종류 인증서

인증서발급 위치인증 권한보낼 수 있는 푸시
APNs 인증서 (.p8 / .p12)Developer → Certificates일반 푸시 권한일반 푸시만
VoIP Services 인증서Developer → Certificates (별도 항목)VoIP 푸시 권한VoIP 푸시만

호환되지 않음. APNs 인증서로 VoIP 푸시 보내려고 하면 거부됨 (반대도 마찬가지).

Apple이 분리한 이유

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

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

→ 행정적으로 채널을 분리해서 "VoIP 인증서로 보내는 푸시는 무조건 통화용" 이라는 강한 약속을 강제. 위반 시 권한 박탈.

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

일반 APNs 푸시:

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

VoIP 푸시:

notification.topic    = 'com.example.myapp.voip';   // ← .voip 접미사 필수
notification.pushType = 'voip';                     // ← voip 명시 필수
notification.payload  = { /* 자유 형식 */ };
provider.send(notification, voipToken);             // VoIP 인증서로 서명
항목일반 푸시VoIP 푸시
Topiccom.example.appcom.example.app.voip
pushType 헤더alert / backgroundvoip
인증서APNsVoIP Services
토큰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)
적용 범위앱 1개한 키로 모든 앱 + 환경
dev/prod 분리따로 발급분리 불필요
VoIP 푸시✅ (apns-push-type: voip 헤더로 구분)

현재는 .p8 권장 — 만료 관리 안 해도 되고 한 키로 다 됨.

[NEEDS VERIFICATION] 단, VoIP 인증서를 명시적으로 요구하는 일부 푸시 서비스(Agora 콘솔 일부 옵션, 일부 PaaS)에서는 .p12를 요구하는 경우가 있습니다. 통합하려는 서비스 문서 확인 필요.

.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 등록 코드 — 한 줄씩 분해

등록 함수

func registerPushKit() {
    let pushRegistry = PKPushRegistry(queue: .main)
    pushRegistry.delegate = self
    pushRegistry.desiredPushTypes = Set([.voIP])
}

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만 의미 있음.

이 줄을 설정하는 순간 iOS가 백그라운드에서 APNs에 토큰을 요청합니다. 잠시 후 토큰이 발급되면 didUpdate 콜백 호출.


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)DataUInt8 배열(0~255 부호 없는 정수 배열)로 변환. 보통은:

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

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


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

토큰은 언제 발급되나

짧은 답: 앱이 매번 등록 요청을 할 때마다 iOS가 캐시된 토큰을 돌려주거나 새 토큰을 발급받아 줌.

긴 답:

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

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

토큰이 바뀌는 트리거:
  - 앱 삭제 후 재설치
  - iOS 메이저 업데이트
  - 백업 복원
  - 키체인 변경

didUpdate는 매번 호출됨

앱 시작할 때마다 호출됩니다. 대부분 같은 토큰이지만 가끔 바뀌니까 매번 서버에 업로드하는 게 안전한 패턴.

func pushRegistry(_ registry: PKPushRegistry, didUpdate ..., for type: PKPushType) {
    // 매번 호출됨. 같은 토큰이어도 무조건 서버에 업로드 → 서버는 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 행을 조회 → 각 디바이스에 푸시 발송
  • 어느 기기에서든 받으면 통화 시작, 다른 기기에서는 "다른 곳에서 받음" 처리
  • ChatMoa 같은 메시징 앱이 정확히 이렇게 동작

토큰 무효화 콜백

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": "앨리스",
  "channel_id": "room_xyz",
  "agora_token": "006a1b2c...",
  "call_uuid": "F47AC10B-58CC-4372-A567-0E02B2C3D479"
}

특징:

  • aps는 거의 비어있어도 됨 — iOS가 알림을 자동 표시 안 하니까
  • 개발자가 정한 임의의 키들 — 통화에 필요한 모든 정보를 자유롭게
  • 이 페이로드 전체가 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 channelId   = dict["channel_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+ 결정적 룰: 이 콜백 안에서 반드시 reportNewIncomingCall을 호출해야 합니다. 안 하면 첫 번째: 앱 강제 종료, 반복되면: PushKit 권한 박탈(사용자가 앱 재설치해야 복구 가능).


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: "Talkito")
        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) {
        // ① CallKit 컨텍스트에서 SDK가 AVAudioSession 못 건드리게 잠금
        rtcEngine.setAudioSessionOperationRestriction(.all)
        
        // ② Agora SDK는 채널 정보만 보관 — 실제 join은 didActivate에서
        pendingChannelId = action.callUUID.uuidString
        
        // ③ 즉시 fulfill — CallKit은 액션이 빨리 끝나길 기대 (timeout 있음)
        action.fulfill()
    }
    
    // ④ 시스템이 AVAudioSession 활성화 완료 → 여기서 채널 조인
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        rtcEngine.joinChannel(byToken: token, channelId: channel, info: nil, uid: 0)
    }
    
    // ⑤ 시스템이 세션 비활성화 → ADM 강제 재시작 준비
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        rtcEngine.disableAudio()
        rtcEngine.enableAudio()
    }
    
    // ⑥ 끊기
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        rtcEngine.leaveChannel(nil)
        
        // ⑦ SDK 권한 복구 — 이후 일반(non-CallKit) 채널 조인 시를 위해
        rtcEngine.setAudioSessionOperationRestriction([])
        
        action.fulfill()
    }
}

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

여기가 senior가 헷갈리는 지점. #35에서 일반 시나리오의 토글 패턴(.none → join → .all)을 다뤘는데, CallKit 통합 시나리오는 패턴이 뒤집힙니다.

시나리오joinChannel 직전leaveChannel 후
일반 (CallKit 없음).none (SDK가 세션 관리).all (호스트 앱이 세션 회수)
CallKit 통합.all (CallKit이 세션 소유)[] = .none (다음 일반 join 대비)

이유:

  • 일반 시나리오: Agora가 세션 주인이므로 join 직전에는 권한을 줘야 함
  • CallKit 시나리오: CallKit이 세션 주인이므로 Agora는 세션을 건드리면 안 됨. .all로 잠가야 안전

[NEEDS VERIFICATION] CallKit 컨텍스트에서 .all 패턴은 커뮤니티에서 널리 쓰이는 추론된 패턴이며 Agora 공식 문서에 명시적으로 없습니다. 일부 자료에서는 .configureSession(category/mode/options만 잠금)을 권장하기도 합니다 — 호스트 앱이 자체적으로 일부 세션 속성을 제어해야 한다면 .configureSession이 더 적절할 수 있음.

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 설정
    rtcEngine.setAudioSessionOperationRestriction(.all)
    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은 모든 액션에 timeout이 있습니다(보통 10~15초). 비동기 작업이 끝나기 전에 fulfill() 호출 권장 — 안 그러면 시스템이 "앱 응답 없음"으로 판단해 통화를 강제 종료.


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

자체 구현 시 해야 할 일들

  1. 인증서 발급/관리 — APNs 인증서 + VoIP Services 인증서 (각 1년 만료)
  2. 푸시 발송 서버 — APNs HTTP/2 연결 풀, JWT 토큰 갱신, 재시도, 큐
  3. 토큰 라이프사이클 — 디바이스별 토큰 DB, 환경별 분리(dev/prod), 갱신 추적
  4. Android FCM — 멀티플랫폼이면 별도 인프라
  5. 운영 모니터링 — 발송 성공률, 인증서 만료 알람

Agora Chat의 매니지드 푸시

중요 제한: Agora Chat의 매니지드 푸시는 메시지 알림용 일반 APNs에 한정됩니다.

// 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)✅ 콘솔에 인증서 업로드 → SDK가 자동 처리
VoIP 푸시 (콜 초대)⚠️ AgoraChatOptions.pushKitCertName 프로퍼티는 존재하나 매니지드 플로우 미문서화 [NEEDS VERIFICATION]
Signaling/RTM 콜 초대 VoIP 푸시❌ 개발자가 자체 PushKit 인프라 구축 필요

의사결정 매트릭스

측면자체 구현Agora Chat 매니지드 (메시지)
초기 개발 시간수 주수 시간
백엔드 인프라필요불필요
인증서 관리직접 관리콘솔 업로드 1회
iOS/Android 통합따로 구현단일 API
발송 성공률 모니터링직접 구축대시보드 제공
운영 비용인프라 + 인력Agora 사용료
커스터마이징 자유도100%제약 있음
VoIP 푸시 처리포함별도 인프라 필요

SA 입장의 정리

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

  1. 메시지 알림 푸시: Agora Chat 통합 시 매니지드로 해결 가능
  2. VoIP 통화 푸시: 자체 PushKit 인프라 + CallKit 통합 필요. Agora가 매니지드로 제공하지 않음

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

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

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


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

PushKit은 별도 푸시 시스템이 아니라 APNs 위의 특수 모드다. 인증서 · 토큰 · 페이로드 · 처리 프레임워크가 모두 분리되어 있고, 통화 VoIP 푸시 인프라는 (Agora Chat을 쓰더라도) 자체 구축이 기본이다.

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 인증서 (.p8 권장)
□ VoIP Services 인증서 (별도 발급)
□ 서버: topic = "...app.voip" (.voip 접미사)
□ 서버: pushType 헤더 = "voip"
□ 서버: VoIP Services 인증서로 서명
□ 만료 알람 설정 (1년)

CallKit 통합

□ CXProviderConfiguration의 supportsVideo / handleTypes 서비스에 맞게
□ CXAnswerCallAction에서 setAudioSessionOperationRestriction(.all) → fulfill만
□ didActivate에서 joinChannel
□ CXEndCallAction에서 leaveChannel → setAudioSessionOperationRestriction([])
□ 모든 CXAction 콜백에서 timeout 안에 fulfill
□ didDeactivate에서 disableAudio() → enableAudio() (다음 통화 대비)

엣지 케이스

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

한 장으로 머릿속 정리

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

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.