diff --git a/src/apis/chatSocket.ts b/src/apis/chatSocket.ts index d885d798..e9f82b20 100644 --- a/src/apis/chatSocket.ts +++ b/src/apis/chatSocket.ts @@ -1,5 +1,4 @@ -import { clientApiClient } from '@/lib/client/apiClient'; -import { getClientAuthorization } from '@/lib/client/authToken'; +import { fetchSocketAuthState, isSocketAuthFailure } from '@/lib/client/socketAuth'; import { Client, type IFrame, @@ -15,30 +14,6 @@ if (!WS_BASE_URL) { throw new Error('Missing environment variable: NEXT_PUBLIC_WS_BASE_URL'); } -type SocketAuthResponse = { - success: boolean; - data?: { - authorization?: string; - }; -}; - -const fetchSocketAuthorization = async (): Promise => { - const clientAuthorization = getClientAuthorization(); - if (clientAuthorization) return clientAuthorization; - - try { - const response = await clientApiClient('/api/socket-auth', { - cache: 'no-store', - }); - - if (!response.success) return null; - return response.data?.authorization ?? null; - } catch (error) { - logWsDebug('auth-fetch-error', error); - return null; - } -}; - const withAuthorizationHeader = (authorization: string | null): StompHeaders => authorization ? { Authorization: authorization } : {}; @@ -83,8 +58,8 @@ const WS_DEBUG = process.env.NEXT_PUBLIC_WS_DEBUG === 'true'; export type ChatSocket = { connect: () => Promise; disconnect: () => void; - send: (message: string) => void; - cancel: () => void; + send: (message: string) => Promise; + cancel: () => Promise; }; const toSockJsUrl = (url: string) => { @@ -185,13 +160,107 @@ export const createChatSocket = (options: ChatSocketOptions): ChatSocket => { } = options; let subscription: StompSubscription | null = null; let currentAuthorization: string | null = null; + let currentToken: string | null = null; let connectAttempt = 0; let disconnected = false; + let reconnectPromise: Promise | null = null; const socketEndpoint = useSockJS ? toSockJsUrl(WS_CHAT_ENDPOINT) : toWebSocketUrl(WS_CHAT_ENDPOINT); + const deactivateClient = async () => { + subscription?.unsubscribe(); + subscription = null; + await client.deactivate(); + }; + + const connectWithLatestAuth = async (attempt: number) => { + const authState = await fetchSocketAuthState(); + + if (disconnected || attempt !== connectAttempt) { + logWsDebug('connect-aborted', { attempt, connectAttempt, disconnected }); + return; + } + + if (authState.kind === 'fetch-error') { + currentAuthorization = null; + currentToken = null; + logWsDebug('connect-auth-fetch-failed', authState.error); + onError?.(authState.error); + return; + } + + if (authState.kind === 'missing') { + const error = new Error('세션이 만료되어 소켓에 연결할 수 없습니다. 다시 로그인해 주세요.'); + currentAuthorization = null; + currentToken = null; + logWsDebug('connect-auth-missing'); + onError?.(error); + return; + } + + currentAuthorization = authState.authorization; + currentToken = authState.token; + client.activate(); + }; + + const reconnectWithLatestAuth = async (reason: string) => { + if (disconnected) return; + if (reconnectPromise) return reconnectPromise; + + reconnectPromise = (async () => { + logWsDebug('reconnect', { reason }); + connectAttempt += 1; + currentAuthorization = null; + currentToken = null; + await deactivateClient(); + + if (disconnected) return; + + const nextAttempt = ++connectAttempt; + await connectWithLatestAuth(nextAttempt); + })().finally(() => { + reconnectPromise = null; + }); + + return reconnectPromise; + }; + + const ensureReadyToPublish = async () => { + if (!client.connected) { + const error = new Error('Cannot send: socket is not connected.'); + onError?.(error); + throw error; + } + + const authState = await fetchSocketAuthState(); + if (authState.kind === 'fetch-error') { + onError?.(authState.error); + throw authState.error; + } + + if (authState.kind === 'missing') { + disconnect(); + const error = new Error( + '세션이 만료되어 소켓 연결을 유지할 수 없습니다. 다시 로그인해 주세요.' + ); + onError?.(error); + throw error; + } + + currentAuthorization = authState.authorization; + + if (currentToken !== authState.token) { + await reconnectWithLatestAuth('token-changed'); + const error = new Error( + '인증 세션이 갱신되어 소켓을 다시 연결했습니다. 잠시 후 다시 시도해 주세요.' + ); + onError?.(error); + throw error; + } + }; + const client = new Client({ webSocketFactory: () => useSockJS ? new SockJS(socketEndpoint) : new WebSocket(socketEndpoint), @@ -200,7 +269,16 @@ export const createChatSocket = (options: ChatSocketOptions): ChatSocket => { stompClient.connectHeaders = withAuthorizationHeader(currentAuthorization); }, reconnectDelay: 5000, - onStompError: (frame: IFrame) => onError?.(frame), + onStompError: (frame: IFrame) => { + const message = frame.body || frame.headers.message || 'Socket error'; + if (isSocketAuthFailure(message)) { + onError?.(new Error('인증이 만료되어 소켓을 다시 연결합니다. 잠시 후 다시 시도해 주세요.')); + void reconnectWithLatestAuth('stomp-auth-error'); + return; + } + + onError?.(new Error(message)); + }, onWebSocketError: err => onError?.(err), onWebSocketClose: () => onDisconnect?.(), }); @@ -231,39 +309,20 @@ export const createChatSocket = (options: ChatSocketOptions): ChatSocket => { const connect = async () => { const attempt = ++connectAttempt; disconnected = false; - currentAuthorization = await fetchSocketAuthorization(); - - if (disconnected || attempt !== connectAttempt) { - logWsDebug('connect-aborted', { attempt, connectAttempt, disconnected }); - return; - } - - if (!currentAuthorization) { - const error = new Error('Cannot connect: authentication token is unavailable.'); - logWsDebug('connect-auth-missing'); - onError?.(error); - return; - } - - client.activate(); + await connectWithLatestAuth(attempt); }; const disconnect = () => { connectAttempt += 1; disconnected = true; currentAuthorization = null; + currentToken = null; logWsDebug('disconnect'); - subscription?.unsubscribe(); - subscription = null; - client.deactivate(); + void deactivateClient(); }; - const send = (message: string) => { - if (!client.connected) { - const error = new Error('Cannot send: socket is not connected.'); - onError?.(error); - throw error; - } + const send = async (message: string) => { + await ensureReadyToPublish(); const body = JSON.stringify({ chatId: Number(chatId), message }); logWsDebug('send', { destination: SEND_DEST, body }); client.publish({ @@ -273,12 +332,8 @@ export const createChatSocket = (options: ChatSocketOptions): ChatSocket => { }); }; - const cancel = () => { - if (!client.connected) { - const error = new Error('Cannot cancel: socket is not connected.'); - onError?.(error); - throw error; - } + const cancel = async () => { + await ensureReadyToPublish(); const body = JSON.stringify({ chatId: Number(chatId) }); logWsDebug('cancel', { destination: CANCEL_DEST, body }); client.publish({ diff --git a/src/apis/summarySocket.ts b/src/apis/summarySocket.ts index 9f01235d..6048fe91 100644 --- a/src/apis/summarySocket.ts +++ b/src/apis/summarySocket.ts @@ -1,7 +1,6 @@ 'use client'; -import { clientApiClient } from '@/lib/client/apiClient'; -import { getClientAuthorization } from '@/lib/client/authToken'; +import { fetchSocketAuthState, isSocketAuthFailure } from '@/lib/client/socketAuth'; import { Client, type IFrame, @@ -26,30 +25,6 @@ const WS_DEBUG = process.env.NEXT_PUBLIC_WS_DEBUG === 'true'; const withAuthorizationHeader = (authorization: string | null): StompHeaders => authorization ? { Authorization: authorization } : {}; -type SocketAuthResponse = { - success: boolean; - data?: { - authorization?: string; - }; -}; - -const fetchSocketAuthorization = async (): Promise => { - const clientAuthorization = getClientAuthorization(); - if (clientAuthorization) return clientAuthorization; - - try { - const response = await clientApiClient('/api/socket-auth', { - cache: 'no-store', - }); - - if (!response.success) return null; - return response.data?.authorization ?? null; - } catch (error) { - logWsDebug('auth-fetch-error', error); - return null; - } -}; - export type SummaryStatusPayload = { linkId: number; status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; @@ -164,10 +139,12 @@ export const createSummarySocket = ({ onDisconnect, }: SummarySocketOptions): SummarySocket => { let currentAuthorization: string | null = null; + let currentToken: string | null = null; let connectAttempt = 0; let disconnected = false; let hasNotifiedDisconnect = false; let subscription: StompSubscription | null = null; + let reconnectPromise: Promise | null = null; const socketEndpoint = useSockJS ? toSockJsUrl(WS_SUMMARY_ENDPOINT) @@ -179,33 +156,93 @@ export const createSummarySocket = ({ onDisconnect?.(); }; + const deactivateClient = async () => { + subscription?.unsubscribe(); + subscription = null; + await client.deactivate(); + }; + + const connectWithLatestAuth = async (attempt: number) => { + const authState = await fetchSocketAuthState(); + + if (disconnected || attempt !== connectAttempt) { + logWsDebug('connect-aborted', { attempt, connectAttempt, disconnected }); + console.warn('[summary-socket] connect aborted', { attempt, connectAttempt, disconnected }); + return; + } + + if (authState.kind === 'fetch-error') { + currentAuthorization = null; + currentToken = null; + logWsDebug('connect-auth-fetch-failed', authState.error); + onError?.(authState.error); + return; + } + + if (authState.kind === 'missing') { + currentAuthorization = null; + currentToken = null; + const error = new Error( + '세션이 만료되어 요약 소켓에 연결할 수 없습니다. 다시 로그인해 주세요.' + ); + logWsDebug('connect-auth-missing'); + onError?.(error); + return; + } + + currentAuthorization = authState.authorization; + currentToken = authState.token; + client.activate(); + }; + + const reconnectWithLatestAuth = async (reason: string) => { + if (disconnected) return; + if (reconnectPromise) return reconnectPromise; + + reconnectPromise = (async () => { + logWsDebug('reconnect', { reason }); + connectAttempt += 1; + currentAuthorization = null; + currentToken = null; + await deactivateClient(); + + if (disconnected) return; + + const nextAttempt = ++connectAttempt; + await connectWithLatestAuth(nextAttempt); + })().finally(() => { + reconnectPromise = null; + }); + + return reconnectPromise; + }; + const client = new Client({ webSocketFactory: () => { console.log('[summary-socket] creating socket to', socketEndpoint); return useSockJS ? new SockJS(socketEndpoint) : new WebSocket(socketEndpoint); }, connectHeaders: {}, - beforeConnect: async stompClient => { - currentAuthorization = await fetchSocketAuthorization(); - - if (!currentAuthorization) { - const error = new Error('Cannot connect: authentication token is unavailable.'); - logWsDebug('connect-auth-missing'); - console.error('[summary-socket] auth missing'); - onError?.(error); - throw error; - } - + beforeConnect: stompClient => { stompClient.connectHeaders = withAuthorizationHeader(currentAuthorization); }, reconnectDelay: 5000, onStompError: (frame: IFrame) => { + const message = frame.body || frame.headers.message || 'Socket error'; + if (isSocketAuthFailure(message)) { + onError?.( + new Error('요약 소켓 인증이 만료되어 다시 연결합니다. 잠시 후 상태를 다시 확인해 주세요.') + ); + void reconnectWithLatestAuth('stomp-auth-error'); + return; + } + console.error('[summary-socket] stomp error', { command: frame.command, headers: frame.headers, body: frame.body, }); - onError?.(frame); + onError?.(new Error(message)); }, onWebSocketError: err => { console.error('[summary-socket] websocket error', { @@ -218,6 +255,8 @@ export const createSummarySocket = ({ onWebSocketClose: () => { console.warn('[summary-socket] websocket closed'); notifyDisconnect(); + if (disconnected) return; + void reconnectWithLatestAuth('websocket-close'); }, }); @@ -257,17 +296,16 @@ export const createSummarySocket = ({ return; } - client.activate(); + await connectWithLatestAuth(attempt); }; const disconnect = () => { connectAttempt += 1; disconnected = true; currentAuthorization = null; + currentToken = null; console.log('[summary-socket] disconnected'); - subscription?.unsubscribe(); - subscription = null; - client.deactivate(); + void deactivateClient(); notifyDisconnect(); }; diff --git a/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx b/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx index 2eae0c90..bd06df64 100644 --- a/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx +++ b/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx @@ -141,7 +141,7 @@ export default function ChatApiDemo() { ]); }; - const handleSend = () => { + const handleSend = async () => { const text = question.trim(); if (!text || !selectedChatId) return; if (!connected) { @@ -153,20 +153,20 @@ export default function ChatApiDemo() { ...prev, { id: `${Date.now()}-${crypto.randomUUID()}`, role: 'system', text: `질문 전송: ${text}` }, ]); - setQuestion(''); try { - send(text); + await send(text); + setQuestion(''); } catch (err) { setStreamError((err as Error).message); } }; - const handleCancel = () => { + const handleCancel = async () => { if (!selectedChatId || !connected) return; try { - cancel(); + await cancel(); setStreamLog(prev => [ ...prev, { id: `${Date.now()}-${crypto.randomUUID()}`, role: 'system', text: '취소 요청 전송' }, diff --git a/src/app/(route)/chat/[id]/ChatPage.tsx b/src/app/(route)/chat/[id]/ChatPage.tsx index 00231807..a6d681ce 100644 --- a/src/app/(route)/chat/[id]/ChatPage.tsx +++ b/src/app/(route)/chat/[id]/ChatPage.tsx @@ -243,11 +243,17 @@ export default function Chat() { initialSentRef.current = true; setIsAwaitingResponse(true); queueScrollToBottom(); + const optimisticMessageId = `${Date.now()}-${crypto.randomUUID()}`; setMessages(prev => [ ...prev, - { id: `${Date.now()}-${crypto.randomUUID()}`, role: 'user', text: initialQuestion }, + { id: optimisticMessageId, role: 'user', text: initialQuestion }, ]); - send(initialQuestion); + void send(initialQuestion).catch(err => { + clearResponseUnlockTimer(); + setIsAwaitingResponse(false); + setMessages(prev => prev.filter(message => message.id !== optimisticMessageId)); + setStreamError((err as Error).message ?? '메시지 전송에 실패했습니다.'); + }); window.history.replaceState(window.history.state, '', `/chat/${chatId}`); }, onDisconnect: () => { @@ -389,7 +395,7 @@ export default function Chat() { }); }, [messages, isAwaitingResponse, scrollToBottom]); - const handleSubmit = (value: string) => { + const handleSubmit = async (value: string) => { if (!connected) { setStreamError('소켓이 연결되지 않았습니다.'); return; @@ -402,16 +408,15 @@ export default function Chat() { setIsAwaitingResponse(true); clearResponseUnlockTimer(); queueScrollToBottom(); - setMessages(prev => [ - ...prev, - { id: `${Date.now()}-${crypto.randomUUID()}`, role: 'user', text: trimmedValue }, - ]); + const optimisticMessageId = `${Date.now()}-${crypto.randomUUID()}`; + setMessages(prev => [...prev, { id: optimisticMessageId, role: 'user', text: trimmedValue }]); try { - send(trimmedValue); + await send(trimmedValue); } catch (err) { clearResponseUnlockTimer(); setIsAwaitingResponse(false); + setMessages(prev => prev.filter(message => message.id !== optimisticMessageId)); setStreamError((err as Error).message ?? '메시지 전송에 실패했습니다.'); } }; diff --git a/src/hooks/server/Chats/useChatStream.ts b/src/hooks/server/Chats/useChatStream.ts index 54b0d917..2e8b8cc3 100644 --- a/src/hooks/server/Chats/useChatStream.ts +++ b/src/hooks/server/Chats/useChatStream.ts @@ -77,16 +77,16 @@ export const useChatStream = ({ }, [chatId, enabled, useSockJS]); const send = useCallback( - (message: string) => { + async (message: string) => { if (!enabled) return; - socketRef.current?.send(message); + await socketRef.current?.send(message); }, [enabled] ); - const cancel = useCallback(() => { + const cancel = useCallback(async () => { if (!enabled) return; - socketRef.current?.cancel(); + await socketRef.current?.cancel(); }, [enabled]); return { send, cancel, connected }; diff --git a/src/lib/client/socketAuth.ts b/src/lib/client/socketAuth.ts new file mode 100644 index 00000000..9afdbcca --- /dev/null +++ b/src/lib/client/socketAuth.ts @@ -0,0 +1,90 @@ +import { clientApiClient } from '@/lib/client/apiClient'; +import { getClientAccessToken, getClientAuthorization } from '@/lib/client/authToken'; +import { ApiError } from '@/lib/errors/ApiError'; + +type SocketAuthResponse = { + success: boolean; + data?: { + authorization?: string; + }; +}; + +export type SocketAuthState = + | { + kind: 'authenticated'; + token: string; + authorization: string; + } + | { + kind: 'missing'; + } + | { + kind: 'fetch-error'; + error: Error; + }; + +const parseAuthorizationToken = (authorization: string | null | undefined) => { + if (!authorization) return null; + + const normalized = authorization.trim(); + if (!normalized) return null; + + return normalized.replace(/^Bearer\s+/i, '') || null; +}; + +const toError = (error: unknown) => + error instanceof Error ? error : new Error(String(error ?? 'Failed to fetch socket auth.')); + +export const fetchSocketAuthState = async (): Promise => { + const clientToken = getClientAccessToken(); + const clientAuthorization = getClientAuthorization(); + if (clientToken && clientAuthorization) { + return { + kind: 'authenticated', + token: clientToken, + authorization: clientAuthorization, + }; + } + + try { + const response = await clientApiClient('/api/socket-auth', { + cache: 'no-store', + }); + + if (!response.success) return { kind: 'missing' }; + + const authorization = response.data?.authorization?.trim() ?? ''; + const token = parseAuthorizationToken(authorization); + if (!authorization || !token) return { kind: 'missing' }; + + return { kind: 'authenticated', token, authorization }; + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return { kind: 'missing' }; + } + + return { kind: 'fetch-error', error: toError(error) }; + } +}; + +export const isSocketAuthFailure = (value: unknown) => { + const raw = + typeof value === 'string' + ? value + : value instanceof Error + ? value.message + : typeof value === 'object' && value !== null && 'body' in value + ? String((value as { body?: unknown }).body ?? '') + : String(value ?? ''); + + const message = raw.toLowerCase(); + return ( + message.includes('token') || + message.includes('expired') || + message.includes('unauthorized') || + message.includes('forbidden') || + message.includes('인증') || + message.includes('만료') || + message.includes('유효하지') + ); +};