iOS 오디오 중단과 출력 경로 변경을 상태 머신으로 테스트하기

iOS 오디오 중단과 출력 경로 변경을 상태 머신으로 테스트하기

클라우드 Mac에서 플레이어 자동화 테스트가 모두 통과해도, 사용자가 이어폰을 뽑은 뒤 스피커로 재생이 계속될 수 있습니다. 문제는 디코더가 아니라 ‘출력 경로 변경을 받았을 때 무엇을 할지’를 별도로 테스트하지 않은 데 있을 수 있습니다. 시스템 오디오 중단도 마찬가지입니다. 중단이 끝났다고 해서 사용자가 재생을 원한다는 뜻은 아닙니다. 시스템 알림과 재생 결정을 분리하면 이어폰이 없는 빌드 노드에서도 이 로직을 안정적으로 검사할 수 있습니다.

먼저 이벤트와 기대 동작 정의하기

알림 처리 코드를 작성하기 전에 제품 규칙부터 정합니다. 이 글에서는 보수적인 규칙을 사용합니다. 재생 중 오디오 중단이 시작되면 일시 정지하고, 그전에 재생 중이었다는 사실을 기록합니다. 중단이 끝났을 때 재개가 허용된 경우에만 그 기록에 따라 재개합니다. 중단 중 사용자가 직접 일시 정지를 누르면 기존의 재개 의도는 취소해야 합니다. 출력 장치가 분리되면 즉시 일시 정지하며, 새 경로의 이름만 보고 사용자가 스피커로 계속 듣기를 원하는지 추측하지 않습니다.

입력 이벤트 재생 상태 재개 의도
재생 중 오디오 중단 시작 일시 정지 직전에 재생 중이었음을 기록
오디오 중단 종료 및 재개 허용 재개 의도에 따라 결정 지움
사용자가 직접 일시 정지 일시 정지 지움
이전 출력 장치를 사용할 수 없음 일시 정지 지움

‘이전 출력 장치를 사용할 수 없음’은 이벤트의 원인이지, 모든 출력 경로 변경을 통칭하는 말이 아닙니다. 예를 들어 이미 연결된 다른 장치로 전환하는 상황에 제품 차원의 결정 없이 이어폰 분리 규칙을 적용해서는 안 됩니다.

순수 상태 머신으로 재생 결정 분리하기

알림 파싱은 어댑터 계층에 두고, 핵심 로직에는 명확한 이벤트만 전달합니다. 아래 타입은 앱 타깃에 배치하고 테스트 타깃에서 가져다 쓸 수 있습니다. isPlaying은 앱이 의도하는 재생 상태를 나타냅니다. 플레이어를 실제로 조작하는 부수 효과는 바깥 계층에서 상태 변화에 따라 실행해야 합니다.

enum AudioEvent {
    case interruptionBegan
    case interruptionEnded(shouldResume: Bool)
    case outputDisconnected
    case userPaused
}

struct PlaybackState {
    var isPlaying = false
    var resumeAfterInterruption = false

    mutating func apply(_ event: AudioEvent) {
        switch event {
        case .interruptionBegan:
            resumeAfterInterruption = isPlaying
            isPlaying = false
        case .interruptionEnded(let shouldResume):
            isPlaying = resumeAfterInterruption && shouldResume
            resumeAfterInterruption = false
        case .outputDisconnected, .userPaused:
            isPlaying = false
            resumeAfterInterruption = false
        }
    }
}

이 예시는 한 차례의 오디오 중단만 다룹니다. 앱이 여러 재생 세션을 동시에 처리하거나 일시 정지 후에도 조작할 수 있는 큐가 있다면, 먼저 상태 정의를 확장한 뒤 이벤트의 순서를 결정해야 합니다. 불리언 값 하나에 모든 세션의 의미를 맡기지 마세요.

시스템 알림을 이벤트로 변환하기

어댑터 계층에서 AVAudioSession.interruptionNotification을 수신하고 userInfo에서 중단 유형을 읽습니다. 중단이 끝나면 재개 옵션을 읽어 그 결과를 상태 머신에 전달합니다. AVAudioSession.routeChangeNotification을 수신할 때는 원인을 확인하고, oldDeviceUnavailable만 outputDisconnected로 변환합니다. 필드가 없거나 인식할 수 없다면 기록해 두고 조사해야 하며, 임의로 ‘재개 허용’으로 처리해서는 안 됩니다.

알림 콜백과 UI 조작은 서로 다른 실행 컨텍스트에서 발생할 수 있습니다. 이벤트를 동일한 상태 관리자에게 직렬로 전달한 다음, 변경 전후의 상태에 따라 일시 정지 또는 재생을 실행해야 합니다. 그래야 ‘사용자의 일시 정지’와 ‘오디오 중단 종료’가 동시에 발생했을 때 콜백 도착 순서 때문에 사용자 의도가 뜻하지 않게 덮어써지는 일을 막을 수 있습니다.

빌드 노드에서 경계 조건 테스트하기

먼저 XCTest로 놓치기 쉬운 두 경로를 확인합니다. 중단 전 재생 중이 아니었다면 저절로 재생을 시작하지 않아야 하고, 중단 중 사용자가 일시 정지했다면 자동으로 재개하지 않아야 합니다. 이 테스트에는 실제 이어폰이 필요하지 않으며, 시스템이 특정 시점에 알림을 보낼 필요도 없습니다.

import XCTest

final class PlaybackStateTests: XCTestCase {
    func testInterruptionDoesNotStartIdlePlayer() {
        var state = PlaybackState()
        state.apply(.interruptionBegan)
        state.apply(.interruptionEnded(shouldResume: true))
        XCTAssertFalse(state.isPlaying)
    }

    func testUserPauseCancelsResume() {
        var state = PlaybackState(isPlaying: true)
        state.apply(.interruptionBegan)
        state.apply(.userPaused)
        state.apply(.interruptionEnded(shouldResume: true))
        XCTAssertFalse(state.isPlaying)
    }

    func testDisconnectedOutputStopsPlayback() {
        var state = PlaybackState(isPlaying: true)
        state.apply(.outputDisconnected)
        XCTAssertFalse(state.isPlaying)
        XCTAssertFalse(state.resumeAfterInterruption)
    }
}

클라우드 Mac에서는 먼저 xcodebuild -list -project App.xcodeproj로 프로젝트와 Scheme을 확인한 뒤, 팀에서 실제로 공유하는 Scheme을 사용해 xcodebuild test -project App.xcodeproj -scheme App -destination 'platform=iOS Simulator,name=<已安装的设备名称>'를 실행합니다. 예시의 프로젝트 이름, Scheme, 장치 이름은 바꿔야 합니다. 현재 사용 가능한 장치는 xcrun simctl list devices available로 확인할 수 있습니다. 테스트 타깃에서 상태 머신 타입에 접근할 수 있어야 하고, Scheme에도 해당 테스트 타깃이 포함되어야 합니다.

단위 테스트 통과는 주어진 이벤트 순서에서 기대한 결정이 나왔다는 뜻일 뿐입니다. 시스템 알림이 반드시 전달된다거나 실제로 어느 장치에서 소리가 출력되는지를 증명하지는 못합니다.

실제 기기에서 통합 동작 검증하기

실제 기기 검증에는 최소한 네 단계를 포함합니다. 재생 중 이어폰을 뽑아 스피커가 뜻하지 않게 재생을 이어받지 않는지 확인합니다. 제어 가능한 시스템 오디오 중단을 한 번 발생시켜 시작 및 종료 알림을 관찰합니다. 중단 중 수동으로 일시 정지하고, 중단이 끝난 뒤에도 일시 정지 상태가 유지되는지 확인합니다. 출력 장치를 다시 연결하고, 연결에 성공했다는 이유만으로 앱이 재생을 시작하지 않는지 확인합니다. 수신한 이벤트 유형, 원인, 최종 재생 상태만 기록하고 사용자 콘텐츠나 접근 자격 증명은 로그에 남기지 마세요.

단위 테스트가 통과했는데도 실제 기기에서 문제가 발생한다면, 먼저 세션의 수명 주기 동안 알림 수신이 유효한지 확인합니다. 이어서 알림 필드 파싱, 이벤트 전달 순서, 플레이어의 실제 상태가 PlaybackState와 동기화되는지 검사합니다. 특히 ‘상태 머신이 일시 정지를 요구했다’와 ‘플레이어가 실제로 일시 정지를 완료했다’를 구분해야 합니다. 전자는 테스트로 보장할 수 있지만, 후자는 어댑터 계층 검사와 실제 기기 검증이 필요합니다.

병합 절차에 확인 항목 남기기

병합 전에는 상태 머신 테스트가 유휴 상태, 오디오 중단, 사용자의 일시 정지, 장치 분리를 다루는지 확인합니다. 또한 알림 어댑터 계층이 알 수 없는 필드를 보수적으로 처리하는지, 모든 이벤트가 동일한 직렬 실행 경로로 들어가는지, 팀에서 대상 기기 검증 결과를 최소 한 번 기록했는지도 확인합니다. 클라우드 Mac은 결정적인 상태 테스트를 반복 실행하기에 적합하지만, 실제 오디오 출력 경로는 여전히 기기에서 관찰한 결과를 기준으로 판단해야 합니다. 두 검증 단계를 분리해야 실패했을 때 제품 규칙, 알림 변환, 소리 출력 자체 중 어디를 조사할지 알 수 있습니다.

자주 묻는 질문

시뮬레이터 단위 테스트만으로 이어폰 분리 동작을 확인할 수 있나요?

아니요. 주입한 이벤트에 대한 앱의 판단은 확인할 수 있지만 실제 알림 전달과 출력 경로는 기기에서 검증해야 합니다.

오디오 중단이 끝나면 항상 재생을 재개해야 하나요?

아니요. 중단 전 재생 중이었고 재개가 허용되며 사용자가 그사이 직접 일시 정지하지 않았는지 확인해야 합니다.

UDPMac 클라우드 Mac

다음 Xcode 빌드는 전용 물리 서버에서 실행하세요.

Apple Silicon 모델, 노드, 이용 기간을 선택하고 원격으로 연결해 원하는 빌드 워크플로를 실행하세요. 실제 이용 가능 여부는 주문 시 콘솔에 표시되는 정보를 기준으로 합니다.

클라우드 Mac 이용 플랜 보기