VoIP 푸시 인프라 완전 가이드 — APNs · PushKit · CallKit · Agora 통합 구현
"통화용 푸시 토큰은 일반 알림 토큰이랑 같은 거 아닌가요? 인증서는 왜 두 개나 받으라는 거죠?" RTC를 도입하는 고객이 가장 자주 던지는 질문입니다. 이 글은 PushKit이 별도 시스템이 아니라 APNs 위에서 도는 특수 모드라는 핵심을 짚으면서, 일반 푸시와 VoIP 푸시가 인증서, 토큰, 페이로드, 처리 방식까지 왜 갈라지는지 풀어냅니다. PushKit 등록부터 CallKit과 Agora RTC를 묶는 통합 코드, 그리고 자체 구축과 Agora Chat 매니지드 중 무엇을 골라야 하는지까지 실무 기준으로 정리했습니다.
목차(57개 항목)
- 0. 핵심 명제 — VoIP 푸시는 APNs 위의 "특수 모드"
1. APNs 기초 — 푸시는 어떻게 동작하나
2. 일반 푸시 vs VoIP 푸시 — 5가지 분리 항목
4. PushKit 등록 코드 — 한 줄씩 분해
5. 토큰 수신 콜백 — 누가 호출하나
6. 토큰 라이프사이클 — 발급/캐시/갱신
7. VoIP 푸시 페이로드 형식
8. CallKit + PushKit + Agora RTC 통합 — 단계별
9. 자체 구현 vs Agora Chat 매니지드 푸시
10. 한 줄 결론 + 구현 체크리스트
- 한 장으로 머릿속 정리
- 관련 글
- 참고 자료
"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은 모든 아이폰과 항상 TCP 연결을 유지합니다 (시스템 레벨, 앱 무관). 이 연결 위에서 모든 푸시가 흐릅니다. 앱 서버는 Apple한테만 보내면 되고, Apple이 알아서 해당 아이폰에 라우팅.
"APNs는 서버, PushKit은 도구함" — 위치의 차이
이 둘이 같은 거라고 헷갈리는 경우가 많아서 한 번 정리.
iOS 내부 프레임워크들은 아이폰 저장공간에 파일로 깔려 있습니다 (/System/Library/Frameworks/PushKit.framework/). 앱이 import PushKit하면 그 파일에 있는 코드를 갖다 씁니다.
그래서 "PushKit은 iOS 안에 있다" =
- APNs 서버는 Apple 데이터센터에 (멀리)
- PushKit 프레임워크는 내 폰의 iOS 안에 (가까이)
- 둘이 협업해서 VoIP 푸시 한 번이 동작
PushKit은 서버가 아닙니다. Apple 클라우드에 있는 게 아닙니다. 내 아이폰 저장공간 안에 코드 파일로 존재하는 도구함입니다. CallKit, AVFoundation도 마찬가지.
Device Token — 디바이스의 "주소"
서버가 누구한테 보낼지 알려면 식별자가 필요합니다.
토큰은 특정 앱 + 특정 디바이스 + 특정 환경의 조합 식별자. 같은 앱이라도 다른 폰이면 다른 토큰, 같은 폰이라도 다른 앱이면 다른 토큰.
2. 일반 푸시 vs VoIP 푸시 — 5가지 분리 항목
일반 APNs 푸시의 한계
ChatMoa가 백그라운드일 때 메시지 도착:
사용자가 직접 탭해야 앱이 깨어남. iOS가 배터리 보호 차원에서 강제하는 동작.
통화는 그러면 안 됨
전화는 즉시 통화 화면이 떠야 함. "탭하세요" 알림 보고 누르고 있을 시간이 없음 — 발신자는 지금 벨을 듣고 있음.
Apple의 해결책: PushKit (= VoIP 푸시)
일반 푸시는 알림만 띄우고 끝. VoIP 푸시는 앱 코드를 즉시 실행시킴. 차원이 다른 권한.
분리 항목 5종 매트릭스
Apple은 두 채널을 행정적으로 완전히 분리합니다.
| 항목 | 일반 푸시 | VoIP 푸시 |
|---|---|---|
| 권한 | 알림 표시만 | 앱 코드 실행 |
| 인증서 | APNs 인증서 | VoIP Services 인증서 (별도 발급) |
| 토큰 | APNs device token | VoIP push token (별도) |
| Topic 헤더 | com.example.app | com.example.app.voip (.voip 접미사) |
| pushType 헤더 | alert 또는 background | voip |
| 도착 시 처리 | iOS가 알림 자동 표시 | 앱 코드 즉시 깨움 → PushKit 콜백 |
| 남용 시 | 별일 없음 | iOS가 권한 박탈 |
이 5개 중 하나라도 어긋나면 푸시 거부됩니다. 서버 인프라를 짤 때 두 채널을 명확히 분리해야 함.
같은 앱이 두 토큰을 동시에 가진다
당연히 가능. 보통의 메시징/통화 앱이 그렇습니다.
발급 순서는 무관. 앱 시작 시 둘 다 등록하면 각자 별도 콜백으로 발급됨.
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 푸시:
VoIP 푸시:
| 항목 | 일반 푸시 | VoIP 푸시 |
|---|---|---|
| Topic | com.example.app | com.example.app.voip |
| pushType 헤더 | alert / background | voip |
| 인증서 | APNs | VoIP Services |
| 토큰 | APNs device token | VoIP push token |
| 도착 처리 | iOS가 알림 표시 | 앱 코드 깨움 → PushKit 콜백 |
.p12 vs .p8 — 두 가지 인증 자격증명
Apple은 푸시 인증 자격증명을 두 형식으로 제공합니다.
Option A: .p12 인증서 (전통 방식)
Option B: .p8 인증키 (신형, 권장)
.p8 텍스트로 열면:
비교
| 항목 | .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 발급 절차
중요: 키는 Apple 계정에 저장되지 않으므로 안전한 곳에 백업. 다시 다운로드 불가. Download 버튼이 비활성화되어 있다면 이미 다운받았다는 의미.
JWT 서명 시 함께 필요한 정보:
| 항목 | 어디서 확인 |
|---|---|
| Key ID (10자리) | Keys 페이지에서 키 클릭 시 표시 |
| Team ID (10자리) | Apple Developer 계정 우측 상단 |
| Bundle ID | 앱 프로젝트 설정 |
4. PushKit 등록 코드 — 한 줄씩 분해
등록 함수
PKPushRegistry(queue: .main)
PushKit 시스템과 대화하는 창구 객체. "내가 VoIP 푸시 받을 거야"라고 등록하는 사무소.
queue: .main → 푸시 도착 알림을 메인 스레드(UI 스레드)에서 받음. CallKit 띄우기는 UI 작업이라 메인 스레드가 자연스러움.
pushRegistry.delegate = self
여기가 senior가 놓치기 쉬운 델리게이트 패턴의 핵심.
문제: PKPushRegistry는 시스템 객체. 토큰이 발급되거나 푸시가 도착하면 누구에게 알릴지 모름.
해결: "이런 일 생기면 이 객체한테 말 걸어"라고 미리 정해둠 = delegate.
PKPushRegistryDelegate 프로토콜을 채택하고 정해진 함수들을 구현하면 iOS가 알아서 호출.
pushRegistry.desiredPushTypes = Set([.voIP])
PushKit은 원래 여러 푸시 타입을 처리하도록 설계됐으나(과거 워치 컴플리케이션 등), 현재 사실상 .voIP만 의미 있음.
이 줄을 설정하는 순간 iOS가 백그라운드에서 APNs에 토큰을 요청합니다. 잠시 후 토큰이 발급되면 didUpdate 콜백 호출.
5. 토큰 수신 콜백 — 누가 호출하나
이 함수는 iOS가 호출함
내가 호출하는 게 아닙니다. desiredPushTypes = [.voIP] 설정 후 iOS가 알아서:
- APNs에 VoIP 토큰 요청
- 토큰 수신
- 등록된 delegate의 이 함수를 자동 호출
콜백 패턴 — "토큰 발급되는 시점은 모르겠지만, 발급되면 이 함수 실행시켜줘"라고 미리 함수를 만들어두는 것.
함수 이름의 Swift 컨벤션
자연어로 읽으면: "pushRegistry가 (특정 type을 위한) credentials를 업데이트했다"
파라미터 의미
| 파라미터 | 의미 |
|---|---|
registry: PKPushRegistry | 어떤 registry에서 발생 (보통 한 개라 무시) |
pushCredentials: PKPushCredentials | 토큰 정보 묶음 |
type: PKPushType | 어떤 타입의 푸시 토큰인지 (.voIP) |
pushCredentials.token이란
VoIP 푸시 디바이스 토큰 = 디바이스 주소.
타입은 Data (바이트 묶음). 사람이 읽으라고 만든 게 아니라 컴퓨터가 식별자로 쓰라고 만든 것이라 그냥 바이트 덩어리.
[UInt8](pushCredentials.token)은 Data를 UInt8 배열(0~255 부호 없는 정수 배열)로 변환. 보통은:
Data→ 16진수 문자열 ("4a9cff...")- 그 문자열을 HTTP POST 본문에 담아 서버 전송
서버는 받은 토큰을 (사용자, 토큰) 페어로 DB에 저장.
6. 토큰 라이프사이클 — 발급/캐시/갱신
토큰은 언제 발급되나
짧은 답: 앱이 매번 등록 요청을 할 때마다 iOS가 캐시된 토큰을 돌려주거나 새 토큰을 발급받아 줌.
긴 답:
didUpdate는 매번 호출됨
앱 시작할 때마다 호출됩니다. 대부분 같은 토큰이지만 가끔 바뀌니까 매번 서버에 업로드하는 게 안전한 패턴.
멀티 디바이스 토큰 관리
한 사용자가 아이폰 + 아이패드 + 안드로이드를 동시에 쓰는 경우가 흔합니다. 그래서 보통은 (user_id, device_id) 조합으로 관리:
설계 포인트:
- 한 디바이스가 APNs 토큰 + VoIP 토큰을 둘 다 가질 수 있음 → token_type으로 구분
- vita에게 전화 걸 때 → vita의 모든 device 행을 조회 → 각 디바이스에 푸시 발송
- 어느 기기에서든 받으면 통화 시작, 다른 기기에서는 "다른 곳에서 받음" 처리
- ChatMoa 같은 메시징 앱이 정확히 이렇게 동작
토큰 무효화 콜백
이 콜백 받으면 서버 DB에서 그 토큰을 지워야 함. 구토큰으로 푸시 보내봐야 헛수고.
7. VoIP 푸시 페이로드 형식
일반 푸시 페이로드
aps 안에 시스템이 표시할 정보를 넣음 → iOS가 자동으로 알림 배너 표시.
VoIP 푸시 페이로드
특징:
aps는 거의 비어있어도 됨 — iOS가 알림을 자동 표시 안 하니까- 개발자가 정한 임의의 키들 — 통화에 필요한 모든 정보를 자유롭게
- 이 페이로드 전체가
didReceiveIncomingPushWith콜백의payload.dictionaryPayload로 들어옴
콜백에서 받기 + CallKit 띄우기
iOS 13+ 결정적 룰: 이 콜백 안에서 반드시
reportNewIncomingCall을 호출해야 합니다. 안 하면 첫 번째: 앱 강제 종료, 반복되면: PushKit 권한 박탈(사용자가 앱 재설치해야 복구 가능).
8. CallKit + PushKit + Agora RTC 통합 — 단계별
여기서 #35의 4-레이어가 실제 코드로 만나는 모습.
8-1. CallKit Provider 설정
8-2. 사용자가 "받기" 눌렀을 때 — 정확한 순서
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에 명시적으로 보고해야 통화 시간 카운트가 정확해집니다.
① 발신 시작 요청:
② 델리게이트 콜백 — 실제 발신 작업:
③ 상대방이 받았을 때:
보고 누락 시 영향:
| 누락 | 결과 |
|---|---|
startedConnectingAt 미보고 | CallKit UI가 "연결 시도 중" 표시 안 됨 |
connectedAt 미보고 | 통화 시간 카운트 시작 안 됨, 통화 기록의 duration이 0 |
CXEndCallAction 미보고 | 통화가 끝나도 시스템 UI가 잠금 화면에 남아있음 |
8-5. 그 외 주요 콜백
| 콜백 | 처리 |
|---|---|
CXSetMutedCallAction | agoraKit.muteLocalAudioStream(action.isMuted) |
CXSetHeldCallAction | 통화 보류(hold) |
CXAction.timedOut | 위 액션이 시간 내 fulfill 안 되면 호출 → 정리/롤백 |
CallKit은 모든 액션에 timeout이 있습니다(보통 10~15초). 비동기 작업이 끝나기 전에 fulfill() 호출 권장 — 안 그러면 시스템이 "앱 응답 없음"으로 판단해 통화를 강제 종료.
9. 자체 구현 vs Agora Chat 매니지드 푸시
자체 구현 시 해야 할 일들
- 인증서 발급/관리 — APNs 인증서 + VoIP Services 인증서 (각 1년 만료)
- 푸시 발송 서버 — APNs HTTP/2 연결 풀, JWT 토큰 갱신, 재시도, 큐
- 토큰 라이프사이클 — 디바이스별 토큰 DB, 환경별 분리(dev/prod), 갱신 추적
- Android FCM — 멀티플랫폼이면 별도 인프라
- 운영 모니터링 — 발송 성공률, 인증서 만료 알람
Agora Chat의 매니지드 푸시
중요 제한: Agora Chat의 매니지드 푸시는 메시지 알림용 일반 APNs에 한정됩니다.
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 푸시 어떻게 해요?" 물어보면 두 가지를 깔끔히 분리해서 안내:
- 메시지 알림 푸시: Agora Chat 통합 시 매니지드로 해결 가능
- VoIP 통화 푸시: 자체 PushKit 인프라 + CallKit 통합 필요. Agora가 매니지드로 제공하지 않음
이 두 트랙을 합쳐 풀스택 통화 앱을 만들 때, 보통:
- 메시지/채팅: Agora Chat (매니지드 푸시 포함)
- 통화 시그널링: Agora Signaling/RTM
- 통화 미디어: Agora RTC
- 통화 VoIP 푸시 인프라: 자체 구축 (PushKit + 백엔드 + 인증서)
이 4가지를 묶어 설계해야 합니다.
10. 한 줄 결론 + 구현 체크리스트
PushKit은 별도 푸시 시스템이 아니라 APNs 위의 특수 모드다. 인증서 · 토큰 · 페이로드 · 처리 프레임워크가 모두 분리되어 있고, 통화 VoIP 푸시 인프라는 (Agora Chat을 쓰더라도) 자체 구축이 기본이다.
PushKit 등록
VoIP 푸시 수신
인증서 / 서버
CallKit 통합
엣지 케이스
한 장으로 머릿속 정리
관련 글
- iOS VoIP 통합의 4-레이어 모델 — CallKit · PushKit · AVAudioSession · Agora ADM — 본 글의 전편
- WebRTC란? ICE? STUN? NAT? TURN? — 시그널링/미디어 분리
- 시그널링과 미디어의 분리 — RTSP/SIP vs RTP — VoIP 평면 분리 일반론
- 오디오 파이프라인 해부 — ADM 내부 흐름
- AI 콜봇 멀티콜 — SIP B2BUA vs Bridge — 서버 측 콜 시그널링/푸시 트리거가 맞물리는 백엔드 관점
참고 자료
- Apple — PushKit Framework —
PKPushRegistry/PKPushRegistryDelegate/PKPushPayload공식 레퍼런스 - Apple — Responding to VoIP Notifications from PushKit — iOS 13+에서
didReceiveIncomingPushWith안에서reportNewIncomingCall을 호출해야 하는 규칙 - Apple — Sending Notification Requests to APNs —
apns-topic(.voip접미사),apns-push-type등 HTTP/2 헤더 명세 - Apple — Establishing a Token-Based Connection to APNs —
.p8인증키 + JWT(Key ID/Team ID) 기반 인증 방식 - Apple — CallKit Framework —
CXProvider/CXProviderDelegate/reportNewIncomingCall/reportOutgoingCall - Agora — Apple VoIP Push (PushKit) for Chat / Signaling — Agora Chat의 오프라인 푸시(APNs) 인증서 등록·
bindDeviceToken흐름