블로그 목록
Telephony25분 읽기

iOS VoIP 통합의 4-레이어 모델 — CallKit · PushKit · AVAudioSession · Agora ADM

PushKit 수신, CallKit transaction, AVAudioSession activation, RTC SDK audio module의 책임을 네 계층으로 나눕니다. `didActivate`와 `didDeactivate`, interruption·route change callback을 어떤 상태 전이에 연결할지 설명하며, channel join 시점과 audio module 제어는 사용 중인 SDK 계약에 맞춰 검증합니다.

iOSCallKitPushKitAVAudioSessionVoIPADMAudioUnitAgoraSwift
목차(39개 항목)
  1. 0. 핵심 명제 — CallKit은 UI와 통화 수명 주기를 함께 다룬다
  2. 1. 4-레이어 모델
  3. 2. CallKit이 하는 일
    1. 3가지 실질 효과
    2. CXProvider — 시스템에 통화 신고하는 객체
    3. CXCallUpdate — 통화 메타데이터
    4. 시스템에 통화 도착 신고
  4. 3. PushKit + iOS 13의 강제 규정
    1. iOS 13 이전
    2. iOS 13부터의 규정
  5. 4. AVAudioSession 표준 설정 — 그리고 함정들
    1. Category vs Mode
    2. VoIP 통화의 표준 설정
    3. `mode: .voiceChat`의 정확한 의미
    4. `.defaultToSpeaker`의 함정
    5. Info.plist 필수 키
    6. 마이크·카메라 사용 인디케이터
  6. 5. 백그라운드 통화와 오디오
    1. 서스펜드된 앱은 임의로 마이크를 시작할 수 없다
    2. 포그라운드에서 시작한 마이크는 백그라운드 진입 후에도 유지
    3. 영상 통화의 Picture in Picture
  7. 6. 통합 지점 — 액션과 오디오 활성화를 분리한다
    1. CXProviderDelegate 메서드 (정확한 시그니처)
    2. `joinChannel`과 오디오 장치 시작은 같은 사건이 아니다
    3. `didDeactivate`의 역할
  8. 7. 트러블 케이스 — 진단과 처방
    1. Case 1: 시스템 알람이 Agora 오디오를 끊는다
    2. Case 2: 시스템 전화 배너가 Agora를 끊는다
    3. Case 3: Agora 통화 종료 후 3rd party 오디오 미복구 (Unity 케이스)
    4. Case 4: 다른 통화 앱을 사용한 뒤 오디오 경로가 이상하다
    5. Case 5: 시스템 전화 종료 후 Agora 오디오 미복구
    6. Case 6: CallKit 스피커 버튼이 자꾸 수화기로 풀린다
  9. 8. 수락 직후 무음 — 타임라인으로 원인을 좁힌다
  10. 9. 발신/수신 통화 흐름 정리
  11. 10. 한 줄 결론 + 통합 체크리스트
    1. 통합 체크리스트
    2. 트러블 진단 흐름
  12. 관련 글
  13. 참고 자료

"VoIP 앱에 CallKit을 붙였더니 수신 직후 오디오가 늦게 시작돼요." "통화 중 시스템 인터럽션 뒤에 마이크가 복구되지 않아요." iOS VoIP 통합에서 반복해서 만나는 신고입니다.

답을 하려면 CallKit · PushKit · AVAudioSession · Agora ADM 네 레이어가 어떻게 얽혀 있는지부터 정리해야 합니다. 이 글은 그 4-레이어 모델, 각 레이어가 책임지는 것, 그리고 6가지 대표 트러블 케이스를 정리합니다.


0. 핵심 명제 — CallKit은 UI와 통화 수명 주기를 함께 다룬다

CallKit은 시스템 통화 UI, 통화 액션, 오디오 세션 활성화 콜백을 앱의 VoIP 통화와 연결한다.

잠금화면 수신 UI만 붙이는 API로 보면 오디오 수명 주기를 놓치기 쉽습니다.

잘못된 이해정확한 이해
CallKit은 수신 화면만 그린다시스템 UI와 통화 액션·오디오 활성화 콜백을 함께 제공한다
RTC SDK에 모든 세션 관리를 맡긴다CallKit과 SDK의 AVAudioSession 책임 경계를 정해야 한다
joinChannel 호출만으로 통화 오디오가 준비된다미디어 연결과 오디오 장치 시작을 구분해 상태를 추적한다

CallKit을 쓸 때 시스템은 provider(_:didActivate:)와 provider(_:didDeactivate:)로 오디오 세션 상태를 알려 줍니다. 앱과 RTC SDK가 이 상태를 무시하고 서로 세션을 재설정하면 무음이나 라우트 변경이 생길 수 있습니다.


1. 4-레이어 모델

┌───────────────────────────────────────────────────────┐
│  ① PushKit          (iOS 시스템 → 앱 깨우기)          │
│     "전화 왔다는 신호 배달"                            │
├───────────────────────────────────────────────────────┤
│  ② CallKit          (시스템 통화 UI + 세션 권한 점유)   │
│     "사용자에게 보여주고 AVAudioSession 활성화 결정"    │
├───────────────────────────────────────────────────────┤
│  ③ AVAudioSession   (오디오 라우팅 / 카테고리 / 모드)   │
│     "마이크·스피커·블루투스 누가 쓸지 시스템이 중재"    │
├───────────────────────────────────────────────────────┤
│  ④ Agora SDK ADM    (실제 캡처/재생 + RTP 송수신)       │
│     "PCM 가져와서 Opus로 인코딩 → SD-RTN 송출"          │
└───────────────────────────────────────────────────────┘

흐름:

  1. PushKit이 VoIP 푸시를 받아 앱 깨우기
  2. 앱은 즉시 CallKit에 reportNewIncomingCall (의무)
  3. 사용자가 "수락" → 앱이 CXAnswerCallAction 처리
  4. CallKit이 AVAudioSession을 활성화하면 didActivate에서 오디오 장치를 시작하거나 재개

채널 인증·시그널링은 수락 처리 중 미리 진행할 수 있습니다. 다만 마이크 캡처와 재생 장치 시작은 오디오 세션 활성화 상태와 맞춰야 합니다. Agora SDK 버전에 따라 제공되는 오디오 제어 API가 다르므로 해당 버전의 API 레퍼런스를 기준으로 연결합니다.


2. CallKit이 하는 일

iOS 10에서 도입된 CallKit은 VoIP 통화를 시스템 수준 우선순위로 격상시키는 프레임워크입니다.

3가지 실질 효과

효과의미
① 시스템 통화 UI잠금화면에서 표준 수신 화면, 화면 ON 상태에선 배너
② 통화 우선순위시스템 전화 같은 격으로 취급, 다른 앱 오디오 자동 인터럽트
③ 통화 기록 통합iOS 기본 "최근 통화" 목록에 기록

CXProvider — 시스템에 통화 신고하는 객체

CXProvider는 앱 전체에서 단일 인스턴스로 유지하는 게 권장 패턴입니다(프레임워크가 강제하진 않음).

let config = CXProviderConfiguration(localizedName: "Talkito")
config.supportsVideo = true
config.maximumCallGroups = 1
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.generic]

let provider = CXProvider(configuration: config)
provider.setDelegate(self, queue: nil)   // nil → main queue

queue: nil을 넘기면 콜백이 메인 큐에서 실행됩니다. UI 갱신을 콜백 안에서 그대로 해도 안전.

CXCallUpdate — 통화 메타데이터

수신 통화 신고 시 시스템 UI에 표시할 정보를 담습니다.

let update = CXCallUpdate()
update.localizedCallerName = "김철수"          // 표시명
update.hasVideo = true                         // 영상 통화 여부
update.supportsHolding = true                  // 보류 가능
update.supportsDTMF = false                    // 키패드 톤
update.supportsGrouping = false
update.supportsUngrouping = false
update.remoteHandle = CXHandle(type: .generic, value: "user_42")

CXHandle.type은 .phoneNumber / .emailAddress / .generic 셋 중 하나입니다. 일반 VoIP 앱은 서비스 내부 식별자를 표현할 때 .generic을 사용할 수 있습니다. 화면에 노출될 수 있으므로 토큰이나 개인정보를 그대로 넣지 않습니다.

시스템에 통화 도착 신고

let callUUID = UUID()
provider.reportNewIncomingCall(with: callUUID, update: update) { error in
    if let error = error {
        // 시스템 UI를 표시하지 못했으므로 PushKit completion 호출
        print("CallKit reject: \(error)")
    } else {
        // 시스템 통화 UI 표시 성공
    }
    completion()  // PushKit completion 필수
}

핵심:

  • 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 푸시 도착] ──► [didReceiveIncomingPushWith 콜백]
                            │
                            ▼
                    [reportNewIncomingCall 호출?]
                       │            │
                      예           아니오
                       │            │
                  [통화 UI 표시]  [시스템이 앱 강제 종료]
                                       │
                                       ▼
                              [반복 위반 시 VoIP 푸시 차단]

규정 요약:

항목동작
백그라운드 + VoIP 푸시 수신반드시 CallKit reportNewIncomingCall 호출
누락 시시스템이 앱 종료
반복 위반이후 VoIP 푸시 전달이 중단될 수 있음

실무 팁: VoIP 푸시 → CallKit 보고는 didReceiveIncomingPushWith 콜백 안에서 완료해야 합니다. 비동기 작업으로 미루면 시스템이 그 사이에 앱을 죽일 수 있음. 토큰 검증 같은 무거운 작업이 필요하면 일단 "임시" 정보로 reportNewIncomingCall 먼저 호출 → 정보 들어오는 대로 update(with:)로 갱신하는 패턴.


4. AVAudioSession 표준 설정 — 그리고 함정들

iOS는 AVAudioSession 카테고리·모드·옵션과 인터럽션 알림으로 오디오 사용 목적과 상태 변화를 다룹니다.

Category vs Mode

개념의미
Category"큰 그림 용도" — 재생만, 녹음만, 양방향 등
Mode"세부 용도" — 음성통화, 영상통화, 게임 등
Options부가 동작 — 블루투스 허용, 기본 출력 등

VoIP 통화의 표준 설정

let session = AVAudioSession.sharedInstance()

do {
    try session.setCategory(
        .playAndRecord,
        mode: .voiceChat,
        options: [.allowBluetooth, .defaultToSpeaker]
    )
    // CallKit과 합의한 세션 수명 주기에 따라 활성화 상태를 처리
} catch {
    print("AVAudioSession setup failed: \(error)")
}

mode: .voiceChat의 정확한 의미

.voiceChat은 양방향 음성 통신용 모드입니다. Apple은 playAndRecord 카테고리와 함께 사용하도록 설명하며, 블루투스 HFP 같은 음성 통화 경로에 맞는 라우팅·신호 처리를 적용합니다.

DSP효과
Echo Cancellation스피커→마이크 피드백 제거
Noise Suppression배경 소음 약화
Automatic Gain Control (AGC)입력 볼륨 자동 조정

주의: .voiceChat 지정만으로 앱이 기대하는 에코 제거·노이즈 억제 수준이 보장되지는 않습니다. 실제 처리는 선택한 오디오 I/O 경로, 기기, OS, RTC SDK 설정에 따라 달라지므로 루프백과 실기기 통화로 확인합니다. setPrefersEchoCancelledInput(_:)은 일부 2024년 이후 iPhone의 내장 마이크 조합을 위한 별도 선호 설정이며, .voiceChat의 일반적인 대체 스위치가 아닙니다.

.defaultToSpeaker의 함정

영상 통화처럼 "처음부터 스피커폰"이 자연스러운 케이스에 적절. 귀에 대고 통화하는 음성 전용 앱에서는 빼야 합니다 — 안 그러면 사용자가 귀에 대도 스피커폰으로 고정.

Info.plist 필수 키

<key>NSMicrophoneUsageDescription</key>
<string>음성 통화를 위해 마이크 권한이 필요합니다</string>

<key>NSCameraUsageDescription</key>
<string>영상 통화를 위해 카메라 권한이 필요합니다</string>

<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
    <string>voip</string>
</array>

NSMicrophoneUsageDescription이 없거나 빈 문자열이면 권한 요청 시점에 앱이 즉시 크래시합니다(iOS 10+).

마이크·카메라 사용 인디케이터

iOS는 마이크/카메라 사용 중 상태바에 인디케이터를 띄웁니다.

인디케이터의미
🟠 주황 점마이크만 사용 중
🟢 초록 점카메라 사용 중 (마이크 동시 사용 가능)

자주 틀리는 단순화: Apple은 주황 인디케이터를 마이크 사용, 초록 인디케이터를 카메라 또는 카메라+마이크 사용으로 설명합니다. 앱은 이 시스템 표시를 전제로 권한과 통화 상태 UI를 설계합니다.


5. 백그라운드 통화와 오디오

서스펜드된 앱은 임의로 마이크를 시작할 수 없다

일반 앱은 서스펜드된 상태에서 코드를 실행할 수 없습니다. VoIP 수신은 PushKit으로 앱을 깨우고 CallKit에 통화를 보고하는 별도 수명 주기를 따르며, 사용자가 통화를 수락한 뒤 시스템이 오디오 세션을 활성화할 때 캡처를 시작합니다.

포그라운드에서 시작한 마이크는 백그라운드 진입 후에도 유지

조건:

  • Capabilities → Background Modes → "Audio, AirPlay, and Picture in Picture" 활성화
  • 포그라운드 시점에 이미 마이크가 활성화되어 있어야 함
  • 사용자에게 상태바 주황 점으로 노출

영상 통화의 Picture in Picture

영상 통화 PiP는 AVPictureInPictureController.ContentSource(activeVideoCallSourceView:contentViewController:)를 사용하는 별도 통합입니다. PiP를 마이크 시작 우회 수단으로 취급하지 말고, CallKit·AVAudioSession 통화 수명 주기와 독립적으로 구성합니다.


6. 통합 지점 — 액션과 오디오 활성화를 분리한다

여기가 senior가 놓치기 쉬운 핵심입니다. CallKit 통합 환경에서 Agora의 ADM은 시스템이 알려줄 때까지 기다려야 합니다.

CXProviderDelegate 메서드 (정확한 시그니처)

extension CallController: CXProviderDelegate {
    
    func providerDidReset(_ provider: CXProvider) { /* 정리 */ }
    
    // 발신 시작
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        action.fulfill()
    }
    
    // 수신 수락 — 서버 수락 처리와 채널 준비
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        signaling.accept(callUUID: action.callUUID)
        prepareRtcChannelIfNeeded()
        action.fulfill()
    }
    
    // 종료
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        agoraKit.leaveChannel(nil)
        action.fulfill()
    }
    
    // Mute
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        agoraKit.muteLocalAudioStream(action.isMuted)
        action.fulfill()
    }
    
    // 시스템이 AVAudioSession 활성화 완료 — 오디오 장치 시작/재개
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        resumeRtcAudio()
    }
    
    // 시스템이 세션 비활성화 — 오디오 장치 일시 정지/중지
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        pauseRtcAudio()
    }
}

joinChannel과 오디오 장치 시작은 같은 사건이 아니다

채널 조인은 토큰 검증·시그널링·미디어 전송 준비를 포함할 수 있고, 오디오 장치 시작은 AVAudioSession 활성화가 필요합니다. SDK가 두 단계를 분리하는 공개 API를 제공한다면 다음처럼 상태를 나눠 추적합니다.

[수락 액션] → 서버 수락/채널 준비 → action.fulfill()
                           │
[didActivate] ─────────────┴→ RTC 오디오 장치 시작 또는 재개

[didDeactivate] ────────────→ RTC 오디오 장치 일시 정지 또는 중지

didDeactivate의 역할

시스템이 오디오 세션을 비활성화하면 호출됩니다. 이 콜백에서 오디오를 다시 활성화하려고 경쟁하지 말고, 캡처·재생을 멈추고 상태를 기록합니다. 재개는 didActivate 또는 AVAudioSession.interruptionNotification의 종료 이벤트를 확인한 뒤, 사용하는 SDK의 공개 재개 API로 수행합니다.


7. 트러블 케이스 — 진단과 처방

이제 4-레이어 모델 위에서 실무 트러블 케이스를 풀어봅니다.

Case 1: 시스템 알람이 Agora 오디오를 끊는다

증상: 통화 중에 알람 한 번 → 알람 종료 후 Agora 마이크가 죽어 있음.

진단: AVAudioSession.interruptionNotification에서 .began/.ended, AVAudioSessionInterruptionOptionShouldResume, 현재 route를 함께 기록합니다. Apple은 인터럽션이 끝나도 세션이 자동으로 다시 활성화된다고 보장하지 않습니다.

처방: .began에서 캡처·재생 상태를 멈추고, .ended에서 shouldResume과 통화 상태를 확인한 뒤 SDK의 공개 오디오 재개 API를 호출합니다. 복구가 실패하면 Agora SDK 버전, 기기, iOS 버전과 로그를 묶어 공식 지원 경로에서 확인합니다. 문서화되지 않은 setParameters 키를 제품 코드에 고정하지 않습니다.

Case 2: 시스템 전화 배너가 Agora를 끊는다

증상: 통화 중 시스템 전화 도착(배너 또는 풀스크린) → Agora 오디오 인터럽트.

제한적으로 사용할 수 있는 설정:

if #available(iOS 14.0, *) {
    try? AVAudioSession.sharedInstance()
        .setPrefersNoInterruptionsFromSystemAlerts(true)
}

이 API는 수신 전화가 배너로 표시되는 일부 경우 시스템이 앱의 오디오를 중단하지 않도록 선호를 전달합니다. 전체 화면 수신 UI에는 효과가 없고, 사용자가 전화를 받으면 시스템은 기존 오디오를 중단합니다. .overrideMutedMicrophoneInterruption은 Smart Folio처럼 하드웨어가 마이크를 음소거한 입력 인터럽션을 재정의하는 옵션이지, 전화·알람 인터럽션 우회 옵션이 아닙니다.

Case 3: Agora 통화 종료 후 3rd party 오디오 미복구 (Unity 케이스)

증상: Unity 앱(자체 BGM 재생) 안에서 Agora 통화를 시작했다 끝내면, BGM이 자동으로 안 돌아옴.

진단: 통화 전후 AVAudioSession 카테고리·모드·활성화 주체를 로그로 비교합니다. RTC SDK가 종료 시 세션을 비활성화했는지, 호스트 엔진이 인터럽션 종료 후 재생을 재개했는지 분리합니다.

처방 — 정밀 제어 (공개 API):

// 예: 호스트 앱이 통화 뒤에도 세션을 유지해야 하는 경우
agoraKit.setAudioSessionOperationRestriction(.deactivateSession)

setAudioSessionOperationRestriction은 SDK의 AVAudioSession 조작 범위를 제한하는 공개 API입니다. 현재 설치한 SDK header와 Agora iOS API 레퍼런스에서 지원 값과 동작을 확인합니다.

값의미사용 시나리오
.none (0)제한 없음 — SDK가 전부 관리 (기본)일반 통화 앱
.setCategoryCategory 변경 제한호스트가 카테고리를 관리할 때
.configureSession세션 구성 제한호스트가 category/mode/options를 관리할 때
.deactivateSession세션 비활성화 제한호스트 오디오를 계속 유지해야 할 때
.all모든 해당 조작 제한호스트가 전체 수명 주기를 책임질 때

제한을 켜면 SDK가 하던 설정과 활성화 책임이 앱으로 넘어옵니다. 통화 시작·종료·인터럽션·블루투스 변경을 모두 처리할 준비가 된 범위에서 가장 좁은 제한만 적용합니다.

Case 4: 다른 통화 앱을 사용한 뒤 오디오 경로가 이상하다

증상:

1. RTC 통화 진행 중
2. 다른 통화 앱의 수신 전화를 받음
3. 기존 앱으로 돌아왔을 때 입력·출력 route 또는 mute 상태가 예상과 다름

진단: 앱 이름이나 OS 버전만으로 원인을 단정하지 않습니다. interruption begin/end, CallKit activate/deactivate, route change reason, currentRoute, mute 상태를 같은 타임라인에 기록합니다.

처방: 복귀 시 임의의 비공개 파라미터를 켜기보다 현재 CallKit 통화가 활성인지 확인하고, 세션 재활성화 후 공개 API로 오디오 장치를 재개합니다. .mixWithOthers는 실제로 다른 오디오와 혼합해야 하는 제품 요구가 있을 때만 선택합니다.

Case 5: 시스템 전화 종료 후 Agora 오디오 미복구

증상: 시스템 전화 → 종료 → 앱 복귀 후 마이크가 동작하지 않음.

진단: didDeactivate 뒤에 새 didActivate가 왔는지, interruption .ended의 shouldResume 값, route와 캡처 프레임 콜백을 확인합니다. 이 로그 없이 "stale AudioUnit"이나 SDK 워치독 문제로 단정할 수 없습니다.

처방 — 활성화 콜백에서 공개 재개 API 호출:

func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
    pauseRtcAudio()
}

func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
    resumeRtcAudio()
}

Agora의 실제 메서드는 SDK 버전과 선택한 오디오 모듈에 맞춰 구현합니다. didDeactivate 안에서 즉시 다시 활성화하면 CallKit과 세션 소유권 경쟁이 생길 수 있습니다.

Case 6: CallKit 스피커 버튼이 자꾸 수화기로 풀린다

증상: CallKit 시스템 통화 화면에서 스피커 버튼을 켜도 잠시 후 자동으로 수화기(earpiece)로 라우트가 되돌아감.

메커니즘: 세 주체가 같은 AVAudioSession을 두고 경쟁.

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   CallKit   │ ◄►  │ AVAudioSess │ ◄►  │  Agora SDK  │
│ (시스템 UI)  │     │   (공용자원)  │     │  (RTC ADM)  │
└─────────────┘     └─────────────┘     └─────────────┘

원인을 밝히려면 스피커 액션 직후의 routeChangeNotification과 SDK의 세션 구성 로그를 비교해야 합니다. 앱과 SDK가 모두 category/mode/options를 쓰는 구조라면 마지막 설정이 예상 경로를 덮을 수 있습니다.

처방 — 세션 소유자를 하나로 정하고 공개 라우팅 API 사용:

func joinChannel() {
    // 앱이 AVAudioSession 구성을 맡는 설계라면
    try? AVAudioSession.sharedInstance().setCategory(
        .playAndRecord,
        mode: .voiceChat,
        options: [.allowBluetooth, .defaultToSpeaker]
    )

    // SDK 버전에서 지원하는 공개 제한 API로 책임 경계를 고정
    agoraKit.setAudioSessionOperationRestriction(.configureSession)
    agoraKit.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0)
}

통화 중 블루투스 양방향 입력은 HFP 계열 경로를 사용합니다. A2DP는 고음질 출력용 경로이므로 스피커 버튼 복구용 옵션으로 넣지 않습니다. 사용자 스피커 선택은 overrideOutputAudioPort(.speaker) 또는 Agora SDK의 공개 스피커폰 API로 처리하고 route change 결과를 확인합니다.

부작용설명
세션 소유권.configureSession을 쓰면 앱이 category/mode/options를 계속 책임짐
블루투스HFP와 A2DP의 입력 지원·품질 차이를 실기기로 확인
사용자 선택route change 뒤 앱 UI와 실제 currentRoute를 동기화

8. 수락 직후 무음 — 타임라인으로 원인을 좁힌다

"수락 직후 몇 초간 무음"은 증상이지 원인이 아닙니다. 네 가지 시각을 한 타임라인에 남겨야 합니다.

시각확인할 사건
PushKit푸시 수신, CallKit 보고, completion
CallKitanswer action, fulfill, didActivate
RTCjoin 요청/성공, 로컬 오디오 활성화, 첫 송신 프레임
원격첫 오디오 패킷 수신, 첫 디코드·재생 프레임

didActivate가 늦다면 시스템 통화 수명 주기를, 활성화는 빠른데 첫 캡처 프레임이 늦다면 로컬 오디오 장치와 SDK 설정을 봅니다. 송신은 즉시 시작했는데 원격 재생이 늦다면 네트워크·구독·디코더 경로 문제입니다. 한 숫자만 보고 "AudioUnit이 stale하다"고 결론내리지 않습니다.


9. 발신/수신 통화 흐름 정리

            발신측                    수신측
              │                         │
   ① CXStartCallAction request          │
              │                         │
   ② perform CXStartCallAction          │
              │                         │
   ③ reportOutgoingCall.connecting   ④ VoIP push 도착
              │                         │
   ⑤ reportOutgoingCall.connected    ⑥ reportNewIncomingCall
              │                         │
              │              ┌──────────┴──────────┐
              │              │                     │
              │       ⑦ perform Answer       ⑧ perform End (거절)
              │              │                     │
              │              ▼                     │
              │         CallKit didActivate        │
              │         on both sides              │
              │              │                     │
              └──────────────┴─────────┐           │
                                       ▼           │
                            joinChannel + RTP      │
                                       │           │
                                       ▼           │
                            ⑨ end / endCallAction  │
                                       │           │
                                       ▼           ▼
                              perform CXEndCallAction

규칙:

  • 발신측 액션 트랜잭션은 CXCallController.requestTransaction으로 제출
  • 시스템이 검증 → CXProvider에 perform... 콜백 → 거기서 비즈니스 로직
  • 수신측은 reportNewIncomingCall로 시작
  • 양쪽 모두 오디오 장치 시작·재개는 didActivate와 동기화

10. 한 줄 결론 + 통합 체크리스트

CallKit 액션과 AVAudioSession 활성화 상태를 RTC 연결 상태와 따로 추적하고, 오디오 장치 시작·중지는 didActivate/didDeactivate에 맞춘다.

통합 체크리스트

□ Info.plist
   □ NSMicrophoneUsageDescription (빈 문자열 금지)
   □ NSCameraUsageDescription (영상 통화면)
   □ UIBackgroundModes: audio, voip

□ PushKit
   □ didReceiveIncomingPushWith 핸들러에서 즉시 reportNewIncomingCall
   □ completion handler 누락 없음

□ CallKit
   □ CXProvider 단일 인스턴스 유지
   □ CXProviderConfiguration의 supportsVideo / maximumCallGroups 설정
   □ performAnswerCallAction에서 서버 수락·채널 준비 후 fulfill
   □ didActivate에서 RTC 오디오 장치 시작 또는 재개
   □ didDeactivate에서 RTC 오디오 장치 일시 정지 또는 중지

□ AVAudioSession
   □ category: .playAndRecord, mode: .voiceChat
   □ defaultToSpeaker는 제품의 기본 출력 정책에 맞춰 선택
   □ CallKit과 SDK 중 AVAudioSession 구성 책임자를 명확히 지정
   □ interruption·routeChange·currentRoute 로그 수집

□ Agora 통합
   □ 현재 SDK의 공개 API 레퍼런스와 릴리스 노트 확인
   □ 필요한 범위에서만 setAudioSessionOperationRestriction 사용
   □ 비공개 setParameters 키는 제품 코드에 고정하지 않음
   □ join·first local frame·first remote frame 시각 기록

트러블 진단 흐름

                [VoIP 통합 이슈 보고]
                         │
                         ▼
              [언제 일어나나?]
            ┌────────────┼────────────┬────────────┐
        통화 시작     통화 도중      통화 종료     라우트 변경
            │            │             │            │
       수락 직후 무음   알람/전화      호스트 오디오   스피커/BT
            │       인터럽션       미복구            │
            ▼            ▼             ▼            ▼
      이벤트 시각      began/ended    세션 책임자     routeChange
      4종 비교       shouldResume    설정 비교       currentRoute

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.