Bläddra i källkod

perf(market/stock): 체감 로딩 개선 — 스켈레톤·SignalR/차트 지연·익명 데이터 캐시

원인: 서버/백엔드는 빠름(<1s). 3초 체감은 프론트(렌더차단 CSS·JS 하이드레이션·상시 채팅/SignalR·스켈레톤 부재)에서 발생. 서버 데이터 캐시는 전무(fetchJson no-store).

A) loading.tsx 스켈레톤(market·stock·stock/[code]) + 정렬/페이저 useTransition(전환 중 흐림) + 채팅 MainChat·SignalR 핸드셰이크 idle 지연(알림/Kick 유지) + optimizePackageImports
B) 익명 공개 데이터 Next Data Cache(신규 fetchPublicJson, 쿠키/토큰 미전송): market/stock 600s·trending 300s(tag stock/market-data), config/nav 3600s(tag site-config) + POST /api/revalidate 태그 무효화 라우트(REVALIDATE_SECRET)
C) recharts 동적 임포트(ssr:false) → /stock/[code] first-load 235→125kB(약 110kB↓)

빌드 0에러. 적대적 리뷰(3차원×검증) 확정결함 0. 보류: C2 Layout 서버분리(Header 클라 필수라 비현실적), C3 optimizeCss(운영 FOUC 리스크 — 별도 검증 후).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 1 vecka sedan
förälder
incheckning
4d86f36a38

+ 8 - 26
app/(main)/_components/Sparkline.tsx

@@ -1,13 +1,15 @@
 'use client';
 
 import './sparkline.scss';
-import { Area, AreaChart, ResponsiveContainer, YAxis } from 'recharts';
+import dynamic from 'next/dynamic';
 import type { MoveDir } from '@/types/worldIndex';
 
-// 미니 스파크라인 — 최근 종가 시계열(과거→최신)을 방향색(상승 적/하락 청)으로 표시.
-// 색은 래퍼의 dir 클래스(.spark--up/down/flat)가 지정하는 CSS color 를 currentColor 로 상속받는다.
-// (recharts stroke/fill 은 SVG presentation attribute 로 렌더돼 var() 가 해석되지 않으므로 currentColor 사용.)
-// 축·툴팁·점 없음. 부모 슬롯의 고정 크기를 100% 채운다(카드/그리드 셀 공용). 데이터 2개 미만이면 빈 placeholder.
+// recharts(d3) 지연 로드(ssr:false) — 스파크라인 청크를 초기 번들에서 분리. 데이터 2개 미만이면 빈 placeholder.
+const SparklineInner = dynamic(() => import('./SparklineInner'), {
+	ssr: false,
+	loading: () => <div className='spark spark--empty' aria-hidden='true' />
+});
+
 type Props = {
 	data: number[];
 	dir: MoveDir;
@@ -19,25 +21,5 @@ export default function Sparkline({ data, dir }: Props)
 		return <div className='spark spark--empty' aria-hidden='true' />;
 	}
 
-	const chartData = data.map((v, i) => ({ i, v }));
-
-	return (
-		<div className={`spark spark--${dir}`} aria-hidden='true'>
-			<ResponsiveContainer width='100%' height='100%'>
-				<AreaChart data={chartData} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
-					<YAxis hide domain={['dataMin', 'dataMax']} />
-					<Area
-						type='monotone'
-						dataKey='v'
-						stroke='currentColor'
-						strokeWidth={1.5}
-						fill='currentColor'
-						fillOpacity={0.12}
-						dot={false}
-						isAnimationActive={false}
-					/>
-				</AreaChart>
-			</ResponsiveContainer>
-		</div>
-	);
+	return <SparklineInner data={data} dir={dir} />;
 }

+ 37 - 0
app/(main)/_components/SparklineInner.tsx

@@ -0,0 +1,37 @@
+'use client';
+
+import { Area, AreaChart, ResponsiveContainer, YAxis } from 'recharts';
+import type { MoveDir } from '@/types/worldIndex';
+
+// recharts(d3 포함) 실제 렌더 본체 — Sparkline 이 next/dynamic({ssr:false})로 지연 로드.
+// 색은 래퍼의 dir 클래스(.spark--up/down/flat)가 지정하는 CSS color 를 currentColor 로 상속.
+// 호출부에서 data.length>=2 보장.
+type Props = {
+	data: number[];
+	dir: MoveDir;
+};
+
+export default function SparklineInner({ data, dir }: Props)
+{
+	const chartData = data.map((v, i) => ({ i, v }));
+
+	return (
+		<div className={`spark spark--${dir}`} aria-hidden='true'>
+			<ResponsiveContainer width='100%' height='100%'>
+				<AreaChart data={chartData} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
+					<YAxis hide domain={['dataMin', 'dataMax']} />
+					<Area
+						type='monotone'
+						dataKey='v'
+						stroke='currentColor'
+						strokeWidth={1.5}
+						fill='currentColor'
+						fillOpacity={0.12}
+						dot={false}
+						isAnimationActive={false}
+					/>
+				</AreaChart>
+			</ResponsiveContainer>
+		</div>
+	);
+}

+ 29 - 0
app/(main)/market/loading.tsx

@@ -0,0 +1,29 @@
+// /market 진입/네비게이션 시 즉시 노출되는 Suspense 스켈레톤 — 서버 렌더 대기 중 "멈춤" 체감 제거.
+export default function Loading()
+{
+	return (
+		<div className='w-full p-4 space-y-6 animate-pulse' aria-hidden='true'>
+			<div className='space-y-2'>
+				<div className='h-8 w-24 rounded bg-gray-500/15' />
+				<div className='h-4 w-64 rounded bg-gray-500/10' />
+			</div>
+
+			<div className='grid grid-cols-2 md:grid-cols-4 gap-3'>
+				{Array.from({ length: 4 }).map((_, i) => (
+					<div key={i} className='h-24 rounded bg-gray-500/10' />
+				))}
+			</div>
+
+			<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
+				<div className='h-40 rounded bg-gray-500/10' />
+				<div className='h-40 rounded bg-gray-500/10' />
+			</div>
+
+			<div className='space-y-2'>
+				{Array.from({ length: 8 }).map((_, i) => (
+					<div key={i} className='h-8 w-full rounded bg-gray-500/10' />
+				))}
+			</div>
+		</div>
+	);
+}

+ 61 - 0
app/(main)/stock/[code]/_components/PriceChartInner.tsx

@@ -0,0 +1,61 @@
+'use client';
+
+import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
+import type { StockPriceRow } from '@/types/stock';
+import { changeDirection, formatNumber } from '@/lib/utils/stock';
+
+// recharts(d3 포함) 실제 렌더 본체 — StockPriceChart 가 next/dynamic({ssr:false})로 지연 로드.
+// 색은 래퍼 dir 클래스(.stock-chart--up/down/flat)의 CSS color 를 currentColor 로 상속
+// (recharts stroke/fill 은 SVG presentation attribute 라 var() 미해석 → currentColor 사용).
+type Props = {
+	// GetDetail.recentPrices — 최신순(newest-first). 호출부에서 length>=2 보장.
+	prices: StockPriceRow[];
+	name: string;
+};
+
+export default function PriceChartInner({ prices, name }: Props)
+{
+	// 과거→최신 순서로 뒤집어 종가 시계열 구성
+	const series = [...prices].reverse().map(p => ({ date: p.tradingDate, close: p.close }));
+	const dir = changeDirection(series[series.length - 1].close - series[0].close);
+
+	return (
+		<figure className={`stock-chart stock-chart--${dir}`}>
+			<figcaption className='stock-chart__caption'>최근 {series.length}일 종가 추이</figcaption>
+			<div className='stock-chart__canvas' aria-label={`${name} 최근 ${series.length}일 종가 추이 그래프`} role='img'>
+				<ResponsiveContainer width='100%' height='100%'>
+					<AreaChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
+						<XAxis dataKey='date' hide />
+						<YAxis hide domain={['dataMin', 'dataMax']} />
+						<Tooltip
+							cursor={{ stroke: 'currentColor', strokeOpacity: 0.3 }}
+							contentStyle={{
+								background: 'var(--bg-elevated)',
+								border: '1px solid var(--border-default)',
+								borderRadius: 0,
+								fontSize: 'var(--fs-xs)',
+								color: 'var(--text-primary)'
+							}}
+							labelStyle={{ color: 'var(--text-muted)' }}
+							formatter={(value) => {
+								const n = Array.isArray(value) ? Number(value[0]) : Number(value);
+								return [formatNumber(Number.isFinite(n) ? n : null), '종가'];
+							}}
+							labelFormatter={(label) => String(label)}
+						/>
+						<Area
+							type='monotone'
+							dataKey='close'
+							stroke='currentColor'
+							strokeWidth={2}
+							fill='currentColor'
+							fillOpacity={0.1}
+							dot={false}
+							isAnimationActive={false}
+						/>
+					</AreaChart>
+				</ResponsiveContainer>
+			</div>
+		</figure>
+	);
+}

+ 12 - 48
app/(main)/stock/[code]/_components/StockPriceChart.tsx

@@ -1,13 +1,19 @@
 'use client';
 
 import './price-chart.scss';
-import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
+import dynamic from 'next/dynamic';
 import type { StockPriceRow } from '@/types/stock';
-import { changeDirection, formatNumber } from '@/lib/utils/stock';
 
-// 종목 상세 시세 그래프 — 최근 종가 시계열(과거→최신) 영역 차트. stock-detail__basis 바로 하단.
-// 색은 래퍼 dir 클래스(.stock-chart--up/down/flat)의 CSS color 를 currentColor 로 상속
-// (recharts stroke/fill 은 SVG presentation attribute 라 var() 미해석 → currentColor 사용).
+// recharts(d3) 지연 로드 — 상단 요약/표 페인트 뒤 차트 청크를 client-only(ssr:false)로 로드해 first-load JS 축소.
+const PriceChartInner = dynamic(() => import('./PriceChartInner'), {
+	ssr: false,
+	loading: () => (
+		<div className='stock-chart stock-chart--flat'>
+			<div className='stock-chart__canvas' aria-hidden='true' />
+		</div>
+	)
+});
+
 type Props = {
 	// GetDetail.recentPrices — 최신순(newest-first)
 	prices: StockPriceRow[];
@@ -21,47 +27,5 @@ export default function StockPriceChart({ prices, name }: Props)
 		return null;
 	}
 
-	// 과거→최신 순서로 뒤집어 종가 시계열 구성
-	const series = [...prices].reverse().map(p => ({ date: p.tradingDate, close: p.close }));
-	const dir = changeDirection(series[series.length - 1].close - series[0].close);
-
-	return (
-		<figure className={`stock-chart stock-chart--${dir}`}>
-			<figcaption className='stock-chart__caption'>최근 {series.length}일 종가 추이</figcaption>
-			<div className='stock-chart__canvas' aria-label={`${name} 최근 ${series.length}일 종가 추이 그래프`} role='img'>
-				<ResponsiveContainer width='100%' height='100%'>
-					<AreaChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
-						<XAxis dataKey='date' hide />
-						<YAxis hide domain={['dataMin', 'dataMax']} />
-						<Tooltip
-							cursor={{ stroke: 'currentColor', strokeOpacity: 0.3 }}
-							contentStyle={{
-								background: 'var(--bg-elevated)',
-								border: '1px solid var(--border-default)',
-								borderRadius: 0,
-								fontSize: 'var(--fs-xs)',
-								color: 'var(--text-primary)'
-							}}
-							labelStyle={{ color: 'var(--text-muted)' }}
-							formatter={(value) => {
-								const n = Array.isArray(value) ? Number(value[0]) : Number(value);
-								return [formatNumber(Number.isFinite(n) ? n : null), '종가'];
-							}}
-							labelFormatter={(label) => String(label)}
-						/>
-						<Area
-							type='monotone'
-							dataKey='close'
-							stroke='currentColor'
-							strokeWidth={2}
-							fill='currentColor'
-							fillOpacity={0.1}
-							dot={false}
-							isAnimationActive={false}
-						/>
-					</AreaChart>
-				</ResponsiveContainer>
-			</div>
-		</figure>
-	);
+	return <PriceChartInner prices={prices} name={name} />;
 }

+ 31 - 0
app/(main)/stock/[code]/loading.tsx

@@ -0,0 +1,31 @@
+// /stock/[code] 진입/네비게이션 시 즉시 노출되는 Suspense 스켈레톤 — 서버 렌더 대기 중 "멈춤" 체감 제거.
+export default function Loading()
+{
+	return (
+		<div className='w-full p-4 space-y-4 animate-pulse' aria-hidden='true'>
+			<div className='space-y-3'>
+				<div className='h-8 w-48 rounded bg-gray-500/15' />
+				<div className='h-10 w-40 rounded bg-gray-500/10' />
+				<div className='flex gap-4'>
+					<div className='h-6 w-20 rounded bg-gray-500/10' />
+					<div className='h-6 w-20 rounded bg-gray-500/10' />
+					<div className='h-6 w-20 rounded bg-gray-500/10' />
+				</div>
+			</div>
+
+			<div className='h-40 w-full rounded bg-gray-500/10' />
+
+			<div className='flex gap-2'>
+				{Array.from({ length: 7 }).map((_, i) => (
+					<div key={i} className='h-8 w-14 rounded bg-gray-500/10' />
+				))}
+			</div>
+
+			<div className='space-y-2'>
+				{Array.from({ length: 10 }).map((_, i) => (
+					<div key={i} className='h-8 w-full rounded bg-gray-500/10' />
+				))}
+			</div>
+		</div>
+	);
+}

+ 11 - 4
app/(main)/stock/_components/Pager.tsx

@@ -1,6 +1,7 @@
 'use client';
 
 import { useRouter } from 'next/navigation';
+import { useTransition } from 'react';
 import Pagination from '@/app/component/Pagination';
 
 type Props = {
@@ -12,17 +13,23 @@ type Props = {
 	query?: Record<string, string>;
 };
 
-// SSR 목록 페이지용 페이저 — 기존 Pagination(onChange 콜백형)을 router.push 로 연결
+// SSR 목록 페이지용 페이저 — 기존 Pagination(onChange 콜백형)을 router.push 로 연결.
+// useTransition 으로 전환 중 현재 목록 유지(스켈레톤 플래시 없이) + 흐림 표시.
 export default function Pager({ total, page, perPage, basePath, query }: Props) {
 	const router = useRouter();
+	const [isPending, startTransition] = useTransition();
 
 	const handleChange = (next: number) => {
 		const params = new URLSearchParams(query);
 		params.set('page', String(next));
-		router.push(`${basePath}?${params.toString()}`);
+		startTransition(() => {
+			router.push(`${basePath}?${params.toString()}`);
+		});
 	};
 
 	return (
-		<Pagination total={total} page={page} perPage={perPage} onChange={handleChange} />
+		<div className={isPending ? 'data-table--pending' : undefined}>
+			<Pagination total={total} page={page} perPage={perPage} onChange={handleChange} />
+		</div>
 	);
-}
+}

+ 24 - 0
app/(main)/stock/loading.tsx

@@ -0,0 +1,24 @@
+// /stock 진입/네비게이션 시 즉시 노출되는 Suspense 스켈레톤 — 서버 렌더 대기 중 "멈춤" 체감 제거.
+export default function Loading()
+{
+	return (
+		<div className='w-full p-4 space-y-4 animate-pulse' aria-hidden='true'>
+			<div className='space-y-2'>
+				<div className='h-8 w-24 rounded bg-gray-500/15' />
+				<div className='h-4 w-40 rounded bg-gray-500/10' />
+			</div>
+
+			<div className='flex gap-2'>
+				{Array.from({ length: 4 }).map((_, i) => (
+					<div key={i} className='h-8 w-16 rounded bg-gray-500/10' />
+				))}
+			</div>
+
+			<div className='space-y-2 mt-2'>
+				{Array.from({ length: 12 }).map((_, i) => (
+					<div key={i} className='h-11 w-full rounded bg-gray-500/10' />
+				))}
+			</div>
+		</div>
+	);
+}

+ 25 - 0
app/api/revalidate/route.ts

@@ -0,0 +1,25 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { revalidateTag } from 'next/cache';
+
+// 캐시 태그 수동 무효화 — Admin/운영에서 config·market·stock 데이터 변경 후 즉시 반영용.
+// 사용: POST /api/revalidate?tag=site-config&secret=XXX  (secret = 환경변수 REVALIDATE_SECRET)
+// 태그: 'site-config'(설정/메뉴) · 'market-data'(시장 T+1) · 'stock-data'(종목 T+1)
+// REVALIDATE_SECRET 미설정 시 항상 401 (안전 기본값) — 설정 전엔 revalidate 주기(초)로만 갱신.
+export async function POST(request: NextRequest)
+{
+	const secret = request.nextUrl.searchParams.get('secret');
+
+	if (!process.env.REVALIDATE_SECRET || secret !== process.env.REVALIDATE_SECRET) {
+		return NextResponse.json({ success: false, message: 'unauthorized' }, { status: 401 });
+	}
+
+	const tag = request.nextUrl.searchParams.get('tag');
+
+	if (!tag) {
+		return NextResponse.json({ success: false, message: 'tag required' }, { status: 400 });
+	}
+
+	revalidateTag(tag);
+
+	return NextResponse.json({ success: true, tag });
+}

+ 8 - 1
app/component/ChatDock.tsx

@@ -5,7 +5,14 @@ import '@/app/styles/chat-dock.scss';
 import { useCallback, useEffect, useState } from 'react';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faComments, faAnglesRight, faXmark } from '@fortawesome/free-solid-svg-icons';
-import MainChat from '@/app/(main)/chat/MainChat';
+import dynamic from 'next/dynamic';
+
+// 채팅 본체는 하이드레이션 임계경로에서 빼기 위해 client-only 지연 로드(ssr:false).
+// (main)/Layout 에 상시 마운트되는 단일 인스턴스라 최초 로드 후 유지 → 'main' 룸 단일 참가자 불변식 보존.
+const MainChat = dynamic(() => import('@/app/(main)/chat/MainChat'), {
+	ssr: false,
+	loading: () => null
+});
 
 const STORAGE_KEY = 'az-chat-collapsed';
 const WIDTH_EXPANDED = '340px';

+ 7 - 0
app/styles/data-table.scss

@@ -198,3 +198,10 @@
 		color: var(--price-flat);
 	}
 }
+
+// 정렬/페이지 전환 중 목록 흐림 — useTransition isPending 동안(스켈레톤 플래시 없이 현재 데이터 유지)
+.data-table--pending {
+	opacity: 0.55;
+	transition: opacity 0.15s ease;
+	pointer-events: none;
+}

+ 17 - 11
components/table/StockRow.tsx

@@ -6,7 +6,7 @@
 // 거래대금/시총 등 우선순위 낮은 열은 mobile drop (data-priority='low').
 import Link from 'next/link';
 import { useRouter } from 'next/navigation';
-import { useCallback } from 'react';
+import { useCallback, useTransition } from 'react';
 import DataTable, { DataTableColumn } from '@/components/table/DataTable';
 import PriceChange from '@/components/table/PriceChange';
 import { StockListRow, StockSortKey, StockSortOrder } from '@/types/stock';
@@ -93,10 +93,12 @@ interface StockDataTableProps {
 	emptyMessage?: string;
 }
 
-// 종목 목록 편의 래퍼 — 정렬 헤더 클릭 시 sort/order URL 로 SSR 재조회(필터 유지·페이지 1로 리셋)
+// 종목 목록 편의 래퍼 — 정렬 헤더 클릭 시 sort/order URL 로 SSR 재조회(필터 유지·페이지 1로 리셋).
+// useTransition 으로 재조회 중 현재 표 유지(스켈레톤 플래시 없이) + 흐림 표시.
 export default function StockDataTable({ rows, sort, order, market, q, caption, emptyMessage }: StockDataTableProps)
 {
 	const router = useRouter();
+	const [isPending, startTransition] = useTransition();
 
 	const handleSort = useCallback((key: string) => {
 		const k = key as StockSortKey;
@@ -113,17 +115,21 @@ export default function StockDataTable({ rows, sort, order, market, q, caption,
 		params.set('sort', k);
 		params.set('order', nextOrder);
 
-		router.push(`/stock?${params.toString()}`);
+		startTransition(() => {
+			router.push(`/stock?${params.toString()}`);
+		});
 	}, [router, sort, order, market, q]);
 
 	return (
-		<DataTable<StockListRow>
-			caption={caption ?? '종목 목록 — 종목명, 현재가(전일 종가), 등락률, 거래대금, 시가총액, 시장'}
-			columns={buildStockColumns(sort, order)}
-			rows={rows}
-			rowKey={(row) => row.code}
-			onSort={handleSort}
-			emptyMessage={emptyMessage}
-		/>
+		<div className={isPending ? 'data-table--pending' : undefined}>
+			<DataTable<StockListRow>
+				caption={caption ?? '종목 목록 — 종목명, 현재가(전일 종가), 등락률, 거래대금, 시가총액, 시장'}
+				columns={buildStockColumns(sort, order)}
+				rows={rows}
+				rowKey={(row) => row.code}
+				onSort={handleSort}
+				emptyMessage={emptyMessage}
+			/>
+		</div>
 	);
 }

+ 26 - 3
contexts/signalrProvider.tsx

@@ -55,12 +55,35 @@ export function SignalRProvider({ children, accessToken, signalRChatUrl, signalR
 	const [appConnected, setAppConnected] = useState<boolean>(false);
 	const [lastFeedPost, setLastFeedPost] = useState<FeedPostEvent|null>(null);
 
-	// 초기 1회만 시작 — 토큰 갱신 시에는 reconnectChat / reconnectApp 으로 수동 재연결
+	// 초기 1회만 시작 — 토큰 갱신 시에는 reconnectChat / reconnectApp 으로 수동 재연결.
+	// ⚡ 최초 페인트/하이드레이션 임계경로에서 빼기 위해 idle 로 지연(핸드셰이크 2건). 알림·쪽지·Kick 은 그대로 동작.
 	useEffect(() => {
-		initChatConnection(accessToken);
-		initAppConnection(accessToken);
+		let idleId: number|undefined;
+		let timeoutId: ReturnType<typeof setTimeout>|undefined;
+
+		const start = () => {
+			initChatConnection(accessToken);
+			initAppConnection(accessToken);
+		};
+
+		const w = window as typeof window & {
+			requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
+			cancelIdleCallback?: (id: number) => void;
+		};
+
+		if (typeof w.requestIdleCallback === 'function') {
+			idleId = w.requestIdleCallback(start, { timeout: 2000 });
+		} else {
+			timeoutId = setTimeout(start, 800);
+		}
 
 		return () => {
+			if (idleId !== undefined && typeof w.cancelIdleCallback === 'function') {
+				w.cancelIdleCallback(idleId);
+			}
+			if (timeoutId !== undefined) {
+				clearTimeout(timeoutId);
+			}
 			stopConnections();
 		};
 	}, []);

+ 4 - 5
lib/api/community.ts

@@ -12,6 +12,7 @@ import {
 	EquippedItemsResponse
 } from '@/types/community';
 import { fetchJson } from '@/lib/utils/server';
+import { fetchPublicJson } from '@/lib/utils/publicFetch';
 
 const JSON_HEADERS = { 'Accept': 'application/json' } as const;
 
@@ -29,17 +30,15 @@ export async function fetchStockBoardPosts(code: string, params: { page?: number
 	});
 }
 
-// 종목 트렌딩 랭킹 (익명 · SSR) — GET /api/stock-boards/trending?count=
+// 종목 트렌딩 랭킹 (익명 공통 · SSR) — GET /api/stock-boards/trending?count=
+// Data Cache(tag 'stock-data', 5분 재검증) — /stock 목록 워터폴의 두 번째 콜을 캐시로 흡수.
 export async function fetchTrendingStocks(params: { count?: number } = {}): Promise<ResultDto<TrendingStocksResponse>>
 {
 	const queryParams = new URLSearchParams();
 
 	queryParams.set('count', String(params.count ?? 20));
 
-	return await fetchJson<TrendingStocksResponse>(`/api/stock-boards/trending?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<TrendingStocksResponse>(`/api/stock-boards/trending?${queryParams.toString()}`, { revalidate: 300, tags: ['stock-data'] });
 }
 
 // 글의 예측 메타 + 채점 상태 (익명 · SSR) — GET /api/posts/{id}/prediction

+ 17 - 55
lib/api/market.ts

@@ -22,18 +22,16 @@ import {
 	RateTypeName,
 	DisclosuresResponse
 } from '@/types/market';
-import { fetchJson } from '@/lib/utils/server';
+import { fetchPublicJson } from '@/lib/utils/publicFetch';
 
-// 공통 헤더 (익명 · JSON)
-const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+// 시장 T+1 공개 데이터 — 익명 공통·일 1회 갱신. Data Cache(tag 'market-data', 10분 재검증).
+const MARKET_REVALIDATE = 600;
+const MARKET_TAGS = ['market-data'];
 
 // 지수 요약 (KOSPI/KOSDAQ/KRX/채권/파생) — 최신 1건
 export async function fetchMarketIndices(): Promise<ResultDto<MarketIndicesResponse>>
 {
-	return await fetchJson<MarketIndicesResponse>('/api/market/indices', {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<MarketIndicesResponse>('/api/market/indices', { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // ETP (ETF/ETN/ELW) — type 은 enum 멤버명 문자열로 전송 (ASP.NET model binding)
@@ -44,10 +42,7 @@ export async function fetchMarketEtp(params: { type?: EtpTypeName; page?: number
 	if (params.type) queryParams.set('type', params.type);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<EtpResponse>(`/api/market/etp?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<EtpResponse>(`/api/market/etp?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 채권 (국채전문/일반/소액)
@@ -59,10 +54,7 @@ export async function fetchMarketBonds(params: { market?: BondMarketName; date?:
 	if (params.date) queryParams.set('date', params.date);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<BondsResponse>(`/api/market/bonds?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<BondsResponse>(`/api/market/bonds?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 선물 (일반/주식선물) — session 은 숫자 1(정규)/2(야간) 전송, 미지정 시 백엔드 기본 정규
@@ -75,10 +67,7 @@ export async function fetchMarketFutures(params: { kind?: FuturesKindName; date?
 	if (params.session) queryParams.set('session', String(params.session));
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<FuturesResponse>(`/api/market/futures?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<FuturesResponse>(`/api/market/futures?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 옵션 (일반/주식옵션) — session 은 숫자 1(정규)/2(야간) 전송, 미지정 시 백엔드 기본 정규
@@ -91,10 +80,7 @@ export async function fetchMarketOptions(params: { kind?: OptionsKindName; date?
 	if (params.session) queryParams.set('session', String(params.session));
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<OptionsResponse>(`/api/market/options?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<OptionsResponse>(`/api/market/options?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 상품 (석유/금/배출권)
@@ -106,10 +92,7 @@ export async function fetchMarketCommodities(params: { market?: CommodityMarketN
 	if (params.date) queryParams.set('date', params.date);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<CommoditiesResponse>(`/api/market/commodities?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<CommoditiesResponse>(`/api/market/commodities?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // ESG 증권
@@ -120,10 +103,7 @@ export async function fetchMarketEsgSecurities(params: { date?: string; page?: n
 	if (params.date) queryParams.set('date', params.date);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<EsgSecuritiesResponse>(`/api/market/esg/securities?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<EsgSecuritiesResponse>(`/api/market/esg/securities?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // ESG 지수
@@ -134,10 +114,7 @@ export async function fetchMarketEsgIndices(params: { date?: string; page?: numb
 	if (params.date) queryParams.set('date', params.date);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<EsgIndicesResponse>(`/api/market/esg/indices?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<EsgIndicesResponse>(`/api/market/esg/indices?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // SRI 채권 (사회책임투자채권)
@@ -149,10 +126,7 @@ export async function fetchMarketSriBonds(params: { date?: string; bondType?: st
 	if (params.bondType) queryParams.set('bondType', params.bondType);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<SriBondsResponse>(`/api/market/esg/sri-bonds?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<SriBondsResponse>(`/api/market/esg/sri-bonds?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 환율 (매매기준율)
@@ -164,10 +138,7 @@ export async function fetchMarketExchangeRates(params: { date?: string }): Promi
 
 	const qs = queryParams.toString();
 
-	return await fetchJson<ExchangeRatesResponse>(`/api/market/exchange-rates${qs ? `?${qs}` : ''}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<ExchangeRatesResponse>(`/api/market/exchange-rates${qs ? `?${qs}` : ''}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 금리 (대출/국제)
@@ -180,10 +151,7 @@ export async function fetchMarketInterestRates(params: { type?: RateTypeName; da
 
 	const qs = queryParams.toString();
 
-	return await fetchJson<InterestRatesResponse>(`/api/market/interest-rates${qs ? `?${qs}` : ''}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<InterestRatesResponse>(`/api/market/interest-rates${qs ? `?${qs}` : ''}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 공시 피드 (전체 — corpCls: Y/K/N/E 필터, 페이징). 경로는 /api/disclosures (market 하위 아님)
@@ -194,10 +162,7 @@ export async function fetchDisclosures(params: { corpCls?: string; page?: number
 	if (params.corpCls) queryParams.set('corpCls', params.corpCls);
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<DisclosuresResponse>(`/api/disclosures?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<DisclosuresResponse>(`/api/disclosures?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }
 
 // 종목별 공시 (6자리 코드) — /api/stocks/{code}/disclosures
@@ -207,8 +172,5 @@ export async function fetchStockDisclosures(code: string, params: { page?: numbe
 
 	queryParams.set('page', String(params.page ?? 1));
 
-	return await fetchJson<DisclosuresResponse>(`/api/stocks/${encodeURIComponent(code)}/disclosures?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: JSON_HEADERS
-	});
+	return await fetchPublicJson<DisclosuresResponse>(`/api/stocks/${encodeURIComponent(code)}/disclosures?${queryParams.toString()}`, { revalidate: MARKET_REVALIDATE, tags: MARKET_TAGS });
 }

+ 9 - 20
lib/api/stock.ts

@@ -2,7 +2,11 @@
 
 import { ResultDto } from '@/types/response/common';
 import { StockListResponse, StockDetailResponse, StockHistoryResponse } from '@/types/stock';
-import { fetchJson } from '@/lib/utils/server';
+import { fetchPublicJson } from '@/lib/utils/publicFetch';
+
+// 종목 T+1 공개 데이터 — 익명 공통·일 1회 갱신. Data Cache(tag 'stock-data', 10분 재검증).
+const STOCK_REVALIDATE = 600;
+const STOCK_TAGS = ['stock-data'];
 
 // 종목 목록 조회 (시장 필터/검색/페이징)
 export async function fetchStockList(params: { q?: string; market?: string; page?: number; perPage?: number; sort?: string; order?: string }): Promise<ResultDto<StockListResponse>>
@@ -17,23 +21,13 @@ export async function fetchStockList(params: { q?: string; market?: string; page
 	if (params.sort) queryParams.set('sort', params.sort);
 	if (params.order) queryParams.set('order', params.order);
 
-	return await fetchJson<StockListResponse>(`/api/stocks?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: {
-			'Accept': 'application/json'
-		}
-	});
+	return await fetchPublicJson<StockListResponse>(`/api/stocks?${queryParams.toString()}`, { revalidate: STOCK_REVALIDATE, tags: STOCK_TAGS });
 }
 
 // 종목 상세 조회 (마스터 + 최근 시세)
 export async function fetchStockDetail(code: string): Promise<ResultDto<StockDetailResponse>>
 {
-	return await fetchJson<StockDetailResponse>(`/api/stocks/${encodeURIComponent(code)}`, {
-		method: 'GET',
-		headers: {
-			'Accept': 'application/json'
-		}
-	});
+	return await fetchPublicJson<StockDetailResponse>(`/api/stocks/${encodeURIComponent(code)}`, { revalidate: STOCK_REVALIDATE, tags: STOCK_TAGS });
 }
 
 // 일별 시세 히스토리 조회 (페이징)
@@ -47,10 +41,5 @@ export async function fetchStockHistory(code: string, params: { page?: number; p
 	if (params.from) queryParams.set('from', params.from);
 	if (params.to) queryParams.set('to', params.to);
 
-	return await fetchJson<StockHistoryResponse>(`/api/stocks/${encodeURIComponent(code)}/history?${queryParams.toString()}`, {
-		method: 'GET',
-		headers: {
-			'Accept': 'application/json'
-		}
-	});
-}
+	return await fetchPublicJson<StockHistoryResponse>(`/api/stocks/${encodeURIComponent(code)}/history?${queryParams.toString()}`, { revalidate: STOCK_REVALIDATE, tags: STOCK_TAGS });
+}

+ 9 - 10
lib/api/system.ts

@@ -1,20 +1,19 @@
 import { cache } from 'react';
 import { ResultDto } from '@/types/response/common';
-import { fetchJson } from '@/lib/utils/server';
+import { fetchPublicJson } from '@/lib/utils/publicFetch';
 import Config, { NavMenuLink } from '@/types/config';
 
+// 전역 익명 설정 — 모든 사용자 공통이라 Data Cache 안전. React.cache(요청 내 dedupe) + tag 'site-config'(요청 간).
+// 설정 변경 즉시 반영이 필요하면 POST /api/revalidate?tag=site-config (REVALIDATE_SECRET 설정 시).
+const CONFIG_REVALIDATE = 3600;
+const CONFIG_TAGS = ['site-config'];
+
 // Config 값 조회
 export const fetchConfig = cache(async (): Promise<ResultDto<Config|null>> => {
-    return await fetchJson<Config>('/api/config', {
-        method: 'GET',
-        headers: {'Content-Type': 'application/json'},
-    });
+    return await fetchPublicJson<Config>('/api/config', { revalidate: CONFIG_REVALIDATE, tags: CONFIG_TAGS });
 });
 
 // 관리자 관리형 상단 메뉴 조회 (노출 항목만 정렬순)
 export const fetchNavMenu = cache(async (): Promise<ResultDto<{ items: NavMenuLink[] }|null>> => {
-    return await fetchJson<{ items: NavMenuLink[] }>('/api/config/nav-menu', {
-        method: 'GET',
-        headers: {'Content-Type': 'application/json'},
-    });
-});
+    return await fetchPublicJson<{ items: NavMenuLink[] }>('/api/config/nav-menu', { revalidate: CONFIG_REVALIDATE, tags: CONFIG_TAGS });
+});

+ 55 - 0
lib/utils/publicFetch.ts

@@ -0,0 +1,55 @@
+import 'server-only';
+import { ResultDto } from '@/types/response/common';
+
+const API_URL = process.env.API_URL;
+
+type PublicFetchOptions = {
+	// Next Data Cache 재검증 주기(초). 기본 300초.
+	revalidate?: number;
+	// revalidateTag 로 무효화할 태그
+	tags?: string[];
+	headers?: Record<string, string>;
+};
+
+// 익명 공개 데이터 전용 서버 fetch — 요청자 쿠키/토큰을 붙이지 않으므로 응답이 모든 사용자 공통이고,
+// 따라서 Next Data Cache(revalidate/tags)로 요청 간 캐시가 안전하다. 라우트가 cookies()/headers() 로
+// 동적이어도 개별 fetch 는 Data Cache 에서 서빙된다.
+// ⚠️ 사용자별 데이터·인증 필요 호출은 반드시 기존 fetchJson 사용(그쪽은 쿠키/Authorization 주입 → 캐시 금지).
+export async function fetchPublicJson<T>(url: string, options: PublicFetchOptions = {}): Promise<ResultDto<T>>
+{
+	const { revalidate = 300, tags, headers } = options;
+
+	try {
+		const actionURL = `${API_URL}${url.startsWith('/') ? url : `/${url}`}`;
+
+		const res = await fetch(actionURL, {
+			method: 'GET',
+			headers: {
+				'Accept': 'application/json',
+				...(headers ?? {})
+			},
+			next: {
+				revalidate,
+				...(tags && tags.length > 0 ? { tags } : {})
+			}
+		});
+
+		if (res.status === 204) {
+			return { success: true, status: 204, message: null, data: null, errors: null } satisfies ResultDto<T>;
+		}
+
+		const contentType = res.headers.get('content-type');
+
+		if (!contentType || !contentType.includes('application/json')) {
+			return { success: false, status: res.status, message: null, data: null, errors: null } satisfies ResultDto<T>;
+		}
+
+		return await res.json() as ResultDto<T>;
+
+	} catch (err) {
+		const message = err instanceof Error ? err.message : '서버와 통신이 불가합니다.';
+		console.warn(`[500] ${message}`);
+
+		return { success: false, status: 500, message, data: null, errors: null } satisfies ResultDto<T>;
+	}
+}

+ 10 - 1
next.config.ts

@@ -48,7 +48,16 @@ const nextConfig: NextConfig = {
 
 		serverActions: {
 			bodySizeLimit: '200mb' // 최대 요청 본문 크기 설정
-		}
+		},
+
+		// 배럴 임포트 트리셰이킹 — 아이콘/차트 라이브러리 first-load JS 축소
+		optimizePackageImports: [
+			'recharts',
+			'@fortawesome/free-solid-svg-icons',
+			'@fortawesome/free-regular-svg-icons',
+			'@fortawesome/free-brands-svg-icons',
+			'lucide-react'
+		]
 	},
 
 	// 배포 최적화