← 블로그 목록 2026-05-19

Socket.io LAN 리슨 서버 + 룸 시스템

처음엔 WebRTC P2P를 검토했습니다 — 진짜 UDP에 가까운 DataChannel, 시그널링 서버 부담 적음. 하지만 "회사 와이파이에서 5분 한 판"이라는 요구사항을 보면 P2P의 NAT 통과 복잡성은 오버킬이었습니다. 결국 평범한 Socket.io + Node 리슨 서버로 갔습니다.

구조: 호스트가 곧 서버

누군가 자기 노트북에서 npm start를 띄우면 그 PC가 바로 게임 서버가 됩니다. 다른 동료들은 그 LAN IP로 브라우저 접속:

┌──────────────────┐         ┌─────────┐
│  Host PC         │ <-WS--> │ Player1 │
│  Node + Sim 60Hz │ <-WS--> │ Player2 │
│  (Express +      │ <-WS--> │ Bot1    │ (server-side)
│   Socket.io)     │ <-WS--> │ Bot2    │
└──────────────────┘

봇은 클라이언트가 아니라 서버 내부 상태입니다. isBot:true 플래그가 붙은 플레이어로 sim에 추가됩니다.

이벤트 카탈로그

클라이언트 → 서버:

서버 → 클라이언트:

룸 채널

Socket.io의 room 기능을 그대로 활용:

socket.on('createRoom', ({ name, password, nick }, cb) => {
  const room = new Room({ name, password, hostSocketId: socket.id });
  rooms.set(room.id, room);
  socket.join('room:' + room.id);   // 채널 가입
  room.addPlayer(socket.id, nick);
  cb({ ok: true, id: room.id });
});

// 매 tick 호스트(노드)는:
io.to('room:' + room.id).emit('snap', snapshot);

io.to(channel).emit는 그 채널에 속한 모든 socket에만 broadcasting. 다른 룸의 트래픽과 격리됩니다.

호스트 권한 모델

클라이언트는 입력({w,a,s,d, fire, angle})만 보냅니다. 서버가 위치·HP·데미지를 모두 계산. 클라이언트는 받은 스냅샷을 보간해서 렌더만 합니다. 치트 방어와 동기화 단순화를 동시에 얻는 표준 패턴입니다.

매치 종료 후 룸 재사용

5분이 지나면 sim이 ended:true를 띄우고 matchEnd 이벤트로 스코어보드 전송. 호스트가 "대기실로" 버튼 누르면 룸 상태가 lobby로 돌아가고, 모든 참가자가 다시 startGame을 기다립니다. 새 매치 = 새 Sim 인스턴스.

호스트 끊김 처리

호스트의 socket이 disconnect 되면 룸이 dissolve됩니다 (마이그레이션 없음). 다른 참가자들은 roomClosed 이벤트를 받고 로비로 돌아갑니다. v1은 단순성을 우선.

LAN IP 자동 표시

서버 부트 시 os.networkInterfaces()로 비-내부 IPv4를 찾아 콘솔에 박스로 출력합니다 — 호스트가 동료들에게 URL 공유할 때 보기 좋게:

┌────────────────────────────────────┐
│  DOGKOV ARENA — LAN listen server  │
│  LAN: http://192.168.50.52:3000    │
│  LAN: http://192.168.50.203:3000   │
└────────────────────────────────────┘
← Canvas 엔진 Node Listen Server 패턴 →