WebSocket 활용기 with Next.js, Socket.io

2023.12.31

Table of Contents

서론

팀 프로젝트에서 심지어 이력서도 Figma로 작성할 정도로 정말 잘 활용하고 있었다. 문득 실시간으로 다른 사람들과 작업할 수 있는 네트워크를 어떻게 구현했을까라는 생각이 들었고 개발자 도구를 확인했다.
선긋기와 개발자 도구
선긋기와 개발자 도구
양방향 데이터 전송이 WebSocket을 통해 이뤄진다는 점을 확인했다. 토이 프로젝트를 통해 경험해보고 싶다는 생각을 했고 간단한 채팅 애플리케이션을 떠올리게 되었다. 프로젝트를 시작하기 앞서 HTTP 통신과 관련 내용들에 대해 학습하고 정리하였으나 이번 글에서는 다루지 않을 예정이다.
이 과정에서 Next.js 13버전과 Socket.io를 활용했고 이전에 새로운 버전의 Next.js 연습하기 위해 세팅해두었던 프로젝트에 새로운 페이지를 생성하며 시작했다.

버전 정보

"next": "13.4.12", "socket.io": "^4.7.2", "socket.io-client": "^4.7.2"

서버 세팅

Node.js를 통해 백엔드 서버를 직접 구현해야 한다는 점과 서버가 켜져 있어야 한다는 불편함에 Next.js의 백엔드 기능(Route Handlers)을 활용해 서버를 구축하기로 결정하게 되었다. 그리고 편의성을 제공하는 라이브러리인 Socket.io를 선택했다.

문제점1 - app router와의 호환성

시작하자마자 문제가 생겼다. 13버전의 app router의 router handler와 pages router의 API routes는 다른 방법으로 코드를 작성해야 한다.
// Route Handlers export async function GET(request: Request) {}
// API routes export default function handler(req, res) {}
우선 pages router의 API Routes를 활용한 방법을 채택하고 구현하게 되었다.
이유 1 - Next.jsSocket.io를 연결할 방법이 없다.
코드를 작성하며 느꼈던 문제는 Next의 서버 인스턴스를 받아 이를 Socket.io와 연결해야 하는데 그 방법이 제공되지 않는다는 점이였다. 반면 handler의 res를 사용하면 서버의 인스턴스에 접근할 수 있었다.
이유 2 - Socket.io와 제대로 호환되지 않는다.
추후 서버 연결이 아닌 POST를 처리하는 과정에서도 다시 한번 Route Handlers를 사용해봤으나 역시 호환이 제대로 되지 않아 문제가 발생했다.

구현 with TS

// pages/api/socket/io.ts import {Server as NetServer} from 'http'; import {Socket} from 'net'; import {NextApiRequest, NextApiResponse} from 'next'; import {Server as ServerIO} from 'socket.io'; import {ServerToClientEvents} from '@/types/socket'; export type NextApiResponseServerIO = NextApiResponse & { socket: Socket & { server: NetServer & { io: ServerIO<ServerToClientEvents>; }; }; }; const ioHandler = (req: NextApiRequest, res: NextApiResponseServerIO) => { if (!res.socket.server.io) { const httpServer = res.socket.server as NetServer; const io = new ServerIO(httpServer, { path: '/api/socket/io', addTrailingSlash: false, }); res.socket.server.io = io; } res.end(); }; export default ioHandler;
Socket.io에 타입스크립트를 적용하기 위해서는 공식 문서를 참고하는 것을 추천드린다. 따로 타입을 지정함으로 자동완성이나 에러를 쉽게 발견할 수 있었다.

클라이언트 구현

간단한 애플리케이션이기에 상태 관리 라이브러리를 활용하는 것보다는 Context API와 커스텀훅을 활용했다.
context로 구현하고 싶은신 분들을 위해 링크로 빼겠습니다.

연결 관련 이벤트 등록

// component/provider/SocketProvider.tsx 'use client'; ... export const SocketProvider = ({children}: {children: React.ReactNode}) => { const [socket, setSocket] = useState<ClientSocketType | null>(null); const [isConnected, setIsConnected] = useState(false); useEffect(() => { const socket: ClientSocketType = io(process.env.NEXT_PUBLIC_SITE_URL!, { path: '/api/socket/io', addTrailingSlash: false, }); socket.on('connect', () => { setIsConnected(true); }); socket.on('error', (error: Error) => { console.error(error); }); socket.on('disconnect', () => { setIsConnected(false); }); setSocket(socket); return () => { if (socket) { socket.disconnect(); } }; }, []); ... };
서버와 클라이언트가 연결되면 이벤트를 등록하고 chat 페이지에만 socket을 사용하고 있어 해당 페이지의 layout에 provider로 감싸주었다. 그리고 언마운트되면 cleanup function을 통해 정리하도록 구현했다. 제대로 연결되는지 인디케이터를 만들어서 확인해봤다.
인디케이터로 동작 확인
인디케이터로 동작 확인

메시지 이벤트

socket을 활용한 다른 기능 활용시 메시지 이벤트를 등록하지 않아도 되는지 확인하고 싶어서 chat 페이지에서 이벤트를 등록하는 방법을 활용해봤다.
'use client'; ... import {useSocket} from '@/components/provider/SocketProvider'; import {IMessage} from '@/types/chat'; const Chat = () => { const {socket} = useSocket(); const [messages, setMessages] = useState<IMessage[]>([]); ... const sendMessage = async () => { if (currentMessage) { const res = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ user: username, content: currentMessage, }), }); if (res.ok) setCurrentMessage(''); } }; useEffect(() => { socket?.on('message', (message: IMessage) => { setMessages((prev) => [...prev, message]); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [socket]); ... export default Chat;
message를 송수신할 수 있는 이벤트를 socket이 연결되어 있을때 등록하고 상태를 변경하는 방법으로 화면에 표시할 수 있었고 sendMessage함수의 Post 요청을 처리하는 부분의 코드는 아래와 같다.

chatHandler

// pages/api/chat.ts import {NextApiRequest} from 'next'; import {IMessage} from '@/types/chat'; import {NextApiResponseServerIO} from './socket/io'; const chatHandler = (req: NextApiRequest, res: NextApiResponseServerIO) => { if (req.method === 'POST') { const message = JSON.parse(req.body) as IMessage; res.socket.server.io.emit('message', message); res.status(201).json(message); } }; export default chatHandler;
또 다른 handler를 작성한 이유
sendMessage 안에서 바로 emit을 하는 경우에는 아무 동작도 이루어지지 않는 점 때문이였다. 추론이지만 네트워크 동작을 통해서만 Socket에 접근하고 이벤트를 발생시킬 수 있다고 생각하게 되었다.

결과물

3개의 브라우저를 띄우고 메시지를 보내보았다. 나름 이쁜 결과물을 만들기 위해 Mui를 활용했다.
notion image

마치며

궁금증으로 시작한 토이 프로젝트가 HTTP 통신에 대한 학습, Next.js의 serverless 백엔드 활용까지 다양한 경험을 이끌게 되었다. 초반에 걱정했던 WebSocketSocket.io의 차이점 중 하나인 속도 면에서도 나쁘지 않은 성능을 보여주었다. 신경썼던 타입 적용도 잘 이루어졌다.
지금은 정말 간단한 채팅이지만 추후 Figma처럼 공동 작업이나 아예 새로운 기능을 구현해보고 싶다.
 

Prev
Next.js App Router 페이지 이동 막기
Next
함수형 프로그래밍 - Curry