실시간 통신? 그거 그냥 붙이면 되는 거 아님?
처음 프로젝트를 시작할 때만 해도 이렇게 자신만만했습니다. Socket.IO라는 라이브러리도 있고, React도 익숙했으니까요. 하지만 개발이 진행될수록 예상치 못한 문제들이 하나둘씩 나타나기 시작했습니다.
컴포넌트가 마운트될 때마다 새로운 소켓 연결이 생기고, 페이지를 이동할 때마다 연결이 끊어지고, 여러 컴포넌트에서 같은 소켓 이벤트를 처리하려니 코드는 점점 스파게티가 되어갔죠.
이 글에서는 제가 실시간 웹 게임을 개발하면서 겪은 웹소켓-리액트 관련 문제와 그것을 해결하기 위해 고민하고 적용한 커스텀 계층형 아키텍처에 대해 이야기해보려 합니다.
처음에는 가장 직관적인 방법을 선택했습니다. 컴포넌트에서 직접 WebSocket 연결을 관리하는 거죠.
const GameRoom = () => {
useEffect(() => {
const socket = io('server-url');
socket.on('gameUpdate', handleGameUpdate);
return () => socket.disconnect();
}, []);
return <div>게임 룸</div>;
};
간단해 보이죠? 물론 어떠한 문제가 있을까? 하고 씨게 준비를 하고 있었죠.
예상대로, 다양한 문제가 터져나왔습니다.
React의 선언적이고 단방향적인 데이터 흐름은 실시간 양방향 통신이 필요한 WebSocket과 자연스럽게 어우러지기 어렵습니다. 구체적인 문제점들을 살펴보겠습니다.
한 컴포넌트에 직접 웹소켓 코드를 작성해보겠습니다.
// 🚨 위험한 패턴
const GameLobby = () => {
const [rooms, setRooms] = useState([]);
// 로비에서도 소켓 연결
useEffect(() => {
const socket = io('server-url');
socket.on('roomList', setRooms);
return () => socket.disconnect();
}, []);
};
const GameRoom = () => {
// 게임룸에서도 소켓 연결
useEffect(() => {
const socket = io('server-url');
socket.on('gameState', handleGameState);
return () => socket.disconnect();
}, []);
};
이렇게 되니 리액트의 생명주기에 맞춰 컴포넌트가 리렌더링 되니 의도적이지 않은, 소켓이 연결되고 끊어지는 현상이 생겼습니다.
또한, 웹소켓은 실시간으로 서버와 데이터를 주고받기 위해 탄생한 기능입니다. 그러다보니 자연스럽게 소켓에 붙은 데이터가 많아지기 시작했습니다. 그 소켓의 부산물인 데이터들을 관리하고 다양한 데이터를 하위 컴포넌트에 Props로 내려줘야하는데, 자연스레 Drilling도 생기고 성능 상 문제가 생겨 많은 불편함을 겪었습니다.
이런 문제들이라면 불필요한 연결 시도 많이 생겨 서버 부하도 많이 커지겠네요. 백엔드 개발자가 싫어하겠어요!
// 🚨 도저히 감당하기 힘든 상태 관리
const GameProvider = ({ children }) => {
const [gameState, setGameState] = useState({});
const [chatMessages, setChatMessages] = useState([]);
const [players, setPlayers] = useState([]);
const [drawingData, setDrawingData] = useState({});
// ... 그리고 수많은 상태들
useEffect(() => {
socket.on('gameUpdate', setGameState);
socket.on('newMessage', handleNewMessage);
socket.on('playerJoin', handlePlayerJoin);
socket.on('drawing', handleDrawing);
// ... 끝없는 이벤트 핸들러들
}, []);
// 😱 Props Drilling의 시작
return (
<GameContext.Provider value={{
gameState,
chatMessages,
players,
drawingData,
// ... 더 많은 상태들
}}>
{children}
</GameContext.Provider>
);
};
위에서 말했듯 웹소켓 데이터를 관리하는 것도 중요합니다. 그를 위해 Props로 내려줬더니 불편함이 생겼습니다. 그러면 Context API는 어떨까요?
이제 연결이 끊어지지는 않겠지만 Props Drilling과 마찬가지로 하나의 상태가 변경됐는데 그 웹소켓을 받은 모든 하위 컴포넌트가 리렌더링 되는 현상이 생길 겁니다. 예를 들어, 타이머 관련 상태가 변경됐는데 그림, 플레이어, 채팅 관련 컴포넌트들이 전부 리렌더링 되는 거죠.
심지어 코드를 보면 알겠지만 상태 관리도 그렇게 편한 건 아닙니다. Provider를 나눈다고 한들 문제는 그대로일 거 같네요.
즉, 교훈은 아래와 같습니다.
그러면 어떻게 해야했을까요?
이런 문제들을 겪으면서 깨달은 것이 있습니다. WebSocket 통신은 단순히 데이터를 주고받는 것 이상의 복잡성을 가지고 있다는 것이죠. 우리에게 필요한 건 체계성이었습니다.
웹 게임 프로젝트 특성 상 아래의 목표를 생각해봤습니다.
위 문제들을 해결하기 위해 다음과 같은 4계층 아키텍처를 설계했습니다.
enum SocketNamespace {
GAME = 'game',
DRAWING = 'drawing',
CHAT = 'chat',
}
이러한 커스텀 계층형 구조의 가장 큰 장점은 '관심사의 분리'입니다. 각 계층은 자신의 역할만 수행하면 되죠. 즉, 확장도 가능하고 직관적으로 생각할 수 있게 됐습니다.
물론 어떤 계층인지 알아야하는 러닝 커브가 있지만요 ㅠ
설계대로 구현해보죠!
소켓 설정만을 담은 계층입니다.
// socket.config.ts
export const SOCKET_CONFIG = {
URL: import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000',
PATHS: {
[SocketNamespace.GAME]: '/game',
[SocketNamespace.DRAWING]: '/drawing',
[SocketNamespace.CHAT]: '/chat',
},
BASE_OPTIONS: {
autoConnect: false, // 연결은 우리가 제어
reconnection: true, // 끊어져도 다시 연결을 시도
reconnectionAttempts: 5, // 5번까지만 재시도
}
};
autoConnect: false
로 설정하면 소켓 인스턴스 생성과 연결 시점을 분리할 수 있습니다. 이는 인증 토큰과 같은 정보를 연결 전에 준비할 수 있게 해줍니다.
사용할 소켓 인스턴스와 연결 상태 관리를 담아 각 소켓의 상태를 파악하고, 공통 소켓 액션을 정의한 계층입니다.
즉, Socket Store는 소켓 인스턴스와 연결 상태만 관리합니다. 게임 상태나 채팅 메시지 같은 도메인 데이터는 다른 스토어에서 관리하죠.
// socket.store.ts
export const useSocketStore = create<SocketState>((set) => ({
// 소켓 인스턴스 관리
sockets: {
[SocketNamespace.GAME]: null,
[SocketNamespace.DRAWING]: null,
[SocketNamespace.CHAT]: null,
},
// 연결 상태 관리
connected: {
[SocketNamespace.GAME]: false,
[SocketNamespace.DRAWING]: false,
[SocketNamespace.CHAT]: false,
},
// 연결 관련 액션들
actions: {
connect: (namespace, auth?) => {
const socket = socketCreators[namespace](auth);
socket.connect();
set(state => ({
sockets: { ...state.sockets, [namespace]: socket }
}));
},
// ... 더 많은 액션들
}
}));
아 참고로, 전역 상태를 쓰려고 했기 때문에 상태 관리의 핵심인 Socket Store는 Zustand를 사용해 구현했습니다.
도메인별 상태 관리 계층입니다. 게임, 드로잉, 채팅 소켓의 부산물인 상태를 관리하는 계층입니다.
각 도메인(게임, 채팅, 그리기 등)은 자신만의 모양(Domain Data)이 있습니다. 이 모양을 담는 곳이 바로 Domain Store입니다.
// gameSocket.store.ts
export const useGameSocketStore = create<GameState & GameActions>()(
devtools( // 🔍 Redux DevTools로 디버깅이 가능합니다
(set) => ({
room: null,
players: [],
actions: {
updateRoom: (room) => set({ room }),
updatePlayers: (players) => set({ players }),
}
}),
{ name: 'GameStore' }
)
);
각 도메인 스토어는 자신의 데이터만 관리합니다. 게임 스토어는 게임 상태를, 채팅 스토어는 채팅 메시지를 담당하는 식이죠. 이렇게 하면 한 도메인의 변경이 다른 도메인에 영향을 주지 않습니다.
이제 가장 중요한 부분입니다. 어떻게 이 모든 계층을 실제 컴포넌트에서 쉽게 사용할 수 있을까요?
바로 연결 로직 + 응답 이벤트 등록을 담은 Custom Hooks와 요청 이벤트 함수를 담아낸 Handlers를 구현한 계층으로 이제 컴포넌트와 직접 상호작용할 겁니다.
컴포넌트 수준의 소켓 이벤트 처리를 구현해 호출만으로 컴포넌트에 쉽게 연동하도록 계층을 구현했습니다.
Custom Hooks에는 직접 연결 실행과 서버에게 응답을 받아주는 구독 로직인 이벤트 리스너를 등록해주는 로직을 담아두고 있습니다.
// useGameSocket.ts
export const useGameSocket = () => {
const { roomId } = useParams();
const { sockets, connected, actions: socketActions } = useSocketStore();
const { actions: gameActions } = useGameSocketStore();
useEffect(() => {
if (!roomId) return;
// 1️⃣ 소켓 연결
socketActions.connect(SocketNamespace.GAME);
// 2️⃣ 이벤트 리스너 설정
const handlers = {
// 게임 이벤트 리스너들
joinedRoom: (response: JoinRoomResponse) => {
gameActions.updateRoom(response.room);
gameActions.updatePlayers(response.players);
},
// ... 더 많은 리스너들
};
// 3️⃣ 이벤트 바인딩
Object.entries(handlers).forEach(([event, handler]) => {
socket.on(event, handler);
});
// 4️⃣ 클린업
return () => {
socketActions.disconnect(SocketNamespace.GAME);
};
}, [roomId]);
// 5️⃣ 컴포넌트에서 필요한 것들만 반환
return {
isConnected: connected.game,
actions: gameActions,
};
};
Handlers에는 직접 서버에 요청하는 로직을 담고 있습니다.
import type { JoinRoomRequest, JoinRoomResponse, ReconnectRequest } from '@troublepainter/core';
import { useSocketStore } from '@/stores/socket/socket.store';
// socket 요청만 처리하는 핸들러
export const gameSocketHandlers = {
joinRoom: (request: JoinRoomRequest): Promise<JoinRoomResponse> => {
const socket = useSocketStore.getState().sockets.game;
if (!socket) throw new Error('Socket not connected');
return new Promise(() => {
socket.emit('joinRoom', request);
});
},
reconnect: (request: ReconnectRequest): Promise<void> => {
const socket = useSocketStore.getState().sockets.game;
if (!socket) throw new Error('Socket not connected');
return new Promise(() => {
socket.emit('reconnect', request);
});
},
};
export type GameSocketHandlers = typeof gameSocketHandlers;
GameLayout
!레이아웃에 계층형 아키텍처를 실제로 적용해 구현해보겠습니다.
// GameLayout.tsx
const GameLayout = () => {
// 필요한 것만 : 연결, 액션 등
const { isConnected, actions } = useGameSocket();
// 연결 상태에 따른 UI 처리
if (!isConnected) {
return <LoadingSpinner message="연결 중..." />;
}
return (
<div className="flex min-h-screen flex-col">
<header>
<Logo variant="side" />
</header>
<main className="mx-auto">
<div className="flex">
{/* 플레이어 목록 */}
<PlayerList />
{/* 게임 영역 */}
<section className="flex-1">
<Outlet />
</section>
{/* 채팅 영역 */}
<Chat />
</div>
</main>
</div>
);
};
비포 & 애프터
- 이전 : 소켓 연결, 이벤트 핸들링, 상태 관리가 모두 컴포넌트에 있었습니다.
- 이후 : GameLayout 컴포넌트는 오직 UI 로직에만 집중합니다.
특히 제일 체감됐던 것이 개발 생산성이 증대 됐다는 점입니다.
새로운 기능을 추가하는 데 걸리는 시간도 크게 단축되었습니다. 새로운 게임 모드 추가 시 필요한 작업을 예시로 들어보겠습니다.
생산성 향상 포인트
다른 팀원분의 새로운 웹소켓 및 기능 추가 시 구현 방법 등의 DX가 올라갔다는 피드백을 받았습니다.
물론 이 아키텍처가 완벽한 것은 아닙니다. 프로젝트를 진행하면서 발견한 한계점들도 있었습니다.
계층형 구조를 처음 설정하는 것은 꽤 많은 시간이 필요했습니다. 작은 프로젝트라면 과도한 설계일 수 있죠.
// 작은 프로젝트에서는 이런 방식도 충분할 수 있습니다
const SimpleSocket = () => {
const socket = useSocket();
return <div>{/* 간단한 실시간 기능 */}</div>;
};
팀의 새로운 개발자들이 이 구조를 이해하는 데 시간이 필요했습니다. 하지만 일단 이해하고 나면 개발 속도가 크게 향상되었죠.
여러 계층에 걸친 타입 정의를 관리하는 것이 조금은 어려웠습니다.
// 타입 정의가 많아질수록 관리가 필요합니다
type GameEvents = {
[K in keyof ServerToClientEvents]: {
event: K;
handler: ServerToClientEvents[K];
};
}[keyof ServerToClientEvents];
제가 사용해보지 않거나 자주 사용하지 않았던 enum
이라든지, 제네릭을 한 번 사용해보니 어쩔 때 쓰는지 감이 안잡혀 힘들었습니다. 클로드!!!!!! 난 니가 좋다!!!!!
+ 추가
TS의enum
에 성능 문제가 있는 거 아시나요? JS로 변환 시 보일러 플레이트가 많게끔 설계돼 있어 번들 크기가 커진답니다!
물론 한 1만개가 있어야 로딩이 길어진다고 하네요. 그래도 전 썼습니다.enum
이 주는 익숙함과 편함이 좋았거든요. 적용된 타입도 몇개 안됐기도 합니다.
하나의 아키텍처 패턴은 계속 진화해야합니다. 앞으로 고려하고 있는 개선 방향을 공유하며 글을 마무리하겠습니다.
자동화된 테스트 강화
성능 모니터링 강화
개발자/사용자 경험 개선
💬 궁금한 점
- 여러분은 React에서 WebSocket을 어떻게 관리하시나요?
- 이 아키텍처의 개선점은 무엇이 있을까요?
- 실시간 기능 구현에서 겪은 어려움은 무엇인가요?
이 글이 React와 WebSocket을 함께 사용하시는 분들에게 조금이나마 도움이 되었기를 바랍니다.
// ❌ 컴포넌트 내 로컬 상태 관리의 문제점
const GameRoom = () => {
const [socket, setSocket] = useState<Socket | null>(null);
const [gameState, setGameState] = useState({});
const [connected, setConnected] = useState(false);
// 문제점:
// 1. 상태 전파를 위한 Props Drilling
// 2. 컴포넌트 간 상태 동기화 어려움
// 3. 페이지 새로고침 시 상태 유실
return <GameComponent socket={socket} gameState={gameState} />;
};
// ❌ Context API 사용 시의 문제점
const SocketProvider = ({ children }) => {
const [gameState, setGameState] = useState({});
useEffect(() => {
// 모든 이벤트 핸들러가 한 곳에 집중
socket.on('gameStart', onGameStart);
socket.on('playerJoin', onPlayerJoin);
socket.on('drawing', onDrawing);
// ... 계속되는 이벤트 핸들러들
}, []);
// 문제점:
// 1. Provider가 비대해짐
// 2. 불필요한 리렌더링 발생
// 3. 기능별 분리가 어려움
return (
<SocketContext.Provider value={{ socket, gameState }}>
{children}
</SocketContext.Provider>
);
};
const Room = () => {
// 특정 상태만 구독하여 불필요한 리렌더링 방지
const room = useGameStore((state) => state.room);
const updateRoom = useGameStore((state) => state.actions.updateRoom);
useEffect(() => {
socket.on('roomUpdate', updateRoom);
}, []);
};
const useGameStore = create<GameState>()((set, get) => ({
room: null,
players: [],
actions: {
// 다른 스토어의 상태 참조 가능
updateRoom: (room) => {
set({ room });
get().actions.syncWithSocket(room);
}
}
}));
const useStore = create((set) => ({
socket: null,
connect: () => set({ socket: io() }),
disconnect: () => set({ socket: null })
}));
이러한 이유로 Zustand를 사용한 계층형 아키텍처가 WebSocket 통신과 상태 관리를 효과적으로 다룰 수 있는 최적의 선택이었습니다.