테스트는 전부 초록불인데 프로덕션은 깨졌다 — 랜챗 배포에서 잡은 3가지 통합 버그
오메글 같은 랜덤 1:1 화상채팅(“랜챗”)을 붙였다. 매칭은 서버(Colyseus)가 하고, 영상·음성은 브라우저끼리 직접(P2P WebRTC) 연결하고, 서버는 연결 협상 메시지(offer/answer/ICE)만 중계한다. 단위 테스트도, 룸 테스트도 전부 초록불이었다. 그런데 두 대의 컴퓨터로 실제로 들어가 보면 매칭된 순간 캠이 1초 남짓 보였다가 다시 “상대를 찾는 중”으로 돌아갔다.
결국 프로덕션에서만 드러나는 버그를 세 개 잡았다. 셋 다 공통점이 있다 — 단위/룸 테스트는 실제 WebSocket 직렬화와 전송 계층을 거치지 않기 때문에 절대 못 잡는 종류였다. 오늘은 그 세 개와, 각각을 어떻게 추적했는지 적는다.
버그 1 — 빈 스키마가 핸드셰이크를 통째로 깨뜨리다
랜챗 룸은 전적으로 메시지 기반이라 동기화할 상태가 없었다. 그래서 상태 스키마를 이렇게 비워뒀다.
export class RandChatState extends Schema {} // 필드 없음
룸 단위 테스트는 이 룸을 직접 생성해 메서드를 호출하므로 전부 통과했다. 그런데 배포 후 브라우저로 방에 입장하면 콘솔에 이게 떴다.
TypeError: a is not a constructor
at decode ... at handshake
Colyseus 클라이언트는 접속하자마자 서버가 보낸 스키마 리플렉션을 디코드하는데(핸드셰이크), 루트 스키마에 @type 필드가 하나도 없으면 리플렉션에 유효한 루트 타입이 없어서 디코더가 터진다. 즉 빈 Colyseus 스키마는 방 입장 자체를 불가능하게 만든다. 룸 테스트는 직렬화를 우회하니 이걸 못 봤다.
고치는 건 한 줄이었다. 어차피 “대기 인원 수”는 나중에 UI에 쓸 수도 있으니 의미 있는 필드로 채웠다.
export class RandChatState extends Schema {
@type('number') waiting: number = 0
}
검증은 룸 테스트가 아니라 실제 서버 E2E로 했다. 로컬에 game-server를 띄우고, Colyseus 클라이언트 두 개를 붙여 매칭 → 시그널링 중계까지 실제 WebSocket으로 통과하는지 스크립트로 확인했다. 이게 이후 모든 버그를 잡는 도구가 됐다.
버그 2 — onMessage는 핸들러를 “교체”하지 않고 “누적”한다
재매칭(스킵/상대 이탈)을 붙이면서, 매칭될 때마다 새 RTCPeerConnection을 만들고 시그널링 핸들러를 다시 등록했다.
room.onMessage('offer', handleOffer) // 매 재매칭마다 재등록
나는 “같은 타입으로 다시 등록하면 이전 핸들러를 교체하겠지”라고 가정했다. 코드 리뷰에서 이 가정이 틀렸다는 지적을 받았다. Colyseus.js 0.15의 onMessage는 내부적으로 nanoevents의 on()에 위임하는데, 이건 콜백을 배열에 push하고 emit 때 등록된 전부를 호출한다. 즉 스킵할 때마다 핸들러가 하나씩 쌓이고, 이미 닫힌(이전) peer connection을 붙잡은 스테일 핸들러들이 이후 메시지마다 같이 실행된다.
핵심은 onMessage가 구독 해제 함수를 반환한다는 것. 이걸 받아서 정리(cleanup)에서 호출하면 된다.
const offOffer = room.onMessage('offer', handleOffer)
// ...
return () => { offOffer() /* 구독 해제 */; pc.close() }
교훈: 이벤트 핸들러를 등록하는 API는 “교체”를 가정하지 말고, 반환되는 해제 함수를 항상 정리에 배선하라.
버그 3 — 4KB를 넘는 SDP가 연결을 끊는다 (진짜 범인)
위 둘을 고쳐도 캠은 여전히 1초 후 사라졌다. 이번엔 증상이 미묘했다. 서버 로그도 클라이언트 콘솔도 앱 에러가 없었다. 그래서 범위를 좁히는 실험을 했다.
- 서버 격리: Colyseus 클라이언트 두 개(순수 Node)를 매칭시키고 4초간 유지 → 아무 문제 없음. 서버는 정상.
- 브라우저 격리: 브라우저에서
getUserMedia와RTCPeerConnection생성을 가로채 호출 횟수를 셌다 → 각각 딱 1번. React 컴포넌트가 리마운트되는 게 아니었다. 즉 상태만 되돌아간 것 = 연결이 끊긴 것. - 결정적 단서: pm2로 game-server 로그를 봤더니 이게 있었다.
RangeError: Max payload size exceeded
at Receiver.haveLength (ws/lib/receiver.js)
at Socket.socketOnData (ws/lib/websocket.js)
범인은 Colyseus의 WebSocket 전송 계층 기본값이었다. maxPayload가 4KB인데, 매칭 직후 브라우저가 보내는 WebRTC OFFER(SDP)는 비디오+오디오 코덱 정보까지 담겨 5–15KB에 이른다. 4KB를 넘는 프레임이 도착하면 ws가 “Max payload size exceeded”를 던지며 그 소켓을 끊어버린다. 그러면 서버는 상대에게 “상대가 나갔다”를 보내고, 끊긴 쪽은 대기 화면으로 돌아간다. 캠이 잠깐 보인 건 매칭 메시지로 통화 화면이 렌더된 그 순간이었다.
왜 순수 Node 클라이언트 실험에서는 재현이 안 됐을까? 그 클라이언트는 시그널링만 흉내 내고 실제 SDP를 안 보냈기 때문이다. 오직 진짜 브라우저만 큰 SDP를 보냈고, 그 메시지가 자기 자신의 연결을 끊었다.
new WebSocketTransport({ server: httpServer, maxPayload: 256 * 1024 })
4KB → 256KB로 올리자 14KB짜리 OFFER도 정상 중계됐고, 브라우저는 매칭 후 계속 통화 상태를 유지했다.
남는 이야기 — 초록불의 함정
세 버그 모두 “테스트가 초록불이니 됐겠지”의 사각지대에 있었다. 룸을 직접 생성해 메서드를 호출하는 테스트는 직렬화(스키마 핸드셰이크)도, 실제 전송(WebSocket 프레임 크기)도, 클라이언트 이벤트 구독의 수명도 건드리지 않는다. 이 계층들은 오직 실제 WebSocket을 통과할 때만 드러난다.
그래서 이 프로젝트의 검증 루틴에 한 줄을 추가했다: WebRTC/실시간 기능은 룸 단위 테스트만으로 끝내지 말고, 로컬(또는 프로덕션) 실서버에 실제 클라이언트를 붙이는 E2E와, 서버 프로세스 로그(pm2)를 반드시 본다. 오늘 세 번째 버그의 결정적 단서는 코드가 아니라 pm2 logs 한 줄이었다.