Parcourir la source

feat: dev cutover to production domains

- Remove dev- domain prefix (next.config image hosts, uploads route, Jenkinsfile, deploy README/env template)
- includes pending market/chat/paper/nav working-tree changes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 il y a 2 semaines
Parent
commit
1578631f38
46 fichiers modifiés avec 1784 ajouts et 312 suppressions
  1. 1 1
      Jenkinsfile
  2. 8 14
      app/(main)/_components/DomesticSummary.tsx
  3. 86 0
      app/(main)/_components/MarketMovers.tsx
  4. 43 0
      app/(main)/_components/Sparkline.tsx
  5. 4 14
      app/(main)/_components/WorldMarketMap.tsx
  6. 9 32
      app/(main)/_components/domestic-summary.scss
  7. 1 1
      app/(main)/_components/home.scss
  8. 152 0
      app/(main)/_components/market-movers.scss
  9. 30 0
      app/(main)/_components/sparkline.scss
  10. 34 37
      app/(main)/_components/world-map.scss
  11. 215 53
      app/(main)/chat/MainChat.tsx
  12. 53 0
      app/(main)/chat/_component/ChatParticipants.tsx
  13. 134 0
      app/(main)/chat/_component/ChatReceiveModal.tsx
  14. 89 0
      app/(main)/chat/_component/ChatSettingsMenu.tsx
  15. 492 84
      app/(main)/chat/style.scss
  16. 19 2
      app/(main)/page.tsx
  17. 36 0
      app/(main)/paper/_components/PaperAuthGate.tsx
  18. 4 1
      app/(main)/paper/page.tsx
  19. 4 1
      app/(main)/paper/portfolio/page.tsx
  20. 4 1
      app/(main)/paper/trade/page.tsx
  21. 67 0
      app/(main)/stock/[code]/_components/StockPriceChart.tsx
  22. 32 0
      app/(main)/stock/[code]/_components/price-chart.scss
  23. 4 0
      app/(main)/stock/[code]/page.tsx
  24. 13 0
      app/(main)/stock/[code]/style.scss
  25. 17 5
      app/(main)/stock/page.tsx
  26. 1 1
      app/api/uploads/[...path]/route.ts
  27. 15 0
      app/chat-popup/page.tsx
  28. 10 6
      app/component/Header.tsx
  29. 5 2
      app/layout.tsx
  30. 5 3
      app/styles/chat-dock.scss
  31. 4 5
      app/styles/layout.module.scss
  32. 2 0
      app/styles/tokens/_colors.scss
  33. 54 8
      components/table/StockRow.tsx
  34. 11 9
      deploy/config-templates/README.md
  35. 1 1
      deploy/winsw/antooza-frontend.xml
  36. 22 0
      lib/api/chatSettings.ts
  37. 16 0
      lib/api/marketMovers.ts
  38. 3 1
      lib/api/stock.ts
  39. 9 1
      lib/api/system.ts
  40. 0 10
      next.config.ts
  41. 16 0
      types/chat.ts
  42. 6 0
      types/config.ts
  43. 4 18
      types/domesticSummary.ts
  44. 41 0
      types/marketMover.ts
  45. 2 1
      types/marketQuote.ts
  46. 6 0
      types/stock.ts

+ 1 - 1
Jenkinsfile

@@ -49,7 +49,7 @@ pipeline {
       steps {
         script {
           // Backend 패턴과 동일: 브랜치별로 Jenkins Secret File 주입
-          //   dev  → DEV-ANTOOZA-FRONTEND-ENV  (dev-api.antooza.com)
+          //   dev  → DEV-ANTOOZA-FRONTEND-ENV  (api.antooza.com — dev 서버 운영 도메인)
           //   main → PROD-ANTOOZA-FRONTEND-ENV (api.antooza.com)
           // .env.production 은 .gitignore 됨 (Backend 의 appsettings.Production.json 처럼 빌드 시점에만 존재).
           def credId = (env.BRANCH_NAME == 'main') ? 'PROD-ANTOOZA-FRONTEND-ENV' : 'DEV-ANTOOZA-FRONTEND-ENV'

+ 8 - 14
app/(main)/_components/DomesticSummary.tsx

@@ -1,9 +1,10 @@
 import './domestic-summary.scss';
 import { moveDir, formatFlucRate, formatIndexClose } from '@/types/worldIndex';
-import { type DomesticIndexRow, formatIndexChange, formatCount } from '@/types/domesticSummary';
+import { type DomesticIndexRow, formatIndexChange } from '@/types/domesticSummary';
+import Sparkline from './Sparkline';
 
-// 국내 증시 요약 — 코스피/코스닥/KOSPI200 카드. 지수값·전일비·등락률 + 등락종목수(코스피/코스닥만).
-// (투자자별 순매수·베이시스는 미지원으로 제외)
+// 국내·글로벌 증시 요약 — 코스피/코스닥/KOSPI200 + S&P500/나스닥 카드.
+// 지수값·전일비·등락률 + 카드 하단 스파크라인. 등락색 --price-up/down(상승 적/하락 청).
 type Props = {
 	rows: DomesticIndexRow[];
 };
@@ -17,11 +18,10 @@ export default function DomesticSummary({ rows }: Props)
 	}
 
 	return (
-		<section className='dsum' aria-label='국내 증시 요약'>
+		<section className='dsum' aria-label='국내·글로벌 증시 요약'>
 			<div className='dsum__grid'>
 				{rows.map((row) => {
 					const dir = moveDir(row.flucRateBp);
-					const hasBreadth = row.advances !== null && row.advances !== undefined;
 					return (
 						<div key={row.key} className='dsum__card'>
 							<div className='dsum__name'>{row.name}</div>
@@ -35,15 +35,9 @@ export default function DomesticSummary({ rows }: Props)
 								</span>
 							</div>
 
-							{hasBreadth && (
-								<div className='dsum__breadth'>
-									<span className='dsum__b'>상한 <em className='dsum__b--up'>{formatCount(row.limitUp)}</em></span>
-									<span className='dsum__b'>상승 <em className='dsum__b--up'>{formatCount(row.advances)}</em></span>
-									<span className='dsum__b'>보합 <em>{formatCount(row.unchanged)}</em></span>
-									<span className='dsum__b'>하락 <em className='dsum__b--down'>{formatCount(row.declines)}</em></span>
-									<span className='dsum__b'>하한 <em className='dsum__b--down'>{formatCount(row.limitDown)}</em></span>
-								</div>
-							)}
+							<div className='dsum__spark'>
+								<Sparkline data={row.spark} dir={dir} />
+							</div>
 						</div>
 					);
 				})}

+ 86 - 0
app/(main)/_components/MarketMovers.tsx

@@ -0,0 +1,86 @@
+import './market-movers.scss';
+import { moveDir, formatFlucRate } from '@/types/worldIndex';
+import { type MarketMoversResponse, type MarketMoverGroup, MARKET_LABEL, formatStockPrice, formatMoverCount } from '@/types/marketMover';
+import Sparkline from './Sparkline';
+
+// 등락 상위 종목 — 상승/하락/상한/하한 4그룹 각 상위 5. 종목명·시장·코드 + 미니 스파크라인 + 현재가·등락률.
+// 상승/하락은 상한·하한 제외(비중복). 등락색 --price-up/down(상승 적/하락 청).
+type Props = {
+	data: MarketMoversResponse;
+};
+
+const GROUPS: { key: 'advances'|'declines'|'limitUp'|'limitDown'; label: string; tone: 'up'|'down' }[] = [
+	{ key: 'advances', label: '상승', tone: 'up' },
+	{ key: 'declines', label: '하락', tone: 'down' },
+	{ key: 'limitUp', label: '상한', tone: 'up' },
+	{ key: 'limitDown', label: '하한', tone: 'down' }
+];
+
+const ARROW = { up: '▲', down: '▼', flat: '–' } as const;
+
+export default function MarketMovers({ data }: Props)
+{
+	const hasAny = data.advances.items.length > 0
+		|| data.declines.items.length > 0
+		|| data.limitUp.items.length > 0
+		|| data.limitDown.items.length > 0;
+
+	if (!hasAny) {
+		return null;
+	}
+
+	return (
+		<section className='mov' aria-label='등락 상위 종목'>
+			<div className='mov__head'>
+				<h2 className='mov__title'>등락 종목</h2>
+				{data.tradeDate && <span className='mov__asof'>{data.tradeDate} 기준</span>}
+			</div>
+
+			<div className='mov__grid'>
+				{GROUPS.map((g) => (
+					<GroupPanel key={g.key} label={g.label} tone={g.tone} group={data[g.key]} />
+				))}
+			</div>
+		</section>
+	);
+}
+
+function GroupPanel({ label, tone, group }: { label: string; tone: 'up'|'down'; group: MarketMoverGroup })
+{
+	return (
+		<div className='mov__panel'>
+			<div className={`mov__panel-head mov__panel-head--${tone}`}>
+				<span className='mov__panel-label'>{label}</span>
+				<span className='mov__panel-count'>{formatMoverCount(group.total)}</span>
+			</div>
+
+			{group.items.length === 0 ? (
+				<p className='mov__empty'>해당 종목 없음</p>
+			) : (
+				<ul className='mov__list'>
+					{group.items.map((item) => {
+						const dir = moveDir(item.flucRateBp);
+						return (
+							<li key={item.code} className='mov__row'>
+								<div className='mov__meta'>
+									<span className='mov__name'>{item.name}</span>
+									<span className='mov__sub'>{MARKET_LABEL[item.market] ?? ''} · {item.code}</span>
+								</div>
+								<div className='mov__spark'>
+									<Sparkline data={item.spark} dir={dir} />
+								</div>
+								<div className='mov__nums'>
+									<span className='mov__price'>{formatStockPrice(item.close)}</span>
+									<span className={`mov__rate mov__rate--${dir}`}>
+										<i className='mov__arrow' aria-hidden='true'>{ARROW[dir]}</i>
+										{formatFlucRate(item.flucRateBp)}
+									</span>
+								</div>
+							</li>
+						);
+					})}
+				</ul>
+			)}
+		</div>
+	);
+}

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

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

+ 4 - 14
app/(main)/_components/WorldMarketMap.tsx

@@ -381,7 +381,6 @@ export default function WorldMarketMap({ rows }: Props)
 									<th scope='col' className='world-map__c-num'>전일비</th>
 									<th scope='col' className='world-map__c-num'>등락률</th>
 									<th scope='col' className='world-map__c-time'>시간</th>
-									<th scope='col' className='world-map__c-chart'>차트</th>
 								</tr>
 							</thead>
 							<tbody>
@@ -393,8 +392,12 @@ export default function WorldMarketMap({ rows }: Props)
 										<tr
 											key={row.countryCode}
 											className={`world-map__row${isActive ? ' is-active' : ''}`}
+											tabIndex={0}
+											aria-label={TV_SYMBOL[row.countryCode] ? `${geo.label} ${row.name} 차트 보기` : `${geo.label} ${row.name} 지도에서 보기`}
 											onMouseEnter={() => setActive(row.countryCode)}
 											onMouseLeave={() => setActive((cur) => (cur === row.countryCode ? null : cur))}
+											onClick={() => { if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } else { setActive(row.countryCode); } }}
+											onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } else { setActive(row.countryCode); } } }}
 										>
 											<td className='world-map__c-name'>
 												<span className='world-map__row-country'>{geo.label}</span>
@@ -404,19 +407,6 @@ export default function WorldMarketMap({ rows }: Props)
 											<td className={`world-map__c-num world-map__c-${dir}`}>{formatChangeVal(row.changeVal)}</td>
 											<td className={`world-map__c-num world-map__c-${dir}`}>{formatFlucRate(row.flucRateBp)}</td>
 											<td className='world-map__c-time'>{row.tradeDate}</td>
-											<td className='world-map__c-chart'>
-												<button
-													type='button'
-													className='world-map__chart-btn'
-													title={TV_SYMBOL[row.countryCode] ? `${row.name} 차트 보기` : `${geo.label} 위치 보기`}
-													aria-label={TV_SYMBOL[row.countryCode] ? `${geo.label} ${row.name} 차트 보기` : `${geo.label} ${row.name} 지도에서 보기`}
-													onClick={() => { if (TV_SYMBOL[row.countryCode]) { setChartRow(row); } else { setActive(row.countryCode); } }}
-												>
-													<svg viewBox='0 0 24 24' width='16' height='16' aria-hidden='true'>
-														<polyline points='3,17 9,11 13,15 21,6' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
-													</svg>
-												</button>
-											</td>
 										</tr>
 									);
 								})}

+ 9 - 32
app/(main)/_components/domestic-summary.scss

@@ -1,6 +1,6 @@
 // ─────────────────────────────────────────────────────────────
-// 국내 증시 요약 (DomesticSummary) — 코스피/코스닥/KOSPI200 카드 3
-// 지수값·전일비·등락률 + 등락종목수(코스피/코스닥). 등락색 --price-up/down(상승 적/하락 청).
+// 국내·글로벌 증시 요약 (DomesticSummary) — 코스피/코스닥/KOSPI200 + S&P500/나스닥 카드 5
+// 지수값·전일비·등락률 + 카드 하단 스파크라인. 등락색 --price-up/down(상승 적/하락 청).
 // ─────────────────────────────────────────────────────────────
 
 .dsum {
@@ -28,9 +28,9 @@
 
 	&__price {
 		display: flex;
-		align-items: baseline;
-		flex-wrap: wrap;
-		gap: var(--sp-3);
+		flex-direction: column;
+		align-items: flex-start;
+		gap: var(--sp-2);
 	}
 
 	&__value {
@@ -78,34 +78,11 @@
 		font-style: normal;
 	}
 
-	// 등락 종목 수
-	&__breadth {
-		display: flex;
-		flex-wrap: wrap;
-		gap: var(--sp-3);
+	// 카드 하단 스파크라인
+	&__spark {
+		height: 48px;
 		margin-top: var(--sp-4);
-		padding-top: var(--sp-4);
+		padding-top: var(--sp-3);
 		border-top: 1px solid var(--border-light);
-		font-size: var(--fs-xs);
-		color: var(--text-muted);
-		font-variant-numeric: tabular-nums;
-
-		em {
-			font-style: normal;
-			font-weight: 700;
-			color: var(--text-secondary);
-		}
-
-		.dsum__b--up {
-			color: var(--price-up);
-		}
-
-		.dsum__b--down {
-			color: var(--price-down);
-		}
-	}
-
-	&__b {
-		white-space: nowrap;
 	}
 }

+ 1 - 1
app/(main)/_components/home.scss

@@ -19,7 +19,7 @@
 
 	&__inner {
 		width: 100%;
-		max-width: 2560px;
+		max-width: 1600px;
 		margin: 0 auto;
 		display: flex;
 		flex-direction: column;

+ 152 - 0
app/(main)/_components/market-movers.scss

@@ -0,0 +1,152 @@
+// ─────────────────────────────────────────────────────────────
+// 등락 상위 종목 (MarketMovers) — 상승/하락/상한/하한 4그룹 패널, 각 상위 5.
+// 행: 종목명·시장·코드 | 스파크라인 | 현재가·등락률. 등락색 --price-up/down(상승 적/하락 청).
+// ─────────────────────────────────────────────────────────────
+
+.mov {
+	margin-bottom: var(--sp-6);
+
+	&__head {
+		display: flex;
+		align-items: baseline;
+		gap: var(--sp-3);
+		margin-bottom: var(--sp-4);
+	}
+
+	&__title {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__asof {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__grid {
+		display: grid;
+		grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+		gap: var(--sp-4);
+	}
+
+	&__panel {
+		overflow: hidden;
+	}
+
+	&__panel-head {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		padding: var(--sp-3) var(--sp-4);
+		border-bottom: 1px solid var(--border-light);
+
+		&--up .mov__panel-label {
+			color: var(--price-up);
+		}
+
+		&--down .mov__panel-label {
+			color: var(--price-down);
+		}
+	}
+
+	&__panel-label {
+		font-size: var(--fs-sm);
+		font-weight: 700;
+	}
+
+	&__panel-count {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__empty {
+		padding: var(--sp-5) var(--sp-4);
+		text-align: center;
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+	}
+
+	&__list {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+	}
+
+	&__row {
+		display: grid;
+		grid-template-columns: minmax(0, 1fr) 72px minmax(0, auto);
+		align-items: center;
+		gap: var(--sp-3);
+		padding: var(--sp-3) var(--sp-4);
+
+		& + & {
+			border-top: 1px solid var(--border-light);
+		}
+	}
+
+	&__meta {
+		display: flex;
+		flex-direction: column;
+		min-width: 0;
+	}
+
+	&__name {
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		color: var(--text-primary);
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	&__sub {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		white-space: nowrap;
+	}
+
+	&__spark {
+		height: 32px;
+	}
+
+	&__nums {
+		display: flex;
+		flex-direction: column;
+		align-items: flex-end;
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__price {
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		color: var(--text-primary);
+	}
+
+	&__rate {
+		display: inline-flex;
+		align-items: baseline;
+		gap: 2px;
+		font-size: var(--fs-xs);
+		font-weight: 600;
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+
+		&--flat {
+			color: var(--price-flat);
+		}
+	}
+
+	&__arrow {
+		font-style: normal;
+		font-size: 0.85em;
+	}
+}

+ 30 - 0
app/(main)/_components/sparkline.scss

@@ -0,0 +1,30 @@
+// 미니 스파크라인 — 부모 슬롯 크기를 100% 채움 (카드/그리드 셀에서 크기 지정).
+// 방향색은 color 로 지정하고 recharts path 는 currentColor(stroke/fill)로 상속 → 테마 전환에도 자동 반영.
+.spark {
+	width: 100%;
+	height: 100%;
+
+	&--up {
+		color: var(--price-up);
+	}
+
+	&--down {
+		color: var(--price-down);
+	}
+
+	&--flat {
+		color: var(--price-flat);
+	}
+
+	&--empty {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+
+		&::after {
+			content: '–';
+			color: var(--text-muted);
+			font-size: var(--fs-xs);
+		}
+	}
+}

+ 34 - 37
app/(main)/_components/world-map.scss

@@ -12,10 +12,6 @@
 	--az-map-land-edge: rgba(150, 168, 190, 0.55);
 
 	margin-bottom: var(--sp-6);
-	padding: var(--sp-4);
-	background: var(--bg-elevated);
-	border: 1px solid var(--border-default);
-	border-radius: var(--radius);
 
 	&__head {
 		display: flex;
@@ -23,7 +19,9 @@
 		justify-content: space-between;
 		flex-wrap: wrap;
 		gap: var(--sp-3);
-		margin-bottom: var(--sp-3);
+		margin-bottom: var(--sp-4);
+		padding-bottom: var(--sp-3);
+		border-bottom: 1px solid var(--border-default);
 	}
 
 	&__title {
@@ -70,9 +68,9 @@
 	// ── 레이아웃: 지도(넓게) + 지수 패널(우측). 좁은 화면은 세로 스택 ──
 	&__body {
 		display: grid;
-		grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr);
+		grid-template-columns: minmax(0, 6fr) minmax(300px, 4fr);
 		gap: var(--sp-5);
-		align-items: start;
+		align-items: stretch;
 	}
 
 	@media (max-width: 960px) {
@@ -81,7 +79,7 @@
 		}
 	}
 
-	// ── 지도 무대 ──
+	// ── 지도 무대 ── (7fr 열을 자연 비율 2:1 로 채움. 광폭 오버랩은 home__inner max-width 로 방지)
 	&__stage {
 		position: relative;
 		width: 100%;
@@ -140,7 +138,7 @@
 		font-size: 12px;
 		font-weight: 700;
 		fill: var(--text-primary);
-		stroke: var(--bg-elevated);
+		stroke: var(--bg-page);
 		stroke-width: 3px;
 		paint-order: stroke;
 		vector-effect: non-scaling-stroke;
@@ -150,7 +148,7 @@
 		font-size: 12px;
 		font-weight: 700;
 		font-variant-numeric: tabular-nums;
-		stroke: var(--bg-elevated);
+		stroke: var(--bg-page);
 		stroke-width: 3px;
 		paint-order: stroke;
 		vector-effect: non-scaling-stroke;
@@ -173,7 +171,7 @@
 		font-weight: 600;
 		font-variant-numeric: tabular-nums;
 		fill: var(--text-secondary);
-		stroke: var(--bg-elevated);
+		stroke: var(--bg-page);
 		stroke-width: 3px;
 		paint-order: stroke;
 		vector-effect: non-scaling-stroke;
@@ -298,11 +296,15 @@
 		color: var(--text-muted);
 	}
 
-	// ── 지수 패널 ──
+	// ── 지수 패널 (회색 카드 — 지도는 페이지 배경 위, 배경은 이 패널에만) ──
 	&__panel {
 		display: flex;
 		flex-direction: column;
 		min-width: 0;
+		padding: var(--sp-4);
+		background: var(--bg-elevated);
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
 	}
 
 	// 지역 탭 (언더라인 스타일)
@@ -336,13 +338,31 @@
 		}
 	}
 
-	// 데이터 그리드 (세로 스크롤 — 행 많으면 스크롤)
+	// 데이터 그리드 (세로 스크롤 — 행 많으면 스크롤). 모바일/세로스택 폴백 높이.
 	&__grid-wrap {
 		overflow-x: auto;
 		overflow-y: auto;
 		max-height: 460px;
 	}
 
+	// ── 2열(≥961px): 패널 높이를 지도(aspect-ratio) 높이에 정확히 일치 ──
+	// contain:size → 패널 내용(탭+표)이 grid 행 높이를 부풀리지 않음 → 지도가 행 높이를 결정하고
+	// 패널은 stretch 로 동일 높이. 패널 내부는 grid(탭 auto + 표 1fr)로 표가 남는 높이를 채우며 스크롤.
+	// (전 해상도 연속 적용 — home__inner max-width:1600 로 QHD 에서도 지도 높이 유한)
+	@media (min-width: 961px) {
+		&__panel {
+			display: grid;
+			grid-template-rows: auto minmax(0, 1fr);
+			contain: size;
+			min-height: 0;
+		}
+
+		&__grid-wrap {
+			max-height: none;
+			min-height: 0;
+		}
+	}
+
 	&__grid {
 		width: 100%;
 		border-collapse: collapse;
@@ -371,7 +391,7 @@
 	}
 
 	&__row {
-		cursor: default;
+		cursor: pointer;
 		transition: background 0.1s ease;
 
 		&:hover,
@@ -394,11 +414,6 @@
 		color: var(--text-muted);
 	}
 
-	&__c-chart {
-		text-align: center;
-		width: 1%;
-	}
-
 	&__c-close {
 		color: var(--text-primary);
 		font-weight: 600;
@@ -427,24 +442,6 @@
 		color: var(--text-muted);
 	}
 
-	&__chart-btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		padding: var(--sp-2);
-		background: transparent;
-		border: 1px solid var(--border-default);
-		border-radius: var(--radius);
-		color: var(--text-muted);
-		cursor: pointer;
-		transition: color 0.12s ease, border-color 0.12s ease;
-
-		&:hover {
-			color: var(--text-link);
-			border-color: var(--text-link);
-		}
-	}
-
 	&__empty {
 		padding: var(--sp-6);
 		text-align: center;

+ 215 - 53
app/(main)/chat/MainChat.tsx

@@ -1,13 +1,16 @@
 'use client';
 
-import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
-import Link from 'next/link';
+import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent, type FocusEvent, type ChangeEvent, type ReactNode } from 'react';
 import { HubConnectionState } from '@microsoft/signalr';
 import useAuth from '@/hooks/useAuth';
 import { useSignalRContext } from '@/contexts/signalrProvider';
-import type { ChatMessage } from '@/types/chat';
+import type { ChatMessage, ChatParticipant } from '@/types/chat';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faUsers, faPaperPlane, faBullhorn, faArrowDown } from '@fortawesome/free-solid-svg-icons';
+import { faUsers, faPaperPlane, faBullhorn, faArrowDown, faChevronDown, faBell } from '@fortawesome/free-solid-svg-icons';
+import EmojiPicker from '@/app/component/EmojiPicker';
+import ChatParticipants from './_component/ChatParticipants';
+import ChatSettingsMenu from './_component/ChatSettingsMenu';
+import ChatReceiveModal from './_component/ChatReceiveModal';
 
 const ROOM_KEY = 'main';
 const MAX_MESSAGES = 200; // 최근 200건 유지·오버플로 드랍 (Backend ChatSettings.MaxMessages 와 동일)
@@ -16,6 +19,12 @@ const MAX_CONTENT_LENGTH = 500;
 const GLOBAL_RATE_LIMIT_SECONDS = 2; // Hub 전역 rate limit (ChatSettings.RateLimitSeconds)
 const SCROLL_BOTTOM_THRESHOLD = 50;
 
+// 글씨 크게/작게 — #chat-room 의 --chat-fs 배율(localStorage 영속)
+const FONT_SCALE_MIN = 0.85;
+const FONT_SCALE_MAX = 1.6;
+const FONT_SCALE_STEP = 0.1;
+const FONT_SCALE_KEY = 'az-chat-fs';
+
 type SystemMessage = {
 	id: number;
 	content: string;
@@ -43,11 +52,13 @@ type Props = {
 	slowModeSeconds?: number;
 	// 헤더 우측(.chat-room__meta)에 주입할 컨트롤 — 채팅 도크의 접기 버튼 등 (없으면 미렌더)
 	headerSlot?: ReactNode;
+	// 팝업 창(/chat-popup)에서 렌더 시 true — 전체 창 레이아웃 + "새창으로 보기"/도크 컨트롤 숨김
+	popup?: boolean;
 };
 
-export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot }: Props)
+export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot, popup = false }: Props)
 {
-	const { isAuthenticated } = useAuth();
+	const { isAuthenticated, loginCheck } = useAuth();
 	const { chatConnection, chatConnected } = useSignalRContext();
 
 	const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
@@ -59,12 +70,30 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 	const [cooldownLeft, setCooldownLeft] = useState(0);
 	const [showJump, setShowJump] = useState(false);
 
+	// 참여자 목록 / 설정 UI
+	const [participants, setParticipants] = useState<ChatParticipant[]>([]);
+	const [participantsLoading, setParticipantsLoading] = useState(false);
+	const [showParticipants, setShowParticipants] = useState(false);
+	const [showReceiveModal, setShowReceiveModal] = useState(false);
+	const [fontScale, setFontScale] = useState(1);
+
 	const listRef = useRef<HTMLDivElement>(null);
+	const inputRef = useRef<HTMLInputElement>(null);
 	const autoScrollRef = useRef(true);
 	const systemIdRef = useRef(0);
 	const cooldownEndRef = useRef(0);
 	const activeRef = useRef(true);
 
+	// 글씨 배율 localStorage 복원
+	useEffect(() => {
+		try {
+			const saved = parseFloat(window.localStorage.getItem(FONT_SCALE_KEY) ?? '');
+			if (!Number.isNaN(saved) && saved >= FONT_SCALE_MIN && saved <= FONT_SCALE_MAX) {
+				setFontScale(saved);
+			}
+		} catch {}
+	}, []);
+
 	const pushSystem = useCallback((content: string) => {
 		setSystemMessages((prev) => {
 			const next = [...prev, { id: ++systemIdRef.current, content, receivedAt: new Date().toISOString() }];
@@ -108,6 +137,10 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 		const handleMessage = (message: ChatMessage) => appendMessage(message);
 		const handleSystem = (content: string) => pushSystem(content);
 		const handleCount = (count: number) => setParticipantCount(count);
+		const handleParticipants = (list: ChatParticipant[]) => {
+			setParticipants(list);
+			setParticipantsLoading(false);
+		};
 		const handleKick = () => {
 			setKicked(true);
 			pushSystem('관리자에 의해 채팅 이용이 제한되었습니다.');
@@ -117,6 +150,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 		chatConnection.on('ReceiveMessage', handleMessage);
 		chatConnection.on('ReceiveSystemMessage', handleSystem);
 		chatConnection.on('ReceiveParticipantCount', handleCount);
+		chatConnection.on('ReceiveParticipants', handleParticipants);
 		chatConnection.on('Kick', handleKick);
 
 		// 자동 재연결 상태 UX — signalR 은 이 콜백들의 해제 API 가 없어 activeRef 로 stale 호출 가드
@@ -154,6 +188,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 			chatConnection.off('ReceiveMessage', handleMessage);
 			chatConnection.off('ReceiveSystemMessage', handleSystem);
 			chatConnection.off('ReceiveParticipantCount', handleCount);
+			chatConnection.off('ReceiveParticipants', handleParticipants);
 			chatConnection.off('Kick', handleKick);
 
 			if (chatConnection.state === HubConnectionState.Connected) {
@@ -173,9 +208,10 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 	}, [messages, systemMessages]);
 
 	// 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출
+	// 도크 접힘(display:none) 상태에서는 clientHeight===0 → 스크롤 조작 생략(펼칠 때 ResizeObserver 가 복원)
 	useEffect(() => {
 		const el = listRef.current;
-		if (!el || merged.length === 0) {
+		if (!el || merged.length === 0 || el.clientHeight === 0) {
 			return;
 		}
 		if (autoScrollRef.current) {
@@ -185,6 +221,21 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 		}
 	}, [merged.length]);
 
+	// 접힘→펼침(또는 리사이즈)으로 메시지 영역이 다시 표시되면 하단 고정 상태였을 때 하단으로 재정렬
+	useEffect(() => {
+		const el = listRef.current;
+		if (!el || typeof ResizeObserver === 'undefined') {
+			return;
+		}
+		const observer = new ResizeObserver(() => {
+			if (el.clientHeight > 0 && autoScrollRef.current) {
+				el.scrollTop = el.scrollHeight;
+			}
+		});
+		observer.observe(el);
+		return () => observer.disconnect();
+	}, []);
+
 	const handleScroll = useCallback(() => {
 		const el = listRef.current;
 		if (!el) {
@@ -249,19 +300,115 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 		}
 	}, [handleSend]);
 
+	// 비로그인 입력 게이트 — 입력창 focus(클릭·탭 포함) 시 로그인 confirm 후 blur
+	const handleInputFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
+		if (isAuthenticated) {
+			return;
+		}
+		const el = e.currentTarget;
+		loginCheck();
+		el.blur();
+	}, [isAuthenticated, loginCheck]);
+
+	const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
+		if (!isAuthenticated) {
+			return;
+		}
+		setInputValue(e.target.value);
+	}, [isAuthenticated]);
+
+	// 이모지 선택 — 비로그인은 로그인 유도
+	const handleEmoji = useCallback((emoji: string) => {
+		if (!isAuthenticated) {
+			loginCheck();
+			return;
+		}
+		setInputValue((prev) => {
+			const next = prev + emoji;
+			// 길이 초과 시 이모지 전체 거부 — 코드유닛 slice 로 surrogate/ZWJ 시퀀스가 잘리는 것 방지
+			return next.length <= MAX_CONTENT_LENGTH ? next : prev;
+		});
+	}, [isAuthenticated, loginCheck]);
+
+	// 참여자 목록 토글 — 열 때 서버에 목록 요청
+	const toggleParticipants = useCallback(() => {
+		setShowParticipants((prev) => {
+			const next = !prev;
+			if (next && chatConnection && chatConnection.state === HubConnectionState.Connected) {
+				setParticipantsLoading(true);
+				chatConnection.invoke('RequestParticipants').catch(() => setParticipantsLoading(false));
+			}
+			return next;
+		});
+	}, [chatConnection]);
+
+	// 새로고침 — 메시지 비우고 히스토리·인원 재요청
+	const handleRefresh = useCallback(() => {
+		setMessages([]);
+		setSystemMessages([]);
+		if (chatConnection && chatConnection.state === HubConnectionState.Connected) {
+			chatConnection.invoke('RequestHistory').catch(() => {});
+			chatConnection.invoke('RequestParticipantCount').catch(() => {});
+		}
+	}, [chatConnection]);
+
+	// 지우개 — 내 화면만 비우기(서버 삭제 아님)
+	const handleClearLocal = useCallback(() => {
+		setMessages([]);
+		setSystemMessages([]);
+	}, []);
+
+	// 글씨 크게/작게
+	const changeFontScale = useCallback((delta: number) => {
+		setFontScale((prev) => {
+			const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round((prev + delta) * 100) / 100));
+			try {
+				window.localStorage.setItem(FONT_SCALE_KEY, String(next));
+			} catch {}
+			return next;
+		});
+	}, []);
+
+	// 새창으로 보기 — 별도 팝업 라우트(별개 SignalR 연결)
+	const handleOpenPopup = useCallback(() => {
+		window.open('/chat-popup', 'antoozaChat', 'width=400,height=760,resizable=yes,scrollbars=yes');
+	}, []);
+
+	// 수신설정 modal — 로그인 필수
+	const openReceiveModal = useCallback(() => {
+		if (!isAuthenticated) {
+			loginCheck();
+			return;
+		}
+		setShowReceiveModal(true);
+	}, [isAuthenticated, loginCheck]);
+
 	const canType = connState === 'connected' && !kicked;
-	const canSend = canType && inputValue.trim().length > 0 && cooldownLeft === 0;
+	const canSend = isAuthenticated && canType && inputValue.trim().length > 0 && cooldownLeft === 0;
 
 	return (
-		<div id='chat-room'>
-			{/* 헤더: 제목 + 접속자 수 + 연결 상태 */}
+		<div
+			id='chat-room'
+			className={popup ? 'chat-room--popup' : undefined}
+			style={{ '--chat-fs': fontScale } as CSSProperties}
+		>
+			{/* 헤더: 참여자 수(클릭→목록) + 연결 상태 + 수신설정 + 설정 dropdown */}
 			<header className='chat-room__head'>
-				<h1>실시간 채팅</h1>
+				<button
+					type='button'
+					className='chat-room__count'
+					onClick={toggleParticipants}
+					aria-expanded={showParticipants}
+					aria-controls='chat-participants-panel'
+					title='참여자 목록'
+				>
+					<FontAwesomeIcon icon={faUsers} />
+					<span className='chat-room__count-num'>{participantCount.toLocaleString()}</span>
+					<span className='chat-room__count-label'>명 참여 중</span>
+					<FontAwesomeIcon icon={faChevronDown} className={`chat-room__count-caret${showParticipants ? ' is-open' : ''}`} />
+				</button>
+
 				<div className='chat-room__meta'>
-					<span className='chat-room__count' aria-live='polite'>
-						<FontAwesomeIcon icon={faUsers} />
-						<span>{participantCount.toLocaleString()}명 참여 중</span>
-					</span>
 					{connState === 'connecting' && (
 						<span className='chat-room__state'>연결 중…</span>
 					)}
@@ -269,8 +416,22 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 						<span className='chat-room__state chat-room__state--warn' role='status'>재연결 중…</span>
 					)}
 					{connState === 'disconnected' && (
-						<span className='chat-room__state chat-room__state--error' role='status'>연결 끊김 — 새로고침 후 다시 시도해 주세요</span>
+						<span className='chat-room__state chat-room__state--error' role='status'>연결 끊김</span>
 					)}
+
+					<button type='button' className='chat-room__icon-btn' onClick={openReceiveModal} title='수신 설정' aria-label='수신 설정'>
+						<FontAwesomeIcon icon={faBell} />
+					</button>
+
+					<ChatSettingsMenu
+						onRefresh={handleRefresh}
+						onOpenPopup={handleOpenPopup}
+						onClear={handleClearLocal}
+						onFontLarger={() => changeFontScale(FONT_SCALE_STEP)}
+						onFontSmaller={() => changeFontScale(-FONT_SCALE_STEP)}
+						showPopup={!popup}
+					/>
+
 					{headerSlot}
 				</div>
 			</header>
@@ -283,7 +444,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 				</div>
 			)}
 
-			{/* 메시지 리스트 */}
+			{/* 메시지 리스트 (+ 참여자 목록 오버레이) */}
 			<div className='chat-room__body'>
 				<div className='chat-room__messages' ref={listRef} onScroll={handleScroll} aria-live='off'>
 					{merged.length === 0 && (
@@ -305,6 +466,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 							<div key={messageKey(msg)} className='chat-room__message'>
 								<time className='chat-room__time' dateTime={msg.sentAt}>{formatTime(msg.sentAt)}</time>
 								{badgeUrl && (
+									// eslint-disable-next-line @next/next/no-img-element
 									<img src={badgeUrl} alt={badgeAlt} className='chat-room__badge' aria-hidden={!badgeAlt} />
 								)}
 								<span
@@ -319,6 +481,16 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 					})}
 				</div>
 
+				{showParticipants && (
+					<div id='chat-participants-panel' className='chat-room__participants'>
+						<ChatParticipants
+							participants={participants}
+							loading={participantsLoading}
+							onClose={() => setShowParticipants(false)}
+						/>
+					</div>
+				)}
+
 				{showJump && (
 					<button type='button' className='chat-room__jump' onClick={jumpToBottom}>
 						<FontAwesomeIcon icon={faArrowDown} />
@@ -327,50 +499,40 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
 				)}
 			</div>
 
-			{/* 입력부 — 게스트는 읽기 전용 + 로그인 CTA */}
+			{/* 입력부 — 로그인 사용자만 전송. 비로그인은 입력창 focus 시 로그인 유도 */}
 			<div className='chat-room__footer'>
 				{kicked ? (
 					<div className='chat-room__blocked' role='status'>
 						관리자에 의해 채팅 이용이 제한되었습니다.
 					</div>
-				) : isAuthenticated ? (
-					<>
-						<div className='chat-room__input-row'>
-							<input
-								type='text'
-								placeholder='메시지를 입력하세요'
-								value={inputValue}
-								onChange={(e) => setInputValue(e.target.value)}
-								onKeyDown={handleKeyDown}
-								maxLength={MAX_CONTENT_LENGTH}
-								disabled={!canType}
-								aria-label='채팅 메시지 입력'
-							/>
-							<button type='button' title='전송' onClick={handleSend} disabled={!canSend}>
-								{cooldownLeft > 0 ? (
-									<span className='chat-room__cooldown'>{cooldownLeft}</span>
-								) : (
-									<FontAwesomeIcon icon={faPaperPlane} />
-								)}
-							</button>
-						</div>
-						<div className='chat-room__input-meta'>
-							{slowModeSeconds > GLOBAL_RATE_LIMIT_SECONDS && (
-								<span className='chat-room__slowmode'>슬로우 모드 {slowModeSeconds}초</span>
-							)}
-							{cooldownLeft > 0 && (
-								<span className='chat-room__cooldown-left' aria-live='polite'>{cooldownLeft}초 후 전송 가능</span>
-							)}
-							<span className='chat-room__counter'>{inputValue.length}/{MAX_CONTENT_LENGTH}</span>
-						</div>
-					</>
 				) : (
-					<div className='chat-room__guest'>
-						<span>읽기는 자유롭게, 참여는 로그인 후 가능합니다.</span>
-						<Link href='/login' className='chat-room__login-cta'>로그인 후 참여</Link>
+					<div className='chat-room__input-row'>
+						<input
+							ref={inputRef}
+							type='text'
+							className='chat-room__input'
+							placeholder={isAuthenticated ? '메시지를 입력하세요' : '로그인 후 채팅에 참여하세요'}
+							value={inputValue}
+							onChange={handleInputChange}
+							onFocus={handleInputFocus}
+							onKeyDown={handleKeyDown}
+							maxLength={MAX_CONTENT_LENGTH}
+							disabled={isAuthenticated && !canType}
+							aria-label='채팅 메시지 입력'
+						/>
+						<EmojiPicker onEmojiSelect={handleEmoji} />
+						<button type='button' className='chat-room__send' title='전송' onClick={handleSend} disabled={!canSend}>
+							{cooldownLeft > 0 ? (
+								<span className='chat-room__cooldown'>{cooldownLeft}</span>
+							) : (
+								<FontAwesomeIcon icon={faPaperPlane} />
+							)}
+						</button>
 					</div>
 				)}
 			</div>
+
+			{showReceiveModal && <ChatReceiveModal onClose={() => setShowReceiveModal(false)} />}
 		</div>
 	);
-}
+}

+ 53 - 0
app/(main)/chat/_component/ChatParticipants.tsx

@@ -0,0 +1,53 @@
+'use client';
+
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faXmark, faUser } from '@fortawesome/free-solid-svg-icons';
+import type { ChatParticipant } from '@/types/chat';
+
+type Props = {
+	participants: ChatParticipant[];
+	loading: boolean;
+	onClose: () => void;
+};
+
+// 접속 참여자 목록 — 채팅 본체(.chat-room__body) 위에 오버레이. row: ICON·별명·SID·LEVEL(등급 Order)
+export default function ChatParticipants({ participants, loading, onClose }: Props)
+{
+	return (
+		<div className='chat-participants' role='dialog' aria-label='접속 참여자 목록'>
+			<div className='chat-participants__head'>
+				<span className='chat-participants__title'>접속자 {participants.length.toLocaleString()}명</span>
+				<button type='button' className='chat-participants__close' onClick={onClose} aria-label='목록 닫기' title='목록 닫기'>
+					<FontAwesomeIcon icon={faXmark} />
+				</button>
+			</div>
+			<div className='chat-participants__body'>
+				{loading ? (
+					<div className='chat-participants__empty'>불러오는 중…</div>
+				) : participants.length === 0 ? (
+					<div className='chat-participants__empty'>접속 중인 회원이 없습니다.</div>
+				) : (
+					<ul className='chat-participants__list'>
+						{participants.map((p, idx) => (
+							<li key={p.sid ?? `${p.memberName}-${idx}`} className='chat-participants__item'>
+								<span className='chat-participants__avatar'>
+									{p.icon ? (
+										// eslint-disable-next-line @next/next/no-img-element
+										<img src={p.icon} alt='' aria-hidden='true' />
+									) : (
+										<FontAwesomeIcon icon={faUser} />
+									)}
+								</span>
+								<span className='chat-participants__info'>
+									<span className='chat-participants__name'>{p.memberName}</span>
+									{p.sid && <span className='chat-participants__sid'>@{p.sid}</span>}
+								</span>
+								<span className='chat-participants__level' title='회원 등급'>Lv.{p.level}</span>
+							</li>
+						))}
+					</ul>
+				)}
+			</div>
+		</div>
+	);
+}

+ 134 - 0
app/(main)/chat/_component/ChatReceiveModal.tsx

@@ -0,0 +1,134 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faXmark } from '@fortawesome/free-solid-svg-icons';
+import type { ChatSettings } from '@/types/chat';
+import { fetchChatSettings, updateChatSettings } from '@/lib/api/chatSettings';
+
+type Props = {
+	onClose: () => void;
+};
+
+const TOGGLES: { key: keyof ChatSettings; label: string; desc: string }[] = [
+	{ key: 'allowInvite', label: '초대 수신', desc: '다른 사용자의 대화 초대를 받습니다.' },
+	{ key: 'allowWhisper', label: '귓속말 수신', desc: '다른 사용자의 귓속말을 받습니다.' },
+	{ key: 'showFollowingOnline', label: '팔로잉 접속 표시', desc: '내가 팔로우한 사용자에게 접속 상태를 표시합니다.' }
+];
+
+// 수신설정 modal — 초대/귓속말/팔로잉 접속 표시 토글 (백엔드 회원설정 persist). 도크 overflow 회피 위해 portal.
+export default function ChatReceiveModal({ onClose }: Props)
+{
+	const [settings, setSettings] = useState<ChatSettings>({ allowInvite: true, allowWhisper: true, showFollowingOnline: true });
+	const [loading, setLoading] = useState(true);
+	const [saving, setSaving] = useState(false);
+	const [error, setError] = useState('');
+
+	// 진입 시 현재 설정 로드
+	useEffect(() => {
+		let alive = true;
+
+		fetchChatSettings().then((res) => {
+			if (alive && res.data) {
+				setSettings(res.data);
+			}
+		}).catch((err) => {
+			if (alive) {
+				setError(err instanceof Error ? err.message : '설정을 불러오지 못했습니다.');
+			}
+		}).finally(() => {
+			if (alive) {
+				setLoading(false);
+			}
+		});
+
+		return () => {
+			alive = false;
+		};
+	}, []);
+
+	// ESC 로 닫기
+	useEffect(() => {
+		const onKey = (e: KeyboardEvent) => {
+			if (e.key === 'Escape') {
+				onClose();
+			}
+		};
+
+		document.addEventListener('keydown', onKey);
+
+		return () => {
+			document.removeEventListener('keydown', onKey);
+		};
+	}, [onClose]);
+
+	const toggle = (key: keyof ChatSettings) => {
+		setSettings((prev) => ({ ...prev, [key]: !prev[key] }));
+	};
+
+	const handleSave = async () => {
+		setSaving(true);
+		setError('');
+
+		try {
+			await updateChatSettings(settings);
+			onClose();
+		} catch (err) {
+			setError(err instanceof Error ? err.message : '저장에 실패했습니다.');
+		} finally {
+			setSaving(false);
+		}
+	};
+
+	if (typeof document === 'undefined') {
+		return null;
+	}
+
+	return createPortal(
+		<div className='chat-modal' role='presentation' onMouseDown={onClose}>
+			<div className='chat-modal__panel' role='dialog' aria-modal='true' aria-label='채팅 수신 설정' onMouseDown={(e) => e.stopPropagation()}>
+				<div className='chat-modal__head'>
+					<h2 className='chat-modal__title'>수신 설정</h2>
+					<button type='button' className='chat-modal__close' onClick={onClose} aria-label='닫기' title='닫기'>
+						<FontAwesomeIcon icon={faXmark} />
+					</button>
+				</div>
+
+				<div className='chat-modal__body'>
+					{loading ? (
+						<div className='chat-modal__loading'>불러오는 중…</div>
+					) : (
+						TOGGLES.map((t) => (
+							<label key={t.key} className='chat-toggle'>
+								<span className='chat-toggle__text'>
+									<span className='chat-toggle__label'>{t.label}</span>
+									<span className='chat-toggle__desc'>{t.desc}</span>
+								</span>
+								<span className='chat-toggle__switch'>
+									<input
+										type='checkbox'
+										role='switch'
+										checked={settings[t.key]}
+										onChange={() => toggle(t.key)}
+										disabled={saving}
+									/>
+									<span className='chat-toggle__slider' aria-hidden='true' />
+								</span>
+							</label>
+						))
+					)}
+					{error && <p className='chat-modal__error' role='alert'>{error}</p>}
+				</div>
+
+				<div className='chat-modal__foot'>
+					<button type='button' className='chat-modal__btn chat-modal__btn--ghost' onClick={onClose} disabled={saving}>취소</button>
+					<button type='button' className='chat-modal__btn chat-modal__btn--primary' onClick={handleSave} disabled={loading || saving}>
+						{saving ? '저장 중…' : '저장'}
+					</button>
+				</div>
+			</div>
+		</div>,
+		document.body
+	);
+}

+ 89 - 0
app/(main)/chat/_component/ChatSettingsMenu.tsx

@@ -0,0 +1,89 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faGear, faRotate, faUpRightFromSquare, faEraser, faMagnifyingGlassPlus, faMagnifyingGlassMinus } from '@fortawesome/free-solid-svg-icons';
+
+type Props = {
+	onRefresh: () => void;
+	onOpenPopup: () => void;
+	onClear: () => void;
+	onFontLarger: () => void;
+	onFontSmaller: () => void;
+	// 팝업 창 내부에서는 "새창으로 보기" 숨김
+	showPopup?: boolean;
+};
+
+// 채팅 설정 dropdown (톱니바퀴) — 새로고침 / 새창으로 보기 / 지우개(로컬) / 글씨 크게·작게
+export default function ChatSettingsMenu({ onRefresh, onOpenPopup, onClear, onFontLarger, onFontSmaller, showPopup = true }: Props)
+{
+	const [open, setOpen] = useState(false);
+	const ref = useRef<HTMLDivElement>(null);
+
+	useEffect(() => {
+		const handleClickOutside = (e: MouseEvent) => {
+			if (ref.current && !ref.current.contains(e.target as Node)) {
+				setOpen(false);
+			}
+		};
+
+		if (open) {
+			document.addEventListener('mousedown', handleClickOutside);
+		}
+
+		return () => {
+			document.removeEventListener('mousedown', handleClickOutside);
+		};
+	}, [open]);
+
+	// 실행 후 dropdown 닫기 (글씨 크게/작게는 연속 조작을 위해 열어둠)
+	const run = (fn: () => void, keepOpen = false) => () => {
+		fn();
+		if (!keepOpen) {
+			setOpen(false);
+		}
+	};
+
+	return (
+		<div className='chat-menu' ref={ref}>
+			<button
+				type='button'
+				className='chat-menu__trigger'
+				onClick={() => setOpen((v) => !v)}
+				aria-haspopup='menu'
+				aria-expanded={open}
+				title='채팅 설정'
+			>
+				<FontAwesomeIcon icon={faGear} />
+			</button>
+
+			{open && (
+				<div className='chat-menu__dropdown' role='menu'>
+					<button type='button' role='menuitem' className='chat-menu__item' onClick={run(onRefresh)}>
+						<FontAwesomeIcon icon={faRotate} />
+						<span>새로고침</span>
+					</button>
+					{showPopup && (
+						<button type='button' role='menuitem' className='chat-menu__item' onClick={run(onOpenPopup)}>
+							<FontAwesomeIcon icon={faUpRightFromSquare} />
+							<span>새창으로 보기</span>
+						</button>
+					)}
+					<button type='button' role='menuitem' className='chat-menu__item' onClick={run(onClear)}>
+						<FontAwesomeIcon icon={faEraser} />
+						<span>지우개</span>
+					</button>
+					<div className='chat-menu__divider' role='separator' />
+					<button type='button' role='menuitem' className='chat-menu__item' onClick={run(onFontLarger, true)}>
+						<FontAwesomeIcon icon={faMagnifyingGlassPlus} />
+						<span>글씨 크게</span>
+					</button>
+					<button type='button' role='menuitem' className='chat-menu__item' onClick={run(onFontSmaller, true)}>
+						<FontAwesomeIcon icon={faMagnifyingGlassMinus} />
+						<span>글씨 작게</span>
+					</button>
+				</div>
+			)}
+		</div>
+	);
+}

+ 492 - 84
app/(main)/chat/style.scss

@@ -1,8 +1,8 @@
 // ─────────────────────────────────────────────────────────────
 // /chat — 메인 실시간 채팅 (d2-community §⑤, roomKey=main)
 // 색/치수는 app/styles/tokens 변수만 사용 (하드코딩 금지)
-// .main(그리드 스크롤 컨테이너)을 height:100% 로 채워 mobile/desktop 모두 전체 높이 사용
-// 데스크톱 중앙 컬럼 최대폭 제한 없음 (WIDE fluid — d6 §4)
+// Twitch 스타일: border-radius 0, 선 기반 깔끔한 UI
+// 채팅 도크(우측 고정) + 팝업(/chat-popup) 공용 스타일
 // ─────────────────────────────────────────────────────────────
 #chat-room {
 	display: flex;
@@ -16,37 +16,64 @@
 		padding: var(--sp-3);
 	}
 
+	// ── 헤더: 참여자 수(클릭→목록) + 상태 + 컨트롤 ──
 	.chat-room__head {
 		display: flex;
 		align-items: center;
 		justify-content: space-between;
 		flex-wrap: wrap;
-		gap: var(--sp-3);
+		gap: var(--sp-2);
+	}
 
-		h1 {
-			font-size: var(--fs-xl);
-			font-weight: 700;
+	// 참여자 수 버튼 (실시간 채팅 label 위치)
+	.chat-room__count {
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+		padding: var(--sp-1) var(--sp-2);
+		background: transparent;
+		border: none;
+		cursor: pointer;
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+		font-variant-numeric: tabular-nums;
+
+		&:hover {
+			background: var(--bg-icon);
 			color: var(--text-primary);
 		}
+
+		> svg:first-child {
+			color: var(--text-muted);
+		}
+	}
+
+	.chat-room__count-num {
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	.chat-room__count-caret {
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+		transition: transform 0.15s ease;
+
+		&.is-open {
+			transform: rotate(180deg);
+		}
 	}
 
 	.chat-room__meta {
 		display: flex;
 		align-items: center;
-		gap: var(--sp-4);
+		gap: var(--sp-2);
 		font-size: var(--fs-sm);
 		color: var(--text-secondary);
 	}
 
-	.chat-room__count {
-		display: inline-flex;
-		align-items: center;
-		gap: var(--sp-2);
-		font-variant-numeric: tabular-nums;
-	}
-
 	.chat-room__state {
 		color: var(--text-muted);
+		white-space: nowrap;
 
 		&--warn {
 			color: var(--az-accent);
@@ -64,7 +91,7 @@
 		padding: var(--sp-3) var(--sp-4);
 		background: var(--bg-subtle);
 		border: 1px solid var(--border-default);
-		border-radius: var(--radius);
+		border-radius: 0;
 		font-size: var(--fs-sm);
 		color: var(--text-secondary);
 
@@ -88,7 +115,7 @@
 		padding: var(--sp-3);
 		background: var(--bg-elevated);
 		border: 1px solid var(--border-default);
-		border-radius: var(--radius);
+		border-radius: 0;
 	}
 
 	.chat-room__empty {
@@ -103,9 +130,10 @@
 		align-items: baseline;
 		gap: var(--sp-3);
 		padding: var(--sp-2) var(--sp-3);
-		font-size: var(--fs-base);
+		// 글씨 크게/작게 — --chat-fs 배율 (이름·내용 상속)
+		font-size: calc(var(--fs-base) * var(--chat-fs, 1));
 		line-height: 1.5;
-		border-radius: var(--radius);
+		border-radius: 0;
 
 		&:hover {
 			background: var(--bg-subtle-hover);
@@ -148,6 +176,14 @@
 		color: var(--text-muted);
 	}
 
+	// 참여자 목록 오버레이 — 본체(.chat-room__body) 를 덮음
+	.chat-room__participants {
+		position: absolute;
+		inset: 0;
+		z-index: 20;
+		display: flex;
+	}
+
 	// "새 메시지 ↓" 점프 버튼 — 위로 스크롤 중 새 메시지 도착 시 노출
 	.chat-room__jump {
 		position: absolute;
@@ -163,7 +199,7 @@
 		font-size: var(--fs-sm);
 		font-weight: 600;
 		border: none;
-		border-radius: var(--fs-2xl);
+		border-radius: 0;
 		cursor: pointer;
 		box-shadow: 0 var(--sp-1) var(--sp-3) var(--shadow-color);
 
@@ -173,6 +209,7 @@
 	}
 
 	.chat-room__footer {
+		position: relative;
 		display: flex;
 		flex-direction: column;
 		gap: var(--sp-2);
@@ -180,49 +217,53 @@
 
 	.chat-room__input-row {
 		display: flex;
-		gap: var(--sp-3);
+		align-items: stretch;
+		gap: var(--sp-2);
+	}
 
-		input {
-			flex: 1;
-			min-width: 0;
-			padding: var(--sp-3) var(--sp-4);
-			background: var(--bg-input);
-			color: var(--text-primary);
-			font-size: var(--fs-base);
-			border: 1px solid var(--border-strong);
-			border-radius: var(--radius);
+	.chat-room__input {
+		flex: 1;
+		min-width: 0;
+		padding: var(--sp-3) var(--sp-4);
+		background: var(--bg-input);
+		color: var(--text-primary);
+		font-size: var(--fs-base);
+		border: 1px solid var(--border-strong);
+		border-radius: 0;
 
-			&::placeholder {
-				color: var(--text-muted);
-			}
+		&::placeholder {
+			color: var(--text-muted);
+		}
 
-			&:disabled {
-				opacity: 0.6;
-				cursor: not-allowed;
-			}
+		&:disabled {
+			opacity: 0.6;
+			cursor: not-allowed;
 		}
+	}
 
-		button {
-			flex-shrink: 0;
-			min-width: var(--sp-7);
-			padding: var(--sp-3) var(--sp-5);
-			background: var(--az-accent);
-			color: var(--btn-primary-text);
-			font-size: var(--fs-base);
-			font-weight: 600;
-			border: none;
-			border-radius: var(--radius);
-			cursor: pointer;
+	.chat-room__send {
+		flex-shrink: 0;
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		min-width: var(--sp-7);
+		padding: 0 var(--sp-4);
+		background: var(--az-accent);
+		color: var(--btn-primary-text);
+		font-size: var(--fs-base);
+		font-weight: 600;
+		border: none;
+		border-radius: 0;
+		cursor: pointer;
 
-			&:hover:not(:disabled) {
-				background: var(--az-accent-hover);
-			}
+		&:hover:not(:disabled) {
+			background: var(--az-accent-hover);
+		}
 
-			&:disabled {
-				background: var(--bg-subtle);
-				color: var(--text-muted);
-				cursor: not-allowed;
-			}
+		&:disabled {
+			background: var(--bg-subtle);
+			color: var(--text-muted);
+			cursor: not-allowed;
 		}
 	}
 
@@ -230,58 +271,425 @@
 		font-variant-numeric: tabular-nums;
 	}
 
-	.chat-room__input-meta {
+	// 푸터 이모지 버튼 — 입력창·전송과 높이 정렬(선 기반 아이콘 버튼)
+	#emojiPicker {
+		flex-shrink: 0;
+		position: static; // 팝업(aside)의 위치 기준을 .chat-room__footer 로 이동
+
+		> button {
+			display: inline-flex;
+			align-items: center;
+			justify-content: center;
+			width: var(--sp-7);
+			height: 100%;
+			min-height: var(--sp-7);
+			background: var(--bg-subtle);
+			border: 1px solid var(--border-strong);
+			color: var(--text-secondary);
+			cursor: pointer;
+
+			&:hover, &:focus, &:active {
+				background: var(--bg-subtle-hover);
+				color: var(--text-link);
+			}
+		}
+
+		// 좁은 도크 폭 안에서 위로 열림 — footer 폭에 맞춰 span, 화면 밖 클리핑 방지
+		> aside {
+			position: absolute;
+			top: auto;
+			bottom: calc(100% + var(--sp-2));
+			left: var(--sp-3);
+			right: var(--sp-3);
+			width: auto !important;
+			height: min(340px, 50vh) !important;
+			max-width: none;
+			// Twitch 플랫: 이모지 팝업도 라운드 제거 (라이브러리 기본 8px 상쇄)
+			border-radius: 0;
+			--epr-width: auto;
+			--epr-height: 340px;
+			--epr-picker-border-radius: 0;
+		}
+	}
+
+	.chat-room__blocked {
+		padding: var(--sp-4);
+		text-align: center;
+		background: var(--color-danger-bg);
+		border: 1px solid var(--color-danger-border);
+		border-radius: 0;
+		font-size: var(--fs-sm);
+		color: var(--color-danger);
+	}
+
+	// 팝업(/chat-popup) — 전체 창 채움
+	&.chat-room--popup {
+		flex: 1;
+		min-height: 0;
+	}
+}
+
+// ── 공용 아이콘 버튼 (헤더 수신설정 · 설정 트리거 · 닫기) ──
+.chat-room__icon-btn,
+.chat-menu__trigger,
+.chat-participants__close,
+.chat-modal__close {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: var(--sp-6);
+	height: var(--sp-6);
+	background: transparent;
+	border: none;
+	cursor: pointer;
+	color: var(--text-secondary);
+	font-size: var(--fs-base);
+	border-radius: 0;
+
+	&:hover {
+		background: var(--bg-icon);
+		color: #fff; // --bg-icon(다크)은 양 테마 공통 → 라이트에서 --text-primary(어두움)가 안 보이므로 흰색 고정
+	}
+}
+
+// ── 접속 참여자 목록 ──
+.chat-participants {
+	display: flex;
+	flex: 1;
+	min-width: 0;
+	flex-direction: column;
+	background: var(--bg-elevated);
+	border: 1px solid var(--border-default);
+
+	&__head {
 		display: flex;
 		align-items: center;
-		gap: var(--sp-4);
-		font-size: var(--fs-xs);
-		color: var(--text-muted);
+		justify-content: space-between;
+		padding: var(--sp-3);
+		border-bottom: 1px solid var(--border-default);
 	}
 
-	.chat-room__cooldown-left {
-		color: var(--az-accent);
-		font-variant-numeric: tabular-nums;
+	&__title {
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		color: var(--text-primary);
 	}
 
-	.chat-room__counter {
-		margin-left: auto;
-		font-variant-numeric: tabular-nums;
+	&__body {
+		flex: 1;
+		min-height: 0;
+		overflow-y: auto;
+	}
+
+	&__empty {
+		padding: var(--sp-6) var(--sp-4);
+		text-align: center;
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+	}
+
+	&__list {
+		display: flex;
+		flex-direction: column;
 	}
 
-	// 게스트 CTA — 읽기는 가능, 입력 대신 로그인 유도
-	.chat-room__guest {
+	&__item {
 		display: flex;
 		align-items: center;
-		justify-content: space-between;
-		flex-wrap: wrap;
 		gap: var(--sp-3);
-		padding: var(--sp-4);
+		padding: var(--sp-2) var(--sp-3);
+
+		&:hover {
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__avatar {
+		flex-shrink: 0;
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: var(--sp-6);
+		height: var(--sp-6);
 		background: var(--bg-subtle);
-		border: 1px solid var(--border-default);
-		border-radius: var(--radius);
+		color: var(--text-muted);
+		overflow: hidden;
+
+		img {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+		}
+	}
+
+	&__info {
+		display: flex;
+		flex-direction: column;
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__name {
 		font-size: var(--fs-sm);
-		color: var(--text-secondary);
+		font-weight: 600;
+		color: var(--text-primary);
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
 	}
 
-	.chat-room__login-cta {
-		padding: var(--sp-2) var(--sp-5);
-		background: var(--az-accent);
-		color: var(--btn-primary-text);
+	&__sid {
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	&__level {
+		flex-shrink: 0;
+		padding: 0 var(--sp-2);
+		font-size: var(--fs-2xs);
 		font-weight: 600;
-		border-radius: var(--radius);
+		color: var(--az-accent);
+		border: 1px solid var(--border-default);
+	}
+}
+
+// ── 설정 dropdown (톱니바퀴) ──
+.chat-menu {
+	position: relative;
+	display: inline-flex;
+
+	&__dropdown {
+		position: absolute;
+		top: calc(100% + var(--sp-2));
+		right: 0;
+		z-index: 30;
+		min-width: 160px;
+		padding: var(--sp-2) 0;
+		background: var(--bg-elevated);
+		border: 1px solid var(--border-default);
+		box-shadow: 0 var(--sp-1) var(--sp-3) var(--shadow-color);
+	}
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-3);
+		width: 100%;
+		padding: var(--sp-2) var(--sp-4);
+		background: transparent;
+		border: none;
+		cursor: pointer;
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+		text-align: left;
+
+		svg {
+			width: var(--sp-4);
+			color: var(--text-muted);
+		}
 
 		&:hover {
-			background: var(--az-accent-hover);
+			background: var(--bg-subtle-hover);
+			color: var(--text-primary);
+
+			svg {
+				color: var(--az-accent);
+			}
 		}
 	}
 
-	.chat-room__blocked {
+	&__divider {
+		height: 1px;
+		margin: var(--sp-2) 0;
+		background: var(--border-default);
+	}
+}
+
+// ── 수신설정 modal (portal → body) ──
+.chat-modal {
+	position: fixed;
+	inset: 0;
+	z-index: 1200;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: var(--sp-4);
+	background: var(--overlay-color);
+
+	&__panel {
+		display: flex;
+		flex-direction: column;
+		width: min(420px, 100%);
+		max-height: min(560px, calc(100dvh - 2rem));
+		background: var(--bg-page);
+		border: 1px solid var(--border-default);
+		box-shadow: 0 var(--sp-2) var(--sp-5) var(--shadow-color);
+	}
+
+	&__head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		padding: var(--sp-4);
+		border-bottom: 1px solid var(--border-default);
+	}
+
+	&__title {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__body {
+		display: flex;
+		flex-direction: column;
+		gap: var(--sp-4);
 		padding: var(--sp-4);
+		overflow-y: auto;
+	}
+
+	&__loading {
+		padding: var(--sp-5);
 		text-align: center;
-		background: var(--color-danger-bg);
-		border: 1px solid var(--color-danger-border);
-		border-radius: var(--radius);
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+	}
+
+	&__error {
 		font-size: var(--fs-sm);
 		color: var(--color-danger);
 	}
-}
+
+	&__foot {
+		display: flex;
+		justify-content: flex-end;
+		gap: var(--sp-3);
+		padding: var(--sp-4);
+		border-top: 1px solid var(--border-default);
+	}
+
+	&__btn {
+		padding: var(--sp-2) var(--sp-5);
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		border: 1px solid transparent;
+		border-radius: 0;
+		cursor: pointer;
+
+		&--ghost {
+			background: transparent;
+			border-color: var(--border-strong);
+			color: var(--text-secondary);
+
+			&:hover:not(:disabled) {
+				background: var(--bg-subtle-hover);
+				color: var(--text-primary);
+			}
+		}
+
+		&--primary {
+			background: var(--az-accent);
+			color: var(--btn-primary-text);
+
+			&:hover:not(:disabled) {
+				background: var(--az-accent-hover);
+			}
+		}
+
+		&:disabled {
+			opacity: 0.6;
+			cursor: not-allowed;
+		}
+	}
+}
+
+// ── 토글 스위치 (초대/귓속말/팔로잉 접속 표시) — 선 기반 사각 스위치 ──
+.chat-toggle {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: var(--sp-4);
+	cursor: pointer;
+
+	&__text {
+		display: flex;
+		flex-direction: column;
+		gap: var(--sp-1);
+		min-width: 0;
+	}
+
+	&__label {
+		font-size: var(--fs-base);
+		font-weight: 600;
+		color: var(--text-primary);
+	}
+
+	&__desc {
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+	}
+
+	&__switch {
+		position: relative;
+		flex-shrink: 0;
+		width: 44px;
+		height: 24px;
+
+		input {
+			position: absolute;
+			inset: 0;
+			z-index: 1;
+			margin: 0;
+			opacity: 0;
+			cursor: pointer;
+
+			&:disabled {
+				cursor: not-allowed;
+			}
+		}
+	}
+
+	&__slider {
+		position: absolute;
+		inset: 0;
+		background: var(--bg-subtle);
+		border: 1px solid var(--border-strong);
+		transition: background 0.15s ease, border-color 0.15s ease;
+
+		&::before {
+			content: '';
+			position: absolute;
+			top: 2px;
+			left: 2px;
+			width: 18px;
+			height: 18px;
+			background: var(--text-muted);
+			transition: transform 0.15s ease, background 0.15s ease;
+		}
+	}
+
+	input:checked + &__slider {
+		background: var(--az-accent);
+		border-color: var(--az-accent);
+
+		&::before {
+			transform: translateX(20px);
+			background: var(--btn-primary-text);
+		}
+	}
+
+	input:focus-visible + &__slider {
+		outline: 2px solid var(--az-accent);
+		outline-offset: 2px;
+	}
+}
+
+// ── 채팅 팝업 창(/chat-popup) 래퍼 — 전체 뷰포트 ──
+.chat-popup {
+	display: flex;
+	height: 100vh;
+	height: 100dvh;
+}

+ 19 - 2
app/(main)/page.tsx

@@ -7,10 +7,13 @@ import { fetchWorldIndices } from '@/lib/api/worldIndices';
 import { fetchMarketQuotes } from '@/lib/api/marketQuotes';
 import type { DomesticIndexRow } from '@/types/domesticSummary';
 import { fetchDomesticSummary } from '@/lib/api/domesticSummary';
+import type { MarketMoversResponse } from '@/types/marketMover';
+import { fetchMarketMovers } from '@/lib/api/marketMovers';
 import HomeBannerCarousel from './_components/HomeBannerCarousel';
 import WorldMarketMap from './_components/WorldMarketMap';
 import DomesticSummary from './_components/DomesticSummary';
 import MajorQuotesGrid from './_components/MajorQuotesGrid';
+import MarketMovers from './_components/MarketMovers';
 import LiveChannelGrid from './_components/LiveChannelGrid';
 import LiveChannelEmpty from './_components/LiveChannelEmpty';
 import { FEATURE_CHANNEL } from '@/constants/features';
@@ -74,14 +77,26 @@ async function loadDomesticSummary(): Promise<DomesticIndexRow[]> {
 	return res.data.list;
 }
 
+async function loadMarketMovers(): Promise<MarketMoversResponse|null> {
+	// 백엔드 미기동/데이터 없음이어도 홈은 렌더 — 섹션은 데이터 있을 때만 표시
+	const res = await fetchMarketMovers();
+
+	if (!res.success || !res.data) {
+		return null;
+	}
+
+	return res.data;
+}
+
 export default async function Home() {
 	// 채널 기능 OFF: 라이브 채널 섹션 미노출이므로 조회 생략
-	const [banners, live, worldIndices, marketQuotes, domesticSummary] = await Promise.all([
+	const [banners, live, worldIndices, marketQuotes, domesticSummary, marketMovers] = await Promise.all([
 		loadActiveBanners(),
 		FEATURE_CHANNEL ? loadLiveChannels() : Promise.resolve({ channels: [], total: 0 }),
 		loadWorldIndices(),
 		loadMarketQuotes(),
-		loadDomesticSummary()
+		loadDomesticSummary(),
+		loadMarketMovers()
 	]);
 
 	return (
@@ -91,6 +106,8 @@ export default async function Home() {
 
 				{domesticSummary.length > 0 && <DomesticSummary rows={domesticSummary} />}
 
+				{marketMovers && <MarketMovers data={marketMovers} />}
+
 				{marketQuotes.length > 0 && <MajorQuotesGrid rows={marketQuotes} />}
 
 				<HomeBannerCarousel items={banners} />

+ 36 - 0
app/(main)/paper/_components/PaperAuthGate.tsx

@@ -0,0 +1,36 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import type { ReactNode } from 'react';
+import useAuth from '@/hooks/useAuth';
+
+// 모의투자 인증 게이트 — 비로그인 시 자동 로그인 이동 대신 안내 + 버튼(이동은 사용자 결정).
+// 계좌/주문/포트폴리오 컴포넌트가 /api/paper/* 를 호출해 401→auth:unauthorized→자동 리다이렉트되는 것을,
+// 비로그인 시 자식을 아예 마운트하지 않음으로써 원천 차단한다.
+type Props = {
+	children: ReactNode;
+};
+
+export default function PaperAuthGate({ children }: Props)
+{
+	const { isAuthenticated } = useAuth();
+	const pathname = usePathname();
+
+	if (!isAuthenticated) {
+		return (
+			<div className='paper__empty-account' role='note'>
+				<div className='paper__empty-account-title'>로그인이 필요한 기능이에요</div>
+				<p className='paper__empty-account-desc'>
+					모의투자 계좌·주문·포트폴리오는 로그인 후 이용할 수 있어요.<br />
+					아래 버튼으로 로그인하면 이 페이지로 다시 돌아옵니다.
+				</p>
+				<Link href={`/login?returnUrl=${encodeURIComponent(pathname)}`} className='paper__btn paper__btn--primary'>
+					로그인하러 가기
+				</Link>
+			</div>
+		);
+	}
+
+	return <>{children}</>;
+}

+ 4 - 1
app/(main)/paper/page.tsx

@@ -4,6 +4,7 @@ import PaperNav from './_components/PaperNav';
 import PaperAccountCard from './_components/PaperAccountCard';
 import PaperLeaderboardMini from './_components/PaperLeaderboardMini';
 import PaperNotice from './_components/PaperNotice';
+import PaperAuthGate from './_components/PaperAuthGate';
 
 export const metadata: Metadata = {
 	title: '모의투자 — 개미투자',
@@ -21,7 +22,9 @@ export default function PaperHomePage()
 
 			<PaperNav />
 
-			<PaperAccountCard />
+			<PaperAuthGate>
+				<PaperAccountCard />
+			</PaperAuthGate>
 
 			<PaperNotice />
 

+ 4 - 1
app/(main)/paper/portfolio/page.tsx

@@ -2,6 +2,7 @@ import '../style.scss';
 import { Metadata } from 'next';
 import PaperNav from '../_components/PaperNav';
 import PaperPortfolio from '../_components/PaperPortfolio';
+import PaperAuthGate from '../_components/PaperAuthGate';
 
 export const metadata: Metadata = {
 	title: '모의투자 포트폴리오 — 개미투자',
@@ -19,7 +20,9 @@ export default function PaperPortfolioPage()
 
 			<PaperNav />
 
-			<PaperPortfolio />
+			<PaperAuthGate>
+				<PaperPortfolio />
+			</PaperAuthGate>
 		</div>
 	);
 }

+ 4 - 1
app/(main)/paper/trade/page.tsx

@@ -3,6 +3,7 @@ import { Metadata } from 'next';
 import PaperNav from '../_components/PaperNav';
 import PaperTradeForm from '../_components/PaperTradeForm';
 import PaperNotice from '../_components/PaperNotice';
+import PaperAuthGate from '../_components/PaperAuthGate';
 
 export const metadata: Metadata = {
 	title: '모의투자 주문 — 개미투자',
@@ -20,7 +21,9 @@ export default function PaperTradePage()
 
 			<PaperNav />
 
-			<PaperTradeForm />
+			<PaperAuthGate>
+				<PaperTradeForm />
+			</PaperAuthGate>
 
 			<PaperNotice />
 		</div>

+ 67 - 0
app/(main)/stock/[code]/_components/StockPriceChart.tsx

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

+ 32 - 0
app/(main)/stock/[code]/_components/price-chart.scss

@@ -0,0 +1,32 @@
+// 종목 상세 시세 그래프 — 방향색은 currentColor 로 recharts Area 에 상속 (SVG var() 미해석 대응)
+.stock-chart {
+	margin: var(--sp-4) 0 var(--sp-2);
+	color: var(--price-flat);
+
+	&--up {
+		color: var(--price-up);
+	}
+
+	&--down {
+		color: var(--price-down);
+	}
+
+	&--flat {
+		color: var(--price-flat);
+	}
+
+	&__caption {
+		margin-bottom: var(--sp-2);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__canvas {
+		width: 100%;
+		height: 180px;
+
+		@media (max-width: 767.98px) {
+			height: 140px;
+		}
+	}
+}

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

@@ -15,6 +15,7 @@ import StockDiscussion from './_components/StockDiscussion';
 import StockDividends from './_components/StockDividends';
 import StockLending from './_components/StockLending';
 import StockCbExercises from './_components/StockCbExercises';
+import StockPriceChart from './_components/StockPriceChart';
 
 const TABS = [
 	{ value: 'price', label: '시세' },
@@ -152,6 +153,9 @@ export default async function StockDetailPage({ params, searchParams }: Props)
 				</p>
 			</header>
 
+			{/* 시세 그래프 — stock-detail__basis 바로 하단, 최근 종가 추이 (recentPrices) */}
+			<StockPriceChart prices={detail.recentPrices} name={detail.name} />
+
 			{/* 탭 */}
 			<nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
 				{TABS.map(item => (

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

@@ -170,6 +170,19 @@
 			background: var(--bg-subtle-hover);
 		}
 
+		// 등락률 셀 — 일반 td 색(--text-primary)이 .stock-change--* 를 덮지 않도록 우선순위 역전
+		tbody td.stock-change--up {
+			color: var(--price-up);
+		}
+
+		tbody td.stock-change--down {
+			color: var(--price-down);
+		}
+
+		tbody td.stock-change--flat {
+			color: var(--price-flat);
+		}
+
 		// mobile: 날짜/종가/등락률/거래량만 유지
 		@media (max-width: 767.98px) {
 			.stock-detail__col--sub {

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

@@ -4,7 +4,7 @@ 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 { StockMarketName, StockSortKey, StockSortOrder } from '@/types/stock';
 import { formatNumber } from '@/lib/utils/stock';
 import StockDataTable from '@/components/table/StockRow';
 import TrendingStocks from '@/components/community/TrendingStocks';
@@ -29,18 +29,25 @@ type Props = {
 		market?: string;
 		q?: string;
 		page?: string;
+		sort?: string;
+		order?: string;
 	}>;
 };
 
+const SORT_KEYS: readonly StockSortKey[] = ['name', 'price', 'change', 'cap', 'value'];
+
 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);
+	// 정렬 — 화이트리스트 검증. 기본 시가총액 내림차순
+	const sort: StockSortKey = SORT_KEYS.includes(query.sort as StockSortKey) ? (query.sort as StockSortKey) : 'cap';
+	const order: StockSortOrder = query.order === 'asc' ? 'asc' : 'desc';
 
 	// 백엔드 미기동 시에도 페이지 자체는 렌더 — 테이블 영역만 에러 표시 (request-time fetch)
-	const res = await fetchStockList({ q, market, page, perPage: PER_PAGE });
+	const res = await fetchStockList({ q, market, page, perPage: PER_PAGE, sort, order });
 	const data = res.success ? res.data : null;
 
 	// 트렌딩 위젯 — 검색·필터 없는 1페이지에서만 노출 (실패해도 페이지는 렌더)
@@ -55,6 +62,11 @@ export default async function StockListPage({ searchParams }: Props)
 		if (q) {
 			params.set('q', q);
 		}
+		// 시장 전환 시 현재 정렬 유지 (기본값이면 생략해 URL 을 깔끔하게)
+		if (sort !== 'cap' || order !== 'desc') {
+			params.set('sort', sort);
+			params.set('order', order);
+		}
 		const qs = params.toString();
 		return qs ? `/stock?${qs}` : '/stock';
 	};
@@ -114,15 +126,15 @@ export default async function StockListPage({ searchParams }: Props)
 				<>
 					<p className='stock__total'>전체 {formatNumber(data.total)}종목</p>
 
-					{/* D6 §5.1 — 재사용 DataTable(StockRow) 로 렌더 */}
-					<StockDataTable rows={data.list} />
+					{/* D6 §5.1 — 재사용 DataTable(StockRow) 로 렌더 (정렬 헤더 클릭 → SSR 재조회) */}
+					<StockDataTable rows={data.list} sort={sort} order={order} market={market} q={q} />
 
 					<Pager
 						total={data.total}
 						page={page}
 						perPage={PER_PAGE}
 						basePath='/stock'
-						query={{ ...(market ? { market } : {}), ...(q ? { q } : {}) }}
+						query={{ ...(market ? { market } : {}), ...(q ? { q } : {}), sort, order }}
 					/>
 				</>
 			)}

+ 1 - 1
app/api/uploads/[...path]/route.ts

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
 
 // 정적 리소스 호스트 (배너/팝업/회원 사진/게시판 이미지 등).
 // 로컬: NEXT_PUBLIC_RESOURCE_URL 미설정 시 API_URL 로 폴백 (API 가 wwwroot 정적 서빙).
-// dev/prod: dev-resource.antooza.com / resource.antooza.com 가 Admin/API uploads 통합 서빙.
+// resource.antooza.com 가 Admin/API uploads 통합 서빙.
 const RESOURCE_URL = process.env.NEXT_PUBLIC_RESOURCE_URL || process.env.API_URL;
 
 export async function GET(_: NextRequest, { params }: { params: Promise<{ path: string[] }> })

+ 15 - 0
app/chat-popup/page.tsx

@@ -0,0 +1,15 @@
+'use client';
+
+import '@/app/(main)/chat/style.scss';
+import MainChat from '@/app/(main)/chat/MainChat';
+
+// 채팅 새창(팝업) — (main) 그룹 밖이라 ChatDock 이 마운트되지 않아 'main' 룸 이중 참가 충돌이 없다.
+// 별개 브라우저 컨텍스트 = 별개 SignalR 연결이므로 도크와 독립적으로 동작한다.
+export default function ChatPopupPage()
+{
+	return (
+		<div className='chat-popup'>
+			<MainChat popup />
+		</div>
+	);
+}

+ 10 - 6
app/component/Header.tsx

@@ -16,6 +16,7 @@ import Logo from '@/app/component/Logo';
 import PointChargeIcon from '@/public/icons/layout/point.svg';
 import useCart from '@/hooks/useCart';
 import { FEATURE_CHANNEL } from '@/constants/features';
+import { useConfigContext } from '@/contexts/configProvider';
 
 // 채널/방송 메뉴(생방송·크리에이터)는 채널 기능 ON 일 때만 노출
 const channelNavItems = [
@@ -23,6 +24,7 @@ const channelNavItems = [
 	{ href: '/creators', label: '크리에이터' }
 ];
 
+// 관리자 관리형 메뉴(config.navMenu)가 비어 있을 때의 폴백 — 기존 하드코딩 목록
 const baseNavItems = [
 	{ href: '/market', label: '시장' },
 	{ href: '/stock', label: '종목' },
@@ -36,8 +38,6 @@ const baseNavItems = [
 	{ href: '/board/notice', label: '고객지원' }
 ];
 
-const navItems = FEATURE_CHANNEL ? [...channelNavItems, ...baseNavItems] : baseNavItems;
-
 const SCROLL_AMOUNT = 120;
 
 type Props = {
@@ -53,6 +53,11 @@ export default function Header({ sidebarOpen, onToggle, showAside = true }: Prop
 	const { totalCount: cartCount } = useCart();
 	const pathname = usePathname();
 	const dragScroll = useDragScroll<HTMLDivElement>();
+	const config = useConfigContext();
+
+	// 상단 메뉴: 관리자 관리형(config.navMenu)이 있으면 사용, 없으면 하드코딩 폴백
+	const baseItems = config.navMenu && config.navMenu.length > 0 ? config.navMenu : baseNavItems;
+	const navItems = FEATURE_CHANNEL ? [...channelNavItems, ...baseItems] : baseItems;
 
 	const scrollTabs = useCallback((dir: 'left' | 'right') => {
 		const el = dragScroll.ref.current;
@@ -97,11 +102,10 @@ export default function Header({ sidebarOpen, onToggle, showAside = true }: Prop
 					</ul>
 				</nav>
 
-				{/* 중앙 통합 검색 — 디바운스 suggest + combobox (Ctrl+K/'/' 포커스 포함) */}
-				<HeaderSearch />
-
-				{/* 우측 아이콘 (항상 표시) */}
+				{/* 우측 아이콘 (항상 표시) — 통합 검색을 장바구니 바로 좌측에 배치 */}
 				<div className={Styles.headerActions}>
+					{/* 통합 검색 — 디바운스 suggest + combobox (Ctrl+K/'/' 포커스 포함) */}
+					<HeaderSearch />
 					<Link href="/cart" className={Styles.cartBtn} title="장바구니" aria-label="장바구니">
 						<FontAwesomeIcon icon={faCartShopping} />
 						{cartCount > 0 && (

+ 5 - 2
app/layout.tsx

@@ -11,7 +11,7 @@ import { CartProvider } from "@/contexts/cartProvider";
 import { ThemeProvider } from "@/contexts/themeProvider";
 import { Toaster } from "@/components/ui/sonner";
 import { getAccessToken, getSignalRChatUrl, getSignalRAppUrl } from "@/lib/utils/server";
-import { fetchConfig } from "@/lib/api/system";
+import { fetchConfig, fetchNavMenu } from "@/lib/api/system";
 import { getInitialMember } from "@/lib/api/auth";
 
 const geistSans = Geist({
@@ -102,6 +102,9 @@ export default async function RootLayout({
 	const signalRChatUrl = await getSignalRChatUrl();
 	const signalRAppUrl = await getSignalRAppUrl();
     const config = (await fetchConfig())?.data;
+	const navMenuRes = await fetchNavMenu();
+	const navMenu = navMenuRes?.data?.items ?? [];
+	const configWithMenu = config ? { ...config, navMenu } : config;
 	const initialMember = await getInitialMember();
 
     return (
@@ -129,7 +132,7 @@ export default async function RootLayout({
                     <SignalRProvider accessToken={accessToken} signalRChatUrl={signalRChatUrl} signalRAppUrl={signalRAppUrl}>
                         <AuthProvider initialAuthenticated={!!initialMember}>
                             <MemberProvider initialMember={initialMember}>
-                                <ConfigProvider initialConfig={config}>
+                                <ConfigProvider initialConfig={configWithMenu}>
                                     <CartProvider>
                                         {children}
                                     </CartProvider>

+ 5 - 3
app/styles/chat-dock.scss

@@ -60,8 +60,10 @@
 		padding: var(--sp-3);
 		gap: var(--sp-2);
 
-		.chat-room__head h1 {
-			font-size: var(--fs-lg);
+		// 좁은 폭: 참여자 수 버튼 타이포 축소
+		.chat-room__count {
+			padding: var(--sp-1);
+			font-size: var(--fs-xs);
 		}
 	}
 
@@ -77,7 +79,7 @@
 		color: var(--text-secondary);
 		font-size: var(--fs-lg);
 		cursor: pointer;
-		border-radius: var(--radius);
+		border-radius: 0; // Twitch 플랫: 헤더 접기/닫기 버튼도 사각 (형제 아이콘 버튼과 일치)
 
 		&:hover {
 			background: var(--bg-icon);

+ 4 - 5
app/styles/layout.module.scss

@@ -82,13 +82,12 @@
 		}
 	}
 
-	// 중앙 통합 검색 (D6 §5.2 골격 — UI + Ctrl+K / '/' 포커스만, 자동완성·이동은 후속)
+	// 통합 검색 — 우측 아이콘 클러스터(headerActions) 내부, 장바구니 바로 좌측에 콤팩트 배치
 	.searchBox {
 		position: relative;
-		flex: 1 1 auto;
-		max-width: 560px;
-		margin: 0 auto;
-		padding: 0 16px;
+		flex: 0 1 300px;
+		margin: 0;
+		padding: 0;
 
 		@media (max-width: 1123.98px) {
 			display: none;

+ 2 - 0
app/styles/tokens/_colors.scss

@@ -170,6 +170,8 @@ html.light,
 	--az-accent: #d97800;
 	--az-accent-hover: #b96504;
 	--btn-primary-shadow: #a35a00;
+	// 라이트 테마 accent(#d97800)는 더 어두워 잉크(#201503) 텍스트가 저대비 → 흰색으로 오버라이드(가독성)
+	--btn-primary-text: #ffffff;
 
 	--text-link: #0060a9;
 	--text-link-hover: #c7511f;

+ 54 - 8
components/table/StockRow.tsx

@@ -1,21 +1,30 @@
 'use client';
 
 // D6 §5.1 — 종목행(StockRow) 열 구성 빌더 + StockDataTable 편의 래퍼.
-// 한 줄 완결: 종목명+코드 | 현재가 | 등락률(색+부호) | 시가총액 | 시장.
-// 거래량/시총 등 우선순위 낮은 열은 mobile drop (data-priority='low').
+// 한 줄 완결: 종목명+코드 | 현재가 | 등락률(색+부호) | 거래대금 | 시가총액 | 시장.
+// 정렬: 종목명·현재가·등락률·거래대금·시가총액 헤더 클릭 → SSR 재조회(URL sort/order). 기본 시가총액 desc.
+// 거래대금/시총 등 우선순위 낮은 열은 mobile drop (data-priority='low').
 import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useCallback } from 'react';
 import DataTable, { DataTableColumn } from '@/components/table/DataTable';
 import PriceChange from '@/components/table/PriceChange';
-import { StockListRow } from '@/types/stock';
+import { StockListRow, StockSortKey, StockSortOrder } from '@/types/stock';
 import { formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
 
-export function buildStockColumns(): DataTableColumn<StockListRow>[] {
+// 활성 정렬 컬럼만 방향, 나머지 정렬 가능 컬럼은 null(비활성)
+function colSort(key: StockSortKey, activeSort: StockSortKey, order: StockSortOrder): StockSortOrder|null {
+	return key === activeSort ? order : null;
+}
+
+export function buildStockColumns(sort: StockSortKey, order: StockSortOrder): DataTableColumn<StockListRow>[] {
 	return [
 		{
 			key: 'name',
 			header: '종목명',
 			align: 'left',
 			priority: 'high',
+			sort: colSort('name', sort, order),
 			cellClassName: 'data-table__cell--name',
 			cell: (row) => {
 				const status = tradingStatusLabel(row.tradingStatus);
@@ -36,6 +45,7 @@ export function buildStockColumns(): DataTableColumn<StockListRow>[] {
 			header: <>현재가 <small>(전일종가)</small></>,
 			align: 'right',
 			priority: 'high',
+			sort: colSort('price', sort, order),
 			cell: (row) => formatNumber(row.closePrice)
 		},
 		{
@@ -43,13 +53,23 @@ export function buildStockColumns(): DataTableColumn<StockListRow>[] {
 			header: '등락률',
 			align: 'right',
 			priority: 'high',
+			sort: colSort('change', sort, order),
 			cell: (row) => <PriceChange rate={row.changeRate} />
 		},
+		{
+			key: 'value',
+			header: '거래대금',
+			align: 'right',
+			priority: 'low',
+			sort: colSort('value', sort, order),
+			cell: (row) => formatKrwCompact(row.tradingValue)
+		},
 		{
 			key: 'cap',
 			header: '시가총액',
 			align: 'right',
 			priority: 'low',
+			sort: colSort('cap', sort, order),
 			cell: (row) => formatKrwCompact(row.marketCap)
 		},
 		{
@@ -64,19 +84,45 @@ export function buildStockColumns(): DataTableColumn<StockListRow>[] {
 
 interface StockDataTableProps {
 	rows: StockListRow[];
+	sort: StockSortKey;
+	order: StockSortOrder;
+	// 정렬 URL 재구성 시 유지할 현재 필터
+	market?: string;
+	q?: string;
 	caption?: string;
 	emptyMessage?: string;
 }
 
-// 종목 목록 편의 래퍼 — 기본 열 구성으로 DataTable 렌더
-export default function StockDataTable({ rows, caption, emptyMessage }: StockDataTableProps)
+// 종목 목록 편의 래퍼 — 정렬 헤더 클릭 시 sort/order URL 로 SSR 재조회(필터 유지·페이지 1로 리셋)
+export default function StockDataTable({ rows, sort, order, market, q, caption, emptyMessage }: StockDataTableProps)
 {
+	const router = useRouter();
+
+	const handleSort = useCallback((key: string) => {
+		const k = key as StockSortKey;
+		// 같은 컬럼 재클릭 → 방향 토글, 새 컬럼 → 기본 방향(종목명 asc, 숫자열 desc)
+		const nextOrder: StockSortOrder = k === sort ? (order === 'desc' ? 'asc' : 'desc') : (k === 'name' ? 'asc' : 'desc');
+
+		const params = new URLSearchParams();
+		if (market) {
+			params.set('market', market);
+		}
+		if (q) {
+			params.set('q', q);
+		}
+		params.set('sort', k);
+		params.set('order', nextOrder);
+
+		router.push(`/stock?${params.toString()}`);
+	}, [router, sort, order, market, q]);
+
 	return (
 		<DataTable<StockListRow>
-			caption={caption ?? '종목 목록 — 종목명, 현재가(전일 종가), 등락률, 시가총액, 시장'}
-			columns={buildStockColumns()}
+			caption={caption ?? '종목 목록 — 종목명, 현재가(전일 종가), 등락률, 거래대금, 시가총액, 시장'}
+			columns={buildStockColumns(sort, order)}
 			rows={rows}
 			rowKey={(row) => row.code}
+			onSort={handleSort}
 			emptyMessage={emptyMessage}
 		/>
 	);

+ 11 - 9
deploy/config-templates/README.md

@@ -6,7 +6,7 @@
 
 ```
 config-templates/
-├── dev/                            (CTL-Window-Server / dev.antooza.com)
+├── dev/                            (CTL-Window-Server / antooza.com — dev 서버 운영 도메인)
 │   └── .env.production.template
 └── prod/                           (PROD-Window-Server / antooza.com)
     └── .env.production.template
@@ -30,15 +30,17 @@ config-templates/
    - dev : `F:\IIS\ANTOOZA\Front\`
    - prod: `C:\workspace\IIS\Front\`
 
-## dev → prod 차이 (참고)
+## 도메인 (dev·prod 공통)
 
-| 키 | dev | prod |
-|---|-----|------|
-| `NEXT_PUBLIC_SITE_URL` | `https://dev.antooza.com` | `https://antooza.com` |
-| `NEXT_PUBLIC_API_URL` / `API_URL` | `https://dev-api.antooza.com` | `https://api.antooza.com` |
-| `NEXT_PUBLIC_RESOURCE_URL` | `https://dev-resource.antooza.com` | `https://resource.antooza.com` |
-| `NEXT_PUBLIC_EMBED_DOMAIN` | `dev.antooza.com` | `antooza.com` |
-| `SIGNALR_CHAT_URL` / `SIGNALR_APP_URL` | `https://dev-api.antooza.com/hubs/{chat,app}` | `https://api.antooza.com/hubs/{chat,app}` |
+> dev 서버를 운영 도메인으로 사용 — `dev-` 접두사 제거(2026-07-11). dev/prod 값 동일.
+
+| 키 | 값 |
+|---|-----|
+| `NEXT_PUBLIC_SITE_URL` | `https://antooza.com` |
+| `NEXT_PUBLIC_API_URL` / `API_URL` | `https://api.antooza.com` |
+| `NEXT_PUBLIC_RESOURCE_URL` | `https://resource.antooza.com` |
+| `NEXT_PUBLIC_EMBED_DOMAIN` | `antooza.com` |
+| `SIGNALR_CHAT_URL` / `SIGNALR_APP_URL` | `https://api.antooza.com/hubs/{chat,app}` |
 
 ## 보안 노트
 

+ 1 - 1
deploy/winsw/antooza-frontend.xml

@@ -16,7 +16,7 @@
 <service>
   <id>antooza-frontend</id>
   <name>ANTOOZA Frontend (Next.js standalone)</name>
-  <description>Next.js production server for dev.antooza.com / antooza.com (port 3000)</description>
+  <description>Next.js production server for antooza.com (port 3000)</description>
 
   <executable>node</executable>
   <arguments>server.js</arguments>

+ 22 - 0
lib/api/chatSettings.ts

@@ -0,0 +1,22 @@
+// 채팅 수신 설정 클라이언트 API (수신설정 modal 전용) — Route Handler 프록시 경유(fetchApi)
+// Backend Web.Api/Endpoints/MyPage/{GetChatSettings,UpdateChatSettings}.cs 대응.
+import { fetchApi } from '@/lib/utils/client';
+import { ResultDto } from '@/types/response/common';
+import { ChatSettings } from '@/types/chat';
+
+// 내 채팅 수신 설정 조회 (JWT) — GET /api/mypage/chat-settings
+export async function fetchChatSettings(): Promise<ResultDto<ChatSettings>>
+{
+	return await fetchApi<ChatSettings>('/api/mypage/chat-settings', {
+		method: 'GET'
+	});
+}
+
+// 내 채팅 수신 설정 저장 (JWT) — POST /api/mypage/chat-settings (백엔드 case-insensitive 바인딩)
+export async function updateChatSettings(settings: ChatSettings): Promise<ResultDto<null>>
+{
+	return await fetchApi<null>('/api/mypage/chat-settings', {
+		method: 'POST',
+		body: settings
+	});
+}

+ 16 - 0
lib/api/marketMovers.ts

@@ -0,0 +1,16 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { MarketMoversResponse } from '@/types/marketMover';
+import { fetchJson } from '@/lib/utils/server';
+
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 등락 상위 종목 — 상승/하락/상한/하한 각 상위 5 + 스파크라인 (메인, 익명 열람)
+export async function fetchMarketMovers(): Promise<ResultDto<MarketMoversResponse>>
+{
+	return await fetchJson<MarketMoversResponse>('/api/market/movers', {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 3 - 1
lib/api/stock.ts

@@ -5,7 +5,7 @@ import { StockListResponse, StockDetailResponse, StockHistoryResponse } from '@/
 import { fetchJson } from '@/lib/utils/server';
 
 // 종목 목록 조회 (시장 필터/검색/페이징)
-export async function fetchStockList(params: { q?: string; market?: string; page?: number; perPage?: number }): Promise<ResultDto<StockListResponse>>
+export async function fetchStockList(params: { q?: string; market?: string; page?: number; perPage?: number; sort?: string; order?: string }): Promise<ResultDto<StockListResponse>>
 {
 	const queryParams = new URLSearchParams();
 
@@ -14,6 +14,8 @@ export async function fetchStockList(params: { q?: string; market?: string; page
 
 	if (params.q) queryParams.set('q', params.q);
 	if (params.market) queryParams.set('market', params.market);
+	if (params.sort) queryParams.set('sort', params.sort);
+	if (params.order) queryParams.set('order', params.order);
 
 	return await fetchJson<StockListResponse>(`/api/stocks?${queryParams.toString()}`, {
 		method: 'GET',

+ 9 - 1
lib/api/system.ts

@@ -1,7 +1,7 @@
 import { cache } from 'react';
 import { ResultDto } from '@/types/response/common';
 import { fetchJson } from '@/lib/utils/server';
-import Config from '@/types/config';
+import Config, { NavMenuLink } from '@/types/config';
 
 // Config 값 조회
 export const fetchConfig = cache(async (): Promise<ResultDto<Config|null>> => {
@@ -9,4 +9,12 @@ export const fetchConfig = cache(async (): Promise<ResultDto<Config|null>> => {
         method: 'GET',
         headers: {'Content-Type': 'application/json'},
     });
+});
+
+// 관리자 관리형 상단 메뉴 조회 (노출 항목만 정렬순)
+export const fetchNavMenu = cache(async (): Promise<ResultDto<{ items: NavMenuLink[] }|null>> => {
+    return await fetchJson<{ items: NavMenuLink[] }>('/api/config/nav-menu', {
+        method: 'GET',
+        headers: {'Content-Type': 'application/json'},
+    });
 });

+ 0 - 10
next.config.ts

@@ -23,21 +23,11 @@ const nextConfig: NextConfig = {
 				hostname: "api.antooza.com",
 				pathname: "/uploads/**"
 			},
-			{
-				protocol: "https",
-				hostname: "dev-api.antooza.com",
-				pathname: "/uploads/**"
-			},
 			{
 				protocol: "https",
 				hostname: "resource.antooza.com",
 				pathname: "/uploads/**"
 			},
-			{
-				protocol: "https",
-				hostname: "dev-resource.antooza.com",
-				pathname: "/uploads/**"
-			},
 			{
 				protocol: "https",
 				hostname: "img.youtube.com"

+ 16 - 0
types/chat.ts

@@ -23,3 +23,19 @@ export interface ChatMessage {
 	gradeTextColor?: string|null;
 	memberIcon?: string|null;
 }
+
+// ChatHub.ReceiveParticipants 페이로드 — 접속 참여자 목록 row (SignalR camelCase)
+export interface ChatParticipant {
+	memberName: string;
+	isGuest: boolean;
+	sid?: string|null;
+	icon?: string|null;
+	level: number;
+}
+
+// GET/POST /api/mypage/chat-settings — 채팅 수신 설정 (초대/귓속말/팔로잉 접속 표시)
+export interface ChatSettings {
+	allowInvite: boolean;
+	allowWhisper: boolean;
+	showFollowingOnline: boolean;
+}

+ 6 - 0
types/config.ts

@@ -9,6 +9,12 @@ export default interface Config {
 	external: ExternalApiForm;
 	toss: TossForm;
 	attendance: AttendanceForm;
+	navMenu?: NavMenuLink[];
+}
+
+export interface NavMenuLink {
+	label: string;
+	href: string;
 }
 
 export interface BasicForm {

+ 4 - 18
types/domesticSummary.ts

@@ -1,20 +1,14 @@
-// 국내 증시 요약 도메인 타입 — Backend Application/Features/Api/Stocks/GetDomesticSummary/Response.cs 대응
-// 필드명 camelCase. flucRateBp = %×100. (투자자별 순매수·베이시스는 미지원으로 제외)
+// 국내·글로벌 증시 요약 도메인 타입 — Backend Application/Features/Api/Stocks/GetDomesticSummary/Response.cs 대응
+// 필드명 camelCase. flucRateBp = %×100. 등락종목수는 GetMarketMovers(marketMover.ts)로 분리됨.
 
 export interface DomesticIndexRow {
-	key: string;          // kospi | kosdaq | kospi200
+	key: string;          // kospi | kosdaq | kospi200 | sp500 | nasdaq
 	name: string;
 	close: number;
 	changeVal: number;
 	flucRateBp: number;
 	tradeDate: string;
-
-	// 등락 종목 수 (코스피/코스닥만; KOSPI200 은 null)
-	advances?: number|null;
-	declines?: number|null;
-	unchanged?: number|null;
-	limitUp?: number|null;
-	limitDown?: number|null;
+	spark: number[];      // 최근 종가 (과거→최신), 없으면 빈 배열
 }
 
 export interface DomesticSummaryResponse {
@@ -25,11 +19,3 @@ export interface DomesticSummaryResponse {
 export function formatIndexChange(v: number): string {
 	return Math.abs(v).toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
 }
-
-// 종목 수 — null 은 "–"
-export function formatCount(v?: number|null): string {
-	if (v === null || v === undefined) {
-		return '–';
-	}
-	return v.toLocaleString('ko-KR');
-}

+ 41 - 0
types/marketMover.ts

@@ -0,0 +1,41 @@
+// 등락 상위 종목 도메인 타입 — Backend Application/Features/Api/Stocks/GetMarketMovers/Response.cs 대응
+// 필드명 camelCase. flucRateBp = %×100. 상승/하락은 상한·하한 제외(4그룹 비중복).
+
+export interface MarketMoverItem {
+	code: string;
+	name: string;
+	market: number;      // 1 KOSPI / 2 KOSDAQ / 3 KONEX
+	close: number;
+	changeVal: number;
+	flucRateBp: number;
+	spark: number[];     // 최근 종가 (과거→최신), 없으면 빈 배열
+}
+
+export interface MarketMoverGroup {
+	total: number;
+	items: MarketMoverItem[];
+}
+
+export interface MarketMoversResponse {
+	tradeDate: string;
+	advances: MarketMoverGroup;
+	declines: MarketMoverGroup;
+	limitUp: MarketMoverGroup;
+	limitDown: MarketMoverGroup;
+}
+
+export const MARKET_LABEL: Record<number, string> = {
+	1: 'KOSPI',
+	2: 'KOSDAQ',
+	3: 'KONEX'
+};
+
+// 종가 — 정수 원, 천단위 콤마
+export function formatStockPrice(v: number): string {
+	return v.toLocaleString('ko-KR');
+}
+
+// 그룹 전체 종목 수
+export function formatMoverCount(v: number): string {
+	return v.toLocaleString('ko-KR');
+}

+ 2 - 1
types/marketQuote.ts

@@ -24,7 +24,8 @@ export const GROUP_LABELS: Record<string, string> = {
 	'us-major': '미국 주요',
 	'asia-major': '아시아 주요',
 	'europe-major': '유럽 주요',
-	'commodity': '상품·선물'
+	'commodity': '상품·선물',
+	'macro': '환율·금리'
 };
 
 export function groupLabel(code: string): string {

+ 6 - 0
types/stock.ts

@@ -13,8 +13,14 @@ export interface StockListRow {
 	closePrice: number|null;
 	changeRate: number|null;
 	marketCap: number|null;
+	volume: number|null;
+	tradingValue: number|null;
 }
 
+// 목록 정렬 키 (백엔드 GetList.Query.Sort 와 동일) + 방향
+export type StockSortKey = 'name'|'price'|'change'|'cap'|'value';
+export type StockSortOrder = 'asc'|'desc';
+
 export interface StockListResponse {
 	total: number;
 	list: StockListRow[];