Просмотр исходного кода

feat(market): 시장데이터·공시 화면

- /market (지수·주식·ETP·채권·파생·상품·ESG·환율·금리), /disclosure
- 종목 목록/상세 페이지 + _components
- app/api/market 프록시, types/market, lib/api·utils/market (enum 숫자 const+라벨맵)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 недель назад
Родитель
Сommit
93c54b72ca

+ 83 - 0
app/(main)/disclosure/page.tsx

@@ -0,0 +1,83 @@
+import '../market/style.scss';
+import { Metadata } from 'next';
+import MarketNav from '../market/_components/MarketNav';
+import MarketFilterTabs from '../market/_components/MarketFilterTabs';
+import DisclosureList from '../market/_components/DisclosureList';
+import Pager from '../market/_components/Pager';
+import { fetchDisclosures } from '@/lib/api/market';
+import { CorpClsCode } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+
+export const metadata: Metadata = {
+	title: '공시 — 개미투자',
+	description: 'DART 전자공시 피드 — 유가·코스닥·코넥스·기타 법인 최근 공시 (실시간 반영)'
+};
+
+const CLS_TABS: { value: ''|CorpClsCode; label: string }[] = [
+	{ value: '', label: '전체' },
+	{ value: 'Y', label: '유가' },
+	{ value: 'K', label: '코스닥' },
+	{ value: 'N', label: '코넥스' },
+	{ value: 'E', label: '기타' }
+];
+
+const PER_PAGE = 20;
+
+type Props = {
+	searchParams: Promise<{
+		corpCls?: string;
+		page?: string;
+	}>;
+};
+
+export default async function DisclosureFeedPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const corpCls = CLS_TABS.some(c => c.value === query.corpCls) ? (query.corpCls as ''|CorpClsCode) : '';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchDisclosures({ corpCls: corpCls || undefined, page });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>공시</h1>
+				<span className='market__basis'>DART 전자공시 · 최신순</span>
+			</div>
+
+			<MarketNav />
+
+			<div className='market__filters'>
+				<MarketFilterTabs
+					label='법인구분'
+					paramKey='corpCls'
+					tabs={CLS_TABS}
+					active={corpCls}
+					basePath='/disclosure'
+				/>
+			</div>
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>공시 목록을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 공시가 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}건</p>
+					<DisclosureList rows={data.list} />
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/disclosure'
+						query={{ ...(corpCls ? { corpCls } : {}) }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 72 - 0
app/(main)/market/_components/BondsTable.tsx

@@ -0,0 +1,72 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { BondRow, BOND_MARKET_LABEL } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { formatDecimal, formatRatePercent } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<BondRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.name}</span>
+					<span className='stock-cell__code'>{row.code}</span>
+				</span>
+			)
+		},
+		{
+			key: 'market',
+			header: '시장',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => <span className='market__cls-badge'>{BOND_MARKET_LABEL[row.market] ?? row.market}</span>
+		},
+		{
+			key: 'close',
+			header: '가격',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.close)
+		},
+		{
+			key: 'ytm',
+			header: '수익률(YTM)',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatRatePercent(row.yieldToMaturity, 3)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.volume)
+		},
+		{
+			key: 'maturity',
+			header: '만기',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => row.maturityYears || '—'
+		}
+	];
+}
+
+export default function BondsTable({ rows }: { rows: BondRow[] })
+{
+	return (
+		<DataTable<BondRow>
+			caption='채권 목록 — 종목명, 시장, 가격, 수익률, 거래량, 만기'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row) => row.code}
+			emptyMessage='표시할 채권이 없습니다.'
+		/>
+	);
+}

+ 73 - 0
app/(main)/market/_components/CommoditiesTable.tsx

@@ -0,0 +1,73 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import PriceChange from '@/components/table/PriceChange';
+import { CommodityRow, COMMODITY_MARKET_LABEL } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { formatDecimal } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<CommodityRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.name}</span>
+					<span className='stock-cell__code'>{row.code}</span>
+				</span>
+			)
+		},
+		{
+			key: 'market',
+			header: '시장',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => <span className='market__cls-badge'>{COMMODITY_MARKET_LABEL[row.market] ?? row.market}</span>
+		},
+		{
+			key: 'close',
+			header: '현재가',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.close)
+		},
+		{
+			key: 'change',
+			header: '등락률',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => <PriceChange rate={row.changeRate} />
+		},
+		{
+			key: 'weighted',
+			header: '가중평균가',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatDecimal(row.weightedDiscussionPrice)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.volume)
+		}
+	];
+}
+
+export default function CommoditiesTable({ rows }: { rows: CommodityRow[] })
+{
+	return (
+		<DataTable<CommodityRow>
+			caption='상품 목록 — 종목명, 시장, 현재가, 등락률, 가중평균가, 거래량'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row) => row.code}
+			emptyMessage='표시할 상품이 없습니다.'
+		/>
+	);
+}

+ 43 - 0
app/(main)/market/_components/DisclosureList.tsx

@@ -0,0 +1,43 @@
+import '../style.scss';
+import Link from 'next/link';
+import { DisclosureRow, CORP_CLS_LABEL, CorpClsCode } from '@/types/market';
+import { dartViewerUrl } from '@/lib/utils/market';
+
+function corpClsLabel(cls: string): string {
+	return CORP_CLS_LABEL[cls as CorpClsCode] ?? cls;
+}
+
+// 공시 컴팩트 리스트 — 개요/피드 공용 (RSC). 회사명 | 법인구분 | 보고서명(DART 링크) | 접수일
+export default function DisclosureList({ rows }: { rows: DisclosureRow[] })
+{
+	if (rows.length === 0) {
+		return <p className='market__empty'>표시할 공시가 없습니다.</p>;
+	}
+
+	return (
+		<ul className='market__disclosure-list'>
+			{rows.map(row => (
+				<li key={row.rceptNo} className='market__disclosure-item'>
+					{row.stockCode ? (
+						<Link href={`/stock/${row.stockCode}?tab=disclosure`} className='market__disclosure-corp'>
+							{row.corpName}
+						</Link>
+					) : (
+						<span className='market__disclosure-corp'>{row.corpName}</span>
+					)}
+					<span className='market__cls-badge'>{corpClsLabel(row.corpCls)}</span>
+					<a
+						href={dartViewerUrl(row.rceptNo)}
+						target='_blank'
+						rel='noopener noreferrer'
+						className='market__disclosure-report'
+						title={row.reportNm}
+					>
+						{row.reportNm}
+					</a>
+					<span className='market__disclosure-date'>{row.rceptDt}</span>
+				</li>
+			))}
+		</ul>
+	);
+}

+ 68 - 0
app/(main)/market/_components/EsgIndicesTable.tsx

@@ -0,0 +1,68 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { EsgIndexRow } from '@/types/market';
+import { formatNumber, changeDirection, changeSymbol } from '@/lib/utils/stock';
+import { formatDecimal, formatBpRate } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<EsgIndexRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '지수명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => <span className='stock-cell__name'>{row.name}</span>
+		},
+		{
+			key: 'close',
+			header: '지수',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.close)
+		},
+		{
+			key: 'change',
+			header: '등락률',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => {
+				const direction = changeDirection(row.flucRateBp);
+
+				return (
+					<span className={`market__index-change market__index-change--${direction}`}>
+						<span aria-hidden='true'>{changeSymbol(direction)}</span> {formatBpRate(row.flucRateBp)}
+					</span>
+				);
+			}
+		},
+		{
+			key: 'constituents',
+			header: '구성종목수',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.constituentCount)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatNumber(row.volume)
+		}
+	];
+}
+
+export default function EsgIndicesTable({ rows }: { rows: EsgIndexRow[] })
+{
+	return (
+		<DataTable<EsgIndexRow>
+			caption='ESG 지수 목록 — 지수명, 지수, 등락률, 구성종목수, 거래량'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.name}-${index}`}
+			emptyMessage='표시할 ESG 지수가 없습니다.'
+		/>
+	);
+}

+ 61 - 0
app/(main)/market/_components/EsgSecuritiesTable.tsx

@@ -0,0 +1,61 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import PriceChange from '@/components/table/PriceChange';
+import { EsgSecurityRow } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { formatDecimal } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<EsgSecurityRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => <span className='stock-cell__name'>{row.name}</span>
+		},
+		{
+			key: 'close',
+			header: '현재가',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.close)
+		},
+		{
+			key: 'change',
+			header: '등락률',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => <PriceChange rate={row.changeRate} />
+		},
+		{
+			key: 'shares',
+			header: '상장주식수',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatNumber(row.listedShares)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.volume)
+		}
+	];
+}
+
+export default function EsgSecuritiesTable({ rows }: { rows: EsgSecurityRow[] })
+{
+	return (
+		<DataTable<EsgSecurityRow>
+			caption='ESG 증권 목록 — 종목명, 현재가, 등락률, 상장주식수, 거래량'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row, index) => `${row.name}-${index}`}
+			emptyMessage='표시할 ESG 증권이 없습니다.'
+		/>
+	);
+}

+ 80 - 0
app/(main)/market/_components/EtpTable.tsx

@@ -0,0 +1,80 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import PriceChange from '@/components/table/PriceChange';
+import { EtpRow, ETP_TYPE_LABEL } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { formatDecimal } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<EtpRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.name}</span>
+					<span className='stock-cell__code'>{row.code}</span>
+				</span>
+			)
+		},
+		{
+			key: 'type',
+			header: '종류',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => <span className='market__cls-badge'>{ETP_TYPE_LABEL[row.type] ?? row.type}</span>
+		},
+		{
+			key: 'close',
+			header: '현재가',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatNumber(row.close)
+		},
+		{
+			key: 'change',
+			header: '등락률',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => <PriceChange rate={row.changeRate} />
+		},
+		{
+			key: 'nav',
+			header: 'NAV/IV',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatDecimal(row.nav)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.volume)
+		},
+		{
+			key: 'base',
+			header: '기초지수/기초자산',
+			align: 'left',
+			priority: 'low',
+			cell: (row) => row.baseIndexName || row.underlying || '—'
+		}
+	];
+}
+
+export default function EtpTable({ rows }: { rows: EtpRow[] })
+{
+	return (
+		<DataTable<EtpRow>
+			caption='ETP 목록 — 종목명, 종류, 현재가, 등락률, NAV, 거래량, 기초지수'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row) => row.code}
+			emptyMessage='표시할 종목이 없습니다.'
+		/>
+	);
+}

+ 79 - 0
app/(main)/market/_components/FuturesTable.tsx

@@ -0,0 +1,79 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { FuturesRow } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { formatDecimal } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<FuturesRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.isuName}</span>
+					<span className='stock-cell__code'>{row.isuCode}</span>
+				</span>
+			)
+		},
+		{
+			key: 'close',
+			header: '현재가',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.close)
+		},
+		{
+			key: 'changeAmount',
+			header: '대비',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.changeAmount)
+		},
+		{
+			key: 'settle',
+			header: '정산가',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatDecimal(row.settlePrice)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.volume)
+		},
+		{
+			key: 'oi',
+			header: '미결제약정',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatNumber(row.openInterest)
+		},
+		{
+			key: 'product',
+			header: '상품',
+			align: 'left',
+			priority: 'low',
+			cell: (row) => row.productName || '—'
+		}
+	];
+}
+
+export default function FuturesTable({ rows }: { rows: FuturesRow[] })
+{
+	return (
+		<DataTable<FuturesRow>
+			caption='선물 목록 — 종목명, 현재가, 대비, 정산가, 거래량, 미결제약정, 상품'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row) => row.isuCode}
+			emptyMessage='표시할 선물이 없습니다.'
+		/>
+	);
+}

+ 51 - 0
app/(main)/market/_components/MarketFilterTabs.tsx

@@ -0,0 +1,51 @@
+'use client';
+
+import Link from 'next/link';
+
+export interface FilterTab {
+	value: string;
+	label: string;
+}
+
+interface Props {
+	// 필터 그룹 라벨 (예: 종류·시장·기간)
+	label: string;
+	// 쿼리 파라미터 키 (예: type·market·kind)
+	paramKey: string;
+	tabs: FilterTab[];
+	// 현재 활성 값
+	active: string;
+	// 대상 경로 (예: /market/etp)
+	basePath: string;
+	// 유지할 다른 쿼리 (page 는 필터 변경 시 초기화되므로 미포함)
+	preserve?: Record<string, string>;
+}
+
+// 알약형 필터 탭 — Link 기반(SSR 재조회). 필터 변경 시 page 초기화(미부여).
+export default function MarketFilterTabs({ label, paramKey, tabs, active, basePath, preserve }: Props)
+{
+	const buildHref = (value: string) => {
+		const params = new URLSearchParams(preserve);
+		if (value) {
+			params.set(paramKey, value);
+		}
+		const qs = params.toString();
+		return qs ? `${basePath}?${qs}` : basePath;
+	};
+
+	return (
+		<div className='market__filter-group' role='group' aria-label={label}>
+			<span className='market__filter-label'>{label}</span>
+			{tabs.map(tab => (
+				<Link
+					key={tab.value}
+					href={buildHref(tab.value)}
+					className={`market__tab${active === tab.value ? ' market__tab--active' : ''}`}
+					{...(active === tab.value ? { 'aria-current': 'page' as const } : {})}
+				>
+					{tab.label}
+				</Link>
+			))}
+		</div>
+	);
+}

+ 38 - 0
app/(main)/market/_components/MarketNav.tsx

@@ -0,0 +1,38 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+const TABS = [
+	{ href: '/market', label: '개요' },
+	{ href: '/market/etp', label: 'ETP' },
+	{ href: '/market/bonds', label: '채권' },
+	{ href: '/market/derivatives', label: '파생' },
+	{ href: '/market/commodities', label: '상품' },
+	{ href: '/market/esg', label: 'ESG' },
+	{ href: '/disclosure', label: '공시' }
+];
+
+export default function MarketNav()
+{
+	const pathname = usePathname();
+
+	return (
+		<nav className='market__nav' aria-label='시장 데이터 메뉴'>
+			{TABS.map(tab => {
+				const active = tab.href === '/market' ? pathname === '/market' : pathname.startsWith(tab.href);
+
+				return (
+					<Link
+						key={tab.href}
+						href={tab.href}
+						className={`market__nav-link${active ? ' market__nav-link--active' : ''}`}
+						aria-current={active ? 'page' : undefined}
+					>
+						{tab.label}
+					</Link>
+				);
+			})}
+		</nav>
+	);
+}

+ 79 - 0
app/(main)/market/_components/OptionsTable.tsx

@@ -0,0 +1,79 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { OptionsRow, OPTION_RIGHT_TYPE_LABEL } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { formatDecimal } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<OptionsRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.isuName}</span>
+					<span className='stock-cell__code'>{row.isuCode}</span>
+				</span>
+			)
+		},
+		{
+			key: 'right',
+			header: '권리',
+			align: 'center',
+			priority: 'high',
+			cell: (row) => <span className='market__cls-badge'>{OPTION_RIGHT_TYPE_LABEL[row.rightType] ?? row.rightType}</span>
+		},
+		{
+			key: 'strike',
+			header: '행사가',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatDecimal(row.strikePrice)
+		},
+		{
+			key: 'close',
+			header: '현재가',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatDecimal(row.close)
+		},
+		{
+			key: 'iv',
+			header: '내재변동성',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatDecimal(row.impliedVolatility)
+		},
+		{
+			key: 'volume',
+			header: '거래량',
+			align: 'right',
+			priority: 'medium',
+			cell: (row) => formatNumber(row.volume)
+		},
+		{
+			key: 'oi',
+			header: '미결제약정',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatNumber(row.openInterest)
+		}
+	];
+}
+
+export default function OptionsTable({ rows }: { rows: OptionsRow[] })
+{
+	return (
+		<DataTable<OptionsRow>
+			caption='옵션 목록 — 종목명, 권리유형, 행사가, 현재가, 내재변동성, 거래량, 미결제약정'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row) => row.isuCode}
+			emptyMessage='표시할 옵션이 없습니다.'
+		/>
+	);
+}

+ 28 - 0
app/(main)/market/_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} />
+	);
+}

+ 72 - 0
app/(main)/market/_components/SriBondsTable.tsx

@@ -0,0 +1,72 @@
+'use client';
+
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import { SriBondRow } from '@/types/market';
+import { formatKrwCompact } from '@/lib/utils/stock';
+import { formatRatePercent } from '@/lib/utils/market';
+
+function buildColumns(): DataTableColumn<SriBondRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<span className='stock-cell'>
+					<span className='stock-cell__name'>{row.name}</span>
+					<span className='stock-cell__code'>{row.code}</span>
+				</span>
+			)
+		},
+		{
+			key: 'issuer',
+			header: '발행기관',
+			align: 'left',
+			priority: 'medium',
+			cell: (row) => row.issuerName
+		},
+		{
+			key: 'sriType',
+			header: '유형',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => <span className='market__cls-badge'>{row.sriBondType}</span>
+		},
+		{
+			key: 'coupon',
+			header: '표면금리',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatRatePercent(row.couponRate, 3)
+		},
+		{
+			key: 'issueAmount',
+			header: '발행액',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatKrwCompact(row.issueAmount)
+		},
+		{
+			key: 'redemption',
+			header: '상환일',
+			align: 'center',
+			priority: 'low',
+			cell: (row) => row.redemptionDate || '—'
+		}
+	];
+}
+
+export default function SriBondsTable({ rows }: { rows: SriBondRow[] })
+{
+	return (
+		<DataTable<SriBondRow>
+			caption='SRI 채권 목록 — 종목명, 발행기관, 유형, 표면금리, 발행액, 상환일'
+			columns={buildColumns()}
+			rows={rows}
+			rowKey={(row) => row.code}
+			emptyMessage='표시할 SRI 채권이 없습니다.'
+		/>
+	);
+}

+ 85 - 0
app/(main)/market/bonds/page.tsx

@@ -0,0 +1,85 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import MarketFilterTabs from '../_components/MarketFilterTabs';
+import BondsTable from '../_components/BondsTable';
+import Pager from '../_components/Pager';
+import { fetchMarketBonds } from '@/lib/api/market';
+import { BondMarketName } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { tradeDateLabel } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: '채권 — 개미투자',
+	description: '국내 채권 시세 — 국채전문·일반채권·소액채권 가격·수익률(YTM)·거래량 (전일 종가 기준)'
+};
+
+const MARKET_TABS: { value: BondMarketName; label: string }[] = [
+	{ value: 'KtsGovt', label: '국채전문' },
+	{ value: 'General', label: '일반채권' },
+	{ value: 'Small', label: '소액채권' }
+];
+
+const PER_PAGE = 20;
+
+type Props = {
+	searchParams: Promise<{
+		market?: string;
+		page?: string;
+	}>;
+};
+
+export default async function MarketBondsPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const market: BondMarketName = MARKET_TABS.some(c => c.value === query.market) ? (query.market as BondMarketName) : 'KtsGovt';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchMarketBonds({ market, page });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>채권</h1>
+				<span className='market__basis'>{tradeDateLabel(data?.tradeDate)} · 국채전문/일반/소액</span>
+			</div>
+
+			<MarketNav />
+
+			<div className='market__filters'>
+				<MarketFilterTabs
+					label='시장'
+					paramKey='market'
+					tabs={MARKET_TABS}
+					active={market}
+					basePath='/market/bonds'
+				/>
+			</div>
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>채권 목록을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 채권이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}종목</p>
+					<div className='market__table-wrap'>
+						<BondsTable rows={data.list} />
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/bonds'
+						query={{ market }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 85 - 0
app/(main)/market/commodities/page.tsx

@@ -0,0 +1,85 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import MarketFilterTabs from '../_components/MarketFilterTabs';
+import CommoditiesTable from '../_components/CommoditiesTable';
+import Pager from '../_components/Pager';
+import { fetchMarketCommodities } from '@/lib/api/market';
+import { CommodityMarketName } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { tradeDateLabel } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: '상품 (석유·금·배출권) — 개미투자',
+	description: '국내 파생상품시장 상품 시세 — 석유·금·배출권 현재가·등락률·거래량 (전일 종가 기준)'
+};
+
+const MARKET_TABS: { value: CommodityMarketName; label: string }[] = [
+	{ value: 'Oil', label: '석유' },
+	{ value: 'Gold', label: '금' },
+	{ value: 'Emission', label: '배출권' }
+];
+
+const PER_PAGE = 20;
+
+type Props = {
+	searchParams: Promise<{
+		market?: string;
+		page?: string;
+	}>;
+};
+
+export default async function MarketCommoditiesPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const market: CommodityMarketName = MARKET_TABS.some(c => c.value === query.market) ? (query.market as CommodityMarketName) : 'Oil';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchMarketCommodities({ market, page });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>상품</h1>
+				<span className='market__basis'>{tradeDateLabel(data?.tradeDate)} · 석유/금/배출권</span>
+			</div>
+
+			<MarketNav />
+
+			<div className='market__filters'>
+				<MarketFilterTabs
+					label='시장'
+					paramKey='market'
+					tabs={MARKET_TABS}
+					active={market}
+					basePath='/market/commodities'
+				/>
+			</div>
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>상품 목록을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 상품이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}종목</p>
+					<div className='market__table-wrap'>
+						<CommoditiesTable rows={data.list} />
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/commodities'
+						query={{ market }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 118 - 0
app/(main)/market/derivatives/page.tsx

@@ -0,0 +1,118 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import MarketNav from '../_components/MarketNav';
+import MarketFilterTabs from '../_components/MarketFilterTabs';
+import FuturesTable from '../_components/FuturesTable';
+import OptionsTable from '../_components/OptionsTable';
+import Pager from '../_components/Pager';
+import { fetchMarketFutures, fetchMarketOptions } from '@/lib/api/market';
+import { FuturesKindName, OptionsKindName } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { tradeDateLabel } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: '파생 (선물·옵션) — 개미투자',
+	description: '국내 파생상품 시세 — 선물·옵션 현재가·정산가·미결제약정·거래량 (전일 종가 기준)'
+};
+
+const SECTION_TABS: { value: 'futures'|'options'; label: string }[] = [
+	{ value: 'futures', label: '선물' },
+	{ value: 'options', label: '옵션' }
+];
+
+const KIND_TABS: { value: FuturesKindName&OptionsKindName; label: string }[] = [
+	{ value: 'General', label: '일반' },
+	{ value: 'StockKospi', label: '주식(코스피)' },
+	{ value: 'StockKosdaq', label: '주식(코스닥)' }
+];
+
+const PER_PAGE = 20;
+
+type Props = {
+	searchParams: Promise<{
+		section?: string;
+		kind?: string;
+		page?: string;
+	}>;
+};
+
+export default async function MarketDerivativesPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const section: 'futures'|'options' = SECTION_TABS.some(c => c.value === query.section) ? (query.section as 'futures'|'options') : 'futures';
+	const kind = KIND_TABS.some(c => c.value === query.kind) ? (query.kind as FuturesKindName&OptionsKindName) : 'General';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const futuresRes = section === 'futures' ? await fetchMarketFutures({ kind, page }) : null;
+	const optionsRes = section === 'options' ? await fetchMarketOptions({ kind, page }) : null;
+
+	const res = section === 'futures' ? futuresRes! : optionsRes!;
+	const data = res.success ? res.data : null;
+	const tradeDate = section === 'futures' ? futuresRes?.data?.tradeDate : optionsRes?.data?.tradeDate;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>파생</h1>
+				<span className='market__basis'>{tradeDateLabel(tradeDate)} · 선물/옵션</span>
+			</div>
+
+			<MarketNav />
+
+			{/* 선물/옵션 섹션 탭 (kind 는 섹션 변경 시 초기화) */}
+			<nav className='market__filters' aria-label='파생 구분'>
+				<div className='market__filter-group' role='group'>
+					<span className='market__filter-label'>구분</span>
+					{SECTION_TABS.map(tab => (
+						<Link
+							key={tab.value}
+							href={`/market/derivatives?section=${tab.value}`}
+							className={`market__tab${section === tab.value ? ' market__tab--active' : ''}`}
+							{...(section === tab.value ? { 'aria-current': 'page' as const } : {})}
+						>
+							{tab.label}
+						</Link>
+					))}
+				</div>
+
+				<MarketFilterTabs
+					label='종류'
+					paramKey='kind'
+					tabs={KIND_TABS}
+					active={kind}
+					basePath='/market/derivatives'
+					preserve={{ section }}
+				/>
+			</nav>
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>{section === 'futures' ? '선물' : '옵션'} 목록을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 {section === 'futures' ? '선물' : '옵션'}이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}종목</p>
+					<div className='market__table-wrap'>
+						{section === 'futures' ? (
+							<FuturesTable rows={futuresRes!.data!.list} />
+						) : (
+							<OptionsTable rows={optionsRes!.data!.list} />
+						)}
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/derivatives'
+						query={{ section, kind }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 101 - 0
app/(main)/market/esg/page.tsx

@@ -0,0 +1,101 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import MarketNav from '../_components/MarketNav';
+import EsgSecuritiesTable from '../_components/EsgSecuritiesTable';
+import EsgIndicesTable from '../_components/EsgIndicesTable';
+import SriBondsTable from '../_components/SriBondsTable';
+import Pager from '../_components/Pager';
+import { fetchMarketEsgSecurities, fetchMarketEsgIndices, fetchMarketSriBonds } from '@/lib/api/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { tradeDateLabel } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: 'ESG (사회책임투자) — 개미투자',
+	description: 'ESG 증권·ESG 지수·SRI 채권 시세 — 사회책임투자 시장 데이터 (전일 종가 기준)'
+};
+
+type EsgTab = 'securities'|'indices'|'sri-bonds';
+
+const TABS: { value: EsgTab; label: string }[] = [
+	{ value: 'securities', label: 'ESG 증권' },
+	{ value: 'indices', label: 'ESG 지수' },
+	{ value: 'sri-bonds', label: 'SRI 채권' }
+];
+
+const PER_PAGE = 20;
+
+type Props = {
+	searchParams: Promise<{
+		tab?: string;
+		page?: string;
+	}>;
+};
+
+export default async function MarketEsgPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const tab: EsgTab = TABS.some(c => c.value === query.tab) ? (query.tab as EsgTab) : 'securities';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const securitiesRes = tab === 'securities' ? await fetchMarketEsgSecurities({ page }) : null;
+	const indicesRes = tab === 'indices' ? await fetchMarketEsgIndices({ page }) : null;
+	const sriBondsRes = tab === 'sri-bonds' ? await fetchMarketSriBonds({ page }) : null;
+
+	const res = securitiesRes ?? indicesRes ?? sriBondsRes!;
+	const data = res.success ? res.data : null;
+	const tradeDate = securitiesRes?.data?.tradeDate ?? indicesRes?.data?.tradeDate ?? sriBondsRes?.data?.tradeDate;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>ESG</h1>
+				<span className='market__basis'>{tradeDateLabel(tradeDate)} · 사회책임투자</span>
+			</div>
+
+			<MarketNav />
+
+			<div className='market__filters'>
+				<div className='market__filter-group' role='group' aria-label='ESG 구분'>
+					<span className='market__filter-label'>구분</span>
+					{TABS.map(item => (
+						<Link
+							key={item.value}
+							href={`/market/esg?tab=${item.value}`}
+							className={`market__tab${tab === item.value ? ' market__tab--active' : ''}`}
+							{...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
+						>
+							{item.label}
+						</Link>
+					))}
+				</div>
+			</div>
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>ESG 데이터를 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 항목이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}건</p>
+					<div className='market__table-wrap'>
+						{tab === 'securities' && <EsgSecuritiesTable rows={securitiesRes!.data!.list} />}
+						{tab === 'indices' && <EsgIndicesTable rows={indicesRes!.data!.list} />}
+						{tab === 'sri-bonds' && <SriBondsTable rows={sriBondsRes!.data!.list} />}
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/esg'
+						query={{ tab }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 85 - 0
app/(main)/market/etp/page.tsx

@@ -0,0 +1,85 @@
+import '../style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import MarketNav from '../_components/MarketNav';
+import MarketFilterTabs from '../_components/MarketFilterTabs';
+import EtpTable from '../_components/EtpTable';
+import Pager from '../_components/Pager';
+import { fetchMarketEtp } from '@/lib/api/market';
+import { EtpTypeName } from '@/types/market';
+import { formatNumber } from '@/lib/utils/stock';
+import { tradeDateLabel } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: 'ETP (ETF·ETN·ELW) — 개미투자',
+	description: '국내 상장 ETP 시세 — ETF·ETN·ELW 현재가·등락률·NAV·거래량 (전일 종가 기준)'
+};
+
+const TYPE_TABS: { value: EtpTypeName; label: string }[] = [
+	{ value: 'ETF', label: 'ETF' },
+	{ value: 'ETN', label: 'ETN' },
+	{ value: 'ELW', label: 'ELW' }
+];
+
+const PER_PAGE = 20;
+
+type Props = {
+	searchParams: Promise<{
+		type?: string;
+		page?: string;
+	}>;
+};
+
+export default async function MarketEtpPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const type: EtpTypeName = TYPE_TABS.some(c => c.value === query.type) ? (query.type as EtpTypeName) : 'ETF';
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchMarketEtp({ type, page });
+	const data = res.success ? res.data : null;
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>ETP</h1>
+				<span className='market__basis'>{tradeDateLabel(data?.tradeDate)} · ETF/ETN/ELW</span>
+			</div>
+
+			<MarketNav />
+
+			<div className='market__filters'>
+				<MarketFilterTabs
+					label='종류'
+					paramKey='type'
+					tabs={TYPE_TABS}
+					active={type}
+					basePath='/market/etp'
+				/>
+			</div>
+
+			{!res.success ? (
+				<div className='market__error' role='alert'>
+					<strong>ETP 목록을 불러오지 못했습니다.</strong>
+					잠시 후 다시 시도해 주세요.
+				</div>
+			) : !data || data.list.length === 0 ? (
+				<div className='market__empty'>표시할 종목이 없습니다.</div>
+			) : (
+				<>
+					<p className='market__total'>전체 {formatNumber(data.total)}종목</p>
+					<div className='market__table-wrap'>
+						<EtpTable rows={data.list} />
+					</div>
+					<Pager
+						total={data.total}
+						page={page}
+						perPage={PER_PAGE}
+						basePath='/market/etp'
+						query={{ type }}
+					/>
+				</>
+			)}
+		</div>
+	);
+}

+ 125 - 0
app/(main)/market/page.tsx

@@ -0,0 +1,125 @@
+import './style.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import MarketNav from './_components/MarketNav';
+import DisclosureList from './_components/DisclosureList';
+import { fetchMarketIndices, fetchMarketExchangeRates, fetchMarketInterestRates, fetchDisclosures } from '@/lib/api/market';
+import { MARKET_INDEX_SERIES_LABEL } from '@/types/market';
+import { changeDirection, changeSymbol } from '@/lib/utils/stock';
+import { formatDecimal, formatBpRate, formatRatePercent, tradeDateLabel } from '@/lib/utils/market';
+
+export const metadata: Metadata = {
+	title: '시장 — 개미투자',
+	description: '국내 증시 지수·환율·금리·최근 공시 한눈에 (KOSPI/KOSDAQ/KRX·매매기준율·국제금리)'
+};
+
+// 개요 미니 위젯 노출 개수
+const RATE_MINI_COUNT = 6;
+const FX_MINI_COUNT = 6;
+const DISCLOSURE_MINI_COUNT = 12;
+
+export default async function MarketOverviewPage()
+{
+	// 지수·환율·금리·공시 병렬 조회 (전부 익명 · request-time)
+	const [indicesRes, fxRes, ratesRes, disclosuresRes] = await Promise.all([
+		fetchMarketIndices(),
+		fetchMarketExchangeRates({}),
+		fetchMarketInterestRates({ type: 'International' }),
+		fetchDisclosures({ page: 1 })
+	]);
+
+	const indices = indicesRes.success ? (indicesRes.data?.list ?? []) : [];
+	const fxRows = fxRes.success ? (fxRes.data?.list ?? []) : [];
+	const rateRows = ratesRes.success ? (ratesRes.data?.list ?? []) : [];
+	const disclosures = disclosuresRes.success ? (disclosuresRes.data?.list ?? []) : [];
+
+	return (
+		<div className='market'>
+			<div className='market__head'>
+				<h1 className='market__title'>시장</h1>
+				<span className='market__basis'>전일 종가 기준 (T+1) · 지수/환율/금리/공시</span>
+			</div>
+
+			<MarketNav />
+
+			{/* 지수 요약 카드 */}
+			<section className='market__section'>
+				<div className='market__section-head'>
+					<h2 className='market__section-title'>주요 지수</h2>
+				</div>
+				{indices.length === 0 ? (
+					<p className='market__empty'>지수 데이터를 준비 중입니다.</p>
+				) : (
+					<div className='market__index-grid'>
+						{indices.map(row => {
+							const direction = changeDirection(row.flucRateBp);
+
+							return (
+								<div key={`${row.series}-${row.name}`} className='market__index-card'>
+									<span className='market__index-name'>{MARKET_INDEX_SERIES_LABEL[row.series] ?? row.name} · {row.name}</span>
+									<span className='market__index-close'>{formatDecimal(row.close)}</span>
+									<span className={`market__index-change market__index-change--${direction}`}>
+										<span aria-hidden='true'>{changeSymbol(direction)}</span> {formatDecimal(row.changeVal)} ({formatBpRate(row.flucRateBp)})
+									</span>
+								</div>
+							);
+						})}
+					</div>
+				)}
+			</section>
+
+			{/* 환율 + 금리 미니 위젯 */}
+			<section className='market__section'>
+				<div className='market__widget-row'>
+					<div>
+						<div className='market__section-head'>
+							<h2 className='market__section-title'>환율</h2>
+							<span className='market__basis'>{tradeDateLabel(fxRes.data?.tradeDate)}</span>
+						</div>
+						{fxRows.length === 0 ? (
+							<p className='market__empty'>환율 데이터를 준비 중입니다.</p>
+						) : (
+							<div className='market__mini-list'>
+								{fxRows.slice(0, FX_MINI_COUNT).map(row => (
+									<div key={row.currencyUnit} className='market__mini-item'>
+										<span className='market__mini-label'>{row.currencyName} ({row.currencyUnit})</span>
+										<span className='market__mini-value'>{formatDecimal(row.dealBasRate)}</span>
+									</div>
+								))}
+							</div>
+						)}
+					</div>
+
+					<div>
+						<div className='market__section-head'>
+							<h2 className='market__section-title'>국제 금리</h2>
+							<span className='market__basis'>{tradeDateLabel(ratesRes.data?.tradeDate)}</span>
+						</div>
+						{rateRows.length === 0 ? (
+							<p className='market__empty'>금리 데이터를 준비 중입니다.</p>
+						) : (
+							<div className='market__mini-list'>
+								{rateRows.slice(0, RATE_MINI_COUNT).map((row, index) => (
+									<div key={`${row.itemName}-${index}`} className='market__mini-item'>
+										<span className='market__mini-label'>{row.itemName}</span>
+										<span className='market__mini-value'>{formatRatePercent(row.rate)}</span>
+									</div>
+								))}
+							</div>
+						)}
+					</div>
+				</div>
+			</section>
+
+			{/* 최근 공시 */}
+			<section className='market__section'>
+				<div className='market__section-head'>
+					<h2 className='market__section-title'>최근 공시</h2>
+					<Link href='/disclosure' className='market__section-link'>전체 보기</Link>
+				</div>
+				<DisclosureList rows={disclosures.slice(0, DISCLOSURE_MINI_COUNT)} />
+			</section>
+		</div>
+	);
+}

+ 319 - 0
app/(main)/market/style.scss

@@ -0,0 +1,319 @@
+// ─────────────────────────────────────────────────────────────
+// 시장 데이터 페이지 공용 — /market(개요), /market/etp, /bonds, /derivatives, /commodities, /esg
+// D6 §5.1 고밀도 원칙 (얇은 선·카드 최소·토큰 변수만). BEM 네이밍. 색 하드코딩 금지.
+// ─────────────────────────────────────────────────────────────
+
+.market {
+	padding: var(--sp-6) var(--sp-7);
+
+	@media (max-width: 767.98px) {
+		padding: var(--sp-5) var(--sp-4);
+	}
+
+	&__head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		flex-wrap: wrap;
+		gap: var(--sp-4);
+		margin-bottom: var(--sp-4);
+	}
+
+	&__title {
+		font-size: var(--fs-2xl);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__basis {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	// ── 페이지 간 서브 네비 (개요·ETP·채권·파생·상품·ESG·공시) ──
+	&__nav {
+		display: flex;
+		gap: var(--sp-1);
+		flex-wrap: wrap;
+		margin-bottom: var(--sp-5);
+		border-bottom: 1px solid var(--border-default);
+	}
+
+	&__nav-link {
+		padding: var(--sp-3) var(--sp-5);
+		font-size: var(--fs-base);
+		font-weight: 500;
+		color: var(--text-secondary);
+		text-decoration: none;
+		border-bottom: 2px solid transparent;
+		margin-bottom: -1px;
+
+		&:hover {
+			color: var(--text-primary);
+		}
+
+		&--active {
+			color: var(--text-primary);
+			font-weight: 600;
+			border-bottom-color: var(--az-accent);
+		}
+	}
+
+	// ── 종류/시장 필터 탭 (알약형) ──
+	&__filters {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-4);
+		flex-wrap: wrap;
+		margin-bottom: var(--sp-4);
+	}
+
+	&__filter-group {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-2);
+		flex-wrap: wrap;
+	}
+
+	&__filter-label {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__tab {
+		padding: var(--sp-2) var(--sp-4);
+		border-radius: 999px;
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		text-decoration: none;
+		border: 1px solid var(--border-default);
+		color: var(--text-secondary);
+		background: transparent;
+
+		&:hover {
+			background: var(--bg-subtle);
+			color: var(--text-primary);
+		}
+
+		&--active {
+			background: var(--az-accent);
+			border-color: var(--az-accent);
+			color: var(--btn-primary-text);
+
+			&:hover {
+				background: var(--az-accent-hover);
+				color: var(--btn-primary-text);
+			}
+		}
+	}
+
+	// ── 개요 섹션 ──
+	&__section {
+		margin-bottom: var(--sp-7);
+	}
+
+	&__section-head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-4);
+		margin-bottom: var(--sp-3);
+	}
+
+	&__section-title {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__section-link {
+		font-size: var(--fs-xs);
+		color: var(--text-link);
+		text-decoration: none;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	// ── 지수 요약 카드 그리드 ──
+	&__index-grid {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+		gap: var(--sp-3);
+	}
+
+	&__index-card {
+		display: flex;
+		flex-direction: column;
+		gap: var(--sp-1);
+		padding: var(--sp-4);
+		border: 1px solid var(--border-default);
+		border-radius: 10px;
+		background: var(--bg-card);
+	}
+
+	&__index-name {
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+	}
+
+	&__index-close {
+		font-size: var(--fs-xl);
+		font-weight: 700;
+		color: var(--text-primary);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__index-change {
+		font-size: var(--fs-sm);
+		font-variant-numeric: tabular-nums;
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+
+		&--flat {
+			color: var(--price-flat);
+		}
+	}
+
+	// ── 미니 위젯 그리드 (환율·금리) ──
+	&__widget-row {
+		display: grid;
+		grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+		gap: var(--sp-5);
+	}
+
+	// ── 정의형 리스트 (환율·금리 미니) ──
+	&__mini-list {
+		display: flex;
+		flex-direction: column;
+		border: 1px solid var(--border-default);
+		border-radius: 10px;
+		overflow: hidden;
+	}
+
+	&__mini-item {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-4);
+		padding: var(--sp-3) var(--sp-4);
+		font-size: var(--fs-sm);
+		border-bottom: 1px solid var(--border-light);
+
+		&:last-child {
+			border-bottom: none;
+		}
+	}
+
+	&__mini-label {
+		color: var(--text-secondary);
+	}
+
+	&__mini-value {
+		font-weight: 600;
+		color: var(--text-primary);
+		font-variant-numeric: tabular-nums;
+	}
+
+	// ── 표 스크롤 래퍼 (넓은 파생/옵션 표 mobile 가로 스크롤) ──
+	&__table-wrap {
+		width: 100%;
+		overflow-x: auto;
+	}
+
+	&__total {
+		margin: var(--sp-3) 0;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__empty,
+	&__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);
+	}
+
+	&__error strong {
+		display: block;
+		margin-bottom: var(--sp-3);
+		color: var(--text-secondary);
+	}
+}
+
+// ─────────────────────────────────────────────────────────────
+// 공시 리스트 + 법인구분 뱃지 — .market 밖(종목 상세 공시 탭 등)에서도 재사용되므로 최상위 클래스
+// ─────────────────────────────────────────────────────────────
+
+.market__disclosure-list {
+	display: flex;
+	flex-direction: column;
+}
+
+.market__disclosure-item {
+	display: flex;
+	align-items: baseline;
+	gap: var(--sp-3);
+	padding: var(--sp-3) 0;
+	font-size: var(--fs-sm);
+	border-bottom: 1px solid var(--border-light);
+}
+
+.market__disclosure-corp {
+	flex: 0 0 auto;
+	min-width: 120px;
+	max-width: 160px;
+	overflow: hidden;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+	font-weight: 600;
+	color: var(--text-primary);
+	text-decoration: none;
+
+	&:hover {
+		text-decoration: underline;
+	}
+}
+
+.market__disclosure-report {
+	flex: 1 1 auto;
+	overflow: hidden;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+	color: var(--text-secondary);
+	text-decoration: none;
+
+	&:hover {
+		text-decoration: underline;
+		color: var(--text-primary);
+	}
+}
+
+.market__disclosure-date {
+	flex: 0 0 auto;
+	font-size: var(--fs-xs);
+	color: var(--text-muted);
+	font-variant-numeric: tabular-nums;
+}
+
+.market__cls-badge {
+	flex: 0 0 auto;
+	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;
+}
+

+ 109 - 0
app/(main)/stock/[code]/_components/StockDiscussion.tsx

@@ -0,0 +1,109 @@
+'use client';
+
+// D2 종목 토론 탭 — 통계 헤더 + 글 목록(DefaultListLayout 스타일) + 글쓰기(로그인 게이트).
+// 목록은 SSR 로 받은 initial 데이터를 렌더하고, 페이징은 링크로 처리(?tab=discussion&page=).
+import '@/app/styles/community.scss';
+import Link from 'next/link';
+import { useState } from 'react';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { formatDate } from '@/lib/utils/client';
+import { StockBoardPostsResponse } from '@/types/community';
+import StockWriteForm from './StockWriteForm';
+
+interface Props {
+	code: string;
+	stockName?: string|null;
+	page: number;
+	perPage: number;
+	data: StockBoardPostsResponse;
+}
+
+export default function StockDiscussion({ code, stockName, page, perPage, data }: Props)
+{
+	const { member } = useMemberContext();
+	const [writing, setWriting] = useState(false);
+
+	const stats = data.stats;
+	const list = data.list;
+	const startIndex = data.total - (page - 1) * perPage;
+	const totalPages = Math.max(Math.ceil(data.total / perPage), 1);
+
+	return (
+		<div className='stock-discussion'>
+			<div className='stock-discussion__stats'>
+				<span>전체 글<strong>{stats.posts.toLocaleString()}</strong></span>
+				<span>댓글<strong>{stats.comments.toLocaleString()}</strong></span>
+				{stats.lastPostAt && <span>최근 작성<strong>{formatDate(stats.lastPostAt)}</strong></span>}
+			</div>
+
+			<div className='stock-discussion__head'>
+				<span className='stock-discussion__count'>총 {data.total.toLocaleString()}건</span>
+				{member ? (
+					!writing && (
+						<button type='button' className='stock-write-toggle' onClick={() => setWriting(true)}>
+							글쓰기
+						</button>
+					)
+				) : null}
+			</div>
+
+			{member && writing && (
+				<StockWriteForm stockCode={code} stockName={stockName} onDone={() => setWriting(false)} />
+			)}
+
+			{!member && (
+				<div className='stock-write-login'>
+					<Link href='/login'>로그인</Link> 후 토론에 참여할 수 있습니다.
+				</div>
+			)}
+
+			{list.length === 0 ? (
+				<p className='stock-discussion__empty'>등록된 글이 없습니다. 첫 글을 남겨보세요.</p>
+			) : (
+				<table className='stock-discussion__table'>
+					<caption className='sr-only'>{stockName ?? code} 토론 글 목록 — 번호·제목·작성자·작성일·조회·좋아요</caption>
+					<thead>
+						<tr>
+							<th className='stock-discussion__col--num-h' scope='col'>번호</th>
+							<th scope='col'>제목</th>
+							<th className='stock-discussion__col--author' scope='col'>작성자</th>
+							<th scope='col'>작성일</th>
+							<th className='stock-discussion__col--views' scope='col'>조회</th>
+							<th scope='col'>추천</th>
+						</tr>
+					</thead>
+					<tbody>
+						{list.map((row, i) => (
+							<tr key={row.id}>
+								<td className='stock-discussion__table-num'>{startIndex - i}</td>
+								<td>
+									<Link href={`/post/${row.id}`} className='stock-discussion__subject'>
+										{row.hasPrediction && <span className='prediction-tag prediction-tag--flat' title='예측 글'>예측</span>}
+										<em>{row.subject}</em>
+										{row.comments > 0 && <span className='stock-discussion__comments'>[{row.comments}]</span>}
+									</Link>
+								</td>
+								<td className='stock-discussion__col--author'>{row.name || row.sid || '익명'}</td>
+								<td className='stock-discussion__table-meta'>{formatDate(row.createdAt)}</td>
+								<td className='stock-discussion__col--views stock-discussion__table-meta'>{row.views.toLocaleString()}</td>
+								<td className='stock-discussion__table-meta'>{row.likes.toLocaleString()}</td>
+							</tr>
+						))}
+					</tbody>
+				</table>
+			)}
+
+			{data.total > perPage && (
+				<nav className='track-leaderboard__actions' aria-label='페이지'>
+					{page > 1 && (
+						<Link href={`/stock/${code}?tab=discussion&page=${page - 1}`} className='track-leaderboard__btn'>이전</Link>
+					)}
+					<span className='stock-discussion__count'>{page} / {totalPages}</span>
+					{page < totalPages && (
+						<Link href={`/stock/${code}?tab=discussion&page=${page + 1}`} className='track-leaderboard__btn'>다음</Link>
+					)}
+				</nav>
+			)}
+		</div>
+	);
+}

+ 189 - 0
app/(main)/stock/[code]/_components/StockWriteForm.tsx

@@ -0,0 +1,189 @@
+'use client';
+
+// D2 종목 글쓰기 폼 — 제목/본문 + 선택적 예측(방향·기간·목표가).
+// POST /api/boards/stock/posts (프록시 경유, JWT). 성공 시 라우터 새로고침으로 목록 갱신.
+import '@/app/styles/community.scss';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+import {
+	CreateStockPostRequest,
+	CreateStockPostResponse,
+	PredictionDirection,
+	PredictionHorizon,
+	PredictionDirectionValue,
+	PredictionHorizonValue
+} from '@/types/community';
+
+interface Props {
+	stockCode: string;
+	stockName?: string|null;
+	onDone?: () => void;
+}
+
+export default function StockWriteForm({ stockCode, stockName, onDone }: Props)
+{
+	const router = useRouter();
+	const [subject, setSubject] = useState('');
+	const [content, setContent] = useState('');
+	const [withPrediction, setWithPrediction] = useState(false);
+	const [direction, setDirection] = useState<PredictionDirectionValue>(PredictionDirection.Up);
+	const [horizon, setHorizon] = useState<PredictionHorizonValue>(PredictionHorizon.Short);
+	const [targetPrice, setTargetPrice] = useState('');
+	const [submitting, setSubmitting] = useState(false);
+	const [error, setError] = useState<string|null>(null);
+
+	async function handleSubmit(e: React.FormEvent) {
+		e.preventDefault();
+		setError(null);
+
+		if (!subject.trim()) {
+			setError('제목을 입력해주세요.');
+			return;
+		}
+
+		const body: CreateStockPostRequest = {
+			stockCode,
+			subject: subject.trim(),
+			content
+		};
+
+		if (withPrediction) {
+			const parsedTarget = targetPrice.trim() ? Number(targetPrice.replace(/,/g, '')) : null;
+
+			if (parsedTarget !== null && (Number.isNaN(parsedTarget) || parsedTarget <= 0)) {
+				setError('목표가는 0보다 큰 숫자여야 합니다.');
+				return;
+			}
+
+			body.prediction = {
+				direction,
+				horizonDays: horizon,
+				targetPrice: parsedTarget
+			};
+		}
+
+		setSubmitting(true);
+
+		try {
+			const res = await fetchApi<CreateStockPostResponse>('/api/boards/stock/posts', {
+				method: 'POST',
+				body,
+				silent: true
+			});
+
+			if (!res.success) {
+				setError(res.message || (res.errors && res.errors[0]?.description) || '글 등록에 실패했습니다.');
+				setSubmitting(false);
+				return;
+			}
+
+			setSubject('');
+			setContent('');
+			setWithPrediction(false);
+			setTargetPrice('');
+			onDone?.();
+			router.refresh();
+		} catch (err) {
+			setError(err instanceof Error ? err.message : '글 등록에 실패했습니다.');
+		} finally {
+			setSubmitting(false);
+		}
+	}
+
+	return (
+		<form className='stock-write' onSubmit={handleSubmit}>
+			<div className='stock-write__title'>{stockName ? `${stockName}(${stockCode})` : stockCode} 토론 글쓰기</div>
+
+			<div className='stock-write__field'>
+				<label htmlFor='stock-write-subject'>제목</label>
+				<input
+					id='stock-write-subject'
+					type='text'
+					value={subject}
+					onChange={e => setSubject(e.target.value)}
+					maxLength={200}
+					placeholder='제목을 입력하세요'
+				/>
+			</div>
+
+			<div className='stock-write__field'>
+				<label htmlFor='stock-write-content'>내용</label>
+				<textarea
+					id='stock-write-content'
+					value={content}
+					onChange={e => setContent(e.target.value)}
+					placeholder='내용을 입력하세요. 다른 종목은 $005930 형식으로 태그할 수 있습니다.'
+				></textarea>
+			</div>
+
+			<div className='stock-write__prediction'>
+				<div className='stock-write__prediction-head'>
+					<label>
+						<input
+							type='checkbox'
+							checked={withPrediction}
+							onChange={e => setWithPrediction(e.target.checked)}
+						/>
+						{' '}예측 등록 (방향·기간·목표가) — 채점되어 적중률에 반영됩니다
+					</label>
+				</div>
+
+				{withPrediction && (
+					<div className='stock-write__prediction-grid'>
+						<div className='stock-write__field'>
+							<label htmlFor='stock-write-direction'>방향</label>
+							<select
+								id='stock-write-direction'
+								value={direction}
+								onChange={e => setDirection(Number(e.target.value) as PredictionDirectionValue)}
+							>
+								<option value={PredictionDirection.Up}>▲ 상승</option>
+								<option value={PredictionDirection.Down}>▼ 하락</option>
+							</select>
+						</div>
+
+						<div className='stock-write__field'>
+							<label htmlFor='stock-write-horizon'>기간</label>
+							<select
+								id='stock-write-horizon'
+								value={horizon}
+								onChange={e => setHorizon(Number(e.target.value) as PredictionHorizonValue)}
+							>
+								<option value={PredictionHorizon.Short}>5영업일</option>
+								<option value={PredictionHorizon.Long}>20영업일</option>
+							</select>
+						</div>
+
+						<div className='stock-write__field'>
+							<label htmlFor='stock-write-target'>목표가 (선택)</label>
+							<input
+								id='stock-write-target'
+								type='number'
+								min='0'
+								value={targetPrice}
+								onChange={e => setTargetPrice(e.target.value)}
+								placeholder='예: 75000'
+							/>
+						</div>
+					</div>
+				)}
+
+				<p className='stock-write__hint'>
+					예측 기준가는 작성 시점 최신 종가(T+1)로 스냅샷되며, 삭제해도 채점 기록은 유지됩니다.
+				</p>
+			</div>
+
+			{error && <p className='stock-write__error' role='alert'>{error}</p>}
+
+			<div className='stock-write__actions'>
+				<button type='button' className='stock-write__btn' onClick={() => onDone?.()} disabled={submitting}>
+					취소
+				</button>
+				<button type='submit' className='stock-write__btn stock-write__btn--primary' disabled={submitting}>
+					{submitting ? '등록 중...' : '등록'}
+				</button>
+			</div>
+		</form>
+	);
+}

+ 45 - 1
app/(main)/stock/[code]/page.tsx

@@ -5,8 +5,12 @@ import { Metadata } from 'next';
 import Link from 'next/link';
 import { notFound } from 'next/navigation';
 import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
+import { fetchStockDisclosures } from '@/lib/api/market';
+import { fetchStockBoardPosts } from '@/lib/api/community';
 import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
 import Pager from '../_components/Pager';
+import DisclosureList from '../../market/_components/DisclosureList';
+import StockDiscussion from './_components/StockDiscussion';
 
 const TABS = [
 	{ value: 'price', label: '시세' },
@@ -16,6 +20,7 @@ const TABS = [
 ];
 
 const HISTORY_PER_PAGE = 30;
+const DISCUSSION_PER_PAGE = 20;
 
 type Props = {
 	params: Promise<{
@@ -83,6 +88,12 @@ export default async function StockDetailPage({ params, searchParams }: Props)
 	// 시세 탭만 히스토리 조회 (request-time)
 	const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
 
+	// 공시 탭 — 종목별 DART 공시 조회 (request-time)
+	const disclosures = tab === 'disclosure' ? await fetchStockDisclosures(code, { page }) : null;
+
+	// 토론 탭 — 종목 가상보드 글 목록 (request-time)
+	const discussion = tab === 'discussion' ? await fetchStockBoardPosts(code, { page, perPage: DISCUSSION_PER_PAGE }) : null;
+
 	return (
 		<div id='stock-detail'>
 			{/* 상단 요약 — T+1 데이터임을 명시 */}
@@ -140,7 +151,40 @@ export default async function StockDetailPage({ params, searchParams }: Props)
 			</nav>
 
 			<section className='stock-detail__panel'>
-				{tab !== 'price' ? (
+				{tab === 'disclosure' ? (
+					!disclosures || !disclosures.success || !disclosures.data ? (
+						<div className='stock-detail__error' role='alert'>
+							<strong>공시를 불러오지 못했습니다.</strong>
+							잠시 후 다시 시도해 주세요.
+						</div>
+					) : (
+						<>
+							<DisclosureList rows={disclosures.data.list} />
+							<Pager
+								total={disclosures.data.total}
+								page={page}
+								perPage={HISTORY_PER_PAGE}
+								basePath={`/stock/${detail.code}`}
+								query={{ tab: 'disclosure' }}
+							/>
+						</>
+					)
+				) : tab === 'discussion' ? (
+					!discussion || !discussion.success || !discussion.data ? (
+						<div className='stock-detail__error' role='alert'>
+							<strong>토론을 불러오지 못했습니다.</strong>
+							잠시 후 다시 시도해 주세요.
+						</div>
+					) : (
+						<StockDiscussion
+							code={detail.code}
+							stockName={detail.name}
+							page={page}
+							perPage={DISCUSSION_PER_PAGE}
+							data={discussion.data}
+						/>
+					)
+				) : tab !== 'price' ? (
 					<p className='stock-detail__placeholder'>준비 중입니다.</p>
 				) : !history || !history.success || !history.data ? (
 					<div className='stock-detail__error' role='alert'>

+ 17 - 42
app/(main)/stock/page.tsx

@@ -3,8 +3,11 @@ import '@/app/styles/stock-common.scss';
 import { Metadata } from 'next';
 import Link from 'next/link';
 import { fetchStockList } from '@/lib/api/stock';
+import { fetchTrendingStocks } from '@/lib/api/community';
 import { StockMarketName } from '@/types/stock';
-import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
+import { formatNumber } from '@/lib/utils/stock';
+import StockDataTable from '@/components/table/StockRow';
+import TrendingStocks from '@/components/community/TrendingStocks';
 import Pager from './_components/Pager';
 
 export const metadata: Metadata = {
@@ -40,6 +43,10 @@ export default async function StockListPage({ searchParams }: Props)
 	const res = await fetchStockList({ q, market, page, perPage: PER_PAGE });
 	const data = res.success ? res.data : null;
 
+	// 트렌딩 위젯 — 검색·필터 없는 1페이지에서만 노출 (실패해도 페이지는 렌더)
+	const trending = !q && !market && page === 1 ? await fetchTrendingStocks({ count: 10 }) : null;
+	const trendingRows = trending?.success ? (trending.data?.list ?? []) : [];
+
 	const buildHref = (nextMarket: ''|StockMarketName) => {
 		const params = new URLSearchParams();
 		if (nextMarket) {
@@ -59,6 +66,12 @@ export default async function StockListPage({ searchParams }: Props)
 				<span className='stock__basis'>전일 종가 기준 (T+1)</span>
 			</div>
 
+			{trendingRows.length > 0 && (
+				<section className='stock__trending' aria-label='지금 뜨는 종목'>
+					<TrendingStocks rows={trendingRows} limit={10} />
+				</section>
+			)}
+
 			<div className='stock__controls'>
 				{/* 시장 탭 */}
 				<nav className='stock__tabs' aria-label='시장 구분'>
@@ -101,46 +114,8 @@ export default async function StockListPage({ searchParams }: Props)
 				<>
 					<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>
+					{/* D6 §5.1 — 재사용 DataTable(StockRow) 로 렌더 */}
+					<StockDataTable rows={data.list} />
 
 					<Pager
 						total={data.total}
@@ -153,4 +128,4 @@ export default async function StockListPage({ searchParams }: Props)
 			)}
 		</div>
 	);
-}
+}

+ 12 - 0
app/api/market/[...path]/route.ts

@@ -0,0 +1,12 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+// 시장 데이터 프록시 — 전부 익명 GET (지수/ETP/채권/파생/상품/ESG/환율/금리/공시)
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/market/${path.join('/')}`;
+	const url = new URL(request.url);
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}

+ 211 - 0
lib/api/market.ts

@@ -0,0 +1,211 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import {
+	MarketIndicesResponse,
+	EtpResponse,
+	EtpTypeName,
+	BondsResponse,
+	BondMarketName,
+	FuturesResponse,
+	FuturesKindName,
+	OptionsResponse,
+	OptionsKindName,
+	CommoditiesResponse,
+	CommodityMarketName,
+	EsgSecuritiesResponse,
+	EsgIndicesResponse,
+	SriBondsResponse,
+	ExchangeRatesResponse,
+	InterestRatesResponse,
+	RateTypeName,
+	DisclosuresResponse
+} from '@/types/market';
+import { fetchJson } from '@/lib/utils/server';
+
+// 공통 헤더 (익명 · JSON)
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 지수 요약 (KOSPI/KOSDAQ/KRX/채권/파생) — 최신 1건
+export async function fetchMarketIndices(): Promise<ResultDto<MarketIndicesResponse>>
+{
+	return await fetchJson<MarketIndicesResponse>('/api/market/indices', {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// ETP (ETF/ETN/ELW) — type 은 enum 멤버명 문자열로 전송 (ASP.NET model binding)
+export async function fetchMarketEtp(params: { type?: EtpTypeName; page?: number }): Promise<ResultDto<EtpResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	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
+	});
+}
+
+// 채권 (국채전문/일반/소액)
+export async function fetchMarketBonds(params: { market?: BondMarketName; date?: string; page?: number }): Promise<ResultDto<BondsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.market) queryParams.set('market', params.market);
+	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
+	});
+}
+
+// 선물 (일반/주식선물)
+export async function fetchMarketFutures(params: { kind?: FuturesKindName; date?: string; page?: number }): Promise<ResultDto<FuturesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.kind) queryParams.set('kind', params.kind);
+	if (params.date) queryParams.set('date', params.date);
+	queryParams.set('page', String(params.page ?? 1));
+
+	return await fetchJson<FuturesResponse>(`/api/market/futures?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 옵션 (일반/주식옵션)
+export async function fetchMarketOptions(params: { kind?: OptionsKindName; date?: string; page?: number }): Promise<ResultDto<OptionsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.kind) queryParams.set('kind', params.kind);
+	if (params.date) queryParams.set('date', params.date);
+	queryParams.set('page', String(params.page ?? 1));
+
+	return await fetchJson<OptionsResponse>(`/api/market/options?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 상품 (석유/금/배출권)
+export async function fetchMarketCommodities(params: { market?: CommodityMarketName; date?: string; page?: number }): Promise<ResultDto<CommoditiesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.market) queryParams.set('market', params.market);
+	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
+	});
+}
+
+// ESG 증권
+export async function fetchMarketEsgSecurities(params: { date?: string; page?: number }): Promise<ResultDto<EsgSecuritiesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	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
+	});
+}
+
+// ESG 지수
+export async function fetchMarketEsgIndices(params: { date?: string; page?: number }): Promise<ResultDto<EsgIndicesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	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
+	});
+}
+
+// SRI 채권 (사회책임투자채권)
+export async function fetchMarketSriBonds(params: { date?: string; bondType?: string; page?: number }): Promise<ResultDto<SriBondsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.date) queryParams.set('date', params.date);
+	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
+	});
+}
+
+// 환율 (매매기준율)
+export async function fetchMarketExchangeRates(params: { date?: string }): Promise<ResultDto<ExchangeRatesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.date) queryParams.set('date', params.date);
+
+	const qs = queryParams.toString();
+
+	return await fetchJson<ExchangeRatesResponse>(`/api/market/exchange-rates${qs ? `?${qs}` : ''}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 금리 (대출/국제)
+export async function fetchMarketInterestRates(params: { type?: RateTypeName; date?: string }): Promise<ResultDto<InterestRatesResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	if (params.type) queryParams.set('type', params.type);
+	if (params.date) queryParams.set('date', params.date);
+
+	const qs = queryParams.toString();
+
+	return await fetchJson<InterestRatesResponse>(`/api/market/interest-rates${qs ? `?${qs}` : ''}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 공시 피드 (전체 — corpCls: Y/K/N/E 필터, 페이징). 경로는 /api/disclosures (market 하위 아님)
+export async function fetchDisclosures(params: { corpCls?: string; page?: number }): Promise<ResultDto<DisclosuresResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	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
+	});
+}
+
+// 종목별 공시 (6자리 코드) — /api/stocks/{code}/disclosures
+export async function fetchStockDisclosures(code: string, params: { page?: number }): Promise<ResultDto<DisclosuresResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+
+	return await fetchJson<DisclosuresResponse>(`/api/stocks/${encodeURIComponent(code)}/disclosures?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 41 - 0
lib/utils/market.ts

@@ -0,0 +1,41 @@
+// 시장 데이터 표시용 순수 유틸 (서버·클라이언트 공용 — 'use client'/'use server' 미선언)
+
+// 소수 포함 숫자 (지수·환율·금리 등) — 천 단위 구분 + 소수 자릿수 지정. null 은 —
+export function formatDecimal(value: number|null|undefined, fractionDigits = 2): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return value.toLocaleString('ko-KR', {
+		minimumFractionDigits: fractionDigits,
+		maximumFractionDigits: fractionDigits
+	});
+}
+
+// bp(만분율) → % 문자열 (부호 없이 절대값, 색·부호는 마크업에서 병행). 예: 1234 → "12.34%"
+export function formatBpRate(bp: number|null|undefined): string {
+	if (bp === null || bp === undefined) {
+		return '—';
+	}
+
+	return `${(Math.abs(bp) / 100).toFixed(2)}%`;
+}
+
+// 금리 % (미고시 null → —). 예: 3.5 → "3.50%"
+export function formatRatePercent(value: number|null|undefined, fractionDigits = 2): string {
+	if (value === null || value === undefined) {
+		return '—';
+	}
+
+	return `${value.toFixed(fractionDigits)}%`;
+}
+
+// 최신 기준일 라벨 (tradeDate null 이면 대체 문구)
+export function tradeDateLabel(date: string|null|undefined): string {
+	return date ? `${date} 기준` : '데이터 준비 중';
+}
+
+// 공시 접수번호 → DART 원문 링크
+export function dartViewerUrl(rceptNo: string): string {
+	return `https://dart.fss.or.kr/dsaf001/main.do?rcpNo=${encodeURIComponent(rceptNo)}`;
+}

+ 356 - 0
types/market.ts

@@ -0,0 +1,356 @@
+// 시장 데이터 도메인 타입 — Backend Application/Features/Api/Stocks/{GetIndices,GetEtp,...}/Response.cs 대응
+// 직렬화 주의: 필드명은 camelCase. enum 은 Web.Api Program.cs 에 JsonStringEnumConverter 미등록 →
+//    Response DTO 에서 enum 은 숫자(정수)로 직렬화됨 (Paper 의 문자열 enum 과 다름).
+//    쿼리스트링으로 보낼 때는 ASP.NET model binding 이 enum 멤버명 문자열도 허용하므로 이름으로 전송한다.
+
+// ── enum (응답 = 숫자, 요청 = 멤버명 문자열) ──
+
+// 지수 계열 (MarketIndexSeries)
+export const MarketIndexSeries = {
+	KOSPI: 1,
+	KOSDAQ: 2,
+	KRX: 3,
+	Bond: 4,
+	Derivative: 5
+} as const;
+export type MarketIndexSeriesValue = (typeof MarketIndexSeries)[keyof typeof MarketIndexSeries];
+
+export const MARKET_INDEX_SERIES_LABEL: Record<MarketIndexSeriesValue, string> = {
+	[MarketIndexSeries.KOSPI]: 'KOSPI',
+	[MarketIndexSeries.KOSDAQ]: 'KOSDAQ',
+	[MarketIndexSeries.KRX]: 'KRX',
+	[MarketIndexSeries.Bond]: '채권',
+	[MarketIndexSeries.Derivative]: '파생'
+};
+
+// ETP 종류 (EtpType) — 요청 파라미터명 'type'
+export const EtpType = {
+	ETF: 1,
+	ETN: 2,
+	ELW: 3
+} as const;
+export type EtpTypeValue = (typeof EtpType)[keyof typeof EtpType];
+export type EtpTypeName = keyof typeof EtpType;
+
+export const ETP_TYPE_LABEL: Record<EtpTypeValue, string> = {
+	[EtpType.ETF]: 'ETF',
+	[EtpType.ETN]: 'ETN',
+	[EtpType.ELW]: 'ELW'
+};
+
+// 채권 시장 (BondMarket) — 요청 파라미터명 'market'
+export const BondMarket = {
+	KtsGovt: 1,
+	General: 2,
+	Small: 3
+} as const;
+export type BondMarketValue = (typeof BondMarket)[keyof typeof BondMarket];
+export type BondMarketName = keyof typeof BondMarket;
+
+export const BOND_MARKET_LABEL: Record<BondMarketValue, string> = {
+	[BondMarket.KtsGovt]: '국채전문',
+	[BondMarket.General]: '일반채권',
+	[BondMarket.Small]: '소액채권'
+};
+
+// 선물 종류 (FuturesKind) — 요청 파라미터명 'kind'
+export const FuturesKind = {
+	General: 1,
+	StockKospi: 2,
+	StockKosdaq: 3
+} as const;
+export type FuturesKindValue = (typeof FuturesKind)[keyof typeof FuturesKind];
+export type FuturesKindName = keyof typeof FuturesKind;
+
+export const FUTURES_KIND_LABEL: Record<FuturesKindValue, string> = {
+	[FuturesKind.General]: '일반선물',
+	[FuturesKind.StockKospi]: '주식선물(코스피)',
+	[FuturesKind.StockKosdaq]: '주식선물(코스닥)'
+};
+
+// 옵션 종류 (OptionsKind) — 요청 파라미터명 'kind'
+export const OptionsKind = {
+	General: 1,
+	StockKospi: 2,
+	StockKosdaq: 3
+} as const;
+export type OptionsKindValue = (typeof OptionsKind)[keyof typeof OptionsKind];
+export type OptionsKindName = keyof typeof OptionsKind;
+
+export const OPTIONS_KIND_LABEL: Record<OptionsKindValue, string> = {
+	[OptionsKind.General]: '일반옵션',
+	[OptionsKind.StockKospi]: '주식옵션(코스피)',
+	[OptionsKind.StockKosdaq]: '주식옵션(코스닥)'
+};
+
+// 옵션 권리유형 (OptionRightType)
+export const OptionRightType = {
+	Call: 1,
+	Put: 2
+} as const;
+export type OptionRightTypeValue = (typeof OptionRightType)[keyof typeof OptionRightType];
+
+export const OPTION_RIGHT_TYPE_LABEL: Record<OptionRightTypeValue, string> = {
+	[OptionRightType.Call]: '콜',
+	[OptionRightType.Put]: '풋'
+};
+
+// 상품(파생상품시장) 시장 (CommodityMarket) — 요청 파라미터명 'market'
+export const CommodityMarket = {
+	Oil: 1,
+	Gold: 2,
+	Emission: 3
+} as const;
+export type CommodityMarketValue = (typeof CommodityMarket)[keyof typeof CommodityMarket];
+export type CommodityMarketName = keyof typeof CommodityMarket;
+
+export const COMMODITY_MARKET_LABEL: Record<CommodityMarketValue, string> = {
+	[CommodityMarket.Oil]: '석유',
+	[CommodityMarket.Gold]: '금',
+	[CommodityMarket.Emission]: '배출권'
+};
+
+// 금리 유형 (RateType) — 요청 파라미터명 'type'
+export const RateType = {
+	Loan: 1,
+	International: 2
+} as const;
+export type RateTypeValue = (typeof RateType)[keyof typeof RateType];
+export type RateTypeName = keyof typeof RateType;
+
+export const RATE_TYPE_LABEL: Record<RateTypeValue, string> = {
+	[RateType.Loan]: '대출금리',
+	[RateType.International]: '국제금리'
+};
+
+// 공시 법인구분 (corpCls — 문자열 Y/K/N/E)
+export type CorpClsCode = 'Y'|'K'|'N'|'E';
+
+export const CORP_CLS_LABEL: Record<CorpClsCode, string> = {
+	Y: '유가',
+	K: '코스닥',
+	N: '코넥스',
+	E: '기타'
+};
+
+// ── GET /api/market/indices ──
+export interface MarketIndexRow {
+	series: MarketIndexSeriesValue;
+	name: string;
+	close: number;
+	changeVal: number;
+	// 등락률 basis point (%×100)
+	flucRateBp: number;
+	tradeDate: string;
+}
+
+export interface MarketIndicesResponse {
+	list: MarketIndexRow[];
+}
+
+// ── GET /api/market/etp ──
+export interface EtpRow {
+	type: EtpTypeValue;
+	code: string;
+	name: string;
+	close: number;
+	changeAmount: number;
+	changeRate: number;
+	volume: number;
+	tradeValue: number;
+	marketCap: number|null;
+	nav: number|null;
+	baseIndexName: string|null;
+	underlying: string|null;
+}
+
+export interface EtpResponse {
+	total: number;
+	tradeDate: string|null;
+	list: EtpRow[];
+}
+
+// ── GET /api/market/bonds ──
+export interface BondRow {
+	market: BondMarketValue;
+	code: string;
+	name: string;
+	close: number;
+	changeAmount: number;
+	yieldToMaturity: number|null;
+	volume: number;
+	tradeValue: number;
+	maturityYears: string|null;
+	issueType: string|null;
+}
+
+export interface BondsResponse {
+	total: number;
+	tradeDate: string|null;
+	list: BondRow[];
+}
+
+// ── GET /api/market/futures ──
+export interface FuturesRow {
+	kind: FuturesKindValue;
+	isuCode: string;
+	isuName: string;
+	close: number;
+	changeAmount: number;
+	settlePrice: number|null;
+	volume: number;
+	tradeValue: number;
+	openInterest: number|null;
+	productName: string|null;
+}
+
+export interface FuturesResponse {
+	total: number;
+	tradeDate: string|null;
+	list: FuturesRow[];
+}
+
+// ── GET /api/market/options ──
+export interface OptionsRow {
+	kind: OptionsKindValue;
+	isuCode: string;
+	isuName: string;
+	rightType: OptionRightTypeValue;
+	strikePrice: number|null;
+	close: number;
+	changeAmount: number;
+	impliedVolatility: number|null;
+	volume: number;
+	tradeValue: number;
+	openInterest: number|null;
+	productName: string|null;
+}
+
+export interface OptionsResponse {
+	total: number;
+	tradeDate: string|null;
+	list: OptionsRow[];
+}
+
+// ── GET /api/market/commodities ──
+export interface CommodityRow {
+	market: CommodityMarketValue;
+	code: string;
+	name: string;
+	close: number;
+	changeAmount: number|null;
+	changeRate: number|null;
+	weightedDiscussionPrice: number|null;
+	volume: number;
+	tradeValue: number;
+}
+
+export interface CommoditiesResponse {
+	total: number;
+	tradeDate: string|null;
+	list: CommodityRow[];
+}
+
+// ── GET /api/market/esg/securities ──
+export interface EsgSecurityRow {
+	name: string;
+	close: number;
+	changeAmount: number;
+	changeRate: number;
+	listedShares: number;
+	volume: number;
+	tradeValue: number;
+}
+
+export interface EsgSecuritiesResponse {
+	total: number;
+	tradeDate: string|null;
+	list: EsgSecurityRow[];
+}
+
+// ── GET /api/market/esg/indices ──
+export interface EsgIndexRow {
+	name: string;
+	close: number;
+	changeVal: number;
+	// 등락률 basis point (%×100)
+	flucRateBp: number;
+	constituentCount: number;
+	volume: number;
+	value: number;
+}
+
+export interface EsgIndicesResponse {
+	total: number;
+	tradeDate: string|null;
+	list: EsgIndexRow[];
+}
+
+// ── GET /api/market/esg/sri-bonds ──
+export interface SriBondRow {
+	code: string;
+	issuerName: string;
+	sriBondType: string;
+	name: string;
+	listDate: string|null;
+	issueDate: string|null;
+	redemptionDate: string|null;
+	couponRate: number|null;
+	issueAmount: number;
+	listAmount: number;
+	bondType: string|null;
+}
+
+export interface SriBondsResponse {
+	total: number;
+	tradeDate: string|null;
+	list: SriBondRow[];
+}
+
+// ── GET /api/market/exchange-rates ──
+export interface ExchangeRateRow {
+	currencyUnit: string;
+	currencyName: string;
+	dealBasRate: number;
+	// 전신환 받으실 때
+	ttb: number|null;
+	// 전신환 보내실 때
+	tts: number|null;
+}
+
+export interface ExchangeRatesResponse {
+	total: number;
+	tradeDate: string|null;
+	list: ExchangeRateRow[];
+}
+
+// ── GET /api/market/interest-rates ──
+export interface InterestRateRow {
+	itemName: string;
+	rate: number|null;
+}
+
+export interface InterestRatesResponse {
+	type: RateTypeValue;
+	total: number;
+	tradeDate: string|null;
+	list: InterestRateRow[];
+}
+
+// ── GET /api/disclosures · GET /api/stocks/{code}/disclosures ──
+export interface DisclosureRow {
+	rceptNo: string;
+	corpCode: string;
+	corpName: string;
+	stockCode?: string|null;
+	corpCls: string;
+	reportNm: string;
+	flrNm: string;
+	rceptDt: string;
+	rm: string|null;
+}
+
+export interface DisclosuresResponse {
+	total: number;
+	list: DisclosureRow[];
+}