Socket.IO로 실시간 채팅이랑 시세, 접속자 수를 한 번에 처리했다

코인 사이트를 만들다 보니 실시간으로 처리할 게 꽤 많았다. 시세는 계속 변하고, 채팅은 바로바로 떠야 하고, 지금 몇 명이 보고 있는지도 실시간으로 보여주고 싶었다. 앞에서 다룬 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로 묶으려면 코드가 몇 배로 늘어나는데, 이 숫자는 분위기를 보여주는 용도지 정확한 통계가 아니다.

연결 하나로 시세와 채팅, 접속자 수를 같이 흘려보내는 화면이다.

Socket.IO로 실시간 채팅이랑 시세, 접속자 수를 한 번에 처리했다 이미지 1

처음 붙인 건 이때가 아니었다

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을 어디에 쓸지, 그리고 끊겼다가 다시 붙었을 때 뭘 다시 보내야 하는지부터 정해두길 추천한다. 나머지는 붙이면서 늘려도 된다.