diff --git a/package.json b/package.json index 33db738b..6b23f486 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "cookie": "^1.0.2", "date-fns": "^4.1.0", "mock-socket": "^9.3.1", - "next": "15.5.3", + "next": "^16.0.7", "react": "19.1.0", "react-dom": "19.1.0", "swiper": "^12.0.2", diff --git a/src/entities/chat/lib/useChatSocket.ts b/src/entities/chat/lib/useChatSocket.ts index 87e00b7e..2e6e14b5 100644 --- a/src/entities/chat/lib/useChatSocket.ts +++ b/src/entities/chat/lib/useChatSocket.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { ChatSocket } from "../model/socket"; +import { ChatSocket } from "../model/chatSocket"; import { DealStatus, MessageProps } from "../model/types"; import { PostStatus } from "@/entities/post/model/types/post"; @@ -86,7 +86,7 @@ export const useChatSocket = ( if (chatId) connectSocket(); return () => { - socketRef.current?.leaveRoom(); + socketRef.current?.close(); socketRef.current = null; }; }, [chatId]); diff --git a/src/entities/chat/model/chatSocket.ts b/src/entities/chat/model/chatSocket.ts new file mode 100644 index 00000000..453bb842 --- /dev/null +++ b/src/entities/chat/model/chatSocket.ts @@ -0,0 +1,76 @@ +import { Socket, SocketEvents } from "./socket"; +import type { DealStatus, MessageProps } from "./types"; +import type { PostStatus } from "@/entities/post/model/types/post"; + +export interface ChatSocketEvents extends SocketEvents { + onMessage?: (message: MessageProps) => void; + onSystem?: (system: { type: string; message: string }) => void; + onDealUpdate?: (update: { + postStatus: PostStatus; + dealStatus: DealStatus; + message: string; + }) => void; +} + +export class ChatSocket extends Socket { + private chatId: number; + + constructor(chatId: number, events: ChatSocketEvents = {}) { + super(events); + this.chatId = chatId; + } + + protected getEndpointPath(): string { + return `/ws/chat/${this.chatId}`; + } + + protected getDebugName(): string { + return `ChatSocket (ID: ${this.chatId})`; + } + + protected getCloseCodeName(): string { + return "leave_room"; // 개별 채팅방 종료 이벤트 이름 + } + + // 개별 채팅방 고유의 메시지 처리 로직 구현 + protected handleMessage(event: MessageEvent): void { + try { + const data = JSON.parse(event.data); + + if (data.type === "deal_update") { + this.events.onDealUpdate?.(data); + return; + } + + if (["welcome", "system", "read"].includes(data.type)) { + this.events.onSystem?.(data); + return; + } + + if (data.messageId && data.content) { + // 메시지 수신 로직 + const msg: MessageProps = { + messageId: data.messageId, + type: data.type, + content: data.content, + isMine: false, + sendAt: data.createdAt, + isRead: false, + }; + this.events.onMessage?.(msg); + } + } catch (err) { + console.error("[ChatSocket] Message parse error:", err); + } + } + + public sendMessage(type: "text" | "image", content: string) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + console.warn("[ChatSocket] Not connected"); + return; + } + + const payload = { event: "send_message", type, content }; + this.socket.send(JSON.stringify(payload)); + } +} diff --git a/src/entities/chat/model/socket.ts b/src/entities/chat/model/socket.ts index bc4e1277..b81fadd8 100644 --- a/src/entities/chat/model/socket.ts +++ b/src/entities/chat/model/socket.ts @@ -1,166 +1,132 @@ -import { DealStatus, MessageProps } from "./types"; import { useAuthStore } from "@/features/auth/model/auth.store"; -import { PostStatus } from "@/entities/post/model/types/post"; import { AuthorizationError } from "@/shared/error/error"; import { handleError } from "@/shared/error/handleError"; -export interface ChatSocketEvents { +// 모든 소켓 이벤트가 상속받을 기본 인터페이스 +export interface SocketEvents { onOpen?: () => void; - onMessage?: (message: MessageProps) => void; - onSystem?: (system: { type: string; message: string }) => void; - onDealUpdate?: (update: { - postStatus: PostStatus; - dealStatus: DealStatus; - message: string; - }) => void; - onClose?: (code: number, reason?: string) => void; onError?: (event: Event) => void; + onClose?: (code: number, reason?: string) => void; } -export class ChatSocket { - private socket: WebSocket | null = null; - private chatId: number; - private events: ChatSocketEvents; +// 각 소켓 클래스가 구현해야 하는 이벤트 핸들러 +export abstract class Socket { + protected socket: WebSocket | null = null; + protected events: Events; - constructor(chatId: number, events: ChatSocketEvents = {}) { - this.chatId = chatId; + constructor(events: Events) { this.events = events; } - isOpen(): boolean { + // 서브클래스에서 구현해야 하는 추상 메서드 + protected abstract getEndpointPath(): string; + protected abstract handleMessage(event: MessageEvent): void; + protected abstract getDebugName(): string; + protected abstract getCloseCodeName(): string; + + public isOpen(): boolean { return this.socket !== null && this.socket.readyState === WebSocket.OPEN; } - connect(): Promise { + private getWsUrl(): string { + const { accessToken } = useAuthStore.getState(); + const path = this.getEndpointPath(); + const baseUrl = process.env.NEXT_PUBLIC_API_WS_URL || "ws://localhost:8000"; + + // 쿼리 파라미터로 accessToken을 추가하는 공통 로직 + return `${baseUrl}${path}?token=${accessToken}`; + } + + public connect(): Promise { return new Promise((resolve, reject) => { + const debugName = this.getDebugName(); + if (this.socket) { - console.warn("[Socket] Already connected"); + console.warn(`[${debugName}] Already connected`); resolve(); return; } - const { accessToken } = useAuthStore.getState(); - const wsUrl = `${ - process.env.NEXT_PUBLIC_API_WS_URL || "ws://localhost:8000" - }/ws/chat/${this.chatId}?token=${accessToken}`; - - this.socket = new WebSocket(wsUrl); + try { + this.socket = new WebSocket(this.getWsUrl()); + } catch (e) { + console.error(`[${debugName}] Failed to create WebSocket URL.`); + reject(e); + return; + } this.socket.onopen = () => { - console.log("[Socket] Connected"); + console.log(`[${debugName}] Connected`); this.events.onOpen?.(); resolve(); }; - this.socket.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - if (data.type === "deal_update") { - this.events.onDealUpdate?.({ - dealStatus: data.dealStatus, - postStatus: data.postStatus, - message: data.systemMessage, - }); - return; - } - - if (["welcome", "system", "read"].includes(data.type)) { - this.events.onSystem?.(data); - return; - } - - if (data.messageId && data.content) { - const msg: MessageProps = { - messageId: data.messageId, - type: data.type, - content: data.content, - isMine: false, - sendAt: data.createdAt, - isRead: false, - }; - this.events.onMessage?.(msg); - } - } catch (err) { - console.error("[Socket] Message parse error:", err); - } - }; - - this.socket.onclose = async (event) => { - if (event.code === 4001) { - console.warn("[Socket] Token expired (4001)"); - - const { logout, setAccessToken } = useAuthStore.getState(); - - try { - const refreshed = await fetch("/api/auth/refresh", { - method: "POST", - credentials: "include", - }); - - if (!refreshed.ok) { - logout(); + this.socket.onmessage = (event) => this.handleMessage(event); - throw new AuthorizationError( - "세션이 만료되었습니다.\n다시 로그인 해주세요.", - ); - } - - const { accessToken: newToken } = await refreshed.json(); - setAccessToken(newToken); - - this.reconnect(); - } catch (err) { - handleError(err); - logout(); - return; - } - } - - console.warn("[Socket] Closed:", event.code); - this.events.onClose?.(event.code, event.reason); - this.socket = null; - }; + this.socket.onclose = (event) => this.handleClose(event); this.socket.onerror = (err) => { - console.error("[Socket] Error:", err); + console.error(`[${debugName}] Error:`, err); this.events.onError?.(err); reject(err); }; }); } - private reconnect() { - console.log("[Socket] Reconnecting after refresh…"); - this.socket = null; - this.connect(); - } + // 토큰 만료 시 재연결 로직 (모든 소켓에 공통) + protected async handleClose(event: CloseEvent) { + const debugName = this.getDebugName(); + + if (event.code === 4001) { + console.warn(`[${debugName}] Token expired (4001). Attempting refresh.`); + const { logout, setAccessToken } = useAuthStore.getState(); + + try { + const refreshed = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + + if (!refreshed.ok) { + logout(); + throw new AuthorizationError( + "세션이 만료되었습니다.\\n다시 로그인 해주세요.", + ); + } - sendMessage(type: "text" | "image", content: string) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - console.warn("[Socket] Not connected"); - return; + const { accessToken: newToken } = await refreshed.json(); + setAccessToken(newToken); + this.reconnect(); // 갱신 성공 시 재연결 + } catch (err) { + handleError(err); + logout(); + return; + } } - const payload = { - event: "send_message", - type, - content, - }; + console.warn(`[${debugName}] Closed:`, event.code); + this.events.onClose?.(event.code, event.reason); + this.socket = null; + } - this.socket.send(JSON.stringify(payload)); + protected reconnect() { + console.log(`[${this.getDebugName()}] Reconnecting after refresh…`); + this.socket = null; + this.connect(); } - leaveRoom() { + public close() { if (!this.socket) return; - const state = this.socket.readyState; - - if (state === WebSocket.OPEN) { - this.socket.send(JSON.stringify({ event: "leave_room" })); - this.socket.close(1000, "User left"); + // 서브클래스에서 정의한 종료 이벤트 이름 사용 + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ event: this.getCloseCodeName() })); + this.socket.close(1000, `User left ${this.getDebugName()}`); } else { - this.socket.close(1000, `User left skipped - ${state.toString()}`); + this.socket.close( + 1000, + `User left skipped - ${this.socket.readyState.toString()}`, + ); } this.socket = null; diff --git a/src/features/chat/lib/useChatListSocket.ts b/src/features/chat/lib/useChatListSocket.ts new file mode 100644 index 00000000..0bbe2131 --- /dev/null +++ b/src/features/chat/lib/useChatListSocket.ts @@ -0,0 +1,59 @@ +// src/features/chat/hooks/useChatListSocket.ts + +import { useEffect, useRef } from "react"; +import { ChatListSocket } from "../model/chatListSocket"; +import { handleError } from "@/shared/error/handleError"; +import { Chat, MessageProps } from "@/entities/chat/model/types"; +interface UseChatListSocketProps { + onChatCreated: (chat: Chat) => void; + onChatListUpdate: (update: { + chatId: number; + lastMessage: MessageProps; + }) => void; +} + +export const useChatListSocket = ({ + onChatCreated, + onChatListUpdate, +}: UseChatListSocketProps) => { + const socketRef = useRef(null); + + const connectListSocket = async () => { + if (socketRef.current?.isOpen()) return; + + if (!socketRef.current) { + socketRef.current = new ChatListSocket({ + onOpen: () => { + console.log("[useChatListSocket] List Socket Ready."); + }, + onChatCreated: onChatCreated, + onChatListUpdate: onChatListUpdate, + onClose: (code) => { + console.log(`[useChatListSocket] Closed: ${code}`); + }, + }); + } + + try { + await socketRef.current.connect(); + } catch (error) { + console.error("[useChatListSocket] Initial connection failed:", error); + handleError(error); + } + }; + + useEffect(() => { + connectListSocket(); + + return () => { + socketRef.current?.close(); + socketRef.current = null; + }; + }, []); + + const isConnected = socketRef.current?.isOpen(); + + return { + isConnected, + }; +}; diff --git a/src/features/chat/model/chatListSocket.ts b/src/features/chat/model/chatListSocket.ts new file mode 100644 index 00000000..5c974899 --- /dev/null +++ b/src/features/chat/model/chatListSocket.ts @@ -0,0 +1,53 @@ +import { Socket, SocketEvents } from "../../../entities/chat/model/socket"; +import type { Chat, MessageProps } from "../../../entities/chat/model/types"; + +interface ChatListUpdatePayload { + chatId: number; + lastMessage: MessageProps; +} + +export interface ChatListSocketEvents extends SocketEvents { + onChatCreated?: (chat: Chat) => void; + onChatListUpdate?: (update: ChatListUpdatePayload) => void; + onSystem?: (system: { type: string; message: string }) => void; +} + +export class ChatListSocket extends Socket { + constructor(events: ChatListSocketEvents = {}) { + super(events); + } + + protected getEndpointPath(): string { + return "/ws/chat-list"; + } + + protected getDebugName(): string { + return "ChatListSocket"; + } + + protected getCloseCodeName(): string { + return "leave_chat_list"; // 채팅 목록 종료 이벤트 이름 + } + + protected handleMessage(event: MessageEvent): void { + try { + const data = JSON.parse(event.data); + console.log( + `event : ${JSON.stringify(event)}, data : ${JSON.stringify(data)} 호출`, + ); + switch (data.event) { + case "chat_created": + this.events.onChatCreated?.(data.payload); + break; + case "chat_list_update": + this.events.onChatListUpdate?.(data.payload); + break; + case "system_message": + this.events.onSystem?.(data); + break; + } + } catch (err) { + console.error(`[${this.getDebugName()}] Message parse error:`, err); + } + } +} diff --git a/src/features/chat/ui/ChatList.tsx b/src/features/chat/ui/ChatList.tsx index 437c6b1b..5972a9a6 100644 --- a/src/features/chat/ui/ChatList.tsx +++ b/src/features/chat/ui/ChatList.tsx @@ -2,10 +2,11 @@ import ChatItem from "@/entities/chat/ui/ChatItem"; import { fetchChatList } from "../model/chat.api"; -import type { Chat } from "@/entities/chat/model/types"; -import { useQuery } from "@tanstack/react-query"; +import type { Chat, MessageProps } from "@/entities/chat/model/types"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { DealStatus } from "@/entities/chat/model/types"; import { handleError } from "@/shared/error/handleError"; +import { useChatListSocket } from "../lib/useChatListSocket"; interface ChatListProps { onSelect: (info: { @@ -18,7 +19,9 @@ interface ChatListProps { } const ChatList = ({ onSelect, tab = "all" }: ChatListProps) => { + const queryClient = useQueryClient(); const role = tab === "all" ? undefined : tab === "buyer" ? "buyer" : "seller"; + const queryKey = ["chats", role]; const { data: chats = [], @@ -30,6 +33,49 @@ const ChatList = ({ onSelect, tab = "all" }: ChatListProps) => { queryFn: () => fetchChatList(role), }); + const handleChatCreated = (newChat: Chat) => { + const roles = [newChat.role, undefined]; + roles.forEach((updatedRole) => { + queryClient.setQueryData(["chats", updatedRole], (oldChats) => { + if (!oldChats) return [newChat]; + return [newChat, ...oldChats]; + }); + }); + }; + + const handleChatListUpdated = (update: { + chatId: number; + lastMessage: MessageProps; + }) => { + const roles = [undefined, "buyer", "seller"]; + roles.forEach((updatedRole) => { + const queryKey = ["chats", updatedRole]; + queryClient.setQueryData(queryKey, (oldChats: Chat[] | undefined) => { + if (!oldChats) return undefined; + + const updatedChatIndex = oldChats.findIndex( + (chat) => chat.chatId === update.chatId, + ); + if (updatedChatIndex === -1) return oldChats; + + const updatedChat: Chat = { + ...oldChats[updatedChatIndex], + lastMessage: { + ...update.lastMessage, + }, + }; + const remainChats = oldChats.filter( + (_, index) => index !== updatedChatIndex, + ); + return [updatedChat, ...remainChats]; + }); + }); + }; + + useChatListSocket({ + onChatCreated: handleChatCreated, + onChatListUpdate: handleChatListUpdated, + }); if (isError) { handleError(error); }