WebSocket 음성 스트리밍: PCM과 Speex로 사용자 음성을 보낸 방법
2022년 말 국내 시중은행의 AI 상담원 프로젝트에서 사용자의 목소리를 서버로 실시간 전송하는 구간을 개발했습니다. 사용자가 질문하면 서버가 음성을 처리하고, 화면 속 가상 은행원이 딥페이크 영상과 합성 음성으로 답하는 서비스였습니다.
이 글은 돌아오는 AI 음성이 아니라 서버로 올라가는 사용자 음성을 다룹니다. 당시 구조는 마이크 입력을 16비트 PCM으로 받고, Speex로 압축한 뒤 WebSocket 계열의 소켓 연결로 스트리밍하는 방식이었습니다.
마이크에서는 먼저 PCM이 나왔습니다
iOS에서는 AVAudioEngine의 입력 노드에 탭을 설치해 마이크 버퍼를 받을 수 있습니다. 당시 개발 노트에는 다음과 같은 코드가 남아 있습니다.
let audioEngine = AVAudioEngine()
let inputNode = audioEngine.inputNode
let format = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16_000,
channels: 1,
interleaved: false
)
inputNode.installTap(
onBus: 0,
bufferSize: 2_048,
format: format
) { buffer, time in
guard let samples = buffer.int16ChannelData?[0] else { return }
// PCM 복사, 프레임 재구성, Speex 인코딩과 전송
}Apple의 installTap은 지정한 버스의 오디오 버퍼를 콜백으로 관찰하는 API입니다. AVAudioPCMBuffer의 int16ChannelData는 포맷이 16비트 정수일 때 실제 샘플 포인터를 제공합니다.
당시 설정은 다음과 같았습니다.
| 항목 | 값 |
|---|---|
| 샘플 형식 | 16비트 정수 PCM |
| 샘플레이트 | 16 kHz |
| 채널 | 모노 |
| 인터리빙 | 비인터리브 |
| 요청 버퍼 크기 | 2,048 프레임 |
16 kHz에서 2,048 샘플은 계산상 약 128 ms입니다. 다만 installTap의 요청 버퍼 하나를 그대로 네트워크 패킷 하나로 보내면 된다는 뜻은 아닙니다.
캡처 버퍼와 코덱 프레임은 경계가 달랐습니다
Speex는 음성에 맞춘 손실 압축 코덱입니다. 공식 Speex 코덱 설명은 8 kHz, 16 kHz, 32 kHz 모드를 각각 협대역, 광대역, 초광대역으로 구분합니다. 이 프로젝트의 16 kHz 입력은 광대역 모드와 맞았습니다.
Speex API의 기본 인코딩 단위는 20 ms입니다. 16 kHz에서는 한 코덱 프레임이 320 샘플입니다. 그런데 마이크 탭의 요청 크기는 2,048 샘플이었습니다.
마이크 탭 요청 크기 2,048 samples
Speex 한 프레임 320 samples
비율 6.4 frames두 경계가 맞지 않습니다. 마이크 콜백에서 받은 PCM을 누적하고 320 샘플씩 잘라 인코더에 넘긴 뒤, 남은 샘플은 다음 콜백과 이어야 합니다. 캡처 버퍼, 코덱 프레임, WebSocket 메시지를 모두 같은 단위로 생각하면 샘플이 누락되거나 프레임 중간이 잘릴 수 있습니다.
현재 남아 있는 코드 조각은 PCM을 배열로 바꾸어 출력하는 초기 확인 단계까지만 보여줍니다. 최종 버퍼링 구현을 복원할 소스는 남아 있지 않습니다. 따라서 당시 큐의 크기나 오버플로 정책을 새로 지어내지는 않겠습니다. 다만 2,048과 320이라는 경계 차이는 기록만으로도 확인할 수 있습니다.
오디오 콜백에서는 가능한 한 빨리 빠져나와야 합니다
보관된 테스트 코드에는 Array(values)와 print가 들어 있었습니다. 마이크에서 샘플이 제대로 오는지 확인할 때는 편하지만, 이 코드를 그대로 실시간 전송 루프에 두면 좋지 않습니다.
오디오 콜백 안에서 배열을 반복 생성하고, 인코딩하고, 네트워크 전송까지 기다리면 다음 버퍼를 제때 처리하지 못할 수 있습니다. 실제 구조에서는 콜백이 필요한 만큼만 복사한 뒤 별도의 직렬 큐에서 다음 작업을 이어가는 편이 안전합니다.
Audio callback
-> PCM을 짧게 복사
-> 전용 큐에 전달
-> 프레임 단위로 재구성
-> Speex 인코딩
-> WebSocket binary message 전송여기서 큐를 무한히 늘려서도 안 됩니다. 네트워크가 마이크보다 느려진 상태에서 오래된 음성을 계속 쌓으면 전송은 이어지지만 대화는 점점 과거로 밀립니다. 실시간 음성에서는 모든 데이터를 보존하는 것보다 지연 상한을 정하고 실패를 화면에 알리는 편이 더 중요할 수 있습니다.
WebSocket은 음성 파일이 아니라 프레임을 보냈습니다
일반 HTTP 업로드는 녹음이 끝난 파일을 보내기에 잘 맞습니다. 이 프로젝트는 사용자가 말하는 동안 음성을 계속 흘려보내야 했습니다. 연결을 유지한 채 양쪽이 독립적으로 메시지를 보낼 수 있는 소켓 구조가 필요했습니다.
WebSocket RFC 6455는 텍스트 프레임과 바이너리 프레임을 구분합니다. Speex로 인코딩한 음성은 문자열로 바꿀 이유가 없는 바이너리 데이터입니다. 오디오를 binary message로 보내고, 연결 제어나 서버 상태처럼 사람이 읽을 수 있는 작은 메시지는 별도 형식으로 나누는 구성이 자연스럽습니다.
당시 기록에는 Swift 소켓 라이브러리 이름이 정확하게 남아 있지 않습니다. 그래서 특정 라이브러리를 사용했다고 단정하지 않았습니다. 확인 가능한 사실은 PCM을 Speex로 바꾸고, 소켓 통신을 통해 스트리밍 방식으로 서버에 전송했다는 것입니다.
WebSocket과 WebRTC는 서로 다른 방향을 맡았습니다
이 프로젝트를 짧게 설명하면 Speex + 소켓 + WebRTC가 한 덩어리처럼 보입니다. 실제 데이터 흐름에서는 역할이 나뉘었습니다.
| 방향 | 데이터 | 기술의 역할 |
|---|---|---|
| 앱에서 서버 | 사용자의 질문 음성 | PCM 수집, Speex 압축, 소켓 스트리밍 |
| 서버에서 앱 | 딥페이크 영상과 합성 음성 | WebRTC 미디어 전달과 앱 재생 |
Speex를 WebRTC의 음성 코덱으로 사용했다는 뜻이 아닙니다. WebRTC 표준의 필수 음성 코덱은 Opus와 G.711 계열입니다. 이 프로젝트의 Speex는 WebRTC 미디어 트랙이 아니라 별도의 사용자 음성 업로드 경로에 있었습니다.
끊긴 연결을 다시 여는 것만으로는 부족했습니다
실시간 음성에서 재연결은 단순히 소켓을 다시 여는 문제가 아닙니다. 연결이 끊어진 동안 쌓인 오래된 음성을 보낼지 버릴지, 서버가 이전 발화를 어디까지 받았는지, 사용자가 다시 질문해야 하는지를 함께 결정해야 합니다.
제가 공개 포트폴리오에 남긴 담당 범위에는 연결이 불안정할 때의 화면 처리도 포함돼 있습니다. 내부 인증 방식과 메시지 규격, 실제 재시도 횟수는 공개 글에서 다루지 않습니다. 금융사 프로젝트의 내부 경로나 보안 설정을 포트폴리오를 위해 노출할 이유가 없기 때문입니다.
남은 기록으로 다시 살펴보니 이 작업의 핵심은 특정 라이브러리가 아니었습니다. 마이크가 만드는 PCM의 시간, Speex가 요구하는 프레임의 시간, 네트워크가 감당하는 전송 시간을 한 흐름으로 맞추는 일이었습니다. 음성 파일을 보내는 기능과 사용자가 말하는 지금을 보내는 기능의 차이는 그 경계에서 생겼습니다.
함께 읽기
- WebRTC 기반 AI 상담원: 딥페이크 영상 응답을 앱에서 재생한 방법2022년 말 국내 시중은행의 AI 상담원 프로젝트에 참여했습니다. 사용자가 앱에 말을 하면 서버가 질문을 처리하고, 화면 속 가상 은행원이 딥페이크 영상과 합성 음성으로 답하는 서비스였습니다. 제가 맡은 범위는 사용자의 음성을 실시간으로 보내고, 돌아온 영상 응답을 앱에서 재생하는 구간이었습니다.
- Expo OTA 배포: Git 푸시와 프로덕션 릴리스를 분리한 이유앱 아이콘을 바꾸고 커밋하려다가 아주 기본적인 질문 앞에서 멈췄습니다.
- React Native Debug·Release 구성 차이: No script URL provided 가 뜨는 이유앱 두 개를 아이폰에 올려놓고 하나를 열었더니 빨간 화면에 이 문구가 떴습니다.
- Liquid Glass 아이콘 직접 만들기: .icon 파일 구조와 librsvg 함정앱 아이콘 하나 만드는 데 하루를 썼습니다. 열 번 넘게 갈아엎었고 빌드를 네 번 중간에 끊었습니다. 그 과정에서 알아낸 것 중에 검색해도 잘 안 나오는 게 두 개 있어서 적어둡니다.
- Expo 앱을 통째로 복사해 새 프로젝트 만들기: 그대로 가는 파일과 고쳐야 하는 파일성경 지도라는 앱을 만들면서 npx create-expo-app을 치지 않았습니다. 이미 두 스토어에 배포되고 있는 다른 앱의 폴더를 통째로 복사해서 시작했습니다.