
이전 글에서는 브라우저에서 마이크 입력을 PCM 청크로 변환하는 과정을 정리했다. 이번 글에서는 배포 후 HTTPS를 붙였을 때 왜 Socket.IO 연결이 실패했는지, 그리고 어떻게 해결했는지 정리해보려고 한다.
내가 만든 흐름은 단순하다.
로컬 환경에서는 이 과정이 문제없이 동작했다.
그런데 배포 후 HTTPS를 붙이고 나서부터 소켓 연결이 실패하기 시작했다.
처음에는 SSL 인증서나 서버 CORS 설정 문제라고 생각했다.
하지만 실제 원인은 조금 다른 곳에 있었다.
브라우저 콘솔에는 대략 이런 식의 에러가 찍혔다.
Access to XMLHttpRequest at 'https://.../socket.io/?...'
from origin 'https://...' has been blocked by CORS policy
처음에는 조금 이상했다.
소켓 연결 문제라고 생각했는데, 에러 메시지에는 WebSocket이 아니라 XMLHttpRequest가 보였기 때문이다.
왜 소켓 연결인데 XHR이 등장하는지부터 다시 봐야 했다.
원인은 Socket.IO의 기본 연결 방식이었다.
Socket.IO는 기본적으로 처음부터 WebSocket으로만 연결하지 않는다.
먼저 HTTP long-polling으로 연결을 시작한 뒤, 가능하면 WebSocket으로 업그레이드하는 구조를 사용한다.
즉, 내부적으로는 이런 흐름이다.
polling으로 연결 시작 → 가능하면 websocket으로 업그레이드
문제는 이 초기 polling 요청이 HTTP 요청이라는 점이다.
브라우저 입장에서는 이 요청이 XMLHttpRequest로 처리되고, 여기서 CORS 영향을 받게 된다.
즉, "WebSocket 연결이 안 된다"가 아니라 정확히는 WebSocket까지 도달하기 전에 polling 요청이 먼저 막힌 상황이었다.
해결은 의외로 단순했다.
Socket.IO가 polling을 거치지 않고 처음부터 WebSocket만 사용하도록 강제하면 됐다.
const socket = io(SOCKET_URL, {
transports: ['websocket'], // polling 없이 바로 WebSocket으로 연결
reconnection: true,
reconnectionAttempts: 5,
});
이 옵션을 주면 Socket.IO는 polling 단계를 건너뛰기 때문에 문제였던 초기 XHR 요청 자체가 발생하지 않는다.
다만 이걸 CORS 문제를 무조건 해결하는 만능 옵션으로 보면 안 된다.
서버 쪽 Origin 설정이 올바르다는 전제에서, 내 경우에는 문제를 일으키던 polling 경로를 제거해 해결한 셈이다.
이번 서비스는 실시간 오디오 데이터를 계속 스트리밍해야 하는 구조였다.
즉, 어차피 최종적으로 필요한 건 지속적인 WebSocket 연결이었다.
이런 상황에서 polling은 큰 의미가 없었다.
그래서 이 프로젝트에서는 transports: ['websocket']으로 시작하는 쪽이 훨씬 적합했다.
이 부분도 한 번 헷갈렸다.
HTTP에서 HTTPS로 바뀌었으니 ws://를 wss://로 직접 바꿔야 하나 싶었는데, 일반적으로 HTTPS 기반의 소켓 서버 주소를 사용하면 secure WebSocket(wss)로 연결된다.
즉, 문자열을 직접 바꾸는 방식보다는 배포 환경에 맞는 소켓 서버 주소를 올바르게 설정하는 것이 더 중요하다.
URL을 억지로 조작하기보다, 클라이언트가 바라보는 소켓 주소 자체를 명확하게 관리하는 편이 훨씬 안전했다.
연결 문제를 해결하고 나서도 한 가지 더 고려할 점이 있었다.
마이크에서 받아온 PCM 데이터는 ArrayBuffer, 즉 바이너리 데이터라는 점이다.
처음에는 이 값을 그대로 emit하면 될 것 같았다.
하지만 현재 백엔드에서는 netty-socketio 2.0.9를 사용하고 있었고, 이 환경에서는 바이너리 프레임이 안정적으로 처리되지 않았다.
그래서 지금은 PCM 청크를 Base64 문자열로 변환해서 보내는 방식을 사용하고 있다.
const sendAudio = useCallback((pcmData: ArrayBuffer) => {
const bytes = new Uint8Array(pcmData);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
socketRef.current?.emit('stt:audio', btoa(binary));
}, []);
서버에서는 이 값을 다시 Base64 디코딩해서 사용한다.
물론 Base64로 감싸면 순수 바이너리 전송보다 효율은 조금 떨어진다.
하지만 지금 다루는 데이터는 16kHz mono 100ms 청크, 즉 약 3200 bytes 수준이라 체감 성능 차이는 크지 않았다.
이 단계에서는 전송 효율보다 연결 안정성이 더 중요했다.
| 상황 | 원인 | 해결 |
|---|---|---|
| HTTPS 적용 후 CORS 에러 발생 | Socket.IO가 polling 요청을 먼저 보냄 | transports: ['websocket']으로 websocket만 사용 |
| 소켓 문제인데 XHR 에러가 보임 | 초기 연결이 polling 기반 HTTP 요청이기 때문 | Socket.IO의 기본 연결 순서를 이해해야 함 |
| PCM 바이너리 전송이 불안정함 | 현재 서버 환경에서 바이너리 프레임이 안정적으로 처리되지 않음 | Base64 문자열로 변환 후 전송 |
ws://, wss://를 직접 바꿔야 하나 고민함 | HTTPS 환경에서 secure WebSocket 처리 방식이 헷갈림 | 배포 URL을 올바르게 설정하고 클라이언트 설정을 단순하게 유지 |
처음에는 SSL이나 인증서, 서버 CORS 설정부터 의심했다. 그런데 실제 원인은 Socket.IO의 기본 연결 순서에 있었다.
핵심은 이것이다.
transports: ['websocket']으로 시작하는 편이 더 단순할 수 있다겉으로는 "소켓 연결 실패"처럼 보여도, 실제로는 그 전에 수행되는 초기 HTTP 연결 단계에서 막히고 있을 수 있다.
이번 이슈를 통해 브라우저 콘솔의 에러 메시지를 그대로 받아들이기보다, 실제 연결 흐름을 먼저 이해하는 게 중요하다는 걸 다시 느꼈다.