Socket Context Provider 패턴
React 앱에서 Socket.IO 같은 WebSocket 클라이언트를 사용할 때 근본적인 문제가 하나 있다. 소켓 인스턴스를 어디에 두고, 어떻게 여러 컴포넌트에서 공유할 것인가?
가장 단순한 방법은 모듈 스코프에 소켓 인스턴스를 하나 만들어 두고 각 컴포넌트에서 import하는 것이다.
// socket.ts
export const socket = io("wss://api.example.com");
// SomeComponent.tsx
import { socket } from "@/lib/socket";
function SomeComponent() {
useEffect(() => {
socket.on("message", handler);
return () => { socket.off("message", handler); };
}, []);
}
작동은 한다. 하지만 문제가 많다. 소켓의 연결 상태, 인증 상태, 현재 구독 중인 리소스 같은 상태를 각 컴포넌트가 독립적으로 추적해야 한다. 인증 토큰이 바뀌면 어떤 컴포넌트가 재인증을 담당하는지도 불명확하다. 소켓 연결이 끊어졌다가 재연결되면 구독을 다시 보내야 하는데, 그 책임이 분산되어 있으면 버그가 생기기 쉽다.
Socket Context Provider 패턴은 이 문제를 React의 Context API로 해결한다. 소켓 인스턴스와 그에 따른 모든 상태를 하나의 Provider 컴포넌트에 집중시키고, 하위 컴포넌트들은 Context를 통해 필요한 것만 가져다 쓴다.
아키텍처 구조
전체 구조는 세 계층으로 나뉜다.
SocketProvider (최상위)
├── 소켓 인스턴스 생성/파괴
├── 연결/인증 상태 관리
└── 구독 함수 제공
│
├── useSocketContext() — 전체 context
├── useSocket() — 인스턴스만
└── useSocketState() — 상태만
Provider가 소켓의 전체 생명주기를 책임지고, 소비자 컴포넌트는 필요한 슬라이스만 가져간다. 이 분리가 핵심이다.
Context 타입 설계
먼저 Context가 제공할 값의 타입을 정의한다. 이 타입이 곧 Provider와 소비자 사이의 계약이다.
interface SocketState {
isConnected: boolean;
isAuthenticated: boolean;
currentWorkspaceId: string | null;
currentProjectId: string | null;
error: { code: string; message: string } | null;
}
interface SocketContextValue {
socket: Socket | null;
state: SocketState;
sendMessage: (message: SocketMessage) => void;
subscribeWorkspace: (workspaceId: string) => void;
subscribeProject: (projectId: string, accessKey?: string) => void;
}
SocketState에는 연결과 인증의 현재 상태, 구독 중인 리소스 ID, 에러 정보가 들어간다. SocketContextValue에는 소켓 인스턴스 자체와 상태, 그리고 메시지 전송이나 구독을 위한 함수들이 포함된다.
여기서 socket이 null일 수 있다는 점에 주목해야 한다. 소켓이 아직 생성되지 않았거나 Provider 외부에서 접근하려 할 때를 처리하기 위함이다.
Provider 구현
Provider는 세 가지 책임을 진다: 소켓 생성, 이벤트 리스닝, 상태 동기화.
소켓 생성과 이벤트 바인딩
export function SocketProvider({ children }: PropsWithChildren) {
const [socket, setSocket] = useState<Socket | null>(null);
const [state, setState] = useState<SocketState>(initialState);
const { token } = useAuthStore();
const isInitialized = useRef(false);
useEffect(() => {
if (isInitialized.current) return;
isInitialized.current = true;
const socketInstance = createSocket();
setSocket(socketInstance);
socketInstance.on("connect", () => {
setState(prev => ({ ...prev, isConnected: true, error: null }));
if (token) {
socketInstance.emit("message", {
op: Opcode.AUTH,
d: { token }
});
}
});
socketInstance.on("disconnect", () => {
setState(prev => ({
...prev,
isConnected: false,
isAuthenticated: false,
currentWorkspaceId: null,
currentProjectId: null,
}));
});
socketInstance.connect();
return () => {
destroySocket();
isInitialized.current = false;
};
}, [token]);
// ...
}
isInitialized ref를 쓰는 이유가 있다. React 18의 Strict Mode에서는 개발 환경에서 useEffect가 두 번 실행된다. ref 가드 없이는 소켓이 두 개 생성되어 이벤트가 중복 발생하고 연결이 꼬인다. useRef로 한 번만 초기화되도록 보장한다.
connect 이벤트에서 토큰이 있으면 바로 인증 메시지를 보내는 것도 중요하다. 소켓이 연결되자마자 인증을 시작하면 인증 전 상태에서의 무의미한 대기 시간을 줄일 수 있다.
disconnect 시 상태 초기화
연결이 끊기면 isAuthenticated, currentWorkspaceId, currentProjectId를 모두 null로 되돌린다. 재연결 시 서버는 이전 인증이나 구독 상태를 기억하지 않기 때문에 클라이언트도 초기 상태로 돌아가야 한다. 이 초기화를 빠뜨리면 "연결은 끊겼다 붙었는데 데이터가 안 온다"는 종류의 버그가 생긴다.
서버 메시지 처리
socketInstance.on("message", (data: SocketMessage) => {
switch (data.op) {
case ServerOpcode.ERROR:
setState(prev => ({
...prev,
error: data.d as { code: string; message: string },
}));
break;
case ServerOpcode.SUCCESS_AUTH:
setState(prev => ({
...prev,
isAuthenticated: true,
error: null,
}));
break;
case ServerOpcode.SET_WORKSPACE:
case ServerOpcode.SET_PROJECT:
setState(prev => ({ ...prev, error: null }));
break;
}
});
서버에서 오는 메시지의 opcode에 따라 상태를 업데이트한다. SUCCESS_AUTH를 받으면 인증 완료 상태로 전환하고, ERROR면 에러 정보를 저장한다. 이 상태 변화가 Context를 통해 전파되어 하위 컴포넌트들이 자동으로 리렌더링된다.
토큰 변경 감지
사용자가 로그아웃했다가 다른 계정으로 로그인하면 토큰이 바뀐다. 이때 기존 소켓 연결을 유지하면서 새 토큰으로 재인증해야 한다.
useEffect(() => {
if (socket?.connected) {
socket.emit("message", {
op: Opcode.AUTH,
d: { token: token ?? null },
});
if (!token) {
setState(prev => ({
...prev,
isAuthenticated: false,
currentWorkspaceId: null,
currentProjectId: null,
}));
}
}
}, [token, socket]);
토큰이 null이 되면(로그아웃) 서버에 null 토큰을 보내서 인증을 해제하고, 클라이언트 상태도 초기화한다. 토큰이 새 값으로 바뀌면 새 토큰으로 인증 메시지를 보낸다. 소켓 연결을 끊었다 다시 맺을 필요가 없다.
구독 함수
Provider가 노출하는 subscribeWorkspace와 subscribeProject는 단순히 메시지를 보내는 것 이상의 일을 한다.
const subscribeWorkspace = (workspaceId: string) => {
if (socket?.connected && state.isAuthenticated) {
socket.emit("message", {
op: Opcode.WORKSPACE,
d: { workspaceId },
});
setState(prev => ({ ...prev, currentWorkspaceId: workspaceId }));
}
};
const subscribeProject = (projectId: string, accessKey?: string) => {
if (socket?.connected) {
const message = accessKey
? { op: Opcode.PROJECT, d: { projectId, accessKey } }
: { op: Opcode.PROJECT, d: { projectId } };
socket.emit("message", message);
setState(prev => ({ ...prev, currentProjectId: projectId }));
}
};
두 가지 차이점이 있다. subscribeWorkspace는 인증 상태를 체크하지만 subscribeProject는 하지 않는다. 이유는 게스트 접근이다. 프로젝트는 accessKey를 통해 비인증 사용자도 구독할 수 있어야 하기 때문에 인증 여부를 조건으로 걸지 않는다.
구독 함수를 호출하면 즉시 로컬 상태도 업데이트한다. 서버의 응답을 기다리지 않고 낙관적으로 상태를 반영하는 것이다. 서버에서 에러가 오면 ERROR opcode 핸들러에서 에러 상태가 설정된다.
소비자 훅 분리
Context를 사용하는 커스텀 훅을 목적별로 분리하면 불필요한 리렌더링을 줄이고 코드 의도를 명확히 할 수 있다.
const SocketContext = createContext<SocketContextValue | null>(null);
// 전체 context가 필요할 때
export function useSocketContext() {
const context = useContext(SocketContext);
if (!context) {
throw new Error(
"useSocketContext must be used within a SocketProvider"
);
}
return context;
}
// 소켓 인스턴스만 필요할 때
export function useSocket() {
const { socket } = useSocketContext();
return socket;
}
// 상태만 필요할 때
export function useSocketState() {
const { state } = useSocketContext();
return state;
}
useSocketContext가 null일 때 에러를 던지는 패턴은 Provider 바깥에서 실수로 훅을 호출하는 것을 방지한다. 런타임에 즉시 에러가 나오므로 디버깅이 쉽다.
useSocket과 useSocketState를 따로 둔 이유는 관심사 분리다. 이벤트 핸들러를 등록하려는 컴포넌트는 소켓 인스턴스만 있으면 되고, 연결 상태를 UI에 표시하려는 컴포넌트는 상태만 있으면 된다. 다만 이 분리가 리렌더링 최적화를 해주지는 않는다. React의 Context는 Provider의 value가 바뀌면 해당 Context를 useContext로 구독하는 모든 컴포넌트가 리렌더링된다. useSocket만 호출해도 state가 바뀌면 리렌더링이 발생한다는 뜻이다.
진짜 리렌더링을 분리하려면 Context를 두 개로 나누거나, useSyncExternalStore를 쓰거나, 상태 관리 라이브러리(Zustand 등)를 사용해야 한다.
소켓 인스턴스 관리: 싱글턴 패턴
Provider 안에서 createSocket()을 호출하는데, 이 함수 내부는 싱글턴 패턴으로 구현된다.
let socket: Socket | null = null;
export function createSocket(): Socket {
if (socket) {
return socket;
}
socket = io(getSocketUrl(), {
path: "/events",
transports: ["websocket"],
autoConnect: false,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
return socket;
}
export function destroySocket(): void {
if (socket) {
socket.disconnect();
socket = null;
}
}
모듈 스코프 변수에 인스턴스를 캐싱해서 여러 번 호출해도 같은 인스턴스를 반환한다. autoConnect: false로 설정해서 생성 시점이 아닌 Provider가 준비된 시점에 명시적으로 connect()를 호출한다.
destroySocket()은 연결을 끊고 참조를 null로 설정한다. Provider의 cleanup 함수에서 호출되어 메모리 누수를 방지한다.
재연결 설정(reconnection, reconnectionAttempts, reconnectionDelay)은 네트워크 불안정 상황에서 자동으로 재연결을 시도하게 한다. reconnectionDelayMax로 지수 백오프의 상한을 5초로 제한한다.
Provider 배치
Provider를 컴포넌트 트리 어디에 놓느냐에 따라 소켓의 생명주기가 달라진다.
// 앱 전체에 소켓을 유지하는 경우
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<SocketProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</SocketProvider>
</AuthProvider>
);
}
AuthProvider 안쪽에 SocketProvider를 배치하는 이유는 소켓이 인증 토큰에 의존하기 때문이다. QueryClientProvider와 같은 레벨이나 그 바깥에 있어야 소켓 이벤트 핸들러에서 React Query의 캐시를 무효화할 수 있다.
소켓이 특정 페이지에서만 필요하다면 해당 라우트의 레이아웃에만 Provider를 두는 것도 좋다. 불필요한 연결을 유지하지 않아도 되고, 소켓 관련 상태가 해당 페이지를 벗어나면 자동으로 정리된다.
이벤트 핸들러와 캐시 무효화
Provider가 연결과 인증을 관리한다면, 실제로 서버 이벤트를 받아 처리하는 것은 별도의 훅이 담당한다. 이 분리가 중요하다. Provider에 모든 이벤트 핸들링 로직을 넣으면 Provider가 비대해지고, 특정 페이지에서만 필요한 이벤트 처리까지 항상 실행된다.
export function useSocketEventHandler() {
const socket = useSocket();
const queryClient = useQueryClient();
const { currentWorkspaceId } = useSocketState();
useEffect(() => {
if (!socket) return;
const handleMessage = (data: SocketMessage) => {
if (data.op === ServerOpcode.UPDATE) {
handleUpdateEvent(data.evt, data.d);
} else if (data.op === ServerOpcode.NOTIFICATION) {
queryClient.invalidateQueries({
queryKey: notificationKeys.all,
});
}
};
socket.on("message", handleMessage);
return () => { socket.off("message", handleMessage); };
}, [socket, queryClient, currentWorkspaceId]);
}
이 훅은 소켓에서 오는 업데이트 이벤트를 받아서 React Query의 관련 쿼리를 무효화한다. 무효화된 쿼리는 다음에 접근할 때 자동으로 refetch되므로, 실시간으로 다른 사용자의 변경사항이 반영된다.
이벤트 타입별로 무효화 범위를 세밀하게 조정하는 게 성능의 핵심이다.
const handleUpdateEvent = (evt: string, d: UpdateMessageData) => {
switch (evt) {
case UpdateEvent.EDIT_PROJECT:
// 해당 프로젝트만 무효화
queryClient.invalidateQueries({
queryKey: projectKeys.detail(d.project.id),
});
break;
case UpdateEvent.REMOVE_PROJECT:
// 프로젝트 상세 + 워크스페이스의 프로젝트 목록 무효화
queryClient.invalidateQueries({
queryKey: projectKeys.detail(d.project.id),
});
queryClient.invalidateQueries({
queryKey: workspaceKeys.projects(currentWorkspaceId),
});
break;
case UpdateEvent.ADD_COMMENT:
// 댓글 목록 + 답글 목록 무효화
queryClient.invalidateQueries({
queryKey: commentKeys.versionComments(d.version.id),
});
if (d.comment?.parentId) {
queryClient.invalidateQueries({
queryKey: commentKeys.replies(d.comment.parentId),
});
}
break;
}
};
프로젝트 수정은 해당 프로젝트의 상세 쿼리만 무효화하지만, 프로젝트 삭제는 워크스페이스의 프로젝트 목록까지 무효화한다. 댓글 추가는 버전의 댓글 목록을 무효화하고, 답글인 경우 부모 댓글의 답글 목록도 추가로 무효화한다. 무분별하게 모든 쿼리를 무효화하면 불필요한 네트워크 요청이 폭증한다.
일반 Context Provider와의 비교
일반적인 Context Provider(테마, 언어 설정 등)와 Socket Context Provider의 차이점이 있다.
| 일반 Context Provider | Socket Context Provider |
|---|---|
| 값이 동기적으로 결정됨 | 비동기 연결/인증 과정 필요 |
| 값 변경이 사용자 액션에 의해 발생 | 외부 이벤트(서버 메시지)에 의해 상태 변경 |
| cleanup이 단순 | 연결 해제, 이벤트 리스너 제거 필요 |
| 보통 상태만 제공 | 상태 + 인스턴스 + 함수 제공 |
소켓 Provider는 외부 시스템(서버)과의 연결을 관리하기 때문에 생명주기가 복잡하다. 연결, 인증, 구독이라는 다단계 초기화가 필요하고, 각 단계가 비동기다. 이런 복잡한 비동기 상태를 Context로 감싸서 하위 컴포넌트에게 깔끔한 인터페이스를 제공하는 것이 이 패턴의 가치다.
대안 접근법
Zustand로 소켓 상태 관리
Context 대신 Zustand 같은 외부 상태 관리 라이브러리를 쓸 수도 있다.
const useSocketStore = create<SocketState & SocketActions>((set, get) => ({
socket: null,
isConnected: false,
isAuthenticated: false,
connect: () => {
const socket = io(url);
socket.on("connect", () => set({ isConnected: true }));
set({ socket });
},
disconnect: () => {
get().socket?.disconnect();
set({ socket: null, isConnected: false });
},
}));
장점은 셀렉터를 통한 세밀한 리렌더링 제어가 가능하다는 것이다. useSocketStore(s => s.isConnected)처럼 특정 상태만 구독하면 다른 상태가 바뀌어도 리렌더링되지 않는다. Context는 이게 안 되기 때문에 대규모 앱에서는 Zustand 쪽이 성능상 유리할 수 있다.
useRef로 인스턴스 관리
소켓 인스턴스를 useState가 아닌 useRef로 관리하는 접근법도 있다.
const socketRef = useRef<Socket | null>(null);
인스턴스 자체의 변경이 리렌더링을 트리거하지 않으므로 성능상 유리하다. 다만 소켓 인스턴스를 하위 컴포넌트에 전달할 때 ref의 current 값이 바뀌어도 Context 소비자가 리렌더링되지 않는다는 점을 주의해야 한다. 연결 상태 같은 "리렌더링이 필요한 값"은 별도의 state로 관리하고, 소켓 인스턴스 자체는 ref로 관리하는 하이브리드 방식이 가장 실용적이다.
관련 문서
- Socket.IO 클라이언트 - Socket.IO 클라이언트 기본 개념