junyeokk
Blog
Socket·2025. 12. 11

Socket.IO 클라이언트

웹 애플리케이션에서 서버가 클라이언트에게 먼저 데이터를 보내야 하는 상황이 있다. 채팅 메시지가 도착했을 때, 다른 사용자가 문서를 수정했을 때, 알림이 발생했을 때. HTTP는 이런 상황에 적합하지 않다. HTTP는 항상 클라이언트가 먼저 요청하고 서버가 응답하는 구조이기 때문이다.

이 문제를 해결하는 가장 원시적인 방법은 폴링(polling)이다. 클라이언트가 일정 간격으로 "새 데이터 있어?"하고 서버에 반복 요청하는 것이다. 단순하지만 비효율적이다. 데이터가 없을 때도 요청을 보내니까 서버 부하가 늘어나고, 간격이 길면 실시간성이 떨어진다.

WebSocket이 이 문제를 근본적으로 해결한다. 한 번 연결하면 서버와 클라이언트가 양방향으로 자유롭게 메시지를 주고받을 수 있다. HTTP 업그레이드 핸드셰이크로 시작해서 TCP 연결을 유지하는 방식이다.

그런데 WebSocket만으로는 실제 서비스를 만들기 어렵다. 연결이 끊어졌을 때 자동 재연결, 연결 실패 시 폴백, 메시지 라우팅, 방(room) 개념 등을 직접 구현해야 한다. Socket.IO는 이런 것들을 다 처리해주는 라이브러리다.


Socket.IO가 WebSocket과 다른 점

Socket.IO는 WebSocket 위에 올라가는 프로토콜이지, WebSocket 그 자체가 아니다. 중요한 차이점들이 있다.

자동 재연결

WebSocket은 연결이 끊어지면 그냥 끊어진다. 개발자가 직접 재연결 로직을 구현해야 한다. Socket.IO는 기본적으로 자동 재연결을 지원하고, 재연결 시도 횟수, 간격, 백오프 전략까지 설정할 수 있다.

javascript
const socket = io('https://api.example.com', {
  reconnection: true,           // 자동 재연결 활성화 (기본값: true)
  reconnectionAttempts: 5,      // 최대 재연결 시도 횟수
  reconnectionDelay: 1000,      // 첫 재연결 대기 시간 (ms)
  reconnectionDelayMax: 5000,   // 최대 재연결 대기 시간 (ms)
});

재연결 대기 시간은 시도할 때마다 늘어난다. 첫 번째 시도는 1초 후, 두 번째는 2초 후... 이런 식으로 reconnectionDelayMax까지 증가한다. 이를 지수 백오프(exponential backoff)라고 하는데, 서버에 과부하가 걸렸을 때 모든 클라이언트가 동시에 재연결을 시도해서 부하를 더 가중시키는 것을 방지한다.

트랜스포트 폴백

WebSocket 연결이 불가능한 환경(기업 방화벽, 프록시 등)에서는 자동으로 HTTP Long Polling으로 폴백한다. 클라이언트 입장에서는 어떤 트랜스포트를 쓰는지 신경 쓸 필요가 없다.

javascript
const socket = io('https://api.example.com', {
  transports: ['websocket'],  // WebSocket만 사용 (폴백 비활성화)
});

성능이 중요하고 WebSocket 지원이 확실한 환경이라면 transports: ['websocket']으로 설정해서 Long Polling 단계를 건너뛸 수 있다. 기본값은 ['polling', 'websocket']인데, 이 경우 먼저 Polling으로 연결한 다음 WebSocket으로 업그레이드하는 과정을 거친다.

이벤트 기반 메시징

순수 WebSocket은 onmessage 하나로 모든 메시지를 받는다. 메시지 타입을 구분하려면 직접 파싱 로직을 만들어야 한다. Socket.IO는 이벤트 이름으로 메시지를 구분할 수 있다.

javascript
// 보내기
socket.emit('chat:message', { text: 'hello', roomId: '123' });

// 받기
socket.on('chat:message', (data) => {
  console.log(data.text);
});

네이티브 WebSocket으로 같은 걸 하려면 이런 식이 된다:

javascript
// 보내기
ws.send(JSON.stringify({ type: 'chat:message', text: 'hello', roomId: '123' }));

// 받기
ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.type === 'chat:message') {
    console.log(data.text);
  }
};

메시지 타입이 늘어날수록 if/elseswitch가 커지고, 타입 안전성도 보장하기 어렵다.


클라이언트 설치와 기본 연결

bash
npm install socket.io-client
javascript
import { io } from 'socket.io-client';

const socket = io('https://api.example.com');

io() 함수에 서버 URL을 전달하면 자동으로 연결을 시도한다. 같은 URL로 io()를 여러 번 호출하면 동일한 인스턴스를 반환한다(멀티플렉싱). 별도 인스턴스가 필요하면 forceNew: true 옵션을 사용한다.

연결 옵션

javascript
const socket = io('https://api.example.com', {
  path: '/events',              // 서버의 Socket.IO 엔드포인트 경로
  transports: ['websocket'],    // 사용할 트랜스포트
  autoConnect: false,           // 수동 연결 (기본값: true)
  auth: {                       // 인증 정보 (핸드셰이크 시 서버에 전달)
    token: 'Bearer xxx',
  },
});

// autoConnect: false일 때 수동으로 연결
socket.connect();

path: 서버의 Socket.IO 엔드포인트 경로다. 기본값은 /socket.io/인데, 서버가 다른 경로를 쓰면 일치시켜야 한다. 리버스 프록시 뒤에 있을 때 경로 충돌을 피하기 위해 커스텀 경로를 쓰는 경우가 많다.

autoConnect: false로 설정하면 io()를 호출해도 바로 연결하지 않는다. 인증 토큰을 먼저 준비해야 하는 경우에 유용하다. 토큰이 준비되면 socket.connect()로 수동 연결한다.

auth: 핸드셰이크 시 서버에 전달되는 인증 정보다. 서버 측에서 socket.handshake.auth로 접근할 수 있다. 매 연결/재연결마다 전달되므로 토큰이 갱신되면 자동으로 새 토큰이 전달된다.


이벤트 수신과 발신

Socket.IO의 통신은 전부 이벤트 기반이다. emit으로 이벤트를 보내고, on으로 이벤트를 받는다.

emit — 이벤트 보내기

javascript
// 단순 이벤트
socket.emit('hello');

// 데이터와 함께
socket.emit('message', { text: 'hello', timestamp: Date.now() });

// 여러 인자
socket.emit('update', 'project-123', { name: 'New Name' });

// 콜백(acknowledgement) — 서버의 응답을 받을 수 있음
socket.emit('save', data, (response) => {
  console.log('서버 응답:', response);
});

emit의 첫 번째 인자는 이벤트 이름, 나머지는 데이터다. 마지막 인자로 함수를 전달하면 acknowledgement가 된다. 서버가 처리 결과를 콜백으로 돌려줄 수 있어서, HTTP 요청-응답과 비슷한 패턴을 구현할 수 있다.

on — 이벤트 받기

javascript
socket.on('notification', (data) => {
  console.log('알림:', data);
});

// 리스너 제거
socket.off('notification');

// 한 번만 받기
socket.once('welcome', (data) => {
  console.log('환영 메시지:', data);
});

on으로 등록한 리스너는 같은 이벤트 이름으로 off를 호출하거나 socket.disconnect()할 때까지 유지된다. once는 한 번 실행된 후 자동으로 제거된다.

내장 이벤트

Socket.IO에는 연결 상태를 추적하기 위한 내장 이벤트들이 있다.

javascript
socket.on('connect', () => {
  console.log('연결됨, id:', socket.id);
});

socket.on('disconnect', (reason) => {
  console.log('연결 해제:', reason);
  // reason: 'io server disconnect', 'io client disconnect',
  //         'ping timeout', 'transport close', 'transport error'
});

socket.on('connect_error', (error) => {
  console.log('연결 에러:', error.message);
});

socket.on('reconnect', (attemptNumber) => {
  console.log(`${attemptNumber}번째 시도에서 재연결 성공`);
});

socket.on('reconnect_attempt', (attemptNumber) => {
  console.log(`재연결 시도 #${attemptNumber}`);
});

socket.on('reconnect_failed', () => {
  console.log('재연결 포기');
});

disconnectreason이 중요하다. 'io server disconnect'는 서버가 의도적으로 끊은 것이고, 'ping timeout'은 네트워크 문제로 끊어진 것이다. 'io server disconnect'인 경우 자동 재연결이 동작하지 않으므로 수동으로 socket.connect()를 호출해야 한다.


싱글턴 패턴으로 소켓 관리

애플리케이션 전체에서 하나의 소켓 인스턴스를 공유해야 할 때 싱글턴 패턴을 사용한다. 여러 컴포넌트나 모듈에서 각각 io()를 호출하면 의도치 않게 여러 연결이 만들어질 수 있다.

typescript
import { Socket, io } from 'socket.io-client';

let socket: Socket | null = null;

export function createSocket(): Socket {
  if (socket) {
    return socket;  // 이미 있으면 기존 인스턴스 반환
  }

  socket = io('https://api.example.com', {
    path: '/events',
    transports: ['websocket'],
    autoConnect: false,
    reconnection: true,
    reconnectionAttempts: 5,
    reconnectionDelay: 1000,
    reconnectionDelayMax: 5000,
  });

  return socket;
}

export function getSocket(): Socket | null {
  return socket;
}

export function destroySocket(): void {
  if (socket) {
    socket.disconnect();
    socket = null;
  }
}

createSocket()은 인스턴스가 없을 때만 새로 만들고, 이미 있으면 기존 것을 반환한다. autoConnect: false로 설정해서 생성 시점과 연결 시점을 분리했다. 인증 토큰이 준비된 후에 socket.connect()를 호출하면 된다.

destroySocket()은 연결을 끊고 참조를 null로 초기화한다. 로그아웃하거나 앱을 정리할 때 호출한다. null 초기화를 하지 않으면 다음에 createSocket()을 호출해도 끊어진 소켓을 그대로 반환하는 버그가 생긴다.


메시지에 커스텀 프로토콜 얹기

Socket.IO의 이벤트 이름을 그대로 메시지 타입으로 쓸 수도 있지만, 하나의 이벤트 이름('message')에 커스텀 opcode를 넣는 방식도 있다. 이 패턴은 서버와 클라이언트 간 프로토콜을 명시적으로 정의할 때 유용하다.

typescript
// opcode 정의
const Opcode = {
  AUTH: 10,
  WORKSPACE: 20,
  PROJECT: 30,
} as const;

const ServerOpcode = {
  ERROR: -1,
  SUCCESS_AUTH: 11,
  SET_WORKSPACE: 21,
  SET_PROJECT: 31,
  NOTIFICATION: 41,
  UPDATE: 42,
} as const;

클라이언트 → 서버 방향(Opcode)과 서버 → 클라이언트 방향(ServerOpcode)을 분리해서 정의한다. 숫자로 opcode를 구분하면 페이로드 크기가 줄어들고, 서버/클라이언트 간 프로토콜 문서화가 명확해진다.

typescript
// 메시지 전송
socket.emit('message', { op: Opcode.AUTH, d: { token: 'xxx' } });
socket.emit('message', { op: Opcode.WORKSPACE, d: { workspaceId: 'ws-123' } });

// 메시지 수신
socket.on('message', (data: { op: number; d: any }) => {
  switch (data.op) {
    case ServerOpcode.SUCCESS_AUTH:
      console.log('인증 성공');
      break;
    case ServerOpcode.NOTIFICATION:
      console.log('알림:', data.d);
      break;
    case ServerOpcode.UPDATE:
      console.log('업데이트:', data.d);
      break;
    case ServerOpcode.ERROR:
      console.error('에러:', data.d);
      break;
  }
});

이 패턴의 장점은 프로토콜이 코드에 명시적으로 드러난다는 것이다. socket.on('chat:message'), socket.on('user:typing') 같은 방식은 이벤트 이름이 문자열이라 오타가 나기 쉽고, 어떤 이벤트가 있는지 한눈에 파악하기 어렵다. opcode 방식은 상수 객체 하나만 보면 전체 프로토콜을 파악할 수 있다.


연결 생명주기

Socket.IO 클라이언트의 연결은 다음 순서를 따른다.

text
io() 호출

핸드셰이크 (HTTP 업그레이드 또는 Long Polling)

connect 이벤트 발생 → socket.id 할당

메시지 송수신

연결 유지 (ping/pong heartbeat)

disconnect 이벤트 (서버 종료, 네트워크 끊김, 수동 disconnect)

reconnection: true면 자동 재연결 시도

Heartbeat

Socket.IO는 연결이 살아있는지 확인하기 위해 주기적으로 ping/pong 패킷을 교환한다. 서버가 ping을 보내고, 클라이언트가 pong으로 응답하는 방식이다. 서버가 일정 시간(기본 20초) 내에 pong을 받지 못하면 연결이 끊어진 것으로 간주하고 disconnect 처리한다.

이 heartbeat 메커니즘 덕분에 네트워크 문제로 연결이 끊어졌을 때 빠르게 감지할 수 있다. TCP keep-alive만으로는 연결 끊김 감지에 수 분이 걸릴 수 있는데, Socket.IO의 heartbeat는 초 단위로 감지한다.

수동 연결/해제

javascript
// 연결
socket.connect();

// 연결 해제 — 자동 재연결 비활성화됨
socket.disconnect();

// 연결 상태 확인
console.log(socket.connected); // true/false

disconnect()를 호출하면 자동 재연결도 중지된다. 다시 연결하려면 명시적으로 connect()를 호출해야 한다. 이건 의도적인 설계다. 사용자가 로그아웃해서 disconnect()를 호출했는데 자동으로 재연결되면 곤란하니까.


Socket.IO vs 대안 비교

Socket.IO순수 WebSocketSSE (Server-Sent Events)
방향양방향양방향서버 → 클라이언트 단방향
프로토콜Socket.IO 프로토콜 (WebSocket 위)WebSocketHTTP
자동 재연결✅ 내장❌ 직접 구현✅ 브라우저 내장
폴백Long Polling → WebSocket
이벤트 라우팅✅ (이벤트 이름)❌ (직접 구현)❌ (단일 스트림)
Room/Namespace✅ (서버 측)
번들 크기~45KB (min+gzip)0 (브라우저 내장)0 (브라우저 내장)
호환성Socket.IO 서버 필수표준 WebSocket 서버HTTP 서버

순수 WebSocket을 선택해야 할 때: 바이너리 데이터 전송이 많거나, 번들 크기가 중요하거나, Socket.IO가 아닌 서버(게임 서버 등)와 통신해야 할 때.

SSE를 선택해야 할 때: 서버에서 클라이언트로의 단방향 알림만 필요할 때. 주식 시세, 뉴스 피드 등. HTTP 기반이라 기존 인프라를 그대로 쓸 수 있다.

Socket.IO를 선택해야 할 때: 양방향 실시간 통신이 필요하고, 자동 재연결/폴백/room 등의 기능이 필요할 때. 채팅, 협업 도구, 실시간 알림 시스템에 적합하다.


관련 문서