main-logo

Socket.IO로 구현하는 실시간 양방향 통신

Socket.IO로 실시간 양방향 통신하는 방법을 알아봅시다.

profile
BR
2024년 11월 30일 · 0 분 소요

들어가며

최근 실시간 알림 등 양방향 통신이 필요한 기능에 대한 관심이 많아지면서 자연스럽게 WebSocket에 관심을 갖게 되었습니다. 특히 WebSocket을 좀 더 쉽게 구현할 수 있게 해주는 Socket.IO라는 라이브러리를 알게 되어 공유해보고자 합니다.


WebSocket과 Socket.IO

WebSocket이란?

WebSocket은 클라이언트와 서버 간의 양방향 통신을 가능하게 하는 프로토콜입니다. 기존의 HTTP 통신이 클라이언트의 요청에 서버가 응답하는 단방향 통신이었다면, WebSocket은 연결이 수립된 후에는 양쪽에서 자유롭게 데이터를 주고받을 수 있습니다.

Socket.IO란?

Socket.IO는 WebSocket을 기반으로 하되, 더욱 다양한 기능을 제공하는 라이브러리입니다. 주요 특징은 다음과 같습니다.

  1. 자동 재연결: 연결이 끊어졌을 때 자동으로 재연결을 시도합니다.
  2. 폴백 매커니즘: WebSocket을 지원하지 않는 환경에서는 자동으로 다른 방식(polling)으로 전환됩니다.
  3. 룸과 네임스페이스: 특정 그룹에만 메시지를 전송할 수 있는 기능을 제공합니다.
  4. 이벤트 기반 통신: 커스텀 이벤트를 정의하고 처리할 수 있습니다.

Next.js + TypeScript 환경에서 Socket.IO 설정하기

먼저 필요한 패키지를 설치합니다.

npm install socket.io-client
npm install -D @types/socket.io-client

실시간 채팅 구현해보기

다음은 실시간 채팅 기능을 구현하는 간단한 예시입니다.

// hooks/useSocket.ts
import { useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';

export const useSocket = () => {
  const socket = useRef<Socket | null>(null);

  useEffect(() => {
    // 소켓 연결
    socket.current = io('http://localhost:3000', {
      path: '/api/socketio',
    });

    // 연결 이벤트 리스너
    socket.current.on('connect', () => {
      console.log('소켓 연결 성공!');
    });

    // 클린업
    return () => {
      if (socket.current) {
        socket.current.disconnect();
      }
    };
  }, []);

  return socket.current;
};
// pages/chat.tsx
import { useEffect, useState } from 'react';
import { useSocket } from '../hooks/useSocket';

export default function ChatPage() {
  const [messages, setMessages] = useState<string[]>([]);
  const [inputMessage, setInputMessage] = useState('');
  const socket = useSocket();

  useEffect(() => {
    if (!socket) return;

    // 메시지 수신 리스너
    socket.on('receive_message', (message: string) => {
      setMessages(prev => [...prev, message]);
    });

    return () => {
      socket.off('receive_message');
    };
  }, [socket]);

  const sendMessage = () => {
    if (!socket || !inputMessage.trim()) return;

    socket.emit('send_message', inputMessage);
    setInputMessage('');
  };

  return (
    <div>
      <div>
        {messages.map((msg, index) => (
          <div key={index}>
            {msg}
          </div>
        ))}
      </div>
      <div>
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          placeholder="메시지를 입력하세요"
        />
        <button
          onClick={sendMessage}
          type="button"
        >
          전송
        </button>
      </div>
    </div>
  );
}

API 라우트 설정

Next.js에서 Socket.IO 서버를 설정하기 위해 API 라우트를 생성합니다.

// pages/api/socketio.ts
import { Server } from 'socket.io';
import { NextApiRequest } from 'next';
import { NextApiResponseServerIO } from '../../types/next';

export default function SocketHandler(
  req: NextApiRequest,
  res: NextApiResponseServerIO
) {
  if (!res.socket.server.io) {
    const io = new Server(res.socket.server, {
      path: '/api/socketio',
    });

    io.on('connection', (socket) => {
      socket.on('send_message', (message) => {
        io.emit('receive_message', message);
      });
    });

    res.socket.server.io = io;
  }

  res.end();
}

export const config = {
  api: {
    bodyParser: false,
  },
};

이러한 방식을 통해 간단하게 실시간 양방향 통신을 구현 할 수 있습니다.

이제 보다 안정적이고 효율적인 실시간 통신 기능을 구현하기 위한 socket.io의 이벤트 핸들링 및 에러처리, 성능 최적화 방법에 대해 살펴보도록 하겠습니다.

Socket.IO 이벤트 핸들링

Socket.IO는 다양한 이벤트 핸들링 방식을 제공합니다. 특히 룸(Room) 기능을 활용하면 특정 그룹에만 메시지를 전송하거나, 브로드캐스트를 통해 전체 사용자에게 메시지를 전달할 수 있습니다.

// 서버 사이드
io.on('connection', (socket) => {
  // 룸 참여 - 채팅방이나 게임 룸과 같은 독립된 공간을 만들 때 사용
  socket.on('join_room', (roomId) => {
    socket.join(roomId);
    io.to(roomId).emit('user_joined', `새로운 사용자가 참여했습니다.`);
  });

  // 특정 룸에만 메시지 전송 - 해당 룸에 있는 사용자들에게만 메시지 전달
  socket.on('room_message', ({ roomId, message }) => {
    io.to(roomId).emit('receive_room_message', message);
  });

  // 브로드캐스트 - 공지사항이나 전체 알림을 보낼 때 유용
  socket.on('broadcast', (message) => {
    socket.broadcast.emit('receive_broadcast', message);
  });

  // 연결 해제 시 - 사용자가 퇴장할 때 다른 사용자들에게 알림
  socket.on('disconnect', () => {
    io.emit('user_disconnected', `사용자가 퇴장했습니다.`);
  });
});

에러 처리

실제 서비스 환경에서는 다양한 네트워크 문제와 예외 상황이 발생할 수 있습니다. 다음은 주요 에러 상황에 대한 처리 방법입니다.

// hooks/useSocket.ts
export const useSocket = () => {
  const socket = useRef<Socket | null>(null);

  useEffect(() => {
    // 기본 연결 설정에 재연결 옵션 추가
    socket.current = io('http://localhost:3000', {
      path: '/api/socketio',
      reconnectionAttempts: 5, // 최대 5번까지 재연결 시도
      reconnectionDelay: 1000, // 재연결 시도 간격 1초
    });

    // 연결 에러 발생 시 처리
    socket.current.on('connect_error', (err) => {
      console.error('연결 에러:', err.message);
      // 사용자에게 친숙한 에러 메시지 표시
      toast.error('서버 연결에 실패했습니다.');
    });

    // 재연결 시도 중인 상태를 사용자에게 알림
    socket.current.on('reconnect_attempt', (attemptNumber) => {
      console.log(`재연결 시도 ${attemptNumber}`);
    });

    // 모든 재연결 시도가 실패했을 때 처리
    socket.current.on('reconnect_failed', () => {
      console.error('재연결 실패');
      toast.error('서버 연결이 불안정합니다. 페이지를 새로고침해주세요.');
    });

    // 타임아웃이 있는 메시지 전송을 위한 유틸리티 함수
    const sendWithTimeout = async (event: string, data: any) => {
      return new Promise((resolve, reject) => {
        socket.current?.timeout(5000).emit(event, data, (err: any, response: any) => {
          if (err) {
            reject(err);
            return;
          }
          resolve(response);
        });
      });
    };

    return () => {
      socket.current?.disconnect();
    };
  }, []);

  return socket.current;
};

성능 최적화 

대규모 트래픽이나 실시간 데이터가 많은 서비스에서 고려해야 할 최적화 방법들입니다.

이벤트 배칭 : 여러 메시지를 모아서 한 번에 전송

// 클라이언트 사이드
const batchedMessages: string[] = [];
let timeoutId: NodeJS.Timeout | null = null;

// 배치된 메시지 전송 함수
const sendBatchedMessages = () => {
  if (batchedMessages.length > 0) {
    socket.emit('batch_messages', batchedMessages);
    batchedMessages.length = 0;
  }
};

// 메시지를 배치에 추가하고 일정 시간 후 전송
const sendMessage = (message: string) => {
  batchedMessages.push(message);
  
  if (!timeoutId) {
    timeoutId = setTimeout(() => {
      sendBatchedMessages();
      timeoutId = null;
    }, 100); // 100ms 동안 메시지 모으기
  }
};

메시지 압축 : 대용량 데이터 전송 시 효율성 향상

// 서버 사이드
io.on('connection', (socket) => {
  socket.on('send_message', (message) => {
    // 대용량 데이터의 경우 압축하여 전송
    const compressed = compress(message);
    io.emit('receive_message', compressed);
  });
});

연결 최적화 : 웹소켓 연결의 효율성 향상

// 효율적인 웹소켓 연결 설정
const socket = io('http://localhost:3000', {
  path: '/api/socketio',
  transports: ['websocket'], // polling 단계 건너뛰기로 연결 속도 향상
  upgrade: false, // 웹소켓으로 즉시 시작하여 불필요한 프로토콜 전환 방지
  pingInterval: 10000, // 연결 상태 확인 주기 설정
  pingTimeout: 5000 // 연결 타임아웃 설정
});

 


마치며

이번에 공부하면서 느낀 것은 WebSocket과 Socket.IO를 활용하면 실시간 양방향 통신을 구현하는 것이 생각보다 어렵지 않다는 점이었습니다. Socket.IO는 WebSocket의 복잡한 부분을 추상화하고, 편리한 기능들을 제공하여 개발자가 실시간 기능 구현에 집중할 수 있게 도와주기 때문입니다. 사용법이 어렵지 않으니, 양방향 통신이 필요한 다양한 프로젝트에 적용해보면 좋을 것 같습니다.

오늘도 읽어주셔서 감사합니다. 🙇‍♀️

참고문헌

Socket.io Docs
Websocket (MDN)