[33일차] Socket.IO

송정근·2026년 7월 6일

Socket.IO는 클라이언트와 서버가 실시간으로 데이터를 주고받을 수 있게 해주는 라이브러리다.
일반적인 HTTP 요청/응답 방식은 클라이언트가 요청해야 서버가 응답하지만, Socket.IO를 사용하면 서버도 클라이언트에게 즉시 이벤트를 보낼 수 있다.


1. HTTP 요청/응답 방식

일반적인 웹 요청은 클라이언트가 서버에 요청을 보내고, 서버가 그 요청에 응답하는 구조다.

클라이언트  ---- 요청 ---->  서버
클라이언트  <--- 응답 ----  서버

예를 들어 게시글 목록을 가져오는 요청은 다음처럼 동작한다.

브라우저가 /posts 요청
  ↓
서버가 게시글 목록 응답
  ↓
브라우저가 화면에 출력

이 방식은 요청과 응답이 한 번 끝나면 연결이 끊긴다.
따라서 서버가 새로운 데이터를 클라이언트에게 먼저 보내기 어렵다.


2. 실시간 통신이 필요한 이유

채팅 앱에서는 누군가 메시지를 보내면 다른 사용자 화면에도 즉시 보여야 한다.

HTTP 방식만 사용하면 클라이언트가 계속 서버에 새 메시지가 있는지 물어봐야 한다.

새 메시지 있어?
새 메시지 있어?
새 메시지 있어?

이런 방식은 비효율적이다.
Socket.IO를 사용하면 서버와 클라이언트가 연결을 유지하고, 필요한 순간에 이벤트를 주고받을 수 있다.

클라이언트  <==== 연결 유지 ====>  서버
클라이언트  ---- chat 이벤트 ----> 서버
클라이언트  <--- message 이벤트 -- 서버

3. Socket.IO란?

Socket.IO는 실시간 양방향 통신을 쉽게 구현할 수 있게 해주는 라이브러리다.

대표적으로 다음 기능을 만들 때 사용한다.

기능설명
실시간 채팅메시지를 즉시 주고받음
알림새로운 알림을 실시간 표시
게임사용자 입력과 상태를 빠르게 동기화
실시간 대시보드데이터 변경을 즉시 화면에 반영
협업 도구여러 사용자가 동시에 문서나 화면을 편집

Socket.IO에서는 데이터를 주고받을 때 이벤트 이름을 정해서 사용한다.

socket.emit("chat", { text: "안녕하세요" })
socket.on("chat", (data) => {
  console.log(data)
})

4. 필요한 패키지

Express 서버와 Socket.IO를 사용하려면 다음 패키지가 필요하다.

npm install express socket.io

ES Module 방식으로 작성하기 때문에 파일 확장자는 .mjs를 사용한다.

import express from "express"
import { Server } from "socket.io"
import { createServer } from "http"

5. 서버 기본 구조

import express from "express"
import { Server } from "socket.io"
import { createServer } from "http"
import path from "path"
import { fileURLToPath } from "url"

const app = express()
const server = createServer(app)
const io = new Server(server)

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

app.use(express.static(path.join(__dirname, "public")))

server.listen(3000, () => {
  console.log("서버 실행 중...")
})

6. createServer(app)를 사용하는 이유

Socket.IO는 Express의 app.listen()에 바로 붙이는 것이 아니라, Node.js의 HTTP 서버 객체에 연결한다.

const app = express()
const server = createServer(app)
const io = new Server(server)

각 객체의 역할은 다음과 같다.

코드역할
appExpress 애플리케이션
server실제 HTTP 서버
ioSocket.IO 서버 객체

마지막에도 app.listen()이 아니라 server.listen()을 사용한다.

server.listen(3000, () => {
  console.log("서버 실행 중...")
})

7. ES Module에서 __dirname 만들기

CommonJS에서는 __dirname을 바로 사용할 수 있다.

const path = require("path")
console.log(__dirname)

하지만 .mjs 파일은 ES Module 방식이기 때문에 __dirname이 기본 제공되지 않는다.
따라서 다음처럼 직접 만들어야 한다.

import path from "path"
import { fileURLToPath } from "url"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

이렇게 만든 __dirname을 이용해 public 폴더를 정적 파일 폴더로 등록한다.

app.use(express.static(path.join(__dirname, "public")))

8. 정적 파일 제공

app.use(express.static(path.join(__dirname, "public")))

이 코드는 public 폴더 안의 HTML, CSS, JavaScript 파일을 브라우저에서 접근할 수 있게 만든다.

예를 들어 다음 파일들이 있다.

public/
├── chat_index.html
└── chat_room.html

브라우저에서 다음 주소로 접근할 수 있다.

http://localhost:3000/chat_index.html
http://localhost:3000/chat_room.html

9. 채팅 입장 화면

chat_index.html은 채팅방에 들어가기 전 닉네임과 채널을 선택하는 화면이다.

<form id="loginForm">
  <h2>채팅방 입장</h2>
  <input type="text" id="nickname" placeholder="닉네임을 입력하세요">
  <select id="channel">
    <option value="lobby">대기실</option>
    <option value="sports">스포츠</option>
    <option value="programming">프로그래밍</option>
    <option value="music">음악</option>
  </select>
  <button type="submit">입장</button>
</form>

입력한 닉네임과 선택한 채널은 localStorage에 저장된다.

document.getElementById("loginForm").onsubmit = (e) => {
  e.preventDefault()

  const nickname = document.getElementById("nickname").value.trim()
  const channel = document.getElementById("channel").value

  if (!nickname) return alert("닉네임을 입력하세요")

  localStorage.setItem("nickname", nickname)
  localStorage.setItem("channel", channel)

  location.href = "chat_room.html"
}

10. localStorage를 사용하는 이유

chat_index.html에서 입력한 닉네임과 채널 정보를 chat_room.html에서도 사용해야 한다.
페이지가 이동되면 JavaScript 변수는 사라지기 때문에, 브라우저 저장소인 localStorage에 값을 저장한다.

localStorage.setItem("nickname", nickname)
localStorage.setItem("channel", channel)

채팅방 페이지에서는 다시 꺼내서 사용한다.

const nickname = localStorage.getItem("nickname")
let currentChannel = localStorage.getItem("channel")

값이 없으면 입장 화면으로 되돌린다.

if (!nickname || !currentChannel) {
  alert("닉네임 또는 채널 정보가 없습니다.")
  location.href = "chat_index.html"
}

11. 채팅방 화면 구조

chat_room.html은 메시지 영역, 입력 영역, 채널 선택, 접속자 목록 영역으로 구성되어 있다.

<div id="chat">
  <h3 id="channelName"></h3>

  <div class="chat-container">
    <div class="chat-main">
      <div id="messages"></div>

      <div>
        <input type="text" id="to" placeholder="귓속말 대상(없으면 전체)">
        <input type="text" id="message" placeholder="메시지를 입력하세요">

        <select id="channelSelector">
          <option value="lobby">대기실</option>
          <option value="sports">스포츠</option>
          <option value="programming">프로그래밍</option>
          <option value="music">음악</option>
        </select>

        <button id="emoji">😀</button>
        <button id="send">전송</button>
      </div>
    </div>

    <div id="users">
      <h4>접속자 목록</h4>
      <div id="userCounts"></div>
      <div id="userList"></div>
    </div>
  </div>
</div>
요소역할
#channelName현재 채널 이름 출력
#messages채팅 메시지 출력
#to귓속말 대상 입력
#message채팅 메시지 입력
#channelSelector채널 변경
#userCounts접속자 수 표시 영역
#userList접속자 목록 표시 영역

12. 클라이언트에서 Socket.IO 연결하기

Socket.IO를 사용하려면 클라이언트 HTML에서 다음 스크립트를 불러온다.

<script src="/socket.io/socket.io.js"></script>

이 파일은 직접 만든 파일이 아니라 Socket.IO 서버가 자동으로 제공하는 클라이언트 라이브러리다.

그 다음 io()를 호출해 서버와 연결한다.

const socket = io()
console.log("클라이언트 소켓 생성 성공!")

연결이 성공하면 서버에서는 connection 이벤트가 발생한다.


13. 서버에서 연결 감지하기

const users = {}

io.on("connection", (socket) => {
  console.log("사용자가 연결되었음")
})

connection 이벤트는 클라이언트가 Socket.IO 서버에 접속했을 때 실행된다.

여기서 socket은 접속한 사용자 한 명과의 연결을 나타낸다.

사용자 A 접속 → socket A 생성
사용자 B 접속 → socket B 생성
사용자 C 접속 → socket C 생성

각 소켓은 고유한 socket.id를 가진다.


14. join 이벤트

클라이언트가 채팅방에 들어오면 서버에 join 이벤트를 보낸다.

socket.emit("join", {
  nickname,
  channel: currentChannel,
})

서버는 join 이벤트를 받아 사용자 정보를 저장하고 채널에 입장시킨다.

socket.on("join", ({ nickname, channel }) => {
  socket.nickname = nickname
  socket.channel = channel

  users[socket.id] = { nickname, channel }

  socket.join(channel)

  const msg = {
    user: "system",
    text: `${nickname}님이 입장했습니다.`,
  }

  console.log("nickname:", nickname, "channel: ", channel)
  io.emit("message", msg)
})

15. socket에 값 저장하기

socket.nickname = nickname
socket.channel = channel

socket 객체에 닉네임과 채널 정보를 직접 저장한다.
이렇게 해두면 나중에 채팅 메시지를 보낼 때 누가 보냈는지, 어느 채널에 있는지 확인할 수 있다.

또한 전체 사용자 목록을 관리하기 위해 users 객체에도 저장한다.

users[socket.id] = { nickname, channel }

예시는 다음과 같다.

{
  "소켓ID1": { nickname: "김사과", channel: "lobby" },
  "소켓ID2": { nickname: "반하나", channel: "music" }
}

16. Room과 Channel

Socket.IO의 Room은 특정 소켓들을 묶어놓는 그룹이다.
채팅 앱에서는 Room을 채널처럼 사용할 수 있다.

socket.join(channel)

예를 들어 사용자가 music 채널을 선택하면 music이라는 Room에 들어간다.

lobby Room
├── 사용자 A
└── 사용자 B

music Room
└── 사용자 C

특정 Room에만 메시지를 보내려면 다음처럼 작성한다.

io.to("music").emit("message", {
  user: "system",
  text: "music 채널 공지",
})

17. 메시지 보내기

클라이언트에서 전송 버튼을 누르거나 Enter를 누르면 chat 이벤트를 서버로 보낸다.

const messageInput = document.getElementById("message")

messageInput.addEventListener("keydown", (e) => {
  if (e.key === "Enter") sendMessage()
})

document.getElementById("send").onclick = sendMessage

function sendMessage() {
  const text = messageInput.value.trim()
  const to = toInput.value.trim()

  if (text) {
    socket.emit("chat", { text, to: to || null })
    messageInput.value = ""
  }
}

socket.emit("chat", data)는 서버에게 chat 이벤트를 보내는 코드다.

클라이언트 → 서버
chat 이벤트 발생
{ text: "안녕하세요", to: null }

18. 서버에서 chat 이벤트 처리하기

socket.on("chat", ({ text, to }) => {
  const sender = users[socket.id]

  if (!sender) return

  const payload = {
    user: sender.nickname,
    text,
  }

  if (to) {
    const receiverSocket = Object.entries(users).find(
      ([id, user]) => user.nickname === to
    )?.[0]

    if (receiverSocket) {
      io.to(receiverSocket).emit("whisper", payload)
      socket.emit("whisper", payload)
    }
  } else {
    io.to(sender.channel).emit("message", payload)
  }
})

메시지 처리 흐름은 다음과 같다.

chat 이벤트 수신
  ↓
socket.id로 보낸 사람 정보 찾기
  ↓
귓속말 대상이 있나?
  ├── 있다 → 해당 사용자에게 whisper 이벤트 전송
  └── 없다 → 현재 채널 전체에 message 이벤트 전송

19. 전체 메시지와 귓속말

전체 메시지는 현재 채널에 있는 사용자에게만 보낸다.

io.to(sender.channel).emit("message", payload)

귓속말은 특정 소켓에게만 보낸다.

io.to(receiverSocket).emit("whisper", payload)

귓속말을 보낸 사람도 자기 화면에서 확인할 수 있도록 자신에게도 보낸다.

socket.emit("whisper", payload)

20. 메시지 출력하기

서버에서 message 이벤트가 오면 클라이언트는 메시지를 화면에 추가한다.

const messages = document.getElementById("messages")

socket.on("message", ({ user, text }) => {
  const div = document.createElement("div")
  div.textContent = `[${user}] ${text}`

  if (user === "system") {
    div.className = "system-msg"
  }

  messages.appendChild(div)
  messages.scrollTop = messages.scrollHeight
})

messages.scrollTop = messages.scrollHeight는 새 메시지가 추가될 때 스크롤을 아래로 내리는 코드다.


21. 귓속말 출력하기

서버에서 whisper 이벤트가 오면 귓속말 형식으로 출력한다.

socket.on("whisper", ({ user, text }) => {
  const div = document.createElement("div")
  div.textContent = `(귓속말) [${user}] ${text}`
  div.style.color = "deeppink"

  messages.appendChild(div)
  messages.scrollTop = messages.scrollHeight
})

전체 메시지와 구분하기 위해 색상을 다르게 지정한다.


22. 채널 변경

채널 선택 값이 바뀌면 클라이언트는 서버에 changeChannel 이벤트를 보낸다.

channelSelector.onchange = () => {
  const newChannel = channelSelector.value

  if (newChannel !== currentChannel) {
    socket.emit("changeChannel", { newChannel })

    currentChannel = newChannel
    channelName.textContent = `[채널: ${currentChannel}]`
    messageInput.value = ""
    localStorage.setItem("channel", currentChannel)
  }
}

서버에서는 기존 채널에서 나가고 새 채널에 들어가게 처리한다.

socket.on("changeChannel", ({ newChannel }) => {
  const oldChannel = socket.channel
  const nickname = socket.nickname

  socket.leave(oldChannel)

  io.to(oldChannel).emit("message", {
    user: "system",
    text: `${nickname}님이 ${newChannel} 채널로 이동했습니다.`,
  })

  socket.join(newChannel)

  socket.channel = newChannel
  users[socket.id].channel = newChannel

  const joinMsg = {
    user: "system",
    text: `${nickname}님이 입장했습니다.`,
  }

  io.to(newChannel).emit("message", joinMsg)
})

23. socket.leave()

socket.leave(oldChannel)

현재 소켓을 기존 Room에서 제거한다.
채널 이동 기능에서는 이전 채널 메시지를 더 이상 받지 않게 하기 위해 필요하다.


24. disconnect 이벤트

사용자가 브라우저를 닫거나 새로고침하거나 연결이 끊어지면 disconnect 이벤트가 발생한다.

socket.on("disconnect", () => {
  console.log("사용자가 퇴장했습니다.")
})

실제 채팅 앱에서는 연결이 끊겼을 때 사용자 목록에서도 제거하는 것이 좋다.

socket.on("disconnect", () => {
  const user = users[socket.id]

  if (user) {
    io.to(user.channel).emit("message", {
      user: "system",
      text: `${user.nickname}님이 퇴장했습니다.`,
    })

    delete users[socket.id]
  }
})

25. 이벤트 흐름 정리

이벤트 이름방향역할
connection클라이언트 → 서버소켓 연결 감지
join클라이언트 → 서버닉네임과 채널 정보 전달
chat클라이언트 → 서버채팅 메시지 전송
message서버 → 클라이언트일반 메시지 수신
whisper서버 → 클라이언트귓속말 메시지 수신
changeChannel클라이언트 → 서버채널 변경 요청
disconnect클라이언트 연결 종료접속 종료 처리

26. 서버 전체 흐름

서버 실행
  ↓
public 폴더 정적 파일 제공
  ↓
클라이언트가 chat_index.html 접속
  ↓
닉네임, 채널 선택
  ↓
chat_room.html 이동
  ↓
Socket.IO 연결
  ↓
join 이벤트 발생
  ↓
채팅 메시지 송수신
  ↓
채널 변경 또는 연결 종료

37. 정리

Socket.IO는 서버와 클라이언트가 연결을 유지하면서 이벤트를 주고받을 수 있게 해준다.
일반적인 HTTP 요청/응답 방식과 달리 서버가 클라이언트에게 먼저 메시지를 보낼 수 있어 채팅, 알림, 실시간 대시보드 같은 기능에 적합하다.

이번 채팅 예제의 핵심 흐름은 다음과 같다.

닉네임과 채널 선택
  ↓
localStorage에 저장
  ↓
Socket.IO 연결
  ↓
join 이벤트로 채널 입장
  ↓
chat 이벤트로 메시지 전송
  ↓
message 이벤트로 채널 사용자에게 전달
  ↓
changeChannel 이벤트로 채널 이동
  ↓
disconnect 이벤트로 퇴장 처리

Socket.IO에서 중요한 개념은 emit, on, socket, io, Room이다.
이 다섯 가지를 이해하면 실시간 채팅뿐 아니라 알림, 접속자 목록, 귓속말, 채널 분리 같은 기능도 확장할 수 있다.

profile
기록하며 성장하는 개발자

0개의 댓글