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 전환, 탭 벗어났다 돌아오기, 지하철 터널 통과. 이걸 전부 "퇴장"으로 처리하면 방에 있던 사람이 갑자기 사라졌다 나타나는 걸 반복합니다.
처음엔 즉시 퇴장 처리했는데, 제가 직접 테스트하다 짜증이 나더라고요. 지하철에서 잠깐 끊겼다 돌아왔는데 "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초. 넉넉하게 잡되, 너무 길면 진짜 나간 사람이 유령으로 남아있으니까 이 정도가 적당했습니다.
채팅.. 음.
채팅 기능을 넣는 순간 보안을 신경 써야 했습니다.. 정작 상용 프로젝트에서는 구현한적이 없었어서 AI 도움을 많이 받았었는데요.
// 클라이언트가 뭘 보내든 서버가 덮어씀
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: 아무도 곡을 안 넣을 때

(그려줘서 고마워 GPT야~ ㅋㅋ)
다들 듣기만 하고 큐에 안 넣는 경우가 생각보다 있거든요. 생각해보니 곡을 수동으로 추가하는것도 귀찮다고 생각이 들었습니다.
AutoDJ는 큐가 비었을 때 자동으로 곡을 채웁니다. Radio(현재 곡 기반 추천), Favorites(멤버 즐겨찾기), AI(Gemini 추천) 세 모드를 만들었는데, AI 모드가 생각보다 괜찮았어요. "90년대 일본 시티팝 느낌으로" 같은 태그를 주면 꽤 적절한 곡을 골라줍니다.
돌이켜보면
좋았던 점: 3계층 분리가 유지보수를 정말 편하게 만들었습니다. Grace Period도 모바일 UX를 확실히 개선했고요.
아쉬웠던 점: WebSocket 시스템 이벤트가 35개까지 늘어난 건 과했어요. "나중에 필요할 수도 있으니까" 하고 세분화한 게 오히려 관리 부담이 됐습니다.
다시 한다면: 이벤트를 처음부터 카테고리별로 묶었을 겁니다. 변경된 필드만 보내는 식으로요. 지금은 이벤트 종류가 너무 많아서 클라이언트 핸들러도 복잡해졌어요.