Socket.IO로 실시간 채팅이랑 시세, 접속자 수를 한 번에 처리했다
코인 사이트에서 Socket.IO 연결 하나로 채팅, 시세 알림, 접속자 수를 같이 처리했다. socket.emit과 io.emit을 나눠 쓰는 기준을 실제 서버 코드와 함께 정리했다.
#SocketIO #실시간 #웹소켓 #백엔드
코인 사이트를 만들다 보니 실시간으로 처리할 게 꽤 많았다. 시세는 계속 변하고, 채팅은 바로바로 떠야 하고, 지금 몇 명이 보고 있는지도 실시간으로 보여주고 싶었다. 앞에서 다룬 SSE는 서버에서 클라이언트로 가는 한 방향인데, 채팅처럼 양쪽이 다 보내야 하는 곳은 웹소켓이 맞다. 그래서 Socket.IO 를 썼다. 이 글에서는 연결 하나로 채팅, 시세 알림, 접속자 수를 어떻게 같이 처리했는지 정리해본다. 별도 채널을 세 개 만드는 게 아니라 하나로 다 되는 이유가 있다. 연결되면 과거 채팅부터 보내준다 Socket.IO는 클라이언트가 접속하면 connection 이벤트가 뜬다. 새 사용자가 들어왔는데 채팅창이 텅 비어 있으면 어색하니까, 연결되자마자 최근 메시지 100개를 DB에서 꺼내 보내준다. // app.js const MAX_MESSAGES = 100; io.on('connection', async (socket) = { try { const rows = await query( 'SELECT id, msg_id, username, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?', [MAX_MESSAGES] ); const totalCount = await query('SELECT COUNT(*) as cnt FROM chat_messages'); // 이 사용자에게만 보내준다 (socket.emit = 나한테만) socket.emit('previousMessages', { messages: rows.reverse(), // 최신순으로 뽑았으니 다시 뒤집는다 hasMore: totalCount[0].cnt MAX_MESSAGES, // 위로 더 불러올 게 있는지 }); } catch (err) { socket.emit('previousMessages', { messages: [], hasMore: false }); } io.emit('userCount', io.engine.clientsCount); // 접속자 수는 전체에게 }); 여기서 socket.emit은 방금 접속한 그 사람한테만 보낸다. 과거 기록은 새로 들어온 사람만 필요하니까 다른 사람한테 다시 보낼 이유가 없다. DB 조회가 실패해도 빈 목록이라도 보내서 클라이언트가 계속 기다리는 일이 없게 했다. hasMore를 같이 보내는 건 채팅창을 위로 끌어올렸을 때 더 오래된 메시지를 불러오기 위해서다. 클라이언트가 loadMoreMessages를 보내면 마지막 메시지 id보다 작은 것들을 50개씩 더 준다. 처음부터 다 보내면 메시지가 몇 만 개 쌓였을 때 접속할 때마다 그걸 다 내려받게 된다. emit 두 종류를 구분하는 게 전부다 Socket.IO에서 제일 먼저 익혀야 하는 건 "누구한테 보낼 거냐"다. socket.emit은 지금 이 연결 한 사람에게만 가고, io.emit은 접속한 전체에게 간다. 과거 메시지는 socket.emit으로 나한테만, 새 채팅 메시지는 io.emit으로 전체에게 보낸다. 누가 채팅을 치면 모두에게 동시에 떠야 하니까. 이 둘만 헷갈리지 않으면 나머지는 이벤트 이름 짓는 일이다. socket.on('chatMessage', async (data) = { const message = { id: Date.now() + Math.random(), username: data.username || '익명', message: data.message, timestamp: new Date().toISOString(), }; try { await query('INSERT INTO chat_messages (msg_id, username, message) VALUES (?, ?, ?)', [String(message.id), message.username, message.message]); } catch (err) { console.error('채팅 저장 실패:', err); } io.emit('chatMessage', message); // 보낸 사람 포함 전체에게 }); 메시지 id를 클라이언트가 보낸 값이 아니라 서버에서 만드는 것도 일부러 그렇게 했다. 클라이언트가 정하게 두면 같은 id가 두 번 올 수 있고, 그러면 화면에서 메시지가 하나로 합쳐지거나 지워진다. 저장이 실패해도 메시지는 일단 뿌린다. 채팅이 안 남는 것보다 안 보이는 게 더 큰 문제니까. 시세 알림도 같은 연결로 나간다 비트코인이 특정 가격을 넘거나 내려오면 채팅창에 봇 메시지를 띄우게 했는데, 이것도 같은 Socket.IO 연결을 그대로 쓴다. 서버가 15초마다 시세를 확인하다가 돌파나 이탈이 잡히면 전체에게 보낸다. setInterval(checkBtcMilestone, 15 * 1000); // 15초마다 체크 async function announce(ko, en, positive, banner) { const message = { id: Date.now() + Math.random(), username: '__BOT__', message: ko, messageEn: en, timestamp: new Date().toISOString() }; try { await query('INSERT INTO chat_messages (msg_id, username, message) VALUES (?, ?, ?)', [String(message.id), message.username, ko]); } catch {} io.emit('chatMessage', message); // 채팅창에 봇 메시지로 if (banner) io.emit('btcAlert', { message: ko, messageEn: en, isBreakout: positive }); // 상단 배너 } 봇 메시지는 username을 __BOT__으로 박아서 보낸다. 클라이언트는 이 이름이면 다른 색으로 그린다. 그리고 DB에도 같이 넣어두는데, 나중에 들어온 사람도 "아까 1억 넘었었네"를 채팅 기록에서 볼 수 있게 하려는 거다. INSERT가 실패해도 알림은 나가야 하니 try 안에서 조용히 넘긴다. 채팅이랑 시세 알림이 완전히 다른 기능 같지만, 결국 "서버에서 일이 생기면 모두에게 알린다"는 같은 패턴이다. 연결 하나로 둘 다 처리하니까 클라이언트도 소켓을 하나만 들고 있으면 되고, 재연결 처리도 한 군데만 하면 된다. 접속자 수는 거의 공짜로 나온다 지금 몇 명이 보고 있는지는 따로 세지 않았다. Socket.IO가 현재 연결 수를 io.engine.clientsCount로 들고 있어서, 누가 들어오고 나갈 때마다 그 숫자를 전체에게 보내면 끝이다. io.on('connection', (socket) = { io.emit('userCount', io.engine.clientsCount); // 들어왔을 때 socket.on('disconnect', () = { io.emit('userCount', io.engine.clientsCount); // 나갔을 때 }); }); 정확히는 "접속자 수"가 아니라 "열려 있는 소켓 수"다. 한 사람이 탭을 두 개 열면 2로 센다. 그 정도는 감수하기로 했다. 세션이나 IP로 묶으려면 코드가 몇 배로 늘어나는데, 이 숫자는 분위기를 보여주는 용도지 정확한 통계가 아니다. 연결 하나로 시세와 채팅, 접속자 수를 같이 흘려보내는 화면이다. 처음 붙인 건 이때가 아니었다 git 로그를 보니 이 프로젝트에 소켓을 처음 붙인 건 2024년 7월이다. 그날 커밋이 이렇게 남아 있다. 2024-07-22 socket 2024-07-22 test 2024-07-22 error 2024-07-22 error message 2024-07-22 socket 그때는 붙였다 지웠다만 반복하다 결국 접었다. 커밋 메시지가 error, test인 걸 보면 뭐가 문제인지도 모르고 있었던 것 같다. 1년 반 뒤에 다시 붙였고 그때는 됐다. 그 사이에 뭐가 달라졌나 생각해보면 기술을 더 알게 됐다기보다, 연결이 끊길 수 있다는 걸 전제로 짜기 시작한 게 차이였다. 처음엔 연결이 되면 계속 유지된다고 믿고 짰다. 그래서 재연결됐을 때 구독을 다시 거는 코드도, 과거 메시지를 다시 받는 코드도 없었고, 잠깐만 끊겨도 화면이 그대로 멈췄다. Socket.IO를 처음 붙인다면 이벤트를 몇 개 만들지보다 socket.emit과 io.emit을 어디에 쓸지, 그리고 끊겼다가 다시 붙었을 때 뭘 다시 보내야 하는지부터 정해두길 추천한다. 나머지는 붙이면서 늘려도 된다.