Procházet zdrojové kódy

M1-D2: /chat main chat page

- SSR history via REST + live merge over shared chatConnection (dedupe, 200-msg cap)
- guest read + login-required send, slow-mode countdown, reconnect rejoin, kick handling
- header nav: chat entry
- tsc 0, build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 před 3 týdny
rodič
revize
dae1a891ad

+ 373 - 0
app/(main)/chat/MainChat.tsx

@@ -0,0 +1,373 @@
+'use client';
+
+import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent } from 'react';
+import Link from 'next/link';
+import { HubConnectionState } from '@microsoft/signalr';
+import useAuth from '@/hooks/useAuth';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+import type { ChatMessage } from '@/types/chat';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faUsers, faPaperPlane, faBullhorn, faArrowDown } from '@fortawesome/free-solid-svg-icons';
+
+const ROOM_KEY = 'main';
+const MAX_MESSAGES = 200; // 최근 200건 유지·오버플로 드랍 (Backend ChatSettings.MaxMessages 와 동일)
+const MAX_SYSTEM_MESSAGES = 50;
+const MAX_CONTENT_LENGTH = 500;
+const GLOBAL_RATE_LIMIT_SECONDS = 2; // Hub 전역 rate limit (ChatSettings.RateLimitSeconds)
+const SCROLL_BOTTOM_THRESHOLD = 50;
+
+type SystemMessage = {
+	id: number;
+	content: string;
+	receivedAt: string;
+};
+
+type MergedItem =
+	| { kind: 'chat'; time: string; data: ChatMessage }
+	| { kind: 'system'; time: string; data: SystemMessage };
+
+type ConnectionState = 'connecting'|'connected'|'reconnecting'|'disconnected';
+
+// 메시지 dedupe 키 — ChatMessage 페이로드에 단일 ID 필드가 없어 (memberSID, sentAt, content) 합성 키 사용
+// SSR 초기 히스토리 ↔ JoinRoom ReceiveHistory ↔ 실시간 ReceiveMessage 간 중복 제거
+const messageKey = (m: ChatMessage) => `${m.memberSID}|${m.sentAt}|${m.content}`;
+
+const formatTime = (dateStr: string) => {
+	const date = new Date(dateStr);
+	return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
+};
+
+type Props = {
+	initialMessages: ChatMessage[];
+	notice: string|null;
+	slowModeSeconds: number;
+};
+
+export default function MainChat({ initialMessages, notice, slowModeSeconds }: Props)
+{
+	const { isAuthenticated } = useAuth();
+	const { chatConnection, chatConnected } = useSignalRContext();
+
+	const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
+	const [systemMessages, setSystemMessages] = useState<SystemMessage[]>([]);
+	const [participantCount, setParticipantCount] = useState(0);
+	const [connState, setConnState] = useState<ConnectionState>('connecting');
+	const [kicked, setKicked] = useState(false);
+	const [inputValue, setInputValue] = useState('');
+	const [cooldownLeft, setCooldownLeft] = useState(0);
+	const [showJump, setShowJump] = useState(false);
+
+	const listRef = useRef<HTMLDivElement>(null);
+	const autoScrollRef = useRef(true);
+	const systemIdRef = useRef(0);
+	const cooldownEndRef = useRef(0);
+	const activeRef = useRef(true);
+
+	const pushSystem = useCallback((content: string) => {
+		setSystemMessages((prev) => {
+			const next = [...prev, { id: ++systemIdRef.current, content, receivedAt: new Date().toISOString() }];
+			return next.length > MAX_SYSTEM_MESSAGES ? next.slice(next.length - MAX_SYSTEM_MESSAGES) : next;
+		});
+	}, []);
+
+	const appendMessage = useCallback((message: ChatMessage) => {
+		setMessages((prev) => {
+			const key = messageKey(message);
+			if (prev.some((c) => messageKey(c) === key)) {
+				return prev;
+			}
+			const next = [...prev, message];
+			return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next;
+		});
+	}, []);
+
+	// JoinRoom/재연결 시 ReceiveHistory — SSR 초기분과 병합 (dedupe + 시간순 + 상한)
+	const mergeHistory = useCallback((history: ChatMessage[]) => {
+		setMessages((prev) => {
+			const seen = new Set(prev.map(messageKey));
+			const added = history.filter((c) => !seen.has(messageKey(c)));
+			if (added.length === 0) {
+				return prev;
+			}
+			const next = [...prev, ...added].sort((a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime());
+			return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next;
+		});
+	}, []);
+
+	// ── 연결 라이프사이클: provider 의 chatConnection 재사용, JoinRoom 은 마운트 시 · LeaveRoom 은 언마운트 시 ──
+	useEffect(() => {
+		if (!chatConnection || !chatConnected) {
+			return;
+		}
+
+		activeRef.current = true;
+
+		const handleHistory = (history: ChatMessage[]) => mergeHistory(history);
+		const handleMessage = (message: ChatMessage) => appendMessage(message);
+		const handleSystem = (content: string) => pushSystem(content);
+		const handleCount = (count: number) => setParticipantCount(count);
+		const handleKick = () => {
+			setKicked(true);
+			pushSystem('관리자에 의해 채팅 이용이 제한되었습니다.');
+		};
+
+		chatConnection.on('ReceiveHistory', handleHistory);
+		chatConnection.on('ReceiveMessage', handleMessage);
+		chatConnection.on('ReceiveSystemMessage', handleSystem);
+		chatConnection.on('ReceiveParticipantCount', handleCount);
+		chatConnection.on('Kick', handleKick);
+
+		// 자동 재연결 상태 UX — signalR 은 이 콜백들의 해제 API 가 없어 activeRef 로 stale 호출 가드
+		chatConnection.onreconnecting(() => {
+			if (activeRef.current) {
+				setConnState('reconnecting');
+			}
+		});
+		chatConnection.onreconnected(() => {
+			if (!activeRef.current) {
+				return;
+			}
+			setConnState('connected');
+			// 재연결 시 그룹 멤버십 소실 → 재참가 (히스토리 재수신은 mergeHistory 가 dedupe)
+			chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => {
+				console.error('채팅 룸 재참가 실패:', err);
+			});
+		});
+		chatConnection.onclose(() => {
+			if (activeRef.current) {
+				setConnState('disconnected');
+			}
+		});
+
+		setConnState('connected');
+		chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => {
+			console.error('채팅 룸 참가 실패:', err);
+			pushSystem('채팅방 입장에 실패했습니다. 새로고침 후 다시 시도해 주세요.');
+		});
+
+		return () => {
+			activeRef.current = false;
+
+			chatConnection.off('ReceiveHistory', handleHistory);
+			chatConnection.off('ReceiveMessage', handleMessage);
+			chatConnection.off('ReceiveSystemMessage', handleSystem);
+			chatConnection.off('ReceiveParticipantCount', handleCount);
+			chatConnection.off('Kick', handleKick);
+
+			if (chatConnection.state === HubConnectionState.Connected) {
+				chatConnection.invoke('LeaveRoom').catch(() => {});
+			}
+		};
+	}, [chatConnection, chatConnected, mergeHistory, appendMessage, pushSystem]);
+
+	// 채팅 + 시스템 메시지 시간순 병합
+	const merged = useMemo<MergedItem[]>(() => {
+		const items: MergedItem[] = [
+			...messages.map((c) => ({ kind: 'chat' as const, time: c.sentAt, data: c })),
+			...systemMessages.map((c) => ({ kind: 'system' as const, time: c.receivedAt, data: c }))
+		];
+		items.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
+		return items;
+	}, [messages, systemMessages]);
+
+	// 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출
+	useEffect(() => {
+		const el = listRef.current;
+		if (!el || merged.length === 0) {
+			return;
+		}
+		if (autoScrollRef.current) {
+			el.scrollTop = el.scrollHeight;
+		} else {
+			setShowJump(true);
+		}
+	}, [merged.length]);
+
+	const handleScroll = useCallback(() => {
+		const el = listRef.current;
+		if (!el) {
+			return;
+		}
+		const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_BOTTOM_THRESHOLD;
+		autoScrollRef.current = atBottom;
+		if (atBottom) {
+			setShowJump(false);
+		}
+	}, []);
+
+	const jumpToBottom = useCallback(() => {
+		const el = listRef.current;
+		if (!el) {
+			return;
+		}
+		el.scrollTop = el.scrollHeight;
+		autoScrollRef.current = true;
+		setShowJump(false);
+	}, []);
+
+	// 전송 쿨다운 (전역 2초 + 룸 SlowMode 중 큰 값) — 남은 초 표시
+	const cooling = cooldownLeft > 0;
+	useEffect(() => {
+		if (!cooling) {
+			return;
+		}
+		const timer = window.setInterval(() => {
+			const left = Math.ceil((cooldownEndRef.current - Date.now()) / 1000);
+			setCooldownLeft(left > 0 ? left : 0);
+		}, 250);
+		return () => window.clearInterval(timer);
+	}, [cooling]);
+
+	const handleSend = useCallback(async () => {
+		const trimmed = inputValue.trim();
+		if (!trimmed || trimmed.length > MAX_CONTENT_LENGTH || cooldownLeft > 0 || kicked) {
+			return;
+		}
+		if (!chatConnection || chatConnection.state !== HubConnectionState.Connected) {
+			return;
+		}
+
+		try {
+			await chatConnection.invoke('SendMessage', trimmed);
+			setInputValue('');
+
+			const seconds = Math.max(GLOBAL_RATE_LIMIT_SECONDS, slowModeSeconds);
+			cooldownEndRef.current = Date.now() + seconds * 1000;
+			setCooldownLeft(seconds);
+		} catch (error) {
+			console.error('메시지 전송 실패:', error);
+			pushSystem('메시지 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.');
+		}
+	}, [inputValue, cooldownLeft, kicked, chatConnection, slowModeSeconds, pushSystem]);
+
+	const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
+		if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
+			e.preventDefault();
+			handleSend();
+		}
+	}, [handleSend]);
+
+	const canType = connState === 'connected' && !kicked;
+	const canSend = canType && inputValue.trim().length > 0 && cooldownLeft === 0;
+
+	return (
+		<div id='chat-room'>
+			{/* 헤더: 제목 + 접속자 수 + 연결 상태 */}
+			<header className='chat-room__head'>
+				<h1>실시간 채팅</h1>
+				<div className='chat-room__meta'>
+					<span className='chat-room__count' aria-live='polite'>
+						<FontAwesomeIcon icon={faUsers} />
+						<span>{participantCount.toLocaleString()}명 참여 중</span>
+					</span>
+					{connState === 'connecting' && (
+						<span className='chat-room__state'>연결 중…</span>
+					)}
+					{connState === 'reconnecting' && (
+						<span className='chat-room__state chat-room__state--warn' role='status'>재연결 중…</span>
+					)}
+					{connState === 'disconnected' && (
+						<span className='chat-room__state chat-room__state--error' role='status'>연결 끊김 — 새로고침 후 다시 시도해 주세요</span>
+					)}
+				</div>
+			</header>
+
+			{/* 상단 공지 */}
+			{notice && (
+				<div className='chat-room__notice' role='note'>
+					<FontAwesomeIcon icon={faBullhorn} />
+					<span>{notice}</span>
+				</div>
+			)}
+
+			{/* 메시지 리스트 */}
+			<div className='chat-room__body'>
+				<div className='chat-room__messages' ref={listRef} onScroll={handleScroll} aria-live='off'>
+					{merged.length === 0 && (
+						<div className='chat-room__empty'>아직 메시지가 없습니다. 첫 메시지를 남겨보세요.</div>
+					)}
+					{merged.map((item) => {
+						if (item.kind === 'system') {
+							return (
+								<div key={`sys-${item.data.id}`} className='chat-room__system'>
+									{item.data.content}
+								</div>
+							);
+						}
+
+						const msg = item.data;
+						const badgeUrl = msg.titleIconUrl || msg.gradeImageUrl || msg.memberIcon;
+						const badgeAlt = msg.titleName ?? '';
+						return (
+							<div key={messageKey(msg)} className='chat-room__message'>
+								<time className='chat-room__time' dateTime={msg.sentAt}>{formatTime(msg.sentAt)}</time>
+								{badgeUrl && (
+									<img src={badgeUrl} alt={badgeAlt} className='chat-room__badge' aria-hidden={!badgeAlt} />
+								)}
+								<span
+									className='chat-room__name'
+									style={msg.titleColor ? ({ '--title-color': msg.titleColor } as CSSProperties) : undefined}
+								>
+									{msg.memberName || msg.memberSID}
+								</span>
+								<span className='chat-room__content'>{msg.content}</span>
+							</div>
+						);
+					})}
+				</div>
+
+				{showJump && (
+					<button type='button' className='chat-room__jump' onClick={jumpToBottom}>
+						<FontAwesomeIcon icon={faArrowDown} />
+						<span>새 메시지</span>
+					</button>
+				)}
+			</div>
+
+			{/* 입력부 — 게스트는 읽기 전용 + 로그인 CTA */}
+			<div className='chat-room__footer'>
+				{kicked ? (
+					<div className='chat-room__blocked' role='status'>
+						관리자에 의해 채팅 이용이 제한되었습니다.
+					</div>
+				) : isAuthenticated ? (
+					<>
+						<div className='chat-room__input-row'>
+							<input
+								type='text'
+								placeholder='메시지를 입력하세요'
+								value={inputValue}
+								onChange={(e) => setInputValue(e.target.value)}
+								onKeyDown={handleKeyDown}
+								maxLength={MAX_CONTENT_LENGTH}
+								disabled={!canType}
+								aria-label='채팅 메시지 입력'
+							/>
+							<button type='button' title='전송' onClick={handleSend} disabled={!canSend}>
+								{cooldownLeft > 0 ? (
+									<span className='chat-room__cooldown'>{cooldownLeft}</span>
+								) : (
+									<FontAwesomeIcon icon={faPaperPlane} />
+								)}
+							</button>
+						</div>
+						<div className='chat-room__input-meta'>
+							{slowModeSeconds > GLOBAL_RATE_LIMIT_SECONDS && (
+								<span className='chat-room__slowmode'>슬로우 모드 {slowModeSeconds}초</span>
+							)}
+							{cooldownLeft > 0 && (
+								<span className='chat-room__cooldown-left' aria-live='polite'>{cooldownLeft}초 후 전송 가능</span>
+							)}
+							<span className='chat-room__counter'>{inputValue.length}/{MAX_CONTENT_LENGTH}</span>
+						</div>
+					</>
+				) : (
+					<div className='chat-room__guest'>
+						<span>읽기는 자유롭게, 참여는 로그인 후 가능합니다.</span>
+						<Link href='/login' className='chat-room__login-cta'>로그인 후 참여</Link>
+					</div>
+				)}
+			</div>
+		</div>
+	);
+}

+ 24 - 0
app/(main)/chat/page.tsx

@@ -0,0 +1,24 @@
+import './style.scss';
+import { Metadata } from 'next';
+import { fetchChatRoomHistory } from '@/lib/api/chat';
+import MainChat from './MainChat';
+
+export const metadata: Metadata = {
+	title: '채팅 — 개미투자',
+	description: '개미투자 메인 실시간 채팅 — 시장 이야기를 실시간으로 나누세요'
+};
+
+export default async function ChatPage()
+{
+	// 초기 히스토리는 REST 서버 fetch — 실패해도 페이지는 렌더 (JoinRoom 시 ReceiveHistory 로 보충)
+	const res = await fetchChatRoomHistory('main', 50);
+	const initial = res.success ? res.data : null;
+
+	return (
+		<MainChat
+			initialMessages={initial?.messages ?? []}
+			notice={initial?.notice ?? null}
+			slowModeSeconds={initial?.slowModeSeconds ?? 0}
+		/>
+	);
+}

+ 287 - 0
app/(main)/chat/style.scss

@@ -0,0 +1,287 @@
+// ─────────────────────────────────────────────────────────────
+// /chat — 메인 실시간 채팅 (d2-community §⑤, roomKey=main)
+// 색/치수는 app/styles/tokens 변수만 사용 (하드코딩 금지)
+// .main(그리드 스크롤 컨테이너)을 height:100% 로 채워 mobile/desktop 모두 전체 높이 사용
+// 데스크톱 중앙 컬럼 최대폭 제한 없음 (WIDE fluid — d6 §4)
+// ─────────────────────────────────────────────────────────────
+#chat-room {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	min-height: 0;
+	padding: var(--sp-4) var(--sp-5);
+	gap: var(--sp-3);
+
+	@media (max-width: 767.98px) {
+		padding: var(--sp-3);
+	}
+
+	.chat-room__head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+
+		h1 {
+			font-size: var(--fs-xl);
+			font-weight: 700;
+			color: var(--text-primary);
+		}
+	}
+
+	.chat-room__meta {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-4);
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+	}
+
+	.chat-room__count {
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+		font-variant-numeric: tabular-nums;
+	}
+
+	.chat-room__state {
+		color: var(--text-muted);
+
+		&--warn {
+			color: var(--az-accent);
+		}
+
+		&--error {
+			color: var(--color-danger);
+		}
+	}
+
+	.chat-room__notice {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-3);
+		padding: var(--sp-3) var(--sp-4);
+		background: var(--bg-subtle);
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+
+		svg {
+			flex-shrink: 0;
+			color: var(--az-accent);
+		}
+	}
+
+	.chat-room__body {
+		position: relative;
+		display: flex;
+		flex: 1;
+		min-height: 0;
+	}
+
+	.chat-room__messages {
+		flex: 1;
+		min-height: 0;
+		overflow-y: auto;
+		padding: var(--sp-3);
+		background: var(--bg-elevated);
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+	}
+
+	.chat-room__empty {
+		padding: var(--sp-6) var(--sp-4);
+		text-align: center;
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+	}
+
+	.chat-room__message {
+		display: flex;
+		align-items: baseline;
+		gap: var(--sp-3);
+		padding: var(--sp-2) var(--sp-3);
+		font-size: var(--fs-base);
+		line-height: 1.5;
+		border-radius: var(--radius);
+
+		&:hover {
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	.chat-room__time {
+		flex-shrink: 0;
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	.chat-room__badge {
+		flex-shrink: 0;
+		align-self: center;
+		width: var(--sp-5);
+		height: var(--sp-5);
+		object-fit: contain;
+	}
+
+	.chat-room__name {
+		flex-shrink: 0;
+		font-weight: 600;
+		color: var(--title-color, var(--text-secondary));
+	}
+
+	.chat-room__content {
+		color: var(--text-primary);
+		word-break: break-word;
+		overflow-wrap: anywhere;
+	}
+
+	// 시스템 메시지 — 일반 메시지와 스타일 구분
+	.chat-room__system {
+		padding: var(--sp-2) var(--sp-3);
+		text-align: center;
+		font-size: var(--fs-xs);
+		font-style: italic;
+		color: var(--text-muted);
+	}
+
+	// "새 메시지 ↓" 점프 버튼 — 위로 스크롤 중 새 메시지 도착 시 노출
+	.chat-room__jump {
+		position: absolute;
+		bottom: var(--sp-4);
+		left: 50%;
+		transform: translateX(-50%);
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+		padding: var(--sp-2) var(--sp-4);
+		background: var(--az-accent);
+		color: var(--btn-primary-text);
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		border: none;
+		border-radius: var(--fs-2xl);
+		cursor: pointer;
+		box-shadow: 0 var(--sp-1) var(--sp-3) var(--shadow-color);
+
+		&:hover {
+			background: var(--az-accent-hover);
+		}
+	}
+
+	.chat-room__footer {
+		display: flex;
+		flex-direction: column;
+		gap: var(--sp-2);
+	}
+
+	.chat-room__input-row {
+		display: flex;
+		gap: var(--sp-3);
+
+		input {
+			flex: 1;
+			min-width: 0;
+			padding: var(--sp-3) var(--sp-4);
+			background: var(--bg-input);
+			color: var(--text-primary);
+			font-size: var(--fs-base);
+			border: 1px solid var(--border-strong);
+			border-radius: var(--radius);
+
+			&::placeholder {
+				color: var(--text-muted);
+			}
+
+			&:disabled {
+				opacity: 0.6;
+				cursor: not-allowed;
+			}
+		}
+
+		button {
+			flex-shrink: 0;
+			min-width: var(--sp-7);
+			padding: var(--sp-3) var(--sp-5);
+			background: var(--az-accent);
+			color: var(--btn-primary-text);
+			font-size: var(--fs-base);
+			font-weight: 600;
+			border: none;
+			border-radius: var(--radius);
+			cursor: pointer;
+
+			&:hover:not(:disabled) {
+				background: var(--az-accent-hover);
+			}
+
+			&:disabled {
+				background: var(--bg-subtle);
+				color: var(--text-muted);
+				cursor: not-allowed;
+			}
+		}
+	}
+
+	.chat-room__cooldown {
+		font-variant-numeric: tabular-nums;
+	}
+
+	.chat-room__input-meta {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-4);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	.chat-room__cooldown-left {
+		color: var(--az-accent);
+		font-variant-numeric: tabular-nums;
+	}
+
+	.chat-room__counter {
+		margin-left: auto;
+		font-variant-numeric: tabular-nums;
+	}
+
+	// 게스트 CTA — 읽기는 가능, 입력 대신 로그인 유도
+	.chat-room__guest {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+		padding: var(--sp-4);
+		background: var(--bg-subtle);
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+	}
+
+	.chat-room__login-cta {
+		padding: var(--sp-2) var(--sp-5);
+		background: var(--az-accent);
+		color: var(--btn-primary-text);
+		font-weight: 600;
+		border-radius: var(--radius);
+
+		&:hover {
+			background: var(--az-accent-hover);
+		}
+	}
+
+	.chat-room__blocked {
+		padding: var(--sp-4);
+		text-align: center;
+		background: var(--color-danger-bg);
+		border: 1px solid var(--color-danger-border);
+		border-radius: var(--radius);
+		font-size: var(--fs-sm);
+		color: var(--color-danger);
+	}
+}

+ 1 - 0
app/component/Header.tsx

@@ -26,6 +26,7 @@ const baseNavItems = [
 	{ href: '/stock', label: '종목' },
 	{ href: '/games', label: '게임' },
 	{ href: '/feed/all', label: '피드' },
+	{ href: '/chat', label: '채팅' },
 	{ href: '/store', label: '상점' },
 	{ href: '/attendance', label: '출석부' },
 	{ href: '/board/notice', label: '고객지원' }

+ 16 - 0
lib/api/chat.ts

@@ -0,0 +1,16 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { ChatRoomHistory } from '@/types/chat';
+import { fetchJson } from '@/lib/utils/server';
+
+// 룸 최근 메시지 히스토리 조회 (익명 허용 — /chat SSR 초기 로드용)
+export async function fetchChatRoomHistory(roomKey: string, count: number = 50): Promise<ResultDto<ChatRoomHistory>>
+{
+	return await fetchJson<ChatRoomHistory>(`/api/chat/rooms/${encodeURIComponent(roomKey)}/messages?count=${count}`, {
+		method: 'GET',
+		headers: {
+			'Accept': 'application/json'
+		}
+	});
+}

+ 8 - 0
types/chat.ts

@@ -1,3 +1,11 @@
+// GET /api/chat/rooms/{roomKey}/messages 응답 (SSR 초기 로드용 — 익명 허용)
+export interface ChatRoomHistory {
+	roomKey: string;
+	notice: string|null;
+	slowModeSeconds: number;
+	messages: ChatMessage[];
+}
+
 export interface ChatMessage {
 	memberID: number;
 	memberSID: string;