7 min read

ShareAux 개발기 #3 — 방 기반 실시간 시스템

WS 게이트웨이를 직접 구현하면서

1편에서 socket.io 대신 raw ws를 쓰기로 했다고 했죠. 바이너리 프로토콜을 자유롭게 설계할 수 있다는 장점이 있었지만, 그 대가로 연결 관리, 인증, heartbeat, 재연결 감지, 방 입퇴장, 메시지 라우팅을 전부 직접 만들어야 했습니다.

처음에는 하나의 Gateway 클래스에 다 넣었습니다. 금방 1000줄을 넘겼고, 수정할 때마다 다른 부분이 깨질까 불안했습니다.

결국 세 계층으로 쪼갰습니다. Gateway(연결/인증), Router(메시지 처리), Broadcaster(전송). 분리하고 나니 각 계층을 독립적으로 테스트할 수 있게 됐어요. 처음부터 이렇게 할 걸.

// Gateway — 연결만 관리. 메시지 처리는 Router에 위임.
httpServer.on('upgrade', (req, socket, head) => {
  // IP ban 체크, Origin 검증, Rate limit 여기서 끝내고
  this.wss.handleUpgrade(req, socket, head, (client) => {
    void this.handleConnection(client, req);
  });
});

// Router — OpCode 보고 적절한 핸들러로 넘김
client.on('message', (raw: Buffer) => {
  const opcode = raw[0];
  if (opcode === WsOpCode.Chat) this.handleChat(client, raw.subarray(1));
  if (opcode === WsOpCode.Resync) this.handleResync(client);
  // ...
});

모바일에서 테스트하니까 보이는 것들

데스크톱에서만 테스트할 때는 몰랐던 문제가 모바일에서 쏟아졌습니다.

가장 짜증났던 건 연결이 수시로 끊기는 것. WiFi → LTE 전환, 탭 벗어났다 돌아오기, 지하철 터널 통과. 이걸 전부 "퇴장"으로 처리하면 방에 있던 사람이 갑자기 사라졌다 나타나는 걸 반복합니다.

처음엔 즉시 퇴장 처리했는데, 직접 테스트해보니 UX가 좋지 않았습니다. 지하철에서 잠깐 끊겼다 돌아왔는데 "OO님이 퇴장했습니다" "OO님이 입장했습니다"가 연달아 뜨는 거.

30초의 유예 시간(Grace Period)을 뒀습니다. 연결이 끊기면 타이머를 시작하고, 30초 안에 다시 연결되면 퇴장을 취소합니다. 다른 사람들에게는 아무 일도 없었던 것처럼 보여요.

// 끊김 감지 → 바로 퇴장 처리하지 않고 타이머 시작
private handleDisconnect(client: WsClient) {
  this.pendingDisconnects.set(key, setTimeout(() => {
    // 30초 지나도 안 돌아오면 그때 진짜 퇴장
    void this.finalizeDisconnect(roomId, userId);
  }, 30_000));
}

// 재연결 시 → 타이머 취소, 아무 일도 없었던 것처럼
private handleReconnect(client: WsClient) {
  clearTimeout(this.pendingDisconnects.get(key));
  this.pendingDisconnects.delete(key);
}

30초라는 값은 — WiFi→LTE 전환이 보통 5~15초, 탭 전환 후 복귀가 보통 즉시~10초. 넉넉하게 잡되, 너무 길면 진짜 나간 사람이 유령으로 남아있으니까 이 정도가 적당했습니다.


채팅 보안

채팅 기능을 넣으면서 보안을 신경 써야 했습니다.

// 클라이언트가 뭘 보내든 서버가 덮어씀
message.userId = client.userId;     // JWT에서 추출
message.nickname = client.nickname; // JWT에서 추출
message.role = client.role;         // JWT에서 추출

이렇게 안 하면 개발자 도구에서 닉네임을 바꿔서 다른 사람인 척 채팅할 수 있어요. 단순하지만 빠뜨리기 쉬운 포인트입니다.

그 외에도 기본적인 방어를 넣었습니다. 1초에 1개 rate limit, 300자 길이 제한, <> 제거로 XSS 방지, 연속 동일 메시지 감지 시 자동 mute.

자동 mute는 좀 고민했어요. 오탐이 있을 수 있으니까요. 근데 실제로 돌려보니 정상적인 사용에서는 거의 안 걸리고, 봇이나 도배만 깔끔하게 잡아줘서 그대로 두기로 했습니다.


누가 뭘 할 수 있는가를 설계하는 게 생각보다 어려웠다

권한 시스템은 처음에 단순하게 시작했어요. "호스트 / 일반 유저" 두 역할만. 근데 금방 부족해졌습니다. 이 사람은 음악은 추가할 수 있지만 스킵은 못하게 하고 싶다 같은 요구가 생기니까요.

결국 두 단계로 나눴습니다.

  • 계정 권한: 초대 코드를 만들 때 설정. 영구적.
  • 방 권한: 호스트가 방 안에서 개별 설정. 임시적.

유효 권한은 둘의 교집합입니다. 계정에서 막으면 방에서 열어줘도 소용없어요.

// 계정에서 Chat 권한을 뺐으면, 방에서 열어줘도 채팅 불가
const effective = accountPermissions & roomPermissions;

// NestJS Guard로 깔끔하게 적용
@UseGuards(RoomPermissionGuard)
@SetMetadata('permission', Permission.AddQueue)
async addToQueue() { /* ... */ }

이렇게 한 이유는 — 초대 코드로 들어온 구경만 하는 사람에게 영구적 제한을 걸고 싶었고, 동시에 호스트가 방 분위기에 따라 임시로 제한을 걸 수 있어야 했거든요. NestJS Guard로 구현해서 데코레이터 하나면 끝나니까 코드도 깔끔하게 나왔습니다.


스킵 권한은 투표로 풀었다

스킵을 누가 할 수 있게 할까? "아무나"는 트롤링에 취약하고, 호스트만은 호스트가 자리 비우면 답이 없습니다.

투표제로 갔어요. 방에 있는 사람의 절반 이상이 투표하면 스킵. 2명이면 1표, 5명이면 3표. 단순하지만 잘 동작합니다.


봇...이 왜 여기까지?

WS Flood 방지. 인증 없이 WebSocket 연결을 반복적으로 시도하는 공격을 막는 건데, 실제로 배포하고 나니 하루에 몇 번씩 걸리더라고요. 넣어둔 게 맞았습니다.

10초에 10회 초과하면 경고, 3회 위반하면 30분 ban, 10회면 24시간 ban. 자동 IP 차단입니다.


AutoDJ: 아무도 곡을 안 넣을 때

autodj.png

다들 듣기만 하고 큐에 안 넣는 경우가 생각보다 있습니다. 곡을 수동으로 추가하는 것도 번거롭고요.

AutoDJ는 큐가 비었을 때 자동으로 곡을 채웁니다. Radio(현재 곡 기반 추천), Favorites(멤버 즐겨찾기), AI(Gemini 추천) 세 모드를 만들었는데, AI 모드가 생각보다 괜찮았어요. "90년대 일본 시티팝 느낌으로" 같은 태그를 주면 꽤 적절한 곡을 골라줍니다.


돌이켜보면

좋았던 점: 3계층 분리가 유지보수를 정말 편하게 만들었습니다. Grace Period도 모바일 UX를 확실히 개선했고요.

아쉬웠던 점: WebSocket 시스템 이벤트가 35개까지 늘어난 건 과했어요. 나중에 필요할 수도 있으니까 하고 세분화한 게 오히려 관리 부담이 됐습니다.

다시 한다면: 이벤트를 처음부터 카테고리별로 묶었을 겁니다. 변경된 필드만 보내는 식으로요. 지금은 이벤트 종류가 너무 많아서 클라이언트 핸들러도 복잡해졌어요.


참고 자료