Explorar el Código

M1: stock list/detail pages, header suggest search

- /stock SSR list (market tabs, search, paging), /stock/[code] detail with T+1 label + history tab
- header combobox search: 300ms debounce suggest, keyboard nav, IME-safe
- token-only colors, thin-line dense tables, mobile column drops
- tsc 0, build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 hace 3 semanas
padre
commit
d04be0738b

+ 202 - 0
app/(main)/stock/[code]/page.tsx

@@ -0,0 +1,202 @@
+import './style.scss';
+import '@/app/styles/stock-common.scss';
+import { cache } from 'react';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
+import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
+import Pager from '../_components/Pager';
+
+const TABS = [
+	{ value: 'price', label: '시세' },
+	{ value: 'news', label: '뉴스' },
+	{ value: 'disclosure', label: '공시' },
+	{ value: 'discussion', label: '토론' }
+];
+
+const HISTORY_PER_PAGE = 30;
+
+type Props = {
+	params: Promise<{
+		code: string;
+	}>;
+	searchParams: Promise<{
+		tab?: string;
+		page?: string;
+	}>;
+};
+
+// generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
+const getDetail = cache(async (code: string) => {
+	return await fetchStockDetail(code);
+});
+
+export async function generateMetadata({ params }: Props): Promise<Metadata>
+{
+	const { code } = await params;
+	const res = await getDetail(code);
+
+	if (!res.success || !res.data) {
+		return {
+			title: '종목 상세 — 개미투자'
+		};
+	}
+
+	return {
+		title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
+		description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
+	};
+}
+
+export default async function StockDetailPage({ params, searchParams }: Props)
+{
+	const { code } = await params;
+	const query = await searchParams;
+	const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await getDetail(code);
+
+	// 없는 코드(형식 불일치 포함)는 404
+	if (!res.success && res.status === 404) {
+		return notFound();
+	}
+
+	// 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
+	if (!res.success || !res.data) {
+		return (
+			<div id='stock-detail'>
+				<div className='stock-detail__error' role='alert'>
+					<strong>종목 정보를 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			</div>
+		);
+	}
+
+	const detail = res.data;
+	const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
+	const direction = changeDirection(detail.changeRate);
+	const statusLabel = tradingStatusLabel(detail.tradingStatus);
+
+	// 시세 탭만 히스토리 조회 (request-time)
+	const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
+
+	return (
+		<div id='stock-detail'>
+			{/* 상단 요약 — T+1 데이터임을 명시 */}
+			<header className='stock-detail__summary'>
+				<div className='stock-detail__title'>
+					<h1>{detail.name}</h1>
+					<span className='stock-detail__code'>{detail.code}</span>
+					<span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
+					{statusLabel && (
+						<span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
+					)}
+					{detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
+				</div>
+
+				<div className='stock-detail__price'>
+					<strong className={`stock-detail__close stock-change stock-change--${direction}`}>
+						{formatNumber(detail.closePrice)}
+					</strong>
+					<span className={`stock-detail__diff stock-change stock-change--${direction}`}>
+						{changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
+					</span>
+				</div>
+
+				<dl className='stock-detail__meta'>
+					<div>
+						<dt>거래량</dt>
+						<dd>{formatNumber(latest?.volume)}</dd>
+					</div>
+					<div>
+						<dt>거래대금</dt>
+						<dd>{formatKrwCompact(latest?.tradingValue)}</dd>
+					</div>
+					<div>
+						<dt>시가총액</dt>
+						<dd>{formatKrwCompact(detail.marketCap)}</dd>
+					</div>
+				</dl>
+
+				<p className='stock-detail__basis'>
+					전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
+				</p>
+			</header>
+
+			{/* 탭 */}
+			<nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
+				{TABS.map(item => (
+					<Link
+						key={item.value}
+						href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
+						{...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
+					>
+						{item.label}
+					</Link>
+				))}
+			</nav>
+
+			<section className='stock-detail__panel'>
+				{tab !== 'price' ? (
+					<p className='stock-detail__placeholder'>준비 중입니다.</p>
+				) : !history || !history.success || !history.data ? (
+					<div className='stock-detail__error' role='alert'>
+						<strong>일별 시세를 불러오지 못했습니다.</strong>
+						잠시 후 다시 시도해 주세요.
+					</div>
+				) : history.data.list.length === 0 ? (
+					<p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
+				) : (
+					<>
+						<table className='stock-detail__table'>
+							<caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
+							<thead>
+								<tr>
+									<th scope='col'>날짜</th>
+									<th scope='col'>종가</th>
+									<th scope='col'>등락률</th>
+									<th scope='col' className='stock-detail__col--sub'>시가</th>
+									<th scope='col' className='stock-detail__col--sub'>고가</th>
+									<th scope='col' className='stock-detail__col--sub'>저가</th>
+									<th scope='col'>거래량</th>
+									<th scope='col' className='stock-detail__col--sub'>거래대금</th>
+								</tr>
+							</thead>
+							<tbody>
+								{history.data.list.map(row => {
+									const rowDirection = changeDirection(row.changeRate);
+
+									return (
+										<tr key={row.tradingDate}>
+											<td>{row.tradingDate}</td>
+											<td>{formatNumber(row.close)}</td>
+											<td className={`stock-change stock-change--${rowDirection}`}>
+												{changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
+											</td>
+											<td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
+											<td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
+											<td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
+											<td>{formatNumber(row.volume)}</td>
+											<td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
+										</tr>
+									);
+								})}
+							</tbody>
+						</table>
+
+						<Pager
+							total={history.data.total}
+							page={page}
+							perPage={HISTORY_PER_PAGE}
+							basePath={`/stock/${detail.code}`}
+							query={{}}
+						/>
+					</>
+				)}
+			</section>
+		</div>
+	);
+}

+ 196 - 0
app/(main)/stock/[code]/style.scss

@@ -0,0 +1,196 @@
+// 종목 상세 (/stock/[code]) — 상단 요약 + 탭 (시세 = 일별 시세 고밀도 테이블)
+#stock-detail {
+	padding: var(--sp-6) var(--sp-7);
+
+	@media (max-width: 767.98px) {
+		padding: var(--sp-5) var(--sp-4);
+	}
+
+	// 상단 요약
+	.stock-detail__summary {
+		padding-bottom: var(--sp-5);
+		border-bottom: 1px solid var(--border-default);
+	}
+
+	.stock-detail__title {
+		display: flex;
+		flex-wrap: wrap;
+		align-items: center;
+		gap: var(--sp-3);
+
+		h1 {
+			font-size: var(--fs-2xl);
+			font-weight: 700;
+			color: var(--text-primary);
+		}
+	}
+
+	.stock-detail__code {
+		font-size: var(--fs-base);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	.stock-detail__sector {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	.stock-detail__price {
+		display: flex;
+		align-items: baseline;
+		gap: var(--sp-4);
+		margin-top: var(--sp-4);
+	}
+
+	.stock-detail__close {
+		font-size: 28px;
+		font-weight: 700;
+		font-variant-numeric: tabular-nums;
+	}
+
+	.stock-detail__diff {
+		font-size: var(--fs-lg);
+	}
+
+	.stock-detail__meta {
+		display: flex;
+		flex-wrap: wrap;
+		gap: var(--sp-3) var(--sp-6);
+		margin-top: var(--sp-4);
+
+		div {
+			display: flex;
+			align-items: baseline;
+			gap: var(--sp-3);
+		}
+
+		dt {
+			font-size: var(--fs-xs);
+			color: var(--text-muted);
+		}
+
+		dd {
+			font-size: var(--fs-sm);
+			color: var(--text-primary);
+			font-variant-numeric: tabular-nums;
+		}
+
+		// mobile 1열 스택
+		@media (max-width: 767.98px) {
+			flex-direction: column;
+			gap: var(--sp-2);
+		}
+	}
+
+	// T+1 명시 라벨
+	.stock-detail__basis {
+		margin-top: var(--sp-3);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	// 탭
+	.stock-detail__tabs {
+		display: flex;
+		gap: var(--sp-1);
+		margin-top: var(--sp-5);
+		border-bottom: 1px solid var(--border-default);
+
+		a {
+			display: inline-block;
+			padding: var(--sp-3) var(--sp-5);
+			font-size: var(--fs-base);
+			color: var(--text-secondary);
+			border-bottom: 2px solid transparent;
+
+			&:hover {
+				color: var(--text-primary);
+			}
+
+			&[aria-current='page'] {
+				color: var(--text-primary);
+				font-weight: 600;
+				border-bottom-color: var(--az-accent);
+			}
+		}
+	}
+
+	.stock-detail__panel {
+		margin-top: var(--sp-5);
+	}
+
+	// 일별 시세 테이블 — 얇은 선, 밀도 변수 소비
+	.stock-detail__table {
+		width: 100%;
+		border-collapse: collapse;
+
+		caption {
+			position: absolute;
+			width: 1px;
+			height: 1px;
+			overflow: hidden;
+			clip: rect(0 0 0 0);
+			white-space: nowrap;
+		}
+
+		thead th {
+			position: sticky;
+			top: 0;
+			height: var(--row-h);
+			padding: 0 var(--cell-px);
+			font-size: var(--cell-fs);
+			font-weight: 600;
+			color: var(--text-muted);
+			text-align: right;
+			background: var(--bg-elevated);
+			border-bottom: 1px solid var(--border-strong);
+
+			&:first-child {
+				text-align: left;
+			}
+		}
+
+		tbody td {
+			height: var(--row-h);
+			padding: 0 var(--cell-px);
+			font-size: var(--cell-fs);
+			color: var(--text-primary);
+			text-align: right;
+			border-bottom: 1px solid var(--border-light);
+			font-variant-numeric: tabular-nums;
+			white-space: nowrap;
+
+			&:first-child {
+				text-align: left;
+			}
+		}
+
+		tbody tr:hover {
+			background: var(--bg-subtle-hover);
+		}
+
+		// mobile: 날짜/종가/등락률/거래량만 유지
+		@media (max-width: 767.98px) {
+			.stock-detail__col--sub {
+				display: none;
+			}
+		}
+	}
+
+	// 준비 중 placeholder / 에러 / 빈 데이터
+	.stock-detail__placeholder,
+	.stock-detail__empty,
+	.stock-detail__error {
+		padding: var(--sp-7) var(--sp-5);
+		font-size: var(--fs-base);
+		color: var(--text-muted);
+		text-align: center;
+	}
+
+	.stock-detail__error strong {
+		display: block;
+		margin-bottom: var(--sp-3);
+		color: var(--text-secondary);
+	}
+}

+ 28 - 0
app/(main)/stock/_components/Pager.tsx

@@ -0,0 +1,28 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import Pagination from '@/app/component/Pagination';
+
+type Props = {
+	total: number;
+	page: number;
+	perPage: number;
+	// 이동 대상 경로 + 유지할 쿼리 (page 는 자동 갱신)
+	basePath: string;
+	query?: Record<string, string>;
+};
+
+// SSR 목록 페이지용 페이저 — 기존 Pagination(onChange 콜백형)을 router.push 로 연결
+export default function Pager({ total, page, perPage, basePath, query }: Props) {
+	const router = useRouter();
+
+	const handleChange = (next: number) => {
+		const params = new URLSearchParams(query);
+		params.set('page', String(next));
+		router.push(`${basePath}?${params.toString()}`);
+	};
+
+	return (
+		<Pagination total={total} page={page} perPage={perPage} onChange={handleChange} />
+	);
+}

+ 156 - 0
app/(main)/stock/page.tsx

@@ -0,0 +1,156 @@
+import './style.scss';
+import '@/app/styles/stock-common.scss';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import { fetchStockList } from '@/lib/api/stock';
+import { StockMarketName } from '@/types/stock';
+import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
+import Pager from './_components/Pager';
+
+export const metadata: Metadata = {
+	title: '종목 — 개미투자',
+	description: '국내 상장 전 종목 목록 — 전일 종가·등락률·시가총액 (KOSPI/KOSDAQ/KONEX)'
+};
+
+const MARKET_TABS: { value: ''|StockMarketName; label: string }[] = [
+	{ value: '', label: '전체' },
+	{ value: 'KOSPI', label: 'KOSPI' },
+	{ value: 'KOSDAQ', label: 'KOSDAQ' },
+	{ value: 'KONEX', label: 'KONEX' }
+];
+
+const PER_PAGE = 30;
+
+type Props = {
+	searchParams: Promise<{
+		market?: string;
+		q?: string;
+		page?: string;
+	}>;
+};
+
+export default async function StockListPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const market = MARKET_TABS.some(c => c.value === query.market) ? (query.market as ''|StockMarketName) : '';
+	const q = (query.q ?? '').trim();
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	// 백엔드 미기동 시에도 페이지 자체는 렌더 — 테이블 영역만 에러 표시 (request-time fetch)
+	const res = await fetchStockList({ q, market, page, perPage: PER_PAGE });
+	const data = res.success ? res.data : null;
+
+	const buildHref = (nextMarket: ''|StockMarketName) => {
+		const params = new URLSearchParams();
+		if (nextMarket) {
+			params.set('market', nextMarket);
+		}
+		if (q) {
+			params.set('q', q);
+		}
+		const qs = params.toString();
+		return qs ? `/stock?${qs}` : '/stock';
+	};
+
+	return (
+		<div id='stock'>
+			<div className='stock__head'>
+				<h1>종목</h1>
+				<span className='stock__basis'>전일 종가 기준 (T+1)</span>
+			</div>
+
+			<div className='stock__controls'>
+				{/* 시장 탭 */}
+				<nav className='stock__tabs' aria-label='시장 구분'>
+					{MARKET_TABS.map(tab => (
+						<Link
+							key={tab.label}
+							href={buildHref(tab.value)}
+							{...(market === tab.value ? { 'aria-current': 'page' as const } : {})}
+						>
+							{tab.label}
+						</Link>
+					))}
+				</nav>
+
+				{/* 종목명/코드 검색 (GET — SSR 재조회) */}
+				<form className='stock__search' action='/stock' method='get' role='search'>
+					{market && <input type='hidden' name='market' value={market} />}
+					<input
+						type='search'
+						name='q'
+						defaultValue={q}
+						placeholder='종목명 또는 코드'
+						aria-label='종목 검색'
+						autoComplete='off'
+					/>
+					<button type='submit'>검색</button>
+				</form>
+			</div>
+
+			{!res.success ? (
+				<div className='stock__error' role='alert'>
+					<strong>종목 목록을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='stock__empty'>
+					{q ? `'${q}' 검색 결과가 없습니다.` : '표시할 종목이 없습니다.'}
+				</div>
+			) : (
+				<>
+					<p className='stock__total'>전체 {formatNumber(data.total)}종목</p>
+
+					<table className='stock__table'>
+						<caption>종목 목록 — 종목명, 현재가(전일 종가), 등락률, 시가총액, 시장</caption>
+						<thead>
+							<tr>
+								<th scope='col'>종목명</th>
+								<th scope='col'>현재가 <small>(전일종가)</small></th>
+								<th scope='col'>등락률</th>
+								<th scope='col' className='stock__col--cap'>시가총액</th>
+								<th scope='col' className='stock__col--market'>시장</th>
+							</tr>
+						</thead>
+						<tbody>
+							{data.list.map(row => {
+								const direction = changeDirection(row.changeRate);
+								const statusLabel = tradingStatusLabel(row.tradingStatus);
+
+								return (
+									<tr key={row.code}>
+										<td className='stock__name'>
+											<Link href={`/stock/${row.code}`}>
+												<span className='stock__name-text'>{row.name}</span>
+												<span className='stock__code'>{row.code}</span>
+												{statusLabel && (
+													<span className={`stock-badge stock-badge--${row.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
+												)}
+											</Link>
+										</td>
+										<td>{formatNumber(row.closePrice)}</td>
+										<td className={`stock-change stock-change--${direction}`}>
+											{changeSymbol(direction)} {formatChangeRate(row.changeRate)}
+										</td>
+										<td className='stock__col--cap'>{formatKrwCompact(row.marketCap)}</td>
+										<td className='stock__col--market'>
+											<span className={`stock-badge stock-badge--${row.market.toLowerCase()}`}>{row.market}</span>
+										</td>
+									</tr>
+								);
+							})}
+						</tbody>
+					</table>
+
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/stock'
+						query={{ ...(market ? { market } : {}), ...(q ? { q } : {}) }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 226 - 0
app/(main)/stock/style.scss

@@ -0,0 +1,226 @@
+// 종목 목록 (/stock) — 고밀도 선/테이블 클래식 (d6 §5.1, 카드 금지·얇은 선만)
+#stock {
+	padding: var(--sp-6) var(--sp-7);
+
+	@media (max-width: 767.98px) {
+		padding: var(--sp-5) var(--sp-4);
+	}
+
+	.stock__head {
+		display: flex;
+		align-items: baseline;
+		gap: var(--sp-4);
+		margin-bottom: var(--sp-5);
+
+		h1 {
+			font-size: var(--fs-2xl);
+			font-weight: 700;
+			color: var(--text-primary);
+		}
+	}
+
+	.stock__basis {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	// 시장 탭 + 검색 컨트롤 행 — mobile 1열 스택
+	.stock__controls {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: var(--sp-4);
+		margin-bottom: var(--sp-4);
+		border-bottom: 1px solid var(--border-default);
+
+		@media (max-width: 767.98px) {
+			flex-direction: column;
+			align-items: stretch;
+		}
+	}
+
+	.stock__tabs {
+		display: flex;
+		gap: var(--sp-1);
+
+		a {
+			display: inline-block;
+			padding: var(--sp-3) var(--sp-5);
+			font-size: var(--fs-base);
+			color: var(--text-secondary);
+			border-bottom: 2px solid transparent;
+
+			&:hover {
+				color: var(--text-primary);
+			}
+
+			&[aria-current='page'] {
+				color: var(--text-primary);
+				font-weight: 600;
+				border-bottom-color: var(--az-accent);
+			}
+		}
+	}
+
+	.stock__search {
+		display: flex;
+		gap: var(--sp-3);
+		padding-bottom: var(--sp-3);
+
+		input {
+			width: 220px;
+			height: 32px;
+			padding: 0 var(--sp-4);
+			font-size: var(--fs-sm);
+			color: var(--text-primary);
+			background: var(--bg-input);
+			border: 1px solid var(--border-strong);
+			border-radius: 6px;
+
+			&::placeholder {
+				color: var(--text-muted);
+			}
+
+			@media (max-width: 767.98px) {
+				flex: 1 1 auto;
+				width: auto;
+			}
+		}
+
+		button {
+			height: 32px;
+			padding: 0 var(--sp-5);
+			font-size: var(--fs-sm);
+			color: var(--btn-primary-text);
+			background: var(--btn-primary);
+			border-radius: 6px;
+
+			&:hover {
+				background: var(--btn-primary-hover);
+			}
+		}
+	}
+
+	// 고밀도 테이블 — 행 구분 하단 1px 선만 (세로선·zebra 없음), 밀도 변수 소비
+	.stock__table {
+		width: 100%;
+		border-collapse: collapse;
+
+		caption {
+			position: absolute;
+			width: 1px;
+			height: 1px;
+			overflow: hidden;
+			clip: rect(0 0 0 0);
+			white-space: nowrap;
+		}
+
+		thead th {
+			position: sticky;
+			top: 0;
+			height: var(--row-h);
+			padding: 0 var(--cell-px);
+			font-size: var(--cell-fs);
+			font-weight: 600;
+			color: var(--text-muted);
+			text-align: right;
+			background: var(--bg-elevated);
+			border-bottom: 1px solid var(--border-strong);
+
+			&:first-child {
+				text-align: left;
+			}
+
+			&.stock__col--market {
+				text-align: center;
+			}
+
+			small {
+				font-weight: 400;
+				color: var(--text-muted);
+			}
+		}
+
+		tbody td {
+			height: var(--row-h);
+			padding: 0 var(--cell-px);
+			font-size: var(--cell-fs);
+			color: var(--text-primary);
+			text-align: right;
+			border-bottom: 1px solid var(--border-light);
+			font-variant-numeric: tabular-nums;
+			white-space: nowrap;
+
+			&:first-child {
+				text-align: left;
+			}
+
+			&.stock__col--market {
+				text-align: center;
+			}
+		}
+
+		tbody tr:hover {
+			background: var(--bg-subtle-hover);
+		}
+
+		// 우선순위 낮은 열 — mobile drop (d6 §5.1)
+		@media (max-width: 767.98px) {
+			.stock__col--cap {
+				display: none;
+			}
+		}
+	}
+
+	.stock__name {
+		max-width: 0;
+		width: 40%;
+
+		a {
+			display: flex;
+			align-items: center;
+			gap: var(--sp-3);
+			min-height: var(--row-h);
+			color: var(--text-primary);
+
+			&:hover .stock__name-text {
+				text-decoration: underline;
+			}
+		}
+	}
+
+	.stock__name-text {
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+
+	.stock__code {
+		flex: 0 0 auto;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	.stock__total {
+		margin: var(--sp-3) 0;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	// 빈 목록 / 에러
+	.stock__empty,
+	.stock__error {
+		padding: var(--sp-7) var(--sp-5);
+		font-size: var(--fs-base);
+		color: var(--text-muted);
+		text-align: center;
+		border-bottom: 1px solid var(--border-light);
+	}
+
+	.stock__error strong {
+		display: block;
+		margin-bottom: var(--sp-3);
+		color: var(--text-secondary);
+	}
+}

+ 15 - 0
app/api/stocks/[[...path]]/route.ts

@@ -0,0 +1,15 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/stocks${path && path.length > 0 ? `/${path.join('/')}` : ''}`;
+	const url = new URL(request.url);
+
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, {
+		method: 'GET'
+	});
+
+	return NextResponse.json(res);
+}

+ 5 - 41
app/component/Header.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import Styles from '../styles/layout.module.scss';
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback } from 'react';
 import Link from 'next/link';
 import { usePathname } from 'next/navigation';
 import useAuth from '@/hooks/useAuth';
@@ -9,6 +9,7 @@ import useDragScroll from '@/hooks/useDragScroll';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faBars, faXmark, faCartShopping, faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
 import NotificationBell from '@/app/component/NotificationBell';
+import HeaderSearch from '@/app/component/HeaderSearch';
 import Profile from '@/app/component/Profile';
 import Logo from '@/app/component/Logo';
 import PointChargeIcon from '@/public/icons/layout/point.svg';
@@ -22,6 +23,7 @@ const channelNavItems = [
 ];
 
 const baseNavItems = [
+	{ href: '/stock', label: '종목' },
 	{ href: '/games', label: '게임' },
 	{ href: '/feed/all', label: '피드' },
 	{ href: '/store', label: '상점' },
@@ -44,34 +46,6 @@ export default function Header({ sidebarOpen, onToggle }: Props)
 	const { totalCount: cartCount } = useCart();
 	const pathname = usePathname();
 	const dragScroll = useDragScroll<HTMLDivElement>();
-	const searchRef = useRef<HTMLInputElement>(null);
-
-	// 통합 검색 포커스 단축키: Ctrl+K(⌘+K) / '/' (finviz 관례) — 검색 동작 자체는 후속
-	useEffect(() => {
-		const handler = (e: KeyboardEvent) => {
-			if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
-				e.preventDefault();
-				searchRef.current?.focus();
-				return;
-			}
-
-			if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
-				const target = e.target as HTMLElement|null;
-				const tag = target?.tagName;
-				if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
-					return;
-				}
-
-				e.preventDefault();
-				searchRef.current?.focus();
-			}
-		};
-
-		window.addEventListener('keydown', handler);
-		return () => {
-			window.removeEventListener('keydown', handler);
-		};
-	}, []);
 
 	const scrollTabs = useCallback((dir: 'left' | 'right') => {
 		const el = dragScroll.ref.current;
@@ -114,18 +88,8 @@ export default function Header({ sidebarOpen, onToggle }: Props)
 					</ul>
 				</nav>
 
-				{/* 중앙 통합 검색 — UI + Ctrl+K/'/' 포커스만 (자동완성·커맨드 팔레트는 후속 D6-4) */}
-				<div className={Styles.searchBox} role='search'>
-					<input
-						ref={searchRef}
-						type='search'
-						className={Styles.searchInput}
-						placeholder='종목·글·유저 검색'
-						aria-label='통합 검색'
-						autoComplete='off'
-					/>
-					<kbd className={Styles.searchKbd} aria-hidden='true'>Ctrl K</kbd>
-				</div>
+				{/* 중앙 통합 검색 — 디바운스 suggest + combobox (Ctrl+K/'/' 포커스 포함) */}
+				<HeaderSearch />
 
 				{/* 우측 아이콘 (항상 표시) */}
 				<div className={Styles.headerActions}>

+ 221 - 0
app/component/HeaderSearch.tsx

@@ -0,0 +1,221 @@
+'use client';
+
+import '@/app/styles/header-search.scss';
+import '@/app/styles/stock-common.scss';
+import Styles from '../styles/layout.module.scss';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+import { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
+
+const DEBOUNCE_MS = 300;
+const LISTBOX_ID = 'header-suggest-listbox';
+
+// 헤더 중앙 통합 검색 — 디바운스 suggest + combobox 키보드 탐색 (↑↓ Enter Esc), 선택 시 /stock/{code} 이동
+export default function HeaderSearch()
+{
+	const router = useRouter();
+	const boxRef = useRef<HTMLDivElement>(null);
+	const inputRef = useRef<HTMLInputElement>(null);
+	const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
+	const requestSeq = useRef(0);
+
+	const [keyword, setKeyword] = useState('');
+	const [items, setItems] = useState<StockSuggestItem[]>([]);
+	const [open, setOpen] = useState(false);
+	const [activeIndex, setActiveIndex] = useState(-1);
+
+	// 통합 검색 포커스 단축키: Ctrl+K(⌘+K) / '/' (finviz 관례)
+	useEffect(() => {
+		const handler = (e: KeyboardEvent) => {
+			if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
+				e.preventDefault();
+				inputRef.current?.focus();
+				return;
+			}
+
+			if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
+				const target = e.target as HTMLElement|null;
+				const tag = target?.tagName;
+				if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
+					return;
+				}
+
+				e.preventDefault();
+				inputRef.current?.focus();
+			}
+		};
+
+		window.addEventListener('keydown', handler);
+		return () => {
+			window.removeEventListener('keydown', handler);
+		};
+	}, []);
+
+	// 외부 클릭 시 드롭다운 닫기
+	useEffect(() => {
+		const handler = (e: MouseEvent) => {
+			if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
+				setOpen(false);
+			}
+		};
+
+		document.addEventListener('mousedown', handler);
+		return () => {
+			document.removeEventListener('mousedown', handler);
+		};
+	}, []);
+
+	useEffect(() => {
+		return () => {
+			if (debounceRef.current) {
+				clearTimeout(debounceRef.current);
+			}
+		};
+	}, []);
+
+	const requestSuggest = useCallback(async (q: string) => {
+		const seq = ++requestSeq.current;
+		const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
+
+		// 늦게 도착한 이전 응답 무시 (out-of-order 방지)
+		if (seq !== requestSeq.current) {
+			return;
+		}
+
+		setItems(res.success ? (res.data?.list ?? []) : []);
+		setActiveIndex(-1);
+		setOpen(true);
+	}, []);
+
+	// 입력 디바운스 300ms → suggest API
+	const handleChange = useCallback((value: string) => {
+		setKeyword(value);
+		setActiveIndex(-1);
+
+		if (debounceRef.current) {
+			clearTimeout(debounceRef.current);
+		}
+
+		const trimmed = value.trim();
+		if (!trimmed) {
+			requestSeq.current++;
+			setItems([]);
+			setOpen(false);
+			return;
+		}
+
+		debounceRef.current = setTimeout(() => {
+			requestSuggest(trimmed);
+		}, DEBOUNCE_MS);
+	}, [requestSuggest]);
+
+	const moveTo = useCallback((item: StockSuggestItem) => {
+		setOpen(false);
+		setKeyword('');
+		setItems([]);
+		setActiveIndex(-1);
+		inputRef.current?.blur();
+		router.push(`/stock/${item.code}`);
+	}, [router]);
+
+	const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
+		// 한글 IME 조합 중 Enter 오동작 방지
+		if (e.nativeEvent.isComposing) {
+			return;
+		}
+
+		if (e.key === 'ArrowDown') {
+			if (open && items.length > 0) {
+				e.preventDefault();
+				setActiveIndex(prev => (prev + 1) % items.length);
+			}
+			return;
+		}
+
+		if (e.key === 'ArrowUp') {
+			if (open && items.length > 0) {
+				e.preventDefault();
+				setActiveIndex(prev => (prev <= 0 ? items.length - 1 : prev - 1));
+			}
+			return;
+		}
+
+		if (e.key === 'Enter') {
+			e.preventDefault();
+
+			if (open && activeIndex >= 0 && activeIndex < items.length) {
+				moveTo(items[activeIndex]);
+				return;
+			}
+
+			// 선택 없이 Enter — 종목 목록 검색으로 이동
+			const trimmed = keyword.trim();
+			if (trimmed) {
+				setOpen(false);
+				router.push(`/stock?q=${encodeURIComponent(trimmed)}`);
+			}
+			return;
+		}
+
+		if (e.key === 'Escape') {
+			setOpen(false);
+			setActiveIndex(-1);
+		}
+	}, [open, items, activeIndex, keyword, moveTo, router]);
+
+	return (
+		<div className={Styles.searchBox} role='search' ref={boxRef}>
+			<input
+				ref={inputRef}
+				type='search'
+				className={Styles.searchInput}
+				placeholder='종목·글·유저 검색'
+				aria-label='통합 검색'
+				autoComplete='off'
+				role='combobox'
+				aria-expanded={open}
+				aria-controls={LISTBOX_ID}
+				aria-autocomplete='list'
+				aria-activedescendant={activeIndex >= 0 ? `${LISTBOX_ID}-${activeIndex}` : undefined}
+				value={keyword}
+				onChange={e => handleChange(e.target.value)}
+				onKeyDown={handleKeyDown}
+				onFocus={() => {
+					if (items.length > 0 && keyword.trim()) {
+						setOpen(true);
+					}
+				}}
+			/>
+			<kbd className={Styles.searchKbd} aria-hidden='true'>Ctrl K</kbd>
+
+			{open && (
+				<ul id={LISTBOX_ID} className='header-suggest' role='listbox' aria-label='종목 자동완성'>
+					{items.length === 0 ? (
+						<li className='header-suggest__empty' role='presentation'>검색 결과가 없습니다.</li>
+					) : (
+						items.map((item, index) => (
+							<li
+								key={item.code}
+								id={`${LISTBOX_ID}-${index}`}
+								role='option'
+								aria-selected={index === activeIndex}
+								className={`header-suggest__item${index === activeIndex ? ' header-suggest__item--active' : ''}`}
+								onMouseDown={e => {
+									// blur 로 드롭다운이 먼저 닫히지 않게 기본 동작 차단 후 이동
+									e.preventDefault();
+									moveTo(item);
+								}}
+								onMouseEnter={() => setActiveIndex(index)}
+							>
+								<span className='header-suggest__name'>{item.name}</span>
+								<span className='header-suggest__code'>{item.code}</span>
+								<span className={`stock-badge stock-badge--${item.market.toLowerCase()}`}>{item.market}</span>
+							</li>
+						))
+					)}
+				</ul>
+			)}
+		</div>
+	);
+}

+ 52 - 0
app/styles/header-search.scss

@@ -0,0 +1,52 @@
+// 헤더 통합 검색 자동완성 드롭다운 (combobox — HeaderSearch.tsx)
+// 인풋/박스 자체 스타일은 layout.module.scss(.searchBox/.searchInput/.searchKbd) 재사용
+.header-suggest {
+	position: absolute;
+	top: calc(100% + 4px);
+	left: 16px;
+	right: 16px;
+	z-index: 1000;
+	max-height: 420px;
+	overflow-y: auto;
+	padding: var(--sp-2) 0;
+	background: var(--bg-elevated);
+	border: 1px solid var(--border-strong);
+	border-radius: 8px;
+	box-shadow: 0 8px 24px var(--shadow-color);
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-3);
+		min-height: var(--row-h);
+		padding: 0 var(--sp-4);
+		font-size: var(--cell-fs);
+		cursor: pointer;
+
+		&--active {
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__name {
+		flex: 1 1 auto;
+		overflow: hidden;
+		color: var(--text-primary);
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+
+	&__code {
+		flex: 0 0 auto;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__empty {
+		padding: var(--sp-4);
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+		text-align: center;
+	}
+}

+ 59 - 0
app/styles/stock-common.scss

@@ -0,0 +1,59 @@
+// ─────────────────────────────────────────────────────────────
+// 종목 공용 표시 요소 — 시장/거래상태 뱃지 + 등락 색 (토큰 변수만 사용)
+// 사용처: /stock 목록, /stock/[code] 상세, HeaderSearch 자동완성
+// ─────────────────────────────────────────────────────────────
+
+// 시장·거래상태 뱃지
+.stock-badge {
+	display: inline-block;
+	padding: 1px 6px;
+	border: 1px solid var(--border-strong);
+	border-radius: 4px;
+	font-size: var(--fs-2xs);
+	line-height: 1.5;
+	color: var(--text-secondary);
+	white-space: nowrap;
+	vertical-align: middle;
+
+	&--kospi {
+		color: var(--color-blue);
+		border-color: var(--color-blue);
+	}
+
+	&--kosdaq {
+		color: var(--color-purple);
+		border-color: var(--color-purple);
+	}
+
+	&--konex {
+		color: var(--text-muted);
+	}
+
+	&--halted {
+		color: var(--color-danger);
+		border-color: var(--color-danger);
+	}
+
+	&--watch {
+		color: var(--color-warning-text);
+		background: var(--color-warning-bg);
+		border-color: var(--color-warning-text);
+	}
+}
+
+// 등락 색 — 상승 빨강 / 하락 파랑 / 보합 (부호 기호 병행은 마크업에서)
+.stock-change {
+	font-variant-numeric: tabular-nums;
+
+	&--up {
+		color: var(--price-up);
+	}
+
+	&--down {
+		color: var(--price-down);
+	}
+
+	&--flat {
+		color: var(--price-flat);
+	}
+}

+ 54 - 0
lib/api/stock.ts

@@ -0,0 +1,54 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { StockListResponse, StockDetailResponse, StockHistoryResponse } from '@/types/stock';
+import { fetchJson } from '@/lib/utils/server';
+
+// 종목 목록 조회 (시장 필터/검색/페이징)
+export async function fetchStockList(params: { q?: string; market?: string; page?: number; perPage?: number }): Promise<ResultDto<StockListResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+	queryParams.set('perPage', String(params.perPage ?? 20));
+
+	if (params.q) queryParams.set('q', params.q);
+	if (params.market) queryParams.set('market', params.market);
+
+	return await fetchJson<StockListResponse>(`/api/stocks?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: {
+			'Accept': 'application/json'
+		}
+	});
+}
+
+// 종목 상세 조회 (마스터 + 최근 시세)
+export async function fetchStockDetail(code: string): Promise<ResultDto<StockDetailResponse>>
+{
+	return await fetchJson<StockDetailResponse>(`/api/stocks/${encodeURIComponent(code)}`, {
+		method: 'GET',
+		headers: {
+			'Accept': 'application/json'
+		}
+	});
+}
+
+// 일별 시세 히스토리 조회 (페이징)
+export async function fetchStockHistory(code: string, params: { page?: number; perPage?: number; from?: string; to?: string }): Promise<ResultDto<StockHistoryResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+	queryParams.set('perPage', String(params.perPage ?? 30));
+
+	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'
+		}
+	});
+}

+ 68 - 0
lib/utils/stock.ts

@@ -0,0 +1,68 @@
+// 종목 표시용 순수 유틸 (서버·클라이언트 공용 — 'use client'/'use server' 미선언)
+
+export type ChangeDirection = 'up'|'down'|'flat';
+
+// 등락 방향 — 색+부호 병행 원칙 (d6 §5.1 색맹 대응)
+export function changeDirection(rate: number|null|undefined): ChangeDirection {
+	if (rate === null || rate === undefined || rate === 0) {
+		return 'flat';
+	}
+
+	return rate > 0 ? 'up' : 'down';
+}
+
+export function changeSymbol(direction: ChangeDirection): string {
+	switch (direction) {
+		case 'up': return '▲';
+		case 'down': return '▼';
+		default: return '—';
+	}
+}
+
+// 천 단위 구분 숫자 (null 은 —)
+export function formatNumber(value: number|null|undefined): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return value.toLocaleString('ko-KR');
+}
+
+// 등락률 % (부호는 changeSymbol 로 병행 표기하므로 절대값)
+export function formatChangeRate(rate: number|null|undefined): string {
+	if (rate === null || rate === undefined) {
+		return '—';
+	}
+
+	return `${Math.abs(rate).toFixed(2)}%`;
+}
+
+// 원 단위 금액 → 조/억 축약 (시가총액·거래대금)
+export function formatKrwCompact(value: number|null|undefined): string {
+	if (!value) {
+		return '—';
+	}
+
+	const eok = value / 100_000_000;
+
+	if (eok >= 10_000) {
+		const jo = Math.floor(eok / 10_000);
+		const rest = Math.floor(eok % 10_000);
+		return rest > 0 ? `${jo.toLocaleString('ko-KR')}조 ${rest.toLocaleString('ko-KR')}억` : `${jo.toLocaleString('ko-KR')}조`;
+	}
+
+	if (eok >= 1) {
+		return `${Math.floor(eok).toLocaleString('ko-KR')}억`;
+	}
+
+	return `${value.toLocaleString('ko-KR')}원`;
+}
+
+// 거래 상태 뱃지 라벨 (정상은 뱃지 미표시)
+export function tradingStatusLabel(status: string): string {
+	switch (status) {
+		case 'Halted': return '거래정지';
+		case 'Watch': return '관리종목';
+		default: return '';
+	}
+}

+ 74 - 0
types/stock.ts

@@ -0,0 +1,74 @@
+// 종목 도메인 타입 — Backend Application/Features/Api/Stocks/*/Response.cs 대응 (camelCase 직렬화)
+
+export type StockMarketName = 'KOSPI'|'KOSDAQ'|'KONEX';
+export type TradingStatusName = 'Normal'|'Halted'|'Watch';
+
+// GET /api/stocks — 목록 행
+export interface StockListRow {
+	code: string;
+	name: string;
+	market: StockMarketName;
+	tradingStatus: TradingStatusName;
+	lastTradingDate: string|null;
+	closePrice: number|null;
+	changeRate: number|null;
+	marketCap: number|null;
+}
+
+export interface StockListResponse {
+	total: number;
+	list: StockListRow[];
+}
+
+// 일별 시세 행 (GetDetail.RecentPrices / GetHistory 공용 필드)
+export interface StockPriceRow {
+	tradingDate: string;
+	open: number;
+	high: number;
+	low: number;
+	close: number;
+	volume: number;
+	tradingValue: number;
+	changeAmount: number;
+	changeRate: number;
+}
+
+// GET /api/stocks/{code}
+export interface StockDetailResponse {
+	code: string;
+	isin: string|null;
+	name: string;
+	englishName: string|null;
+	market: StockMarketName;
+	tradingStatus: TradingStatusName;
+	sectorName: string|null;
+	isActive: boolean;
+	listedDate: string;
+	delistedDate: string|null;
+	lastTradingDate: string|null;
+	closePrice: number|null;
+	changeRate: number|null;
+	marketCap: number|null;
+	recentPrices: StockPriceRow[];
+}
+
+// GET /api/stocks/{code}/history
+export interface StockHistoryRow extends StockPriceRow {
+	marketCap: number|null;
+}
+
+export interface StockHistoryResponse {
+	total: number;
+	list: StockHistoryRow[];
+}
+
+// GET /api/stocks/suggest
+export interface StockSuggestItem {
+	code: string;
+	name: string;
+	market: StockMarketName;
+}
+
+export interface StockSuggestResponse {
+	list: StockSuggestItem[];
+}