SharedWorker·IndexedDB로 멀티 탭 채팅 최적화하기
탭별 중복 WebSocket 연결 문제를 SharedWorker로 통합하고, IndexedDB 캐시로 새로고침·오프라인에서도 대화 내역을 유지한 구현 과정을 정리했습니다.
KOIN 서비스의 쪽지 기능을 개선하던 중, 멀티 탭 환경에서 구조적인 문제가 드러났습니다. 처음에는 일시적인 연결 이슈라고 생각했지만, 개발자 도구를 열어보니 같은 사용자 탭에서 WebSocket 연결이 5개까지 열려 있었습니다.
탭마다 독립 연결이 만들어져 서버 리소스를 낭비했고, 새로고침 시에는 이전 메시지가 사라져 다시 서버에서 받아와야 했습니다.
이 글에서는 SharedWorker와 IndexedDB를 적용해 이 문제를 해결한 과정을 정리합니다.
1. 기존 시스템의 문제
기존 채팅 시스템은 React 컴포넌트에서 STOMP 클라이언트를 직접 생성하고 관리했습니다.
// 기존 코드
const clientRef = useRef<Client | null>(null);
useEffect(() => {
const stompClient = new Client({
brokerURL: `${process.env.NEXT_PUBLIC_API_PATH}/ws-stomp`,
connectHeaders: { Authorization: token },
// ...
});
stompClient.activate();
clientRef.current = stompClient;
return () => {
stompClient.deactivate();
};
}, [articleId, chatroomId, token]);
이 구조의 문제점은 명확했습니다.
1. 탭마다 중복 연결
사용자가 쪽지 페이지를 3개 탭에서 열면 WebSocket 연결도 3개가 생성됐습니다. 결과적으로 같은 메시지를 중복 수신했고, 서버는 불필요한 연결을 유지해야 했습니다.
2. 메시지 유실 문제
새로고침하면 useState로 들고 있던 메시지가 모두 사라졌습니다.
이전 대화 내역을 보려면 API를 다시 호출해야 했고, 네트워크 상태가 나쁘면 조회 자체가 실패했습니다.
3. 오프라인 미지원
네트워크가 끊기면 채팅 기능이 사실상 중단됐습니다. 이전에 주고받은 메시지조차 확인하기 어려웠습니다.
2. 해결 전략: SharedWorker + IndexedDB
2.1 SharedWorker 선택 이유
일반 Web Worker는 생성한 탭에서만 접근할 수 있지만, SharedWorker는 동일 Origin의 모든 탭이 하나의 인스턴스를 공유합니다.
[Before - 탭마다 독립 연결]
Tab 1 ──→ WebSocket 1
Tab 2 ──→ WebSocket 2
Tab 3 ──→ WebSocket 3
[After - 단일 연결 공유]
Tab 1 ──┐
Tab 2 ──┼──→ SharedWorker ──→ WebSocket (1개)
Tab 3 ──┘
SharedWorker를 적용하면 다음 효과를 얻을 수 있습니다.
- 모든 탭이 하나의 WebSocket 연결을 공유
- 한 탭에서 받은 메시지를 다른 탭에서도 실시간 확인
- 서버 리소스 절약 (연결 수 = 사용자 수)
2.2 IndexedDB 선택 이유
LocalStorage는 동기 API이고 용량이 5MB로 제한적입니다. 반면 IndexedDB는:
| 특징 | LocalStorage | IndexedDB |
|---|---|---|
| 작동 방식 | 동기 (메인 스레드 차단) | 비동기 (Non-Blocking) |
| 용량 | 약 5MB | 디스크 여유 공간 비례 (수백 MB 이상) |
| 데이터 타입 | 문자열만 | JS 객체, Binary 등 |
| 검색 | 전체 로드 후 필터링 | Index 기반 고속 검색 |
채팅 메시지는 시간이 지나며 계속 누적되므로, 용량 여유가 큰 IndexedDB가 적합합니다.
3. 구현
3.1 IndexedDB 스키마 설계
먼저 채팅 메시지를 저장할 데이터베이스 구조를 설계했습니다.
// src/utils/db/chatDB.ts
interface ChatMessage {
id?: number; // 자동 증가 PK
chatroomId: number; // 채팅방 ID
articleId: number; // 게시글 ID
userId: number;
userNickname: string;
content: string;
timestamp: string;
isImage: boolean;
isSynced: 0 | 1; // 서버 동기화 여부
}
interface ChatDBSchema extends DBSchema {
messages: {
key: number;
value: ChatMessage;
indexes: {
"by-chatroom": [number, number]; // articleId, chatroomId
"by-timestamp": string;
"by-synced": 0 | 1;
"by-chatroom-timestamp": [number, number, string];
};
};
}
인덱스 설계 의도
by-chatroom: 특정 채팅방의 모든 메시지 조회by-timestamp: 오래된 메시지 자동 삭제by-chatroom-timestamp: 채팅방별 최신 50개 메시지 유지
3.2 데이터 관리 정책
무제한 저장은 기기 저장공간과 조회 성능에 부담을 줄 수 있어, 다음 정책을 적용했습니다.
export const CLEANUP_MAX_AGE_DAYS = 3; // 3일 지난 메시지 삭제
export const CLEANUP_MAX_MESSAGES_PER_CHATROOM = 50; // 채팅방당 최대 50개
export async function cleanupOldMessages(): Promise<void> {
const db = await getChatDB();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - CLEANUP_MAX_AGE_DAYS);
// 1. 3일 지난 메시지 삭제
const tx1 = db.transaction("messages", "readwrite");
const index = tx1.store.index("by-timestamp");
const range = IDBKeyRange.upperBound(cutoffDate.toISOString(), true);
let cursor = await index.openCursor(range);
while (cursor) {
await cursor.delete();
cursor = await cursor.continue();
}
// 2. 채팅방당 50개 초과 메시지 삭제
const tx2 = db.transaction("messages", "readwrite");
const index2 = tx2.store.index("by-chatroom-timestamp");
let cursor2 = await index2.openCursor();
let currentKey = "";
let count = 0;
while (cursor2) {
const msg = cursor2.value;
const key = `${msg.articleId}:${msg.chatroomId}`;
if (key !== currentKey) {
currentKey = key;
count = 0;
}
count++;
if (count > CLEANUP_MAX_MESSAGES_PER_CHATROOM) {
await cursor2.delete();
}
cursor2 = await cursor2.continue();
}
}
3.3 SharedWorker 구현
SharedWorker는 일반 Worker와 달리 onconnect 이벤트로 탭별 포트를 관리합니다.
// src/utils/worker/chat.shared-worker.ts
const ports: MessagePort[] = []; // 연결된 탭들
const subscriptions: Map<string, Set<MessagePort>> = new Map(); // 채팅방 구독 관리
let stompClient: Client | null = null;
let connectionOwnerPort: MessagePort | null = null; // WebSocket 연결 소유자
self.onconnect = (event: MessageEvent) => {
const port = event.ports[0];
ports.push(port);
port.onmessage = (e: MessageEvent) => handleMessage(port, e.data);
// 현재 연결 상태 전달
port.postMessage({
type: stompConnected ? "CONNECTED" : "DISCONNECTED",
payload: { portsCount: ports.length },
});
port.start();
};
탭 연결 관리 로직
function removePort(port: MessagePort): void {
const index = ports.indexOf(port);
if (index !== -1) {
ports.splice(index, 1);
}
// 채팅방 구독에서 제거
subscriptions.forEach((portSet) => portSet.delete(port));
// 연결 소유 탭이 닫히면 다른 탭으로 이전
if (port === connectionOwnerPort) {
const otherPorts = ports.filter((p) => p !== port);
if (otherPorts.length > 0 && currentToken && currentWsUrl) {
connectionOwnerPort = otherPorts[0];
} else {
disconnectStomp(); // 모든 탭이 닫히면 연결 해제
connectionOwnerPort = null;
}
}
}
3.4 메시지 수신 처리
서버에서 메시지를 받으면 IndexedDB에 저장한 뒤, 해당 채팅방을 구독 중인 탭에만 전달합니다.
async function handleIncomingMessage(
destination: string,
message: ChatMessageData,
): Promise<void> {
const chatMatch = destination.match(/\/topic\/chat\/(\d+)\/(\d+)/);
if (chatMatch) {
const articleId = parseInt(chatMatch[1], 10);
const chatroomId = parseInt(chatMatch[2], 10);
// IndexedDB에 저장
await addMessage({
articleId,
chatroomId,
userId: message.user_id,
userNickname: message.user_nickname,
content: message.content,
timestamp: message.timestamp,
isImage: message.is_image,
isSynced: 1,
});
// 구독 중인 탭들에게만 브로드캐스트
const key = `${articleId}:${chatroomId}`;
const subscribedPorts = subscriptions.get(key);
subscribedPorts?.forEach((port) => {
port.postMessage({
type: "NEW_MESSAGE",
payload: { articleId, chatroomId, message },
});
});
}
}
3.5 React 훅 구현
SharedWorker와 통신하는 React Hook도 함께 구성했습니다.
// src/utils/hooks/chat/useChatWorker.ts
export default function useChatWorker({
token,
userId,
articleId,
chatroomId,
onChatroomListUpdated,
}: UseChatWorkerOptions): UseChatWorkerReturn {
const [isConnected, setIsConnected] = useState(false);
const [realtimeMessages, setRealtimeMessages] = useState<
LostItemChatroomDetailMessage[]
>([]);
const portRef = useRef<MessagePort | null>(null);
// WebSocket 연결 (token, userId만 의존)
useEffect(() => {
const worker = getSharedWorker();
if (!worker || !userId) return;
const port = worker.port;
portRef.current = port;
const handleMessage = (event: MessageEvent<WorkerMessage>) => {
switch (event.data.type) {
case "CONNECTED":
setIsConnected(true);
break;
case "NEW_MESSAGE":
const { message } = event.data.payload as NewMessagePayload;
setRealtimeMessages((prev) => [...prev, message]);
break;
case "MESSAGES": {
// IndexedDB에서 가져온 캐시된 메시지
const { messages } = event.data.payload as MessagesPayload;
const formattedMessages = messages.map((msg) => ({
user_id: msg.userId,
user_nickname: msg.userNickname,
content: msg.content,
timestamp: msg.timestamp,
is_image: msg.isImage,
}));
setRealtimeMessages(formattedMessages);
break;
}
}
};
port.addEventListener("message", handleMessage);
port.start();
port.postMessage({ type: "CONNECT", payload: { token, userId, wsUrl } });
return () => {
port.removeEventListener("message", handleMessage);
port.postMessage({ type: "DISCONNECT" });
};
}, [token, userId]); // articleId, chatroomId 제외
// 채팅방 구독 (별도 useEffect)
useEffect(() => {
const port = portRef.current;
if (!port || !articleId || !chatroomId) return;
port.postMessage({
type: "SUBSCRIBE_CHATROOM",
payload: { articleId, chatroomId },
});
port.postMessage({
type: "GET_MESSAGES",
payload: { articleId, chatroomId },
});
return () => {
port.postMessage({
type: "UNSUBSCRIBE_CHATROOM",
payload: { articleId, chatroomId },
});
setRealtimeMessages([]);
};
}, [articleId, chatroomId]);
return { isConnected, realtimeMessages, sendMessage, syncMessages };
}
핵심 설계 결정: useEffect 분리
초기 구현에서는 token, userId, articleId, chatroomId를 하나의 useEffect 의존성 배열에 넣었습니다.
그 결과 채팅방을 바꿀 때마다 WebSocket이 재연결되는 문제가 발생했습니다.
해결 방법은 의존성을 역할별로 분리하는 것이었습니다.
- WebSocket 연결:
token,userId에만 의존 → 한 번 연결 후 유지 - 채팅방 구독:
articleId,chatroomId에 의존 → 채팅방 변경 시 구독만 변경
3.6 컴포넌트 적용
컴포넌트 기준으로 보면, WebSocket 관리 코드는 약 150줄에서 10줄 수준으로 줄었습니다.
// Before: 150줄의 STOMP 클라이언트 관리 코드
const clientRef = useRef<Client | null>(null);
const [currentMessageList, setCurrentMessageList] = useState([]);
useEffect(() => {
// WebSocket 연결, 구독, 메시지 핸들링...
// 150줄 생략
}, [articleId, chatroomId, token]);
// After: 10줄
const { realtimeMessages, sendMessage, mergeMessages } = useChatWorker({
token,
userId: userInfo?.id ?? 0,
articleId: articleId ? Number(articleId) : null,
chatroomId: chatroomId ? Number(chatroomId) : null,
onChatroomListUpdated: invalidateChatroomList,
});
// 서버 메시지를 IndexedDB에 동기화
useEffect(() => {
if (messages && messages.length > 0) {
mergeMessages(messages);
}
}, [messages, mergeMessages]);
3.7 오프라인 모드 UI
navigator.onLine을 기준으로 오프라인에서는 IndexedDB 캐시만 표시하도록 처리했습니다.
const isOnline = useNetworkStatus();
// 오프라인 시 API 호출 방지
const { data: messages } = useQuery({
queryKey: ['chatroom', 'lost-item', 'messages', articleId, chatroomId],
queryFn: isOnline
? () => getMessages(token, articleId, chatroomId)
: skipToken, // 오프라인 시 API 호출 스킵
});
// UI
{!isOnline && (
<div className={styles['offline-banner']}>
오프라인 상태입니다. 저장된 메시지만 볼 수 있습니다.
</div>
)}
<textarea
placeholder={isOnline ? '메시지 보내기' : '오프라인 상태입니다'}
disabled={!isOnline}
/>
4. 디버깅 과정
4.1 채팅방 변경 시 WebSocket 추가 연결
초기 구현 후 chrome://inspect/#workers에서 확인해보니, 채팅방을 변경할 때마다 WebSocket 연결이 새로 생성되고 있었습니다.
원인
useEffect 의존성 배열에 articleId, chatroomId가 포함되어 있었습니다.
// 문제 코드
useEffect(() => {
port.postMessage({ type: 'CONNECT', ... });
return () => port.postMessage({ type: 'DISCONNECT' });
}, [token, userId, articleId, chatroomId]); // 채팅방 변경 시 재연결
채팅방이 바뀔 때 cleanup이 실행되며 DISCONNECT가 전송됐고, 이어서 CONNECT가 다시 호출됐습니다.
해결
WebSocket 연결과 채팅방 구독을 별도 useEffect로 분리했습니다.
// 수정 코드
useEffect(() => {
/* 연결 */
}, [token, userId]); // 연결은 유지
useEffect(() => {
/* 구독 */
}, [articleId, chatroomId]); // 구독만 변경
4.2 연결 완료 전 구독 요청
간헐적으로 SUBSCRIBE_CHATROOM 메시지가 CONNECTED보다 먼저 처리되어 구독이 실패했습니다.
원인
useEffect 실행 타이밍이 WebSocket 연결 완료 시점보다 앞설 수 있었습니다.
// Worker에서
onConnect: () => {
stompConnected = true;
broadcast({ type: 'CONNECTED' });
}
// React에서 (이미 실행됨)
useEffect(() => {
port.postMessage({ type: 'SUBSCRIBE_CHATROOM', ... });
}, [articleId, chatroomId]);
해결
Pending Subscriptions 패턴을 적용해 순서 문제를 해결했습니다.
const pendingSubscriptions: Set<string> = new Set();
function stompSubscribe(destination: string): void {
if (!stompClient || !stompConnected) {
pendingSubscriptions.add(destination); // 대기열에 추가
return;
}
// 실제 구독
}
// onConnect에서 대기열 처리
onConnect: () => {
stompConnected = true;
pendingSubscriptions.forEach((dest) => stompSubscribe(dest));
pendingSubscriptions.clear();
};
5. 적용 결과
5.1 WebSocket 연결 수 감소
Before: 탭마다 독립 연결
여러 탭에서 쪽지 페이지를 열면 탭마다 WebSocket 연결이 생성됐습니다.
탭 1 → ws://api.koreatech.in/ws-stomp
탭 2 → ws://api.koreatech.in/ws-stomp
탭 3 → ws://api.koreatech.in/ws-stomp
탭 4 → ws://api.koreatech.in/ws-stomp
탭 5 → ws://api.koreatech.in/ws-stomp
After: 단일 연결 공유
여러 탭을 열어도 SharedWorker 내부에서 WebSocket 연결은 1개만 유지됩니다.
탭 1 ┐
탭 2 ├─→ SharedWorker → ws://api.koreatech.in/ws-stomp (1개)
탭 3 ┘
5.2 메시지 로딩 개선
Before: API 의존
새로고침 직후에는 서버 응답을 기다리는 동안 빈 화면이 잠시 표시됐습니다. 네트워크 상태가 나쁘면 메시지 표시가 더 늦어졌습니다.
After: IndexedDB 캐시 우선
IndexedDB 캐시를 먼저 표시하고, 백그라운드에서 서버 동기화를 진행하도록 바꿨습니다. 사용자는 새로고침 직후부터 이전 대화 내역을 볼 수 있습니다.
5.3 오프라인 지원
네트워크가 끊겨도 최근 3일, 채팅방당 최대 50개 메시지는 IndexedDB에서 조회할 수 있습니다.
6. 남은 과제
6.1 모바일 브라우저 지원
SharedWorker는 대부분의 모바일 브라우저에서 지원되지 않습니다.
| 브라우저 | 데스크톱 | 모바일 |
|---|---|---|
| Chrome | O | X |
| Safari | O | X |
| Firefox | O | X |
현재는 isSharedWorkerSupported()로 지원 여부를 확인하고, 미지원 환경에서는 기존 STOMP 경로로 폴백합니다.
향후 Broadcast Channel API로 대체를 검토 중입니다.
6.2 메시지 전송 재시도
현재는 오프라인 시 메시지 전송이 불가능합니다. 향후 오프라인 메시지를 IndexedDB에 큐잉하고, 연결 복구 시 자동 전송하는 기능을 추가할 예정입니다.
7. 마무리
마지막으로 정리하면, 이번 작업에서 얻은 핵심은 다음 네 가지였습니다.
1. 브라우저 API의 활용
SharedWorker와 IndexedDB는 멀티 탭 환경 문제를 구조적으로 해결하는 데 효과적이었습니다. 상태 관리 레이어만으로는 해결하기 어려운 문제였고, 브라우저 API 선택이 핵심이었습니다.
2. 의존성 배열의 중요성
useEffect 의존성 배열이 잘못되면 예상치 못한 재실행이 발생합니다. “연결”과 “구독”을 분리한 것이 문제 해결의 핵심이었습니다.
3. 점진적 개선
처음부터 모든 기능을 한 번에 넣지 않고, IndexedDB -> SharedWorker -> 오프라인 지원 순서로 점진적으로 적용했습니다. 단계별로 검증하면서 진행하니 디버깅 난이도를 크게 낮출 수 있었습니다.
4. 브라우저 호환성
SharedWorker의 모바일 미지원은 분명한 제약입니다. 다만 폴백을 함께 두어 Progressive Enhancement 방식으로 운영하면, 데스크톱에는 최적화 경로를 제공하고 모바일에는 안정적인 기본 기능을 유지할 수 있습니다.