문제
소리가 정상적으로 입, 출력되는지 테스트하기 위해선 오디오 인터페이스부터 기타까지 실제로 연결해야만 테스트 할 수 있는 환경이었고, 그 결과 비효율적인 작업이 반복되었습니다.
원인 분석
AudioManager가 너무 많은 역할을 하다보니 필연적으로 코드가 길어지게 되었습니다.
mark 주석으로 역할을 구분해두었지만 함수 내에서 다른 함수를 호출할 수 있어 사이드 이펙트 발생 위험이 존재했습니다.
또한 실제 장치를 연결하지 않고 테스트할 수 없는 환경이었습니다.
final class AudioManager {
static let shared = AudioManager()
private var cancellables = Set<AnyCancellable>()
// 사용 가능한 입력 장치 목록
var availableInputDevices: CurrentValueSubject<[AVAudioSessionPortDescription], Never> = .init([])
// 선택된 디바이스 정보
var currentInputDevice: CurrentValueSubject<AVAudioSessionPortDescription?, Never> = .init(nil)
var currentOutputDevice: CurrentValueSubject<AVAudioSessionPortDescription?, Never> = .init(nil)
private let session = AVAudioSession.sharedInstance()
private let engine = AVAudioEngine()
private init() {
// 각종 초기화 및 옵저빙 코드
}
// MARK: - 오디오 장치 선택
@discardableResult
func selectInputDevice(withName name: String) -> Bool { ... }
// MARK: - Audio Effect
func setReverbMix(_ value: Float) { ... }
// MARK: - Observe Notification
private func observeAudioSessionRouteChange() { ... }
// MARK: - Update State
private func updateAvailableInputDevices() { ... }
private func updateCurrentInputDevice() { ... }
private func updateCurrentOutputDevice() { ... }
// MARK: - Audio Setup
private func setupAudioSession() { ... }
// MARK: - Engine Control
private func startEngine() { ... }
// MARK: - Logging
private func logAvailableAudioDevices() { ... }
}
해결 과정
Audio 관리 객체 재설계
기존 AudioManager 클래스는 아래와 같이 많은 책임을 가지고 있었습니다.
- 오디오 세션 설정 및 라우팅 처리
- 오디오 엔진 설정 및 관리
- 입출력 장치 관리
- 오디오 이펙트 관리
- 로깅
따라서 추후 개발에 용이하도록, AuidoManager 객체를 SRP와 DIP를 고려하여 재설계하기로 했습니다.
재설계한 구조는 아래와 같습니다.
네이밍에 대한 고민
우선 일관되고 명확한 네이밍은 유지보수에 직접적인 영향을 준다고 생각했기 때문에,
객체의 역할을 분명하게 드러낼 수 있는 클래스명 선정을 가장 먼저 고민하게 되었습니다.
기존의 AudioManager라는 이름은 역할이 모호하다고 느껴졌고,
새로운 책임 분리에 맞는 이름을 고민하면서 Service, Manager, Controller, System, Environment, State 같은 다양한 네이밍 패턴을 후보로 떠올렸습니다.
그중에서도 이 객체는 상태와 행위 모두를 포괄하며, Swift에도 어울리는 이름으로 짓고 싶어 AudioEnvironment라는 이름으로 정하게 되었습니다.
하위 객체들은 Service와 Manager 중 어떤 것이 더 적합할까를 고민했습니다.
사실 아직까지도 두 용어의 경계가 명확하게 구분되지 않아 모호하게 느껴지긴 합니다.
하지만 각 객체가 어떤 기능의 상태를 관리하고 직접 동작을 수행하는 책임까지 포함하고 있기 때문에 ~Manager 라는 네이밍을 사용하기로 결정했습니다.
책임의 범위
객체를 책임 단위로 분리해야 한다는 점에는 동의하지만, 어디까지를 하나의 객체 책임으로 봐야 하는지는 항상 고민되는 지점입니다.
특히 이번 설계 과정에서는 Session 관리와 라우트 변경 처리를 각각 별도의 객체로 분리해야 할지에 대해 고민되었습니다.
하나의 객체가 단일 책임을 가져야 한다는 원칙은, 결국 변경에 강한 구조를 만들기 위함이라고 생각합니다.
만약 AudioSessionManager가 Session 설정과 라우트 변경에 대한 책임을 모두 갖고 있다면, 둘 중 하나의 기능만 수정하더라도 같은 객체 내부를 수정해야 하므로, 변경에 강하다고 보기 어려울 수 있습니다.
하지만 “변경에 강해야 하는 이유는 무엇인가?" 에 대해 다시 생각해보았습니다.
그 이유는 결국 유지보수에 용이하고, 더 안전한 코드를 만들기 위해서라고 생각합니다.
이러한 관점에서 보면, Session 설정과 라우트 변경 처리를 완전히 분리해서 두 개의 객체로 나누는 것이 진짜 유지보수에 더 유리한가?라는 질문이 남았습니다.
그리고 이 프로젝트의 규모에서는 두 책임을 완전히 분리해서 객체를 설계하는 것이 오버 엔지니어링일 수 있겠다는 생각이 들었습니다.
무엇보다 RouteChangeNotification과 CurrentRoute 자체가 AVAudioSession의 속성이라는 점을 고려했을 때,
이 둘을 완전히 다른 책임으로 보기보다는 하나의 객체에서 포괄적으로 다루는 것이 유지보수에 유리하며 자연스러운 구조라고 판단했습니다.
그리고 최종적으로 AudioSessionManager라는 하나의 객체 안에서 관리하도록 결정했습니다.
final class AudioSessionManager: AudioSessionManageable {
private var cancellables = Set<AnyCancellable>()
private let session = AVAudioSession.sharedInstance()
var availableInputsPublisher: AnyPublisher<[AudioPortDescription], Never> { ... }
var currentInputPublisher: AnyPublisher<AudioPortDescription?, Never> { ... }
var currentOutputPublisher: AnyPublisher<AudioPortDescription?, Never> { ... }
init() {
setupAudioSession()
}
private let routeChangePublisher = NotificationCenter.default
.publisher(for: AVAudioSession.routeChangeNotification)
.share() // 퍼블리셔 공유
private func setupAudioSession() {
try? session.setCategory(.playAndRecord, mode: .default, options: [
.mixWithOthers,
.allowBluetooth,
.allowBluetoothA2DP
])
try? session.setActive(true)
try? session.setInputGain(0.5)
}
}
AVAudioSessionPortDescription 래핑 프로토콜 추가
AudioSession의 availableInputs나 currentRoute 관련 정보를 UI에 표시하려면, 입출력 장치 정보를 다뤄야 했습니다.
하지만 이를 테스트하거나 UI를 미리 확인해보려면 가상의 입출력 장치를 만들 수 있어야 했고,
이를 위해 인터페이스를 분리해두는 것이 더 유연할 것이라고 판단했습니다.
그러나 실제로 사용되는 availableInputs나 currentRoute.outputs는 모두 AVAudioSessionPortDescription 타입으로 되어 있었고, 이 타입은 portName, uid, portType 등의 속성을 직접 초기화할 수 없기 때문에 Mocking에 어려움이 있었습니다.
이 문제를 해결하기 위해 AVAudioSessionPortDescription 대신, 해당 타입을 랩핑한 커스텀 프로토콜인 AudioPortDescription을 정의하여, 테스트 환경에서는 이를 대체할 수 있는 Mock 타입을 사용하도록 구조를 변경했습니다.
protocol AudioPortDescription {
/// wrapped for AVAudioSesionPortDesription.portName
var name: String { get }
/// wrapped for AVAudioSesionPortDesription.uid
var identifier: String { get }
/// wrapped for AVAudioSesionPortDesription.portType
var port: AVAudioSession.Port { get }
/// wrapped for AVAudioSesionPortDesription.dataSources
var dataSource: [AVAudioSessionDataSourceDescription]? { get }
}
extension AVAudioSessionPortDescription: AudioPortDescription {
var name: String {
return self.portName
}
var identifier: String {
self.uid
}
var port: AVAudioSession.Port {
return self.portType
}
var dataSource: [AVAudioSessionDataSourceDescription]? {
return self.dataSources
}
}
먼저 AVAudioSessionPortDescription 타입에서 필요한 정보들만 추출해 AudioPortDescription 프로토콜에 정의했습니다.
그리고 AVAudioSessionPortDescription를 확장하여 두 타입간에 변환이 용이하도록 했습니다.
AVAudioSessionPortDescription 대신 사용할 수 있는 MockAudioPortDescription 타입을 만들고,
struct MockAudioPortDescription: AudioPortDescription {
var name: String
var identifier: String
var port: AVAudioSession.Port
var dataSource: [AVAudioSessionDataSourceDescription]?
}
MockAudioSessionManager 에서 다음과 같이 MockAudioPortDescription 을 생성하여 AVAudioSessionPortDescription의 Mock 객체처럼 사용할 수 있었습니다.
final class MockAudioSessionManager: AudioSessionManaging {
var availableInputs: [AudioPortDescription] {
return [
MockAudioPortDescription(name: "Mock Input 1", identifier: "1", port: .builtInMic),
MockAudioPortDescription(name: "Mock Input 2", identifier: "2", port: .bluetoothHFP)
]
}
...
}
의존 구조는 아래와 같습니다.
실제 장치를 연결하지 않고 테스트할 수 있는 가상 환경 구성
기존 AudioManager를 사용하면서 가장 불편했던 점은,
소리가 정상적으로 입출력되는지를 테스트하려면 오디오 인터페이스부터 기타까지 직접 연결해야만 가능하다는 점이었습니다.
이러한 문제가 발생한 원인은 AudioEngine의 동작 방식에 따라InputNode -> (시그널 체인) ->MainMixerNode
흐름으로 구성되고 있기에 발생하는 문제였습니다.
문제의 핵심은 입력이 있어야만 테스트할 수 있다는 점이었고,
입력 부분을 가상의 입력으로 대체할 수 있다면 테스트 환경을 만들 수 있을 것이라 판단했습니다.
앞서 AudioEngine에 대한 책임을 AudioEngineManager라는 객체에 위임했기 때문에,
인터페이스를 분리하고 해당 객체를 Mocking함으로써 다음과 같은 구성의 AudioEngine을 만들 수 있었습니다
PlayerNode(음원 재생) -> (시그널 체인) ->MainMixerNode
이를 통해, 실제 입력 장치 없이도 미리 녹음된 음원을 통해 사운드 입출력을 테스트할 수 있는 가상 입력 기반 오디오 엔진 테스트 환경을 구축할 수 있었습니다.
class MockAudioEngineManager: AudioEngineManageable {
private var audioEngine: AVAudioEngine?
private var audioPlayerNode: AVAudioPlayerNode?
private var audioFile: AVAudioFile?
func setup() {
audioEngine = AVAudioEngine()
audioPlayerNode = AVAudioPlayerNode()
}
// 실제 Input 대신 Player로 가상 환경 구성
func start() {
guard let audioFileURL = Bundle.main.url(forResource: "CleanGuitarLoop", withExtension: "wav") else {
print("Audio file not found.")
return
}
do {
audioFile = try AVAudioFile(forReading: audioFileURL)
audioEngine?.attach(audioPlayerNode!)
audioEngine?.connect(audioPlayerNode!, to: audioEngine!.mainMixerNode, format: audioFile?.processingFormat)
audioPlayerNode?.scheduleFile(audioFile!, at: nil, completionHandler: nil)
try audioEngine?.start()
audioPlayerNode?.play()
} catch {
print("audio engine setting error: \(error.localizedDescription)")
}
}
}
'iOS' 카테고리의 다른 글
AVFAudio - AVAudioSession (1) | 2025.03.29 |
---|---|
MVC, MVVM, Clean Architecture 정리 (2) | 2025.03.14 |
nohup 명령어를 사용해도 서버가 꺼지는 문제 (2) | 2024.11.08 |
Tuist 없이 모듈 만들기 with DemoApp (0) | 2024.11.07 |
네이버 클라우드 VPC 서버에 연결이 되지 않는 문제 (0) | 2024.11.03 |