React와 WebSocket은 사이가 안좋아

윤뿔소·2024년 12월 27일
1

React

목록 보기
4/4

실시간 통신? 그거 그냥 붙이면 되는 거 아님?

처음 프로젝트를 시작할 때만 해도 이렇게 자신만만했습니다. Socket.IO라는 라이브러리도 있고, React도 익숙했으니까요. 하지만 개발이 진행될수록 예상치 못한 문제들이 하나둘씩 나타나기 시작했습니다.

컴포넌트가 마운트될 때마다 새로운 소켓 연결이 생기고, 페이지를 이동할 때마다 연결이 끊어지고, 여러 컴포넌트에서 같은 소켓 이벤트를 처리하려니 코드는 점점 스파게티가 되어갔죠.

이 글에서는 제가 실시간 웹 게임을 개발하면서 겪은 웹소켓-리액트 관련 문제와 그것을 해결하기 위해 고민하고 적용한 커스텀 계층형 아키텍처에 대해 이야기해보려 합니다.

문제 상황: 리액트와 웹소켓은 사이가 좋지 않아요

처음에는 가장 직관적인 방법을 선택했습니다. 컴포넌트에서 직접 WebSocket 연결을 관리하는 거죠.

const GameRoom = () => {
  useEffect(() => {
    const socket = io('server-url');
    socket.on('gameUpdate', handleGameUpdate);
    return () => socket.disconnect();
  }, []);
  
  return <div>게임 룸</div>;
};

간단해 보이죠? 물론 어떠한 문제가 있을까? 하고 씨게 준비를 하고 있었죠.
예상대로, 다양한 문제가 터져나왔습니다.

의도적이지않은 소켓 연결 폭탄

React의 선언적이고 단방향적인 데이터 흐름은 실시간 양방향 통신이 필요한 WebSocket과 자연스럽게 어우러지기 어렵습니다. 구체적인 문제점들을 살펴보겠습니다.

React의 생명주기와의 불일치

한 컴포넌트에 직접 웹소켓 코드를 작성해보겠습니다.

// 🚨 위험한 패턴
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도 생기고 성능 상 문제가 생겨 많은 불편함을 겪었습니다.

  1. 컴포넌트 마운트마다 새로운 연결 생성
  2. 페이지 전환시 연결 끊김
  3. 다수 컴포넌트에서 중복 연결 가능성

이런 문제들이라면 불필요한 연결 시도 많이 생겨 서버 부하도 많이 커지겠네요. 백엔드 개발자가 싫어하겠어요!

상태 관리의 복잡성

// 🚨 도저히 감당하기 힘든 상태 관리
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는 어떨까요?

  1. Provider가 거대해짐
  2. 하위 계층에 불필요한 리렌더링 발생
  3. 상태 업데이트 로직이 한 곳에 집중

이제 연결이 끊어지지는 않겠지만 Props Drilling과 마찬가지로 하나의 상태가 변경됐는데 그 웹소켓을 받은 모든 하위 컴포넌트가 리렌더링 되는 현상이 생길 겁니다. 예를 들어, 타이머 관련 상태가 변경됐는데 그림, 플레이어, 채팅 관련 컴포넌트들이 전부 리렌더링 되는 거죠.
심지어 코드를 보면 알겠지만 상태 관리도 그렇게 편한 건 아닙니다. Provider를 나눈다고 한들 문제는 그대로일 거 같네요.


즉, 교훈은 아래와 같습니다.

  • 컴포넌트 레벨에서 WebSocket을 직접 다루면 연결 관리가 어려움
  • Context API만으로는 복잡한 실시간 상태 관리가 버거움

그러면 어떻게 해야했을까요?

위기: 우리의 프로젝트에 알맞는 형태는?

이런 문제들을 겪으면서 깨달은 것이 있습니다. WebSocket 통신은 단순히 데이터를 주고받는 것 이상의 복잡성을 가지고 있다는 것이죠. 우리에게 필요한 건 체계성이었습니다.

설계 목표

웹 게임 프로젝트 특성 상 아래의 목표를 생각해봤습니다.

  1. 소켓 연결은 한 번만
  2. 상태 업데이트 효율 상승 필요
    • 불특정한 리렌더링 최소화
    • 전역 상태로서 컴포넌트에 불러올 수 있어야함
  3. 웹소켓 확장 가능 필요
    • 플레이어, 점수, 타이머 등 게임 진행
    • 그림 데이터 송수신
    • 채팅 등
  4. TS 타입 안전성
    • 소켓 이벤트 데이터의 타입 정의
    • 게임 상태 관리의 타입 체킹

위 문제들을 해결하기 위해 다음과 같은 4계층 아키텍처를 설계했습니다.

설계 특징

  1. 관심사의 분리
    • Socket 로직을 4가지 핵심 영역으로 분리
      1. 상태 관리: Store와 액션을 통한 순수 상태 관리
      2. 연결 관리: 소켓 연결/재연결 생명주기 관리
      3. 요청 처리: 웹소켓 요청 핸들러 구현
      4. 응답 처리: 웹소켓 이벤트 구독 및 처리
    • 각 영역을 재사용 가능한 독립적인 모듈로 구성
  2. 단일 책임 원칙
    • Socket Store : 소켓 자체 연결 관련 상태
    • Domain Store : 게임, 채팅 등 소켓 도메인 관련 상태
    • Custom Hooks + Handlers : 연결, 상태 수정 등 액션 실행 함수
  3. 확장성을 고려한 설계
    enum SocketNamespace {
      GAME = 'game',
      DRAWING = 'drawing',
      CHAT = 'chat',
    }
    • 네임스페이스를 통한 기능별 소켓 분리
    • 새로운 기능 추가가 용이한 모듈러 구조
    • 각 도메인의 독립적인 확장 가능

이러한 커스텀 계층형 구조의 가장 큰 장점은 '관심사의 분리'입니다. 각 계층은 자신의 역할만 수행하면 되죠. 즉, 확장도 가능하고 직관적으로 생각할 수 있게 됐습니다.
물론 어떤 계층인지 알아야하는 러닝 커브가 있지만요 ㅠ

구현: 기초 공사부터 활용까지

설계대로 구현해보죠!

1층: Socket Config - 기초공사

소켓 설정만을 담은 계층입니다.

// 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로 설정하면 소켓 인스턴스 생성과 연결 시점을 분리할 수 있습니다. 이는 인증 토큰과 같은 정보를 연결 전에 준비할 수 있게 해줍니다.

2층: Socket Store - 뼈대 세우기

사용할 소켓 인스턴스와 연결 상태 관리를 담아 각 소켓의 상태를 파악하고, 공통 소켓 액션을 정의한 계층입니다.

즉, 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를 사용해 구현했습니다.

  1. 간결한 코드 : 보일러플레이트 X
  2. 높은 성능 : 필요한 상태만 구독 가능 X, 리렌더링 최소화!
  3. TS 지원 : 자동완성 Good

3층: Domain Store - 인테리어

도메인별 상태 관리 계층입니다. 게임, 드로잉, 채팅 소켓의 부산물인 상태를 관리하는 계층입니다.
각 도메인(게임, 채팅, 그리기 등)은 자신만의 모양(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' }
 )
);

각 도메인 스토어는 자신의 데이터만 관리합니다. 게임 스토어는 게임 상태를, 채팅 스토어는 채팅 메시지를 담당하는 식이죠. 이렇게 하면 한 도메인의 변경이 다른 도메인에 영향을 주지 않습니다.

4층: Custom Hooks / Handlers - 아웃테리어

이제 가장 중요한 부분입니다. 어떻게 이 모든 계층을 실제 컴포넌트에서 쉽게 사용할 수 있을까요?

바로 연결 로직 + 응답 이벤트 등록을 담은 Custom Hooks와 요청 이벤트 함수를 담아낸 Handlers를 구현한 계층으로 이제 컴포넌트와 직접 상호작용할 겁니다.
컴포넌트 수준의 소켓 이벤트 처리를 구현해 호출만으로 컴포넌트에 쉽게 연동하도록 계층을 구현했습니다.

Custom Hooks

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

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 로직에만 집중합니다.

결론: 계층형 아키텍처의 성과는?

관리 효율성 향상

  • Layout 레벨 소켓 관리로 연결 생명주기 단순화
  • 페이지 전환에도 안정적인 소켓 연결 유지
    • 컴포넌트 리렌더링에 의해 소켓 재연결없이 첫 연결 단 1번으로 압축
  • Ts와 Socket.IO의 타입 시스템으로 안전성 확보

성능 최적화

  • Zustand를 활용한 선택적 상태 구독으로 리렌더링 최소화
    • 예: 타이머 진행 시 매 타이머마다 게임 소켓 연결된 부분이 리렌더링, 컴포넌트 분리 등의 최적화 이후 타이머 컴포넌트만 리렌더링되도록
  • Props Drilling 없는 효율적인 상태 공유
  • 페이지 전환 시 끊김없는 실시간 기능 제공

개발 생산성 증대

  • 계층 분리로 코드 유지보수 용이
  • 커스텀 훅을 통한 웹소켓 로직 재사용
  • 새로운 소켓 기능 추가가 용이한 확장 가능한 구조

특히 제일 체감됐던 것이 개발 생산성이 증대 됐다는 점입니다.

개발 생산성 증대

새로운 기능을 추가하는 데 걸리는 시간도 크게 단축되었습니다. 새로운 게임 모드 추가 시 필요한 작업을 예시로 들어보겠습니다.

Before

  1. 여러 컴포넌트의 WebSocket 로직 수정
  2. 상태 관리 로직 중복 구현
  3. 타입 정의 산재
  4. 테스트 후 수정 불편

After

  1. Domain Store 추가
  2. Custom Hook 구현
  3. UI 컴포넌트 작성
  4. 테스트 후 수정 간현

생산성 향상 포인트
다른 팀원분의 새로운 웹소켓 및 기능 추가 시 구현 방법 등의 DX가 올라갔다는 피드백을 받았습니다.

한계와 교훈

물론 이 아키텍처가 완벽한 것은 아닙니다. 프로젝트를 진행하면서 발견한 한계점들도 있었습니다.

1. 초기 설정의 복잡성

계층형 구조를 처음 설정하는 것은 꽤 많은 시간이 필요했습니다. 작은 프로젝트라면 과도한 설계일 수 있죠.

// 작은 프로젝트에서는 이런 방식도 충분할 수 있습니다
const SimpleSocket = () => {
  const socket = useSocket();
  return <div>{/* 간단한 실시간 기능 */}</div>;
};

2. 학습 곡선

팀의 새로운 개발자들이 이 구조를 이해하는 데 시간이 필요했습니다. 하지만 일단 이해하고 나면 개발 속도가 크게 향상되었죠.

3. TypeScript 타입 관리

여러 계층에 걸친 타입 정의를 관리하는 것이 조금은 어려웠습니다.

// 타입 정의가 많아질수록 관리가 필요합니다
type GameEvents = {
  [K in keyof ServerToClientEvents]: {
    event: K;
    handler: ServerToClientEvents[K];
  };
}[keyof ServerToClientEvents];

제가 사용해보지 않거나 자주 사용하지 않았던 enum이라든지, 제네릭을 한 번 사용해보니 어쩔 때 쓰는지 감이 안잡혀 힘들었습니다. 클로드!!!!!! 난 니가 좋다!!!!!

+ 추가
TS의 enum에 성능 문제가 있는 거 아시나요? JS로 변환 시 보일러 플레이트가 많게끔 설계돼 있어 번들 크기가 커진답니다!
물론 한 1만개가 있어야 로딩이 길어진다고 하네요. 그래도 전 썼습니다. enum이 주는 익숙함과 편함이 좋았거든요. 적용된 타입도 몇개 안됐기도 합니다.

결론: 뭘 더 해야되나..

하나의 아키텍처 패턴은 계속 진화해야합니다. 앞으로 고려하고 있는 개선 방향을 공유하며 글을 마무리하겠습니다.

  1. 자동화된 테스트 강화

    • 각 계층별 단위 테스트 자동화
    • E2E 테스트에서 WebSocket 모킹 전략 개선
    • SonarQube를 사용해 코드 품질 관리
  2. 성능 모니터링 강화

    • 실시간 소켓 연결 상태 모니터링
    • 메모리 사용량 추적 시스템 구축
  3. 개발자/사용자 경험 개선

    • 계층형 아키텍처 생성을 위한 CLI 도구 개발
    • 문서화 자동화 => TSDoc
    • 만약 네트워크가 안좋거나 재연결 로직이 필요하다면?

💬 궁금한 점

  • 여러분은 React에서 WebSocket을 어떻게 관리하시나요?
  • 이 아키텍처의 개선점은 무엇이 있을까요?
  • 실시간 기능 구현에서 겪은 어려움은 무엇인가요?

이 글이 React와 WebSocket을 함께 사용하시는 분들에게 조금이나마 도움이 되었기를 바랍니다.


참고: Zustand를 통한 계층형 아키텍처 구현 배경 심층 탐구

1. 로컬 상태 관리의 한계

// ❌ 컴포넌트 내 로컬 상태 관리의 문제점
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} />;
};
  • Props Drilling: 여러 단계의 컴포넌트를 거쳐 소켓과 상태를 전달해야 함
  • 상태 동기화: 여러 페이지에서 동일한 소켓 이벤트를 처리할 때 상태 동기화가 복잡해짐
  • 영속성: 페이지 새로고침이나 네비게이션 시 상태 유지가 어려움
  • 디버깅: 상태 변화를 추적하고 디버깅하기 어려움
  • 테스트: 컴포넌트와 소켓 로직이 강하게 결합되어 테스트가 어려움

2. Context API의 한계

// ❌ 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>
  );
};
  • 이벤트 관리 복잡성: 많은 이벤트를 한 곳에서 관리하면서 코드가 비대해짐
  • 상태 업데이트 최적화: Context API 특성상 Provider 안에서 특정 이벤트로 인한 상태 업데이트가 불필요한 리렌더링 유발
  • 코드 분할: 기능별로 Context를 분리하면 Provider 중첩이 심해짐

3. Zustand를 선택한 이유

  1. 효율적인 상태 구독
    ✅ 필요한 상태만 선택적으로 구독
const Room = () => {
  // 특정 상태만 구독하여 불필요한 리렌더링 방지
  const room = useGameStore((state) => state.room);
  const updateRoom = useGameStore((state) => state.actions.updateRoom);

  useEffect(() => {
    socket.on('roomUpdate', updateRoom);
  }, []);
};
  1. 계층형 구조에 적합한 API
    ✅ 스토어 간 상태 공유 및 조합이 자유로움
const useGameStore = create<GameState>()((set, get) => ({
  room: null,
  players: [],
  actions: {
    // 다른 스토어의 상태 참조 가능
    updateRoom: (room) => {
      set({ room });
      get().actions.syncWithSocket(room);
    }
  }
}));
  1. 개발 생산성
    • TypeScript 지원이 우수
    • DevTools를 통한 상태 디버깅
    • 미들웨어를 통한 기능 확장
  2. 번들 크기
    • 작은 번들 크기 (Redux: ~22KB, MobX: ~16KB, Zustand: ~1KB)
    • 최소한의 보일러플레이트
  3. 학습 곡선: ✅ 직관적인 API
const useStore = create((set) => ({
  socket: null,
  connect: () => set({ socket: io() }),
  disconnect: () => set({ socket: null })
}));

이러한 이유로 Zustand를 사용한 계층형 아키텍처가 WebSocket 통신과 상태 관리를 효과적으로 다룰 수 있는 최적의 선택이었습니다.

profile
코뿔소처럼 저돌적으로

0개의 댓글