iOS VoIP 통합의 4-레이어 모델 — CallKit · PushKit · AVAudioSession · Agora ADM
CallKit을 붙였더니 수신은 되는데 처음 3초가 무음이고, 통화 중 알람 한 번에 마이크가 죽는다면 원인은 한 곳에 있습니다. CallKit의 진짜 역할은 통화 UI를 그리는 게 아니라 오디오 세션을 언제 켤지 결정하는 권한을 시스템이 가져가는 것이기 때문입니다. 이 글은 PushKit, CallKit, AVAudioSession, Agora ADM이 어떻게 얽히는지 4개 레이어로 정리하고, didActivate에서 채널에 들어가고 didDeactivate에서 ADM을 재시작하는 원칙으로 무음과 인터럽트, 멀티앱 오디오 누수까지 여섯 가지 실전 케이스를 풀어냅니다.
목차(42개 항목)
- 0. 핵심 명제 — CallKit은 UI 프레임워크가 아니다
- 1. 4-레이어 모델
3. PushKit + iOS 13의 강제 규정
4. AVAudioSession 표준 설정 — 그리고 함정들
6. 통합 지점 — `didActivate` / `didDeactivate`가 모든 답
8. 3초 무음의 진짜 원인 — Stale AudioUnit
- 9. 발신/수신 통화 흐름 정리
- 관련 글
- 참고 자료
"VoIP 앱에 CallKit 붙였더니 수신은 되는데 처음 3초가 무음이에요". "통화 중에 시스템 알람 한 번 울리면 영상은 멀쩡한데 마이크가 죽어요". "Talkito 통화 도중 ChatMoa 전화 받았다 다시 돌아오면 ChatMoa 목소리가 Agora 룸으로 새요" — iOS VoIP 통합에서 자주 나오는 3가지 신고입니다.
답을 하려면 CallKit · PushKit · AVAudioSession · Agora ADM 네 레이어가 어떻게 얽혀 있는지부터 정리해야 합니다. 이 글은 그 4-레이어 모델, 각 레이어가 책임지는 것, 그리고 6가지 대표 트러블 케이스를 정리합니다.
0. 핵심 명제 — CallKit은 UI 프레임워크가 아니다
CallKit의 진짜 역할은 "전화 UI 그리기"가 아니라 AVAudioSession의 활성화 타이밍을 시스템이 가져가는 것이다.
흔한 오해: "CallKit = 잠금화면 통화 UI". 실제 핵심은 다른 데 있습니다.
| 잘못된 이해 | 정확한 이해 |
|---|---|
| CallKit이 통화 UI를 그려준다 | 시스템이 AVAudioSession 활성화 권한을 가져간다 |
| Agora가 AVAudioSession을 직접 관리한다 | CallKit 통합 시 Agora는 시스템 신호를 기다려야 한다 |
joinChannel 부르면 마이크가 켜진다 | 시스템이 didActivate를 콜백할 때까지 ADM 시작 금지 |
이 명제 하나가 거의 모든 트러블 케이스를 설명합니다. CallKit이 활성화 타이밍을 점유하므로, Agora SDK의 ADM(Audio Device Module)은 자기 마음대로 오디오 하드웨어를 시작/정지할 수 없습니다.
1. 4-레이어 모델
흐름:
- PushKit이 VoIP 푸시를 받아 앱 깨우기
- 앱은 즉시 CallKit에
reportNewIncomingCall(의무) - 사용자가 "수락" → CallKit이 AVAudioSession을 활성화 →
didActivate콜백 - 그제서야 Agora ADM이 마이크를 잡고
joinChannel
이 순서를 거꾸로 하면 — 즉 didActivate를 기다리지 않고 joinChannel → 처음 몇 초 무음. 이게 신고 #1의 원인입니다.
2. CallKit이 하는 일
iOS 10에서 도입된 CallKit은 VoIP 통화를 시스템 수준 우선순위로 격상시키는 프레임워크입니다.
3가지 실질 효과
| 효과 | 의미 |
|---|---|
| ① 시스템 통화 UI | 잠금화면에서 표준 수신 화면, 화면 ON 상태에선 배너 |
| ② 통화 우선순위 | 시스템 전화 같은 격으로 취급, 다른 앱 오디오 자동 인터럽트 |
| ③ 통화 기록 통합 | iOS 기본 "최근 통화" 목록에 기록 |
[NEEDS VERIFICATION] 중국 본토에서 CallKit은 MIIT 규제로 비활성화되어 있습니다. iOS 13+에서 PushKit이 CallKit과 강제 페어링되어 있어, 중국 시장 앱은 일반적으로 CallKit 우회 전략(UserNotifications 기반 알림)으로 전환합니다.
CXProvider — 시스템에 통화 신고하는 객체
CXProvider는 앱 전체에서 단일 인스턴스로 유지하는 게 권장 패턴입니다(프레임워크가 강제하진 않음).
queue: nil을 넘기면 콜백이 메인 큐에서 실행됩니다. UI 갱신을 콜백 안에서 그대로 해도 안전.
CXCallUpdate — 통화 메타데이터
수신 통화 신고 시 시스템 UI에 표시할 정보를 담습니다.
CXHandle.type은 .phoneNumber / .emailAddress / .generic 셋 중 하나. 일반 VoIP 앱은 .generic에 사용자 ID/닉네임을 넣습니다. iOS는 이 값으로 통화 기록을 관리하며, 차단 목록에 있으면 자동 차단합니다.
시스템에 통화 도착 신고
핵심:
UUID는 매 통화마다 새로 생성. 이후 모든 액션(수락/거절/종료)이 이 UUID로 식별됨completion에 error가 없으면 시스템 UI 표시 성공, 있으면 UI 안 뜸reportNewIncomingCall자체에 실패하면didReceiveIncomingPushWith핸들러를 빨리 끝내고 PushKit completion을 호출해야 시스템이 앱을 안 죽임 (iOS 13+ 규정)
3. PushKit + iOS 13의 강제 규정
iOS 13 이전
VoIP 푸시는 매우 강력한 도구였습니다.
- 사용자가 끌 수 없음
- 시스템이 알림 배너를 띄우지 않음 (앱이 알아서 처리)
- 앱이 백그라운드에서 깨어나 코드 실행 가능
이 특권 때문에 VoIP가 아닌 용도로 남용되는 경우가 많았습니다 — 일반 알림(예: Bellring 알람 시스템)을 VoIP 푸시로 바꾸려는 시도들.
iOS 13부터의 규정
규정 요약:
| 항목 | 동작 |
|---|---|
| 백그라운드 + VoIP 푸시 수신 | 반드시 CallKit reportNewIncomingCall 호출 |
| 누락 시 | 시스템이 앱 종료 |
| 반복 위반 | 추가 VoIP 푸시 미배달 (재설치로 복구된다는 보고가 있으나 [NEEDS VERIFICATION] — Apple 공식 문서엔 "reinstall" 언어 없음) |
| 포그라운드 상태 | 명시적 면제 조항은 공식 문서에 없음 [NEEDS VERIFICATION] |
실무 팁: VoIP 푸시 → CallKit 보고는
didReceiveIncomingPushWith콜백 안에서 완료해야 합니다. 비동기 작업으로 미루면 시스템이 그 사이에 앱을 죽일 수 있음. 토큰 검증 같은 무거운 작업이 필요하면 일단 "임시" 정보로reportNewIncomingCall먼저 호출 → 정보 들어오는 대로update(with:)로 갱신하는 패턴.
4. AVAudioSession 표준 설정 — 그리고 함정들
iOS는 Android의 Audio Focus API 같은 "양보해줘" 신호 시스템이 없습니다. 대신 앱이 AVAudioSession에 자기 용도를 선언하면 시스템이 다른 앱들과의 충돌을 중재합니다.
Category vs Mode
| 개념 | 의미 |
|---|---|
| Category | "큰 그림 용도" — 재생만, 녹음만, 양방향 등 |
| Mode | "세부 용도" — 음성통화, 영상통화, 게임 등 |
| Options | 부가 동작 — 블루투스 허용, 기본 출력 등 |
VoIP 통화의 표준 설정
mode: .voiceChat의 정확한 의미
음성 통화에 최적화된 모드 — Voice-Processing I/O Unit이 적용되어 음성 통신용 DSP 체인이 활성화됩니다.
| DSP | 효과 |
|---|---|
| Echo Cancellation | 스피커→마이크 피드백 제거 |
| Noise Suppression | 배경 소음 약화 |
| Automatic Gain Control (AGC) | 입력 볼륨 자동 조정 |
주의:
.voiceChat모드 하나로 위 세 가지 DSP가 "자동으로 완벽히" 켜진다는 단순화는 정확하지 않습니다. iOS 버전과 하드웨어에 따라 동작 차이가 있고, iOS 15+에서는setPrefersEchoCancelledInput(_:)같은 별도 API로 명시적 제어가 가능해졌습니다. WebRTC/Agora SDK는 이 모드를 기반으로 추가 처리를 얹습니다.
.defaultToSpeaker의 함정
영상 통화처럼 "처음부터 스피커폰"이 자연스러운 케이스에 적절. 귀에 대고 통화하는 음성 전용 앱에서는 빼야 합니다 — 안 그러면 사용자가 귀에 대도 스피커폰으로 고정.
Info.plist 필수 키
NSMicrophoneUsageDescription이 없거나 빈 문자열이면 권한 요청 시점에 앱이 즉시 크래시합니다(iOS 10+).
마이크 사용은 항상 사용자 가시성
iOS는 마이크/카메라 사용 중 상태바에 인디케이터를 띄웁니다.
| 인디케이터 | 의미 |
|---|---|
| 🟠 주황 점 | 마이크만 사용 중 |
| 🟢 초록 점 | 카메라 사용 중 (마이크 동시 사용 가능) |
자주 틀리는 단순화: "주황=마이크, 초록=카메라"는 절반만 맞습니다. 마이크만 쓰면 항상 주황. 카메라가 켜진 순간(마이크 동반 여부 무관) 초록으로 승급. 이 보안 모델은 회피 시도가 곧 앱 거부 사유입니다.
5. 백그라운드 마이크 규칙
완전 서스펜드 상태 — 새 마이크 시작 불가
앱이 홈 버튼으로 백그라운드 갔다가 시스템이 서스펜드한 상태에서는 어떤 트리거로도 마이크를 새로 켤 수 없습니다. 푸시, 블루투스 신호, 그 무엇으로도.
포그라운드에서 시작한 마이크는 백그라운드 진입 후에도 유지
조건:
- Capabilities → Background Modes → "Audio, AirPlay, and Picture in Picture" 활성화
- 포그라운드 시점에 이미 마이크가 활성화되어 있어야 함
- 사용자에게 상태바 주황 점으로 노출
Picture-in-Picture (PiP) 모드의 특이성
PiP는 "사용자가 여전히 인지 가능한 활성 상태"로 간주됩니다.
- PiP 윈도우가 화면에 계속 표시됨 → 사용자 가시성 충족
- 프로세스가 활성 상태로 유지
- 시스템 레벨 서비스로 취급
[NEEDS VERIFICATION] "PiP 안에서 비활성 상태였던 마이크를 새로 시작 가능"이라는 클레임은 일부 실무 보고에서 나오나, Apple 공식 문서로 명시 확인되지 않았습니다. 보수적으로는 "PiP는 이미 활성화된 오디오 세션을 유지"하는 모드로 다루는 게 안전합니다. 또한 사용자 명시 요청 없이 PiP를 프로그래밍 방식으로 시작하면 앱 심사 거절 사유가 될 수 있습니다.
6. 통합 지점 — didActivate / didDeactivate가 모든 답
여기가 senior가 놓치기 쉬운 핵심입니다. CallKit 통합 환경에서 Agora의 ADM은 시스템이 알려줄 때까지 기다려야 합니다.
CXProviderDelegate 메서드 (정확한 시그니처)
왜 didActivate 전에 joinChannel 부르면 안 되나
CallKit이 활성화 권한을 점유하는데, Agora가 먼저 ADM을 시작하면 두 가지 상태가 충돌합니다.
didDeactivate의 역할
시스템 통화로 인터럽트되거나 통화 종료 후 시스템이 세션을 해제할 때 호출됩니다. 여기서 disableAudio() → enableAudio() 콤보로 ADM을 강제 재시작 준비 상태로 두면, 다음 통화 진입 시 stale AudioUnit 이슈를 예방할 수 있습니다.
7. 6가지 트러블 케이스 — 진단과 처방
이제 4-레이어 모델 위에서 실무 트러블 케이스를 풀어봅니다.
Case 1: 시스템 알람이 Agora 오디오를 끊는다
증상: 통화 중에 알람 한 번 → 알람 종료 후 Agora 마이크가 죽어 있음.
메커니즘: 시스템이 AVAudioSession.interruptionNotification의 .began/.ended를 보내지만, Agora ADM은 기본 설정에서 .ended 시 자동 재시작을 하지 않음.
처방 — joinChannel 전에 private parameter 활성화:
[NEEDS VERIFICATION] 이는 Agora의 private parameter로 공개 문서에 없습니다. Agora support 채널을 통해 받은 권장 설정이며, 동작은 검증되었으나 향후 변경 가능성 있음.
부가 동작 차이 — 시스템 볼륨 종류에 따라:
- 통화 볼륨(in-call): private API가 알람을 죽이고 앱 오디오 회복
- 미디어 볼륨: 알람은 유지(ducked)되고 앱 오디오 회복.
nonmixable옵션 추가 시 알람도 죽음 - 통화 vs 미디어 볼륨 식별: Agora 도움말의 system_volume 가이드 참조
이 처방은 알람이 울릴 때 짧은 재생 끊김을 동반합니다 — ADM 재시작 자체의 비용.
Case 2: 시스템 전화 배너가 Agora를 끊는다
증상: 통화 중 시스템 전화 도착(배너 또는 풀스크린) → Agora 오디오 인터럽트.
처방 (iOS 14.5+):
중요한 뉘앙스:
setPrefersNoInterruptionsFromSystemAlerts(true)는 비-텔레포니 시스템 알림(타이머, Siri, 일부 알림)에 한해 인터럽션을 억제합니다. 실제 통신사 전화나 FaceTime 수신은 여전히 인터럽트합니다 — 이건 시스템이 사용자 보호 차원에서 강제하는 동작이며 어떤 API로도 우회 불가.
overrideMutedMicrophoneInterruption은 짧은 인터럽션 동안 iOS가 자동으로 적용하는 마이크 mute를 막아줍니다. 위와 별개 개념.
[NEEDS VERIFICATION] iOS 14.5라는 정확한 도입 버전은 변경 가능성. Apple 공식 문서 확인 권장.
Case 3: Agora 통화 종료 후 3rd party 오디오 미복구 (Unity 케이스)
증상: Unity 앱(자체 BGM 재생) 안에서 Agora 통화를 시작했다 끝내면, BGM이 자동으로 안 돌아옴.
메커니즘: leaveChannel 시 Agora가 setActive(false)로 세션 비활성화 → Unity의 오디오 파이프라인이 깨짐.
처방 — 정밀 제어 (공개 API):
AgoraAudioSessionOperationRestriction enum은 5단계 제어를 제공합니다(공식 iOS API 레퍼런스).
| 값 | 의미 | 사용 시나리오 |
|---|---|---|
.none (0) | 제한 없음 — SDK가 전부 관리 (기본) | 일반 통화 앱 |
.setCategory (1) | Category만 SDK가 못 바꿈 | 마이크 OFF 상태에서 .ambient / .playback 쓰고 싶을 때 |
.configureSession (1<<1) | Category + mode + options 전부 SDK가 못 바꿈 | 호스트 앱이 카테고리 정책을 직접 설계할 때 (CallKit 통합 흔한 케이스) |
.deactivateSession (1<<2) | leaveChannel 시 setActive(false) 안 함 | Unity 같은 호스트가 세션을 계속 잡고 싶을 때 |
.all (1<<7) | SDK 일절 관여 금지 | 모든 오디오 라이프사이클을 호스트가 책임질 때 |
Agora 권장 패턴 — 상태 토글:
대안 — private parameter 패턴:
che.audio.keep.audiosession은 .deactivateSession 제한과 사실상 같은 동작을 합니다. 다만 Category/mode 전환은 AudioSession 재시작에 의존하므로, 함부로 켜면 세션 전환 시 음성 이상이 발생할 수 있습니다. 공식 권장은 enum 기반 공개 API.
보너스 — che.audio.specify.category: 마이크 미사용 구간에서 SDK가 어떤 카테고리를 쓸지 명시:
.setCategory 제한과 함께 쓰면 호스트가 별도 코드 없이도 캡처 OFF 시점에 .playback으로 자동 전환 가능. Agora 측에서 권장하는 패턴입니다.
Case 4: ChatMoa 전화 후 두 앱 오디오가 같은 룸으로 새는 이슈 (iOS 15/16)
증상:
iOS 14에서는 Talkito 포그라운드/백그라운드 무관하게 두 앱 음성이 공유됨.
메커니즘: 기본 mixable 세션 옵션 때문에 Talkito와 ChatMoa가 같은 입력 캡처를 공유하는 상태.
처방:
[NEEDS VERIFICATION] private parameter. nonmixable 세션 옵션을 강제해 Agora가 재활성화 시 다른 앱의 오디오를 정상적으로 인터럽트하게 함.
Case 5: 시스템 전화 종료 후 Agora 오디오 미복구
증상: 시스템 전화 → 종료 → Talkito 복귀했는데 마이크가 죽어 있음.
메커니즘: 보통은 자동 복구되지만, 일부 케이스에서 ADM이 stale AudioUnit 잡고 있음 → 0 sample → Agora 워치독이 못 잡는 경우.
처방 — ADM 강제 재시작:
disableAudio → enableAudio 시퀀스가 ADM을 완전히 죽였다 살리는 강제 재시작 콤보입니다. 마치 "오디오 서브시스템 다시 켜기".
Case 6: CallKit 스피커 버튼이 자꾸 수화기로 풀린다
증상: CallKit 시스템 통화 화면에서 스피커 버튼을 켜도 잠시 후 자동으로 수화기(earpiece)로 라우트가 되돌아감.
메커니즘: 세 주체가 같은 AVAudioSession을 두고 경쟁.
사용자가 시스템 스피커 버튼 → CallKit이 라우트를 스피커로 변경 → Agora SDK의 주기적 세션 검사가 자기 기본값으로 덮어쓰기 → 다시 earpiece로 풀림.
처방 — joinChannel 직전에 두 가지를 같이 적용:
왜 .allowBluetoothA2DP가 핵심인가: A2DP(고음질 단방향)와 SCO/HFP(저음질 양방향)는 별개 프로파일. .allowBluetoothA2DP 옵션이 켜져 있으면 iOS가 라우팅 결정 로직을 더 유연하게 가져가는데, 이 부수효과로 CallKit 스피커 버튼 충돌이 풀립니다. [NEEDS VERIFICATION] Apple 공식 문서에는 명시 없음 — StackOverflow 기반의 커뮤니티 우회법.
.configureSession이 핵심인 이유: ① + ②는 세트입니다. ② 없이 ①만 하면 SDK가 2초 간격으로 세션을 검사하면서 자기 기본값으로 덮어쓰는 동작 때문에 효과가 없음. ②를 통해 "Category/mode/options는 내가 관리한다"고 선언해야 ①의 설정이 살아남음.
| 부작용 | 설명 |
|---|---|
| 에코 가능성 | A2DP 라우팅 시 마이크 입력과 출력 분리 → 통화 품질 영향 가능 |
| 구형 차량 호환성 | A2DP 미지원 일부 구형 차량 블루투스에서 연결 자체가 실패할 수 있음 |
| 비공식 우회법 | Apple 공식 가이드에 없음 — 향후 iOS 업데이트로 동작 변경 가능 |
8. 3초 무음의 진짜 원인 — Stale AudioUnit
위에서 잠깐 언급한 신고 #1 ("CallKit 수락 후 처음 3초 무음")의 정확한 메커니즘.
흔한 (하지만 부정확한) 설명
"AVAudioSession에 interrupted 플래그가 남아있어서 Agora가 혼란스러워한다"
이 표현은 직관적이지만 메커니즘적으로 틀립니다. iOS 측에 "interrupted 플래그가 살아있다"는 명시적 상태는 없음.
실제 메커니즘
올바른 처방
disableAudio로 stale AudioUnit을 명시적으로 해제하고, enableAudio로 다음 활성화 사이클에 새로 할당받을 준비를 합니다. 워치독의 자동 복구를 기다리지 않으므로 즉시 음성 흐름.
9. 발신/수신 통화 흐름 정리
규칙:
- 발신측 액션 트랜잭션은
CXCallController.requestTransaction으로 제출 - 시스템이 검증 → CXProvider에
perform...콜백 → 거기서 비즈니스 로직 - 수신측은
reportNewIncomingCall로 시작 - 양쪽 모두 실제 미디어는
didActivate이후에 시작
10. 한 줄 결론 + 통합 체크리스트
CallKit은 UI 프레임워크가 아니라 AVAudioSession 활성화 권한 점유자다. Agora ADM은
didActivate를 기다려야 하고,didDeactivate에서 강제 재시작을 준비해야 한다.
통합 체크리스트
트러블 진단 흐름
관련 글
- #0 WebRTC란? ICE? STUN? NAT? TURN? — 시그널링/미디어 분리의 기초
- #23 오디오 파이프라인 해부 — ADM 내부의 ADC→PCM→Opus 흐름,
didActivate이후 무엇이 흐르나 - #32 시그널링과 미디어의 분리 — RTSP/SIP vs RTP — VoIP 평면 분리의 일반론
- #22 PCM vs WAV — 44바이트 헤더의 오해 — ADM이 다루는 오디오 데이터 포맷
- #36 VoIP 푸시 인프라 — APNs/PushKit/CallKit — PushKit→CallKit 배달 경로를 서버 인프라 관점에서 심화
참고 자료
- CallKit | Apple Developer Documentation — CXProvider/CXProviderDelegate/CXCallUpdate 등 본문에서 다룬 모든 타입의 1차 레퍼런스
- Reporting incoming calls (PushKit + CallKit) | Apple Developer Documentation — iOS 13+의 "VoIP 푸시 수신 시 reportNewIncomingCall 필수" 규정 원문
- AVAudioSession | Apple Developer Documentation — Category/Mode/Options,
setPrefersNoInterruptionsFromSystemAlerts(_:),overrideMutedMicrophoneInterruption의 정확한 시그니처와 가용 버전 - Handling audio interruptions | Apple Developer Documentation —
interruptionNotification의.began/.ended처리 — Case 1/2/5의 시스템 측 동작 - setAudioSessionOperationRestriction(_:) | Agora iOS API Reference —
AgoraAudioSessionOperationRestrictionenum 5단계 — Case 3의 공개 API 처방 - Mode: voiceChat (AVAudioSession.Mode) | Apple Developer Documentation — Voice-Processing I/O 기반 모드 정의 — §4의 DSP 체인 근거