블로그 목록
Telephony25분 읽기

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

CallKit을 붙였더니 수신은 되는데 처음 3초가 무음이고, 통화 중 알람 한 번에 마이크가 죽는다면 원인은 한 곳에 있습니다. CallKit의 진짜 역할은 통화 UI를 그리는 게 아니라 오디오 세션을 언제 켤지 결정하는 권한을 시스템이 가져가는 것이기 때문입니다. 이 글은 PushKit, CallKit, AVAudioSession, Agora ADM이 어떻게 얽히는지 4개 레이어로 정리하고, didActivate에서 채널에 들어가고 didDeactivate에서 ADM을 재시작하는 원칙으로 무음과 인터럽트, 멀티앱 오디오 누수까지 여섯 가지 실전 케이스를 풀어냅니다.

iOSCallKitPushKitAVAudioSessionVoIPADMAudioUnitAgoraSwift
목차(42개 항목)
  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 (PiP) 모드의 특이성
  7. 6. 통합 지점 — `didActivate` / `didDeactivate`가 모든 답
    1. CXProviderDelegate 메서드 (정확한 시그니처)
    2. 왜 `didActivate` 전에 `joinChannel` 부르면 안 되나
    3. `didDeactivate`의 역할
  8. 7. 6가지 트러블 케이스 — 진단과 처방
    1. Case 1: 시스템 알람이 Agora 오디오를 끊는다
    2. Case 2: 시스템 전화 배너가 Agora를 끊는다
    3. Case 3: Agora 통화 종료 후 3rd party 오디오 미복구 (Unity 케이스)
    4. Case 4: ChatMoa 전화 후 두 앱 오디오가 같은 룸으로 새는 이슈 (iOS 15/16)
    5. Case 5: 시스템 전화 종료 후 Agora 오디오 미복구
    6. Case 6: CallKit 스피커 버튼이 자꾸 수화기로 풀린다
  9. 8. 3초 무음의 진짜 원인 — Stale AudioUnit
    1. 흔한 (하지만 부정확한) 설명
    2. 실제 메커니즘
    3. 올바른 처방
  10. 9. 발신/수신 통화 흐름 정리
  11. 10. 한 줄 결론 + 통합 체크리스트
    1. 통합 체크리스트
    2. 트러블 진단 흐름
  12. 관련 글
  13. 참고 자료

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

흐름:

  1. PushKit이 VoIP 푸시를 받아 앱 깨우기
  2. 앱은 즉시 CallKit에 reportNewIncomingCall (의무)
  3. 사용자가 "수락" → CallKit이 AVAudioSession을 활성화 → didActivate 콜백
  4. 그제서야 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는 앱 전체에서 단일 인스턴스로 유지하는 게 권장 패턴입니다(프레임워크가 강제하진 않음).

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에 사용자 ID/닉네임을 넣습니다. iOS는 이 값으로 통화 기록을 관리하며, 차단 목록에 있으면 자동 차단합니다.

시스템에 통화 도착 신고

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 푸시 미배달 (재설치로 복구된다는 보고가 있으나 [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 통화의 표준 설정

let session = AVAudioSession.sharedInstance()

do {
    try session.setCategory(
        .playAndRecord,
        mode: .voiceChat,
        options: [.allowBluetooth, .defaultToSpeaker]
    )
    // ⚠️ CallKit 통합 환경에서는 setActive(true)를 직접 호출하지 않음
    // CallKit이 활성화 타이밍을 가져가므로, didActivate에서 수신
} catch {
    print("AVAudioSession setup failed: \(error)")
}

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 필수 키

<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는 마이크/카메라 사용 중 상태바에 인디케이터를 띄웁니다.

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

자주 틀리는 단순화: "주황=마이크, 초록=카메라"는 절반만 맞습니다. 마이크만 쓰면 항상 주황. 카메라가 켜진 순간(마이크 동반 여부 무관) 초록으로 승급. 이 보안 모델은 회피 시도가 곧 앱 거부 사유입니다.


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 메서드 (정확한 시그니처)

extension CallController: CXProviderDelegate {
    
    func providerDidReset(_ provider: CXProvider) { /* 정리 */ }
    
    // 발신 시작
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        action.fulfill()
    }
    
    // 수신 수락 — 여기서 joinChannel 호출하면 안 됨!
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // 채널 정보만 보관, 실제 join은 didActivate에서
        pendingChannelId = action.callUUID.uuidString
        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 활성화 완료 — 여기서 joinChannel
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        agoraKit.setEnableSpeakerphone(true)
        agoraKit.joinChannel(byToken: token, channelId: channel, info: nil, uid: 0)
    }
    
    // ✅ 시스템이 세션 비활성화 — Agora 정리 + ADM 강제 재시작 준비
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        agoraKit.disableAudio()
        agoraKit.enableAudio()
    }
}

didActivate 전에 joinChannel 부르면 안 되나

CallKit이 활성화 권한을 점유하는데, Agora가 먼저 ADM을 시작하면 두 가지 상태가 충돌합니다.

[잘못된 순서 — 처음 3초 무음]

t=0   사용자 "수락"
t=0+  performAnswerCallAction 콜백
t=0+  agoraKit.joinChannel ← ❌ 너무 빠름. AVAudioSession 아직 비활성
t=1   ADM이 stale AudioUnit으로 캡처 시도 → 0 sample
t=2   시스템이 didActivate 콜백
t=2~5 ADM 워치독이 0 sample 감지 → AudioUnit 재할당
t=5   드디어 음성 흐름

[올바른 순서]

t=0   사용자 "수락"
t=0+  performAnswerCallAction → action.fulfill()만
t=1   시스템이 didActivate 콜백
t=1+  agoraKit.joinChannel ← ✅ 깨끗한 AudioUnit 위에서 시작
t=1+  즉시 음성 흐름

didDeactivate의 역할

시스템 통화로 인터럽트되거나 통화 종료 후 시스템이 세션을 해제할 때 호출됩니다. 여기서 disableAudio()enableAudio() 콤보로 ADM을 강제 재시작 준비 상태로 두면, 다음 통화 진입 시 stale AudioUnit 이슈를 예방할 수 있습니다.


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

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

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

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

메커니즘: 시스템이 AVAudioSession.interruptionNotification.began/.ended를 보내지만, Agora ADM은 기본 설정에서 .ended 시 자동 재시작을 하지 않음.

처방joinChannel 전에 private parameter 활성화:

agoraKit.setParameters("{\"che.audio.restartWhenInterrupted\":true}")
agoraKit.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0)

[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+):

if #available(iOS 14.5, *) {
    try? AVAudioSession.sharedInstance().setCategory(
        .playAndRecord,
        mode: .default,
        options: [
            .allowAirPlay,
            .allowBluetooth,
            .allowBluetoothA2DP,
            .defaultToSpeaker,
            .interruptSpokenAudioAndMixWithOthers,
            .overrideMutedMicrophoneInterruption   // ← 핵심 1
        ]
    )
    try? AVAudioSession.sharedInstance()
        .setPrefersNoInterruptionsFromSystemAlerts(true)  // ← 핵심 2
}

중요한 뉘앙스: 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):

// 옵션 A: 세션 deactivate만 막기 (권장)
agoraKit.setAudioSessionOperationRestriction(.deactivateSession)

// 옵션 B: SDK가 AVAudioSession을 일절 건드리지 않게 (강력)
agoraKit.setAudioSessionOperationRestriction(.all)

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 권장 패턴 — 상태 토글:

// joinChannel 직전: SDK 권한 복구 (캡처/재생 정상 시작 보장)
agoraKit.setAudioSessionOperationRestriction(.none)
agoraKit.joinChannel(byToken: token, channelId: ch, info: nil, uid: 0)

// leaveChannel 직전: SDK 제한 (호스트 앱이 세션 회수)
agoraKit.setAudioSessionOperationRestriction(.all)
agoraKit.leaveChannel(nil)

대안 — private parameter 패턴:

// 동일 효과의 private parameter (공식 KB에 언급)
agoraKit.setParameters("{\"che.audio.keep.audiosession\":true}")

che.audio.keep.audiosession.deactivateSession 제한과 사실상 같은 동작을 합니다. 다만 Category/mode 전환은 AudioSession 재시작에 의존하므로, 함부로 켜면 세션 전환 시 음성 이상이 발생할 수 있습니다. 공식 권장은 enum 기반 공개 API.

보너스 — che.audio.specify.category: 마이크 미사용 구간에서 SDK가 어떤 카테고리를 쓸지 명시:

agoraKit.setParameters("{\"che.audio.specify.category\": 3}")
// 1=SoloAmbient, 2=Ambient, 3=Playback, default=PlayAndRecord

.setCategory 제한과 함께 쓰면 호스트가 별도 코드 없이도 캡처 OFF 시점에 .playback으로 자동 전환 가능. Agora 측에서 권장하는 패턴입니다.

Case 4: ChatMoa 전화 후 두 앱 오디오가 같은 룸으로 새는 이슈 (iOS 15/16)

증상:

1. Talkito(Agora 기반) 통화 진행 중
2. ChatMoa로 전화 옴 → 사용자 수락
3. ChatMoa 활성, Talkito는 백그라운드
4. 사용자가 ChatMoa를 백그라운드로 보내고 Talkito 복귀
5. Agora 룸에서 원격 사용자가 → 로컬 사용자 음성 + ChatMoa 음성 양쪽을 모두 들음

iOS 14에서는 Talkito 포그라운드/백그라운드 무관하게 두 앱 음성이 공유됨.

메커니즘: 기본 mixable 세션 옵션 때문에 Talkito와 ChatMoa가 같은 입력 캡처를 공유하는 상태.

처방:

agoraKit.setParameters("{\"che.audio.nonmixable.option\":true}")
agoraKit.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0)

[NEEDS VERIFICATION] private parameter. nonmixable 세션 옵션을 강제해 Agora가 재활성화 시 다른 앱의 오디오를 정상적으로 인터럽트하게 함.

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

증상: 시스템 전화 → 종료 → Talkito 복귀했는데 마이크가 죽어 있음.

메커니즘: 보통은 자동 복구되지만, 일부 케이스에서 ADM이 stale AudioUnit 잡고 있음 → 0 sample → Agora 워치독이 못 잡는 경우.

처방 — ADM 강제 재시작:

// 옵션 A: 공개 API
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
    agoraKit.disableAudio()
    agoraKit.enableAudio()
}

// 옵션 B: private parameter (Case 1과 동일)
agoraKit.setParameters("{\"che.audio.restartWhenInterrupted\":true}")

disableAudioenableAudio 시퀀스가 ADM을 완전히 죽였다 살리는 강제 재시작 콤보입니다. 마치 "오디오 서브시스템 다시 켜기".

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

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

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

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

사용자가 시스템 스피커 버튼 → CallKit이 라우트를 스피커로 변경 → Agora SDK의 주기적 세션 검사가 자기 기본값으로 덮어쓰기 → 다시 earpiece로 풀림.

처방joinChannel 직전에 두 가지를 같이 적용:

func joinChannel() {
    // ① 앱이 직접 카테고리 설정 (.allowBluetoothA2DP가 핵심)
    try? AVAudioSession.sharedInstance().setCategory(
        .playAndRecord,
        mode: .voiceChat,
        options: [.mixWithOthers, .allowBluetoothA2DP]
    )
    
    // ② SDK가 위 설정을 덮어쓰지 못하도록 제한
    agoraKit.setAudioSessionOperationRestriction(.configureSession)
    
    // ③ 그 뒤에 채널 조인 — 순서가 바뀌면 효과 없음
    agoraKit.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0)
}

.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 플래그가 살아있다"는 명시적 상태는 없음.

실제 메커니즘

t=0  ChatMoa 통화로 인터럽트 시작
     → AVAudioSession이 비활성화됨
     → 그 시점 Agora가 잡고 있던 AudioUnit 인스턴스가 invalidated

t=N  ChatMoa 통화 종료
     → 시스템이 우리 앱의 세션을 다시 활성화
     → didActivate 콜백 도착

t=N+ 만약 Agora ADM이 여전히 invalidated AudioUnit 참조를 잡고 있으면
     → 캡처 콜백에서 0 sample 반환 (실제로 데이터가 안 들어옴)
     → 송신측 Opus 인코더에 무음만 흘러감

t=N+ 몇 초 후
     → ADM의 헬스체크가 "왜 sample이 안 들어오지?" 감지
     → 새 AudioUnit 할당 + 재시작
     → 음성 정상화

올바른 처방

func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
    // 시스템이 세션을 거둘 때 ADM도 명시적으로 정리
    agoraKit.disableAudio()
    agoraKit.enableAudio()
}

disableAudio로 stale AudioUnit을 명시적으로 해제하고, enableAudio로 다음 활성화 사이클에 새로 할당받을 준비를 합니다. 워치독의 자동 복구를 기다리지 않으므로 즉시 음성 흐름.


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은 UI 프레임워크가 아니라 AVAudioSession 활성화 권한 점유자다. Agora ADM은 didActivate를 기다려야 하고, didDeactivate에서 강제 재시작을 준비해야 한다.

통합 체크리스트

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

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

□ CallKit
   □ CXProvider 단일 인스턴스 유지
   □ CXProviderConfiguration의 supportsVideo / maximumCallGroups 설정
   □ performAnswerCallAction에서 joinChannel 부르지 않기
   □ didActivate 콜백에서 joinChannel 부르기
   □ didDeactivate에서 disableAudio() → enableAudio()

□ AVAudioSession
   □ category: .playAndRecord, mode: .voiceChat
   □ defaultToSpeaker는 영상 통화에만
   □ CallKit 환경에선 setActive(true) 직접 호출 금지

□ Agora 트러블 처방 (필요 시)
   □ che.audio.restartWhenInterrupted (시스템 알람 인터럽트)
   □ setAudioSessionOperationRestriction(.deactivateSession 또는 .all) (3rd party 오디오 보존)
   □ che.audio.nonmixable.option (멀티앱 오디오 누수 방지)
   □ setCategory + .allowBluetoothA2DP + .configureSession (CallKit 스피커 버튼 풀림)
   □ overrideMutedMicrophoneInterruption + setPrefersNoInterruptionsFromSystemAlerts (iOS 14.5+)

□ Agora 권장 토글 패턴
   □ joinChannel 직전: setAudioSessionOperationRestriction(.none) — SDK 권한 복구
   □ leaveChannel 직전: setAudioSessionOperationRestriction(.all) — SDK 권한 제한

트러블 진단 흐름

                [VoIP 통합 이슈 보고]
                         │
                         ▼
              [언제 일어나나?]
            ┌────────────┼────────────┬────────────┐
        통화 시작     통화 도중      통화 종료     멀티앱 동시
            │            │             │            │
       3초 무음       알람/전화      3rd party     ChatMoa 누수
            │       인터럽트       오디오 죽음        │
            ▼            ▼             ▼            ▼
      didActivate     restart       keep.       nonmixable
      에서 join      WhenInterrupt   audiosession    .option
            │            │             │            │
            │       (Case 1, 2)    (Case 3)      (Case 4)
            ▼
       (3초 무음 §8)

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.