React에서 Socket.IO 연결 관리하기
싱글톤, 생명주기, React Query 캐시 동기화까지의 설계 과정
들어가며
여러 사용자가 동시에 같은 화면을 보고 있을 때, 한 사람의 변경이 다른 사람의 화면에 즉시 반영되어야 했다. Socket.IO를 도입했지만 연결만 하면 되는 게 아니라 몇 가지 설계 결정이 더 필요했다.
- 소켓 인스턴스가 여러 개 생기면 안 된다
- 연결 → 인증 → 구독이라는 생명주기가 있다
- 서버에서 이벤트가 오면 React Query 캐시를 자동으로 갱신해야 한다
이 글은 이 세 가지 문제를 풀면서 내린 설계 결정과 그 이유를 정리한다.
Socket.IO 결정 계기
raw WebSocket을 직접 쓸 수도 있었지만, Socket.IO를 선택한 이유는 WebSocket 위에서 동작하는 상위 기능들 때문이다. Room(특정 소켓 그룹에게만 이벤트를 보내는 구독 관리 기능)으로 구독 대상을 간단하게 관리할 수 있다. 예를 들어 같은 문서를 편집 중인 사용자에게만 변경 이벤트를 보내고, 다른 문서를 보고 있는 사용자에게는 보내지 않는 식이다. 서버 측에서도 Redis Adapter(여러 서버 간 이벤트를 공유하기 위한 어댑터)를 통해 수평 확장이 가능하기 때문에, 프론트엔드와 백엔드 양쪽에서 실시간 통신 구조를 설계하기에 적합했다.
Socket.IO는 처음에 HTTP long-polling으로 연결을 맺고, 이후 WebSocket으로 업그레이드한다. WebSocket을 지원하지 않는 환경에서도 통신할 수 있도록 만든 폴백 전략이다. 하지만 transports: ['websocket']으로 설정하면 처음부터 WebSocket으로 바로 연결할 수 있다. Socket.IO를 선택한 이유는 이 폴백 기능이 아니라, Room, 재연결, 어댑터 같은 인프라를 직접 만들지 않기 위해서다.
Room 시스템과 Redis Adapter의 동작 원리는 Socket.IO의 Room 기반 Pub/Sub과 Redis Adapter에서 자세히 다룬다.
소켓 인스턴스가 여러 개 생기면 안 되는 이유
React에서 Socket.IO를 처음 도입할 때, 페이지가 마운트되는 시점에 소켓을 생성하는 방식을 떠올릴 수 있다. useEffect에 빈 의존성 배열을 넣어 컴포넌트가 처음 렌더링될 때 연결하는 방식인데, 이렇게 하면 약간의 문제가 있다.
// wrong case
function ChatRoom() {
useEffect(() => {
const socket = io("http://localhost:3000");
socket.on("message", (data) => {
setMessages((prev) => [...prev, data]);
});
return () => {
socket.disconnect();
};
}, []);
}
이 코드의 문제는 크게 두 가지다.
- 컴포넌트가 마운트될 때마다 새 연결이 생긴다.
ChatRoom이 언마운트됐다 다시 마운트되면 이전 연결은 끊기고 새 연결이 만들어진다. React의 Strict Mode에서는 개발 환경에서 컴포넌트를 두 번 마운트하기 때문에, 소켓도 두 개가 생긴다. 서버 입장에서는 같은 사용자가 여러 번 접속한 것으로 보이고, 이벤트도 중복으로 수신된다. - 다른 컴포넌트에서 같은 소켓에 접근할 수 없다.
useEffect안에서 선언한 변수는 그 클로저 안에서만 존재하기 때문에, 사이드바에서 알림을 받고 싶어도ChatRoom의 소켓 인스턴스를 참조할 방법이 없다. 공통 부모에서 소켓을 만들어 props로 넘길 수도 있지만, 소켓이 필요한 모든 컴포넌트에서 props drilling이 발생한다.
그래서 앱 전체에서 소켓 인스턴스를 하나로 유지해야 한다. React 컴포넌트는 사용자 인터랙션이나 상태 변화에 따라 언제든 마운트/언마운트될 수 있기 때문에, 컴포넌트의 생명주기에 소켓의 생명주기를 묶으면 연결이 불안정해진다. 소켓은 컴포넌트보다 긴 생명주기를 가져야 한다.
모듈 시스템을 활용한 싱글톤 구현
보통 JavaScript에서 싱글톤 패턴을 떠올리면 클래스로 getInstance()를 만드는 방식을 생각한다. 하지만 JavaScript 모듈 시스템의 특성을 활용하면 클래스 없이도 싱글톤을 구현할 수 있다.
JavaScript에서 import로 모듈을 불러오면, 해당 파일의 코드는 최초 한 번만 실행된다. 이후에 다른 파일에서 같은 모듈을 import해도 코드가 다시 실행되는 것이 아니라, 이미 실행된 결과를 그대로 재사용한다.
// client.ts
let socket = null; // 다른 파일이 처음 import될 때 딱 한 번 생성됨
// A.tsx
import { createSocket } from './client'; // client.ts 실행, let socket = null
// B.tsx
import { createSocket } from './client'; // client.ts 실행 X, A와 같은 socket 변수를 공유
A.tsx가 먼저 import하면 그때 client.ts가 실행되면서 let socket이 만들어진다. B.tsx가 나중에 import해도 client.ts는 다시 실행되지 않는다. 모듈 시스템이 최초 실행 결과를 모듈 레코드(Module Record)에 저장해두고, 이후 같은 모듈을 요청하면 저장된 결과를 반환하기 때문이다. 결과적으로 A와 B는 같은 socket 변수를 참조하게 되고, 이 특성을 이용하면 파일 최상위에 선언한 변수가 자연스럽게 싱글톤이 된다.
그래서 A에서 createSocket()으로 소켓을 생성하면, B에서 createSocket()을 호출해도 새로 만드는 것이 아니라 A에서 만든 같은 인스턴스가 반환된다.
(더 깊게..)
Module Record는 하나의 모듈 파일에 대한 정보를 담는 객체다. 그 파일이 export하는 변수 목록, import하는 다른 모듈 목록, 실행된 코드의 결과 등이 들어 있다. Module Map은 이 Module Record들을 모아둔 캐시로, 모듈의 파일 경로를 키로 사용한다.
import가 요청되면 Module Map에서 먼저 찾아보고, 이미 있으면 그 Module Record를 그대로 반환한다.ES 모듈의 로딩은 세 단계로 이루어진다.
- Construction -
import구문을 파싱하여 의존성 그래프를 만들고, 각 모듈을 Module Record로 등록한다. 이때 Module Map에 모듈 경로를 키로 저장해둔다.- Instantiation -
let socket처럼 파일 최상위에 선언된 변수들의 메모리 공간을 확보하고, export/import 간의 바인딩을 연결한다.- Evaluation - 실제 코드가 실행되면서
socket = null처럼 값이 할당된다.이 세 단계는 모듈당 한 번만 수행된다. 두 번째
import부터는 Module Map에서 이미 등록된 Module Record를 찾아 반환하기 때문에 코드가 다시 실행되지 않는다. Node.js의 CommonJS(require)에서는require.cache객체가 같은 역할을 한다.
이 특성을 이용해서 소켓 싱글톤을 구현하면 다음과 같다.
// client.ts
let socket: Socket | null = null;
export function createSocket(): Socket {
if (socket) return socket; // 이미 있으면 기존 인스턴스 반환
socket = io(getSocketUrl(), {
transports: ["websocket"],
autoConnect: false,
});
return socket;
}
let socket이 파일 최상위에 있기 때문에 createSocket()을 여러 곳에서 호출해도 이미 생성된 인스턴스가 있으면 그대로 반환한다. 별도의 클래스나 getInstance() 없이 모듈 시스템이 싱글톤을 보장한다.
Socket.IO는 기본적으로 io()를 호출하면 바로 서버에 연결한다. autoConnect: false로 설정한 이유는 Provider가 준비된 시점에 connect()를 호출하기 위해서다. 이렇게 해야 Provider의 마운트/언마운트에 맞춰 연결과 해제를 제어할 수 있다.
소켓 상태를 React 트리와 연결하기
싱글톤 인스턴스를 만들었으면, React 컴포넌트에서 접근할 수 있어야 한다. Zustand 같은 전역 상태 라이브러리를 쓸 수도 있지만, 소켓은 UI 상태가 아니라 인스턴스와 생명주기를 관리하는 것이 중요하다.
Context + useEffect를 선택한 이유는 useEffect의 클린업 함수를 통해 소켓의 생명주기를 React 트리의 마운트/언마운트와 일치시킬 수 있기 때문이다. useEffect의 클린업 함수는 컴포넌트가 언마운트될 때 자동으로 호출되므로, Provider가 사라지면 소켓도 함께 정리된다.
Zustand로 관리하면 소켓 인스턴스가 전역에 남아있기 때문에 언제 끊어야 하는지를 별도로 관리해야 하지만, SocketProvider가 감싸는 범위가 곧 소켓이 살아있는 범위가 된다. 구체적으로 SocketProvider는 다음과 같은 구조를 갖는다.
interface SocketState {
isConnected: boolean;
isAuthenticated: boolean;
currentWorkspaceId: string | null;
currentProjectId: string | null;
}
연결 상태를 isConnected boolean 하나로 관리할 수도 있었지만, 서버가 Room 기반으로 구독 그룹을 관리하고 있었기 때문에 프론트엔드도 이에 맞춰야 했다. 프로젝트 A를 보는 사용자에게 프로젝트 B의 이벤트가 가면 안 되고, 워크스페이스별로 알림 대상이 달랐다. 그래서 "연결됨", "인증됨", "워크스페이스 구독됨", "프로젝트 구독됨"을 각각 독립적인 상태로 분리하고, 구독 훅에서 isAuthenticated가 true일 때만 구독을 시도하는 식으로 각 상태가 다음 단계의 전제 조건이 되도록 설계했다.
isInitialized ref를 사용해서 React의 Strict Mode에서 Provider가 두 번 마운트되더라도 소켓이 중복 생성되지 않도록 방어했다.
이벤트 리스너 중복 등록 방지
소켓 싱글톤을 사용할 때 빠지기 쉬운 함정이 이벤트 리스너 중복 등록이다. 소켓 인스턴스는 하나지만, 이벤트 리스너를 등록하는 컴포넌트는 마운트/언마운트를 반복한다.
// As-is, 리스너가 쌓이는 코드
useEffect(() => {
socket.on("message", handleMessage);
// cleanup이 없으면, 컴포넌트가 리마운트될 때마다 리스너가 하나씩 추가된다
}, []);
// To-be, 올바른 클린업
useEffect(() => {
socket.on("message", handleMessage);
return () => {
socket.off("message", handleMessage);
};
}, []);
socket.off()로 리스너를 제거하지 않으면, 컴포넌트가 10번 리마운트되면 같은 이벤트에 핸들러가 10개 등록된다. 하나의 이벤트에 10번 반응하는 셈이다. 이벤트 핸들러를 등록하는 모든 useEffect에는 반드시 socket.off()를 반환하는 클린업 함수가 있어야 한다.
연결부터 구독까지의 생명주기
소켓은 연결만 하면 바로 이벤트를 받을 수 있는 게 아니다. 서버와 약속된 순서가 있다.
이 순서를 Opcode(Operation Code, 메시지 종류를 숫자로 구분하는 프로토콜) 기반으로 관리했다. 서버에서 정의한 프로토콜에 맞춰 클라이언트를 설계했다.
// 클라이언트 → 서버
Opcode.AUTH = 1; // 인증 요청
Opcode.SUBSCRIBE_WORKSPACE = 3; // 워크스페이스 구독
Opcode.SUBSCRIBE_PROJECT = 5; // 프로젝트 구독
// 서버 → 클라이언트
ServerOpcode.AUTH_SUCCESS = 2; // 인증 성공
ServerOpcode.WORKSPACE_JOINED = 4; // 워크스페이스 구독 성공
ServerOpcode.PROJECT_JOINED = 6; // 프로젝트 구독 성공
ServerOpcode.UPDATE = 7; // 데이터 변경 이벤트
ServerOpcode.NOTIFICATION = 8; // 알림
연결이 되면 자동으로 인증 요청을 보내고, 인증이 성공하면 그때부터 워크스페이스나 프로젝트를 구독할 수 있다. 이 흐름을 훅으로 분리했다.
// useWorkspaceSubscription
useEffect(() => {
if (workspaceId && state.isAuthenticated) {
subscribeWorkspace(workspaceId);
}
}, [workspaceId, state.isAuthenticated]);
isAuthenticated가 true일 때만 구독을 시도한다. 인증 전에 구독 요청을 보내면 서버가 거부하기 때문이다. 이렇게 각 상태가 다음 단계의 전제 조건이 되도록 설계하면, 재연결 시에도 별도 코드 없이 구독이 복원된다.
재연결 시 구독 복원
네트워크가 일시적으로 끊겼다가 재연결되면 어떻게 될까? Socket.IO의 reconnection: true 설정으로 자동 재연결은 되지만, 서버 입장에서는 새로운 연결이기 때문에 이전에 구독하던 워크스페이스나 프로젝트 정보는 사라진다.
재연결이 성공하면 connect 이벤트가 다시 발생하고, Provider가 이를 감지해서 인증 요청을 다시 보낸다. 인증이 성공하면 isAuthenticated가 false에서 true로 바뀐다. 구독 훅의 useEffect 의존성 배열에 isAuthenticated가 들어있기 때문에, 값이 바뀌는 순간 useEffect가 다시 실행되면서 구독을 다시 요청한다.
// isAuthenticated가 바뀌면 이 useEffect가 다시 실행된다
useEffect(() => {
if (workspaceId && state.isAuthenticated) {
subscribeWorkspace(workspaceId); // 재구독
}
}, [workspaceId, state.isAuthenticated]);
결과적으로 재연결을 위한 별도 코드를 작성하지 않았다. 앞서 각 상태를 다음 단계의 전제 조건으로 설계했기 때문에, isAuthenticated가 바뀌면 구독 훅이 다시 실행되고, 재연결 복원은 기존 흐름 안에서 처리된다.
실시간 이벤트와 React Query 캐시 동기화
연결과 구독이 완료되면 서버에서 실시간 이벤트를 수신할 수 있다. 이제 남은 문제는 이 이벤트를 받았을 때 UI를 어떻게 갱신할 것인가다. 두 가지 방법이 있었다.
첫 번째는 이벤트 데이터로 상태를 직접 업데이트하는 것이다. 서버가 보내준 데이터를 그대로 화면에 반영한다. 빠르지만, 이벤트는 "변경이 일어났다"는 알림이지 서버의 최신 상태를 보장하지 않기 때문에, 네트워크 지연이나 이벤트 순서 역전으로 클라이언트와 서버 간 불일치가 생길 위험이 있었다.
두 번째는 이벤트를 신호로만 사용하고, 실제 데이터는 React Query가 다시 요청하는 것이다. "댓글이 추가됐다"는 사실만 알면, React Query가 댓글 목록을 서버에서 다시 가져온다. 항상 최신 데이터가 보장되지만, 네트워크 요청이 한 번 더 발생한다.
React Query 메인테이너 TkDodo도 자신의 블로그에서 이 방식을 권장한다.
"This approach avoids the problem of over pushing, because if we receive an event for an entity that we are not interested in at the moment, nothing will happen."
(이 방식은 과도한 푸시 문제를 피할 수 있다. 현재 관심 없는 엔티티에 대한 이벤트를 받더라도 아무 일도 일어나지 않기 때문이다.)
— TkDodo, Using WebSockets with React Query
invalidateQueries는 해당 쿼리를 구독하는 컴포넌트가 있을 때만 refetch를 실행한다. 관심 없는 데이터에 대한 이벤트가 와도 불필요한 네트워크 요청이 발생하지 않는다.
실제로는 모든 이벤트에 invalidateQueries만 쓴 것은 아니다. 삭제처럼 즉시 반영이 필요한 경우에는 setQueryData로 캐시를 직접 수정하고, 이후 invalidateQueries로 정합성을 확인하는 방식을 함께 사용했다.
switch (evt) {
case "ADD_COMMENT":
// 캐시 무효화, React Query가 최신 데이터를 다시 가져옴
queryClient.invalidateQueries({ queryKey: commentKeys.list(projectId) });
break;
case "REMOVE_COMMENT":
// 즉시 제거 + 무효화, 삭제된 댓글이 잠깐이라도 보이면 안 되기 때문
queryClient.setQueryData(commentKeys.detail(data.commentId), null);
queryClient.invalidateQueries({ queryKey: commentKeys.list(projectId) });
break;
}
REMOVE_COMMENT는 setQueryData로 즉시 제거한 뒤 목록도 무효화한다. 삭제된 댓글이 잠깐이라도 화면에 남아있으면 사용자가 혼란을 느끼기 때문이다. 반면 EDIT_PROJECT는 invalidateQueries만 호출해서 React Query가 알아서 최신 데이터를 가져오게 한다.
정리하면 선택 기준은 이렇다.
| 전략 | 사용 시점 | 이유 |
|---|---|---|
setQueryData | 삭제처럼 즉시 반영이 필요한 경우 | 사용자가 이미 없는 데이터를 보면 혼란 |
invalidateQueries | 추가/수정처럼 약간의 지연이 허용되는 경우 | 서버와 클라이언트 간 데이터 일치 보장, 구현 단순 |
| 혼합 | 즉시 반영 + 정합성 검증이 모두 필요한 경우 | setQueryData로 먼저 반영, invalidate로 검증 |
전체 구조
최종 파일 구조는 다음과 같다.
src/lib/socket/
├── client.ts # 싱글톤 소켓 생성/조회/해제
├── SocketProvider.tsx # React Context + 상태 관리
├── types.ts # 메시지 타입, Opcode 정의
├── constants.ts # URL, 이벤트 상수
├── index.ts # barrel export
└── hooks/
├── useWorkspaceSubscription.ts # 워크스페이스 구독
├── useProjectSubscription.ts # 프로젝트 구독 + 게스트 지원
└── useSocketEventHandler.ts # 이벤트 → React Query 동기화
각 파일이 하나의 관심사만 담당한다. client.ts는 연결만, SocketProvider는 상태 관리만, 각 훅은 구독이나 이벤트 처리만 한다. 새로운 이벤트 타입이 추가되면 useSocketEventHandler에 case를 추가하면 되고, 구독 단위가 늘어나면 새 훅을 만들면 된다.
정리
이 글에서 다룬 설계 결정을 요약하면 다음과 같다.
- JavaScript 모듈 스코프를 활용해 소켓 인스턴스를 하나로 유지했다. 클래스 기반
getInstance()없이도 모듈 시스템이 싱글톤을 보장한다. - Context + useEffect 조합으로 소켓의 생명주기를 React 트리와 일치시켰다. Provider가 감싸는 범위가 곧 소켓이 살아있는 범위가 된다.
- 연결, 인증, 구독을 각각 독립적인 상태로 분리하고, 이전 단계가 완료되어야 다음 단계로 넘어가도록 설계했다. 이 구조 덕분에 재연결 시 별도 코드 없이
isAuthenticated상태 변화만으로 구독이 자동 복원된다. - 소켓 이벤트를 "신호"로 사용하고 실제 데이터는 React Query가 다시 요청하는 방식을 기본으로 했다. 삭제처럼 즉시 반영이 필요한 경우에만
setQueryData로 캐시를 직접 업데이트하고,invalidateQueries로 정합성을 검증하는 혼합 전략을 적용했다.
