Ver Fonte

no message

KIM-JINO5 há 2 meses atrás
pai
commit
fb8da1faa9

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

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

+ 40 - 14
app/component/Layout.tsx

@@ -1,12 +1,28 @@
 'use client';
 
+import { useState, useCallback } from 'react';
 import Link from 'next/link';
 import Styles from '../styles/common.module.scss';
 import useAuth from '@/hooks/useAuth';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons';
 
-export default function Layout({ children }: { children: React.ReactNode })
-{
+type Props = {
+	children: React.ReactNode;
+	sidebarContent?: React.ReactNode;
+};
+
+export default function Layout({ children, sidebarContent }: Props) {
 	const { isAuthenticated, isLoading, logout } = useAuth();
+	const [sidebarOpen, setSidebarOpen] = useState(false);
+
+	const toggleSidebar = useCallback(() => {
+		setSidebarOpen((prev) => !prev);
+	}, []);
+
+	const closeSidebar = useCallback(() => {
+		setSidebarOpen(false);
+	}, []);
 
 	if (isLoading) {
 		return <></>;
@@ -14,16 +30,21 @@ export default function Layout({ children }: { children: React.ReactNode })
 
     return (
         <>
-            <div id='container' className={Styles.container}>
+            <div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}`}>
 
                 {/* 상단 내용 */}
                 <header id='header' className={`${Styles.header} flex items-center justify-between w-full px-4`}>
-                    <Link href='/' className='font-bold text-xl'>
-                        <picture>
-                            <source src="image.webp" type="image/webp" />
-                            <img src="/resources/m-logo.webp" alt="bitforum logo" />
-                        </picture>
-                    </Link>
+					<div className='flex items-center gap-2'>
+						<button type='button' className={Styles.hamburger} onClick={toggleSidebar} aria-label='메뉴'>
+							<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
+						</button>
+						<Link href='/' className='font-bold text-xl'>
+							<picture>
+								<source src="image.webp" type="image/webp" />
+								<img src="/resources/logo.svg" alt="bitforum logo" />
+							</picture>
+						</Link>
+					</div>
                     <nav className='flex grow items-center justify-between'>
                         <ul className='flex gap-4'>
                             <li>
@@ -77,16 +98,21 @@ export default function Layout({ children }: { children: React.ReactNode })
                     </nav>
                 </header>
 
-                {/* 전광판 */}
-                <p id='ticker' className={Styles.panel}></p>
+                {/* 좌측 사이드바 */}
+                <aside id='aside' className={Styles.aside}>
+					{sidebarContent}
+				</aside>
+
+				{/* 모바일 오버레이 */}
+				<div className={Styles.overlay} onClick={closeSidebar} />
 
-                {/* 좌측 내용 */}
+                {/* 메인 내용 */}
                 <main id='main' className={`${Styles.main} relative`}>
                     {children}
                 </main>
 
-                {/* 우측 내용 */}
-                <aside id='aside' className={Styles.aside}></aside>
+				{/* 우측 채팅 사이드바 */}
+				<aside id='chatAside' className={Styles.chatAside}></aside>
 
                 {/* 하단 내용 */}
                 <footer id='footer' className={`${Styles.footer} px-4`}>

+ 95 - 0
app/component/crypto/CryptoDashboard.tsx

@@ -0,0 +1,95 @@
+'use client';
+
+import { useRef, useState, useCallback, useEffect } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import useMarketData from '@/hooks/useMarketData';
+import useTradeSound from '@/hooks/useTradeSound';
+import MarketHeader from './MarketHeader';
+import TradingChart from './TradingChart';
+import Orderbook from './Orderbook';
+import TradeHistory from './TradeHistory';
+import './dashboard.scss';
+
+export default function CryptoDashboard() {
+	const { selectedMarket } = useCryptoContext();
+	useMarketData(selectedMarket);
+	const tradeSound = useTradeSound();
+
+	const [chartHeight, setChartHeight] = useState(500);
+	const [orderbookRatio, setOrderbookRatio] = useState(50); // %
+	const dashboardRef = useRef<HTMLDivElement>(null);
+	const draggingRef = useRef<'chart' | 'split' | null>(null);
+	const startRef = useRef({ y: 0, x: 0, value: 0 });
+
+	const handleMouseDown = useCallback((type: 'chart' | 'split', e: React.MouseEvent) => {
+		e.preventDefault();
+		draggingRef.current = type;
+		if (type === 'chart') {
+			startRef.current = { y: e.clientY, x: 0, value: chartHeight };
+		} else {
+			startRef.current = { y: 0, x: e.clientX, value: orderbookRatio };
+		}
+		document.body.style.cursor = type === 'chart' ? 'row-resize' : 'col-resize';
+		document.body.style.userSelect = 'none';
+	}, [chartHeight, orderbookRatio]);
+
+	useEffect(() => {
+		const handleMouseMove = (e: MouseEvent) => {
+			if (!draggingRef.current) return;
+
+			if (draggingRef.current === 'chart') {
+				const delta = e.clientY - startRef.current.y;
+				const newHeight = Math.min(800, Math.max(200, startRef.current.value + delta));
+				setChartHeight(newHeight);
+			} else if (draggingRef.current === 'split' && dashboardRef.current) {
+				const bottomEl = dashboardRef.current.querySelector('.dashboard-bottom') as HTMLElement;
+				if (!bottomEl) return;
+				const rect = bottomEl.getBoundingClientRect();
+				const totalWidth = rect.width;
+				const relX = e.clientX - rect.left;
+				const ratio = Math.min(80, Math.max(20, (relX / totalWidth) * 100));
+				setOrderbookRatio(ratio);
+			}
+		};
+
+		const handleMouseUp = () => {
+			if (draggingRef.current) {
+				draggingRef.current = null;
+				document.body.style.cursor = '';
+				document.body.style.userSelect = '';
+			}
+		};
+
+		document.addEventListener('mousemove', handleMouseMove);
+		document.addEventListener('mouseup', handleMouseUp);
+		return () => {
+			document.removeEventListener('mousemove', handleMouseMove);
+			document.removeEventListener('mouseup', handleMouseUp);
+		};
+	}, []);
+
+	return (
+		<div className='crypto-dashboard' ref={dashboardRef}>
+			<MarketHeader />
+			<div style={{ height: chartHeight }}>
+				<TradingChart />
+			</div>
+			<div
+				className='resize-handle-h'
+				onMouseDown={(e) => handleMouseDown('chart', e)}
+			/>
+			<div className='dashboard-bottom'>
+				<div className='dashboard-orderbook' style={{ width: `${orderbookRatio}%` }}>
+					<Orderbook playOrderbookSound={tradeSound.playOrderbookSound} />
+				</div>
+				<div
+					className='resize-handle-v'
+					onMouseDown={(e) => handleMouseDown('split', e)}
+				/>
+				<div className='dashboard-trades' style={{ width: `${100 - orderbookRatio}%` }}>
+					<TradeHistory tradeSound={tradeSound} />
+				</div>
+			</div>
+		</div>
+	);
+}

+ 23 - 0
app/component/crypto/CryptoPageContent.tsx

@@ -0,0 +1,23 @@
+'use client';
+
+import { CryptoProvider } from '@/contexts/cryptoProvider';
+import Layout from '@/app/component/Layout';
+import CryptoDashboard from './CryptoDashboard';
+import CryptoSidebar from './CryptoSidebar';
+import PopupModal from '@/app/component/PopupModal';
+import type { TickerRestData } from '@/types/crypto';
+
+type Props = {
+	initialTickers: TickerRestData[];
+};
+
+export default function CryptoPageContent({ initialTickers }: Props) {
+	return (
+		<CryptoProvider>
+			<Layout sidebarContent={<CryptoSidebar initialTickers={initialTickers} />}>
+				<PopupModal position='main' />
+				<CryptoDashboard />
+			</Layout>
+		</CryptoProvider>
+	);
+}

+ 138 - 0
app/component/crypto/CryptoSidebar.tsx

@@ -0,0 +1,138 @@
+'use client';
+
+import { useState, useMemo, useEffect, useRef } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import useTickers from '@/hooks/useTickers';
+import type { TickerRestData } from '@/types/crypto';
+import './sidebar.scss';
+
+const QUOTE_TABS = ['KRW', 'BTC', 'USDT'] as const;
+
+type Props = {
+	initialTickers?: TickerRestData[];
+};
+
+function formatPrice(price: number): string {
+	if (price >= 1000) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
+	}
+	if (price >= 1) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
+	}
+	return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
+}
+
+function formatChangeRate(rate: number): string {
+	const pct = (rate * 100).toFixed(2);
+	return rate >= 0 ? `+${pct}%` : `${pct}%`;
+}
+
+function getChangeClass(change: string): string {
+	if (change === 'RISE') return 'up';
+	if (change === 'FALL') return 'down';
+	return 'neutral';
+}
+
+export default function CryptoSidebar({ initialTickers }: Props) {
+	const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
+	const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);
+	const [search, setSearch] = useState('');
+	const prevPricesRef = useRef<Map<string, number>>(new Map());
+
+	// tickers를 context에 공유
+	useEffect(() => {
+		setContextTickers(tickers);
+	}, [tickers, setContextTickers]);
+
+	useEffect(() => {
+		setContextMeta(meta);
+	}, [meta, setContextMeta]);
+
+	const sortedTickers = useMemo(() => {
+		const arr = Array.from(tickers.values());
+		const keyword = search.toLowerCase().trim();
+
+		const filtered = keyword
+			? arr.filter((t) => {
+				const m = meta.get(t.market);
+				return t.symbol.toLowerCase().includes(keyword) ||
+					t.market.toLowerCase().includes(keyword) ||
+					(m?.korName && m.korName.includes(keyword));
+			})
+			: arr;
+
+		return filtered.sort((a, b) => b.accTradePrice24h - a.accTradePrice24h);
+	}, [tickers, search, meta]);
+
+	// 시세 변동 보더 플래시 애니메이션
+	useEffect(() => {
+		for (const [market, ticker] of tickers) {
+			const prev = prevPricesRef.current.get(market);
+			if (prev !== undefined && prev !== ticker.tradePrice) {
+				const direction = ticker.tradePrice > prev ? 'up' : 'down';
+				const el = document.querySelector(`[data-market="${market}"]`) as HTMLElement | null;
+				if (el) {
+					el.classList.remove('flash-up', 'flash-down');
+					void el.offsetWidth; // force reflow
+					el.classList.add(`flash-${direction}`);
+				}
+			}
+			prevPricesRef.current.set(market, ticker.tradePrice);
+		}
+	}, [tickers]);
+
+	return (
+		<div className='crypto-sidebar'>
+			<div className='sidebar-tabs'>
+				{QUOTE_TABS.map((tab) => (
+					<button
+						key={tab}
+						type='button'
+						className={`tab ${quoteMarket === tab ? 'active' : ''}`}
+						onClick={() => setQuoteMarket(tab)}
+					>
+						{tab}
+					</button>
+				))}
+			</div>
+			<div className='sidebar-search'>
+				<input
+					type='text'
+					placeholder='코인 검색...'
+					value={search}
+					onChange={(e) => setSearch(e.target.value)}
+				/>
+			</div>
+			<div className='sidebar-list'>
+				{sortedTickers.map((ticker) => {
+					const tickerMeta = meta.get(ticker.market);
+					return (
+						<button
+							key={ticker.market}
+							type='button'
+							className={`sidebar-item ${selectedMarket === ticker.market ? 'active' : ''}`}
+							data-market={ticker.market}
+							onClick={() => setSelectedMarket(ticker.market)}
+						>
+							<div className='item-info'>
+								<span className='item-kor-name'>{tickerMeta?.korName ?? ticker.symbol}</span>
+								<span className='item-symbol'>{ticker.symbol}</span>
+							</div>
+							<div className='item-price'>
+								<span className={`price ${getChangeClass(ticker.change)}`}>
+									{formatPrice(ticker.tradePrice)}
+								</span>
+								<span className={`change ${getChangeClass(ticker.change)}`}>
+									{formatChangeRate(ticker.signedChangeRate)}
+								</span>
+							</div>
+						</button>
+					);
+				})}
+				{sortedTickers.length === 0 && (
+					<div className='sidebar-empty'>검색 결과가 없습니다.</div>
+				)}
+			</div>
+		</div>
+	);
+}

+ 158 - 0
app/component/crypto/MarketHeader.tsx

@@ -0,0 +1,158 @@
+'use client';
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import './market-header.scss';
+
+function formatPrice(price: number): string {
+	if (price >= 1000) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
+	}
+	if (price >= 1) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
+	}
+	return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
+}
+
+function formatVolume(volume: number): string {
+	if (volume >= 1_000_000_000) {
+		return `${(volume / 1_000_000_000).toFixed(1)}B`;
+	}
+	if (volume >= 1_000_000) {
+		return `${(volume / 1_000_000).toFixed(1)}M`;
+	}
+	if (volume >= 1_000) {
+		return `${(volume / 1_000).toFixed(1)}K`;
+	}
+	return volume.toFixed(2);
+}
+
+const MAX_SPARKLINE_POINTS = 60;
+
+function VolumeSparkline({ value, market }: { value: number; market: string }) {
+	const historyRef = useRef<number[]>([]);
+	const prevMarketRef = useRef(market);
+	const [open, setOpen] = useState(false);
+	const wrapperRef = useRef<HTMLDivElement>(null);
+
+	// 마켓 변경 시 히스토리 초기화
+	if (market !== prevMarketRef.current) {
+		historyRef.current = [];
+		prevMarketRef.current = market;
+	}
+
+	useEffect(() => {
+		if (value <= 0) return;
+		const h = historyRef.current;
+		h.push(value);
+		if (h.length > MAX_SPARKLINE_POINTS) {
+			h.shift();
+		}
+	}, [value]);
+
+	// 외부 클릭 시 닫기
+	const handleClickOutside = useCallback((e: MouseEvent) => {
+		if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
+			setOpen(false);
+		}
+	}, []);
+
+	useEffect(() => {
+		if (open) {
+			document.addEventListener('mousedown', handleClickOutside);
+			return () => document.removeEventListener('mousedown', handleClickOutside);
+		}
+	}, [open, handleClickOutside]);
+
+	const history = historyRef.current;
+	if (history.length < 2) return null;
+
+	const min = Math.min(...history);
+	const max = Math.max(...history);
+	const range = max - min || 1;
+	const w = 80;
+	const h = 20;
+
+	const points = history.map((v, i) => {
+		const x = (i / (history.length - 1)) * w;
+		const y = h - ((v - min) / range) * h;
+		return `${x.toFixed(1)},${y.toFixed(1)}`;
+	}).join(' ');
+
+	const isUp = history[history.length - 1] >= history[history.length - 2];
+	const color = isUp ? 'hsl(var(--crypto-up))' : 'hsl(var(--crypto-down))';
+
+	return (
+		<div className='volume-sparkline-wrapper' ref={wrapperRef}>
+			<svg
+				className='volume-sparkline'
+				width={w}
+				height={h}
+				viewBox={`0 0 ${w} ${h}`}
+				onClick={() => setOpen((prev) => !prev)}
+			>
+				<polyline
+					points={points}
+					fill='none'
+					stroke={color}
+					strokeWidth='1.5'
+					strokeLinejoin='round'
+					strokeLinecap='round'
+				/>
+			</svg>
+			{open && (
+				<div className='volume-popover'>
+					<span className='volume-popover-label'>거래량(24h)</span>
+					<span className='volume-popover-value'>{formatVolume(value)} KRW</span>
+				</div>
+			)}
+		</div>
+	);
+}
+
+export default function MarketHeader() {
+	const { selectedMarket, tickers } = useCryptoContext();
+	const ticker = tickers.get(selectedMarket);
+
+	if (!ticker) {
+		return (
+			<div className='market-header'>
+				<div className='market-header-loading'>로딩 중...</div>
+			</div>
+		);
+	}
+
+	const changeClass = ticker.change === 'RISE' ? 'up' : ticker.change === 'FALL' ? 'down' : 'neutral';
+	const changeRate = (ticker.signedChangeRate * 100).toFixed(2);
+	const changeSign = ticker.signedChangePrice >= 0 ? '+' : '';
+
+	return (
+		<div className='market-header'>
+			<div className='market-title'>
+				<span className='symbol'>{ticker.symbol}</span>
+				<span className='pair'>/{selectedMarket.split('-')[0]}</span>
+			</div>
+			<div className={`market-price ${changeClass}`}>
+				<span className='current-price'>{formatPrice(ticker.tradePrice)}</span>
+			</div>
+			<div className={`market-change ${changeClass}`}>
+				<span>{changeSign}{formatPrice(ticker.signedChangePrice)}</span>
+				<span>({changeSign}{changeRate}%)</span>
+			</div>
+			<div className='market-stats'>
+				<div className='stat'>
+					<span className='label'>고가</span>
+					<span className='value up'>{formatPrice(ticker.highPrice)}</span>
+				</div>
+				<div className='stat'>
+					<span className='label'>저가</span>
+					<span className='value down'>{formatPrice(ticker.lowPrice)}</span>
+				</div>
+				<div className='stat volume-stat'>
+					<span className='label'>거래량(24h)</span>
+					<VolumeSparkline value={ticker.accTradePrice24h} market={selectedMarket} />
+				</div>
+			</div>
+		</div>
+	);
+}

+ 217 - 0
app/component/crypto/Orderbook.tsx

@@ -0,0 +1,217 @@
+'use client';
+
+import { useMemo, useEffect, useRef, useState } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import useOrderbook from '@/hooks/useOrderbook';
+import useTrades from '@/hooks/useTrades';
+import './orderbook.scss';
+
+interface OrderbookProps {
+	playOrderbookSound: (type: 'add' | 'remove') => void;
+}
+
+function formatPrice(price: number): string {
+	if (price >= 1000) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
+	}
+	if (price >= 1) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
+	}
+	return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
+}
+
+function formatSize(size: number): string {
+	if (size >= 1) {
+		return size.toFixed(4);
+	}
+	return size.toFixed(6);
+}
+
+export default function Orderbook({ playOrderbookSound }: OrderbookProps) {
+	const { selectedMarket, tickers } = useCryptoContext();
+	const orderbook = useOrderbook(selectedMarket);
+	const trades = useTrades(selectedMarket);
+
+	// 최근 체결 가격 추적 (플래시 효과용)
+	const [flashPrice, setFlashPrice] = useState<{ price: number; side: string } | null>(null);
+	const prevTradeRef = useRef<number>(0);
+
+	// 호가 가격 목록 추적 (추가/제거 감지용)
+	const prevPricesRef = useRef<Set<number>>(new Set());
+
+	// LTP 5m ago 가이드선
+	const [ltp5mAgo, setLtp5mAgo] = useState<number | null>(null);
+	const priceHistoryRef = useRef<{ price: number; time: number }[]>([]);
+
+	// 현재 체결가 기록 + 5분 전 가격 계산
+	const ticker = tickers.get(selectedMarket);
+	useEffect(() => {
+		if (!ticker) return;
+
+		const now = Date.now();
+		priceHistoryRef.current.push({ price: ticker.tradePrice, time: now });
+
+		// 5분 이상 된 데이터 정리 (6분까지 보관)
+		const cutoff = now - 6 * 60 * 1000;
+		priceHistoryRef.current = priceHistoryRef.current.filter((p) => p.time > cutoff);
+
+		// 5분 전 가격 찾기
+		const fiveMinAgo = now - 5 * 60 * 1000;
+		const closest = priceHistoryRef.current.reduce<{ price: number; time: number } | null>((best, p) => {
+			if (p.time <= fiveMinAgo) {
+				if (!best || Math.abs(p.time - fiveMinAgo) < Math.abs(best.time - fiveMinAgo)) {
+					return p;
+				}
+			}
+			return best;
+		}, null);
+
+		setLtp5mAgo(closest?.price ?? null);
+	}, [ticker, selectedMarket]);
+
+	// 마켓 변경 시 가격 기록 초기화
+	useEffect(() => {
+		priceHistoryRef.current = [];
+		setLtp5mAgo(null);
+	}, [selectedMarket]);
+
+	// 체결 플래시 효과
+	useEffect(() => {
+		if (trades.length === 0) return;
+		const latest = trades[0];
+		if (latest.sequentialId !== prevTradeRef.current) {
+			prevTradeRef.current = latest.sequentialId;
+			setFlashPrice({ price: latest.tradePrice, side: latest.askBid });
+			const timer = setTimeout(() => setFlashPrice(null), 500);
+			return () => clearTimeout(timer);
+		}
+	}, [trades]);
+
+	const { asks, bids, maxSize, spread, spreadPct } = useMemo(() => {
+		if (!orderbook || !orderbook.units.length) {
+			return { asks: [], bids: [], maxSize: 0, spread: 0, spreadPct: '' };
+		}
+
+		// asks: 오름차순 정렬 (column-reverse로 표시되므로 낮은 가격이 아래/스프레드 근처)
+		const askEntries = orderbook.units
+			.filter((u) => u.askPrice > 0)
+			.map((u) => ({ price: u.askPrice, size: u.askSize }))
+			.sort((a, b) => a.price - b.price)
+			.slice(0, 15);
+
+		const bidEntries = orderbook.units
+			.filter((u) => u.bidPrice > 0)
+			.map((u) => ({ price: u.bidPrice, size: u.bidSize }))
+			.sort((a, b) => b.price - a.price)
+			.slice(0, 15);
+
+		const allSizes = [...askEntries.map((e) => e.size), ...bidEntries.map((e) => e.size)];
+		const max = Math.max(...allSizes, 0.0001);
+
+		const sp = askEntries.length > 0 && bidEntries.length > 0
+			? askEntries[0].price - bidEntries[0].price
+			: 0;
+
+		const midPrice = bidEntries.length > 0 ? bidEntries[0].price : 1;
+		const pct = midPrice > 0 ? ((sp / midPrice) * 100).toFixed(2) : '0.00';
+
+		return {
+			asks: askEntries, // reverse 제거 — CSS column-reverse로 처리
+			bids: bidEntries,
+			maxSize: max,
+			spread: sp,
+			spreadPct: pct,
+		};
+	}, [orderbook]);
+
+	// 호가 사운드 — 매 업데이트마다 재생 (bitFlyer 스타일 연속 딸깍)
+	useEffect(() => {
+		if (asks.length === 0 && bids.length === 0) return;
+
+		const currentPrices = new Set<number>();
+		asks.forEach((e) => currentPrices.add(e.price));
+		bids.forEach((e) => currentPrices.add(e.price));
+
+		const prev = prevPricesRef.current;
+		if (prev.size > 0) {
+			let hasRemove = false;
+			prev.forEach((p) => {
+				if (!currentPrices.has(p)) hasRemove = true;
+			});
+
+			if (hasRemove) {
+				playOrderbookSound('remove');
+			} else {
+				playOrderbookSound('add');
+			}
+		}
+
+		prevPricesRef.current = currentPrices;
+	}, [asks, bids, playOrderbookSound]);
+
+	// LTP 가이드선이 asks/bids 범위에 있는지 확인
+	const ltpInAsks = ltp5mAgo !== null && asks.length > 0 && ltp5mAgo >= asks[0]?.price;
+	const ltpInBids = ltp5mAgo !== null && bids.length > 0 && ltp5mAgo <= bids[0]?.price;
+
+	if (!orderbook) {
+		return (
+			<div className='orderbook'>
+				<div className='orderbook-title'>
+					<span>호가</span>
+				</div>
+				<div className='orderbook-loading'>로딩 중...</div>
+			</div>
+		);
+	}
+
+	return (
+		<div className='orderbook'>
+			<div className='orderbook-title'>
+				<span>호가</span>
+				<span className='orderbook-total'>
+					<span className='total-ask'>매도 {formatSize(orderbook.totalAskSize)}</span>
+					<span className='total-bid'>매수 {formatSize(orderbook.totalBidSize)}</span>
+				</span>
+			</div>
+			<div className='orderbook-body'>
+				{/* 매도 호가 — column-reverse로 낮은 가격이 스프레드 근처 */}
+				<div className='ask-section'>
+					{asks.map((entry, i) => {
+						const isFlash = flashPrice && flashPrice.side === 'ASK' && flashPrice.price === entry.price;
+						const isLtp = ltpInAsks && ltp5mAgo === entry.price;
+						return (
+							<div key={`ask-${i}`} className={`orderbook-row ask ${isFlash ? 'flash-sell' : ''}`}>
+								<div className='bar' style={{ width: `${(entry.size / maxSize) * 100}%` }} />
+								<span className='size'>{formatSize(entry.size)}</span>
+								<span className='price'>{formatPrice(entry.price)}</span>
+								{isLtp && <div className='ltp-guide'>LTP 5m ago</div>}
+							</div>
+						);
+					})}
+				</div>
+
+				{/* 스프레드 — 항상 중앙 고정 */}
+				<div className='spread-row'>
+					<span className='spread-price'>{ticker ? formatPrice(ticker.tradePrice) : ''}</span>
+					<span className='spread-info'>스프레드 {formatPrice(spread)} ({spreadPct}%)</span>
+				</div>
+
+				{/* 매수 호가 */}
+				<div className='bid-section'>
+					{bids.map((entry, i) => {
+						const isFlash = flashPrice && flashPrice.side === 'BID' && flashPrice.price === entry.price;
+						const isLtp = ltpInBids && ltp5mAgo === entry.price;
+						return (
+							<div key={`bid-${i}`} className={`orderbook-row bid ${isFlash ? 'flash-buy' : ''}`}>
+								<div className='bar' style={{ width: `${(entry.size / maxSize) * 100}%` }} />
+								<span className='price'>{formatPrice(entry.price)}</span>
+								<span className='size'>{formatSize(entry.size)}</span>
+								{isLtp && <div className='ltp-guide'>LTP 5m ago</div>}
+							</div>
+						);
+					})}
+				</div>
+			</div>
+		</div>
+	);
+}

+ 115 - 0
app/component/crypto/TradeHistory.tsx

@@ -0,0 +1,115 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import useTrades from '@/hooks/useTrades';
+import './trade-history.scss';
+
+interface TradeSoundHandle {
+	playTradeSound: (side: 'BID' | 'ASK', tradeVolume?: number) => void;
+	muted: boolean;
+	toggleMute: () => void;
+}
+
+interface TradeHistoryProps {
+	tradeSound: TradeSoundHandle;
+}
+
+function formatPrice(price: number): string {
+	if (price >= 1000) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
+	}
+	if (price >= 1) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
+	}
+	return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
+}
+
+function formatSize(size: number): string {
+	if (size >= 1) {
+		return size.toFixed(4);
+	}
+	return size.toFixed(6);
+}
+
+function formatTime(timestamp: number): string {
+	const date = new Date(timestamp);
+	return date.toLocaleTimeString('ko-KR', {
+		hour: '2-digit',
+		minute: '2-digit',
+		second: '2-digit',
+		hour12: false,
+	});
+}
+
+export default function TradeHistory({ tradeSound }: TradeHistoryProps) {
+	const { selectedMarket, tickers } = useCryptoContext();
+	const trades = useTrades(selectedMarket);
+	const ticker = tickers.get(selectedMarket);
+	const { playTradeSound, muted, toggleMute } = tradeSound;
+	const prevTradeIdRef = useRef<number>(0);
+
+	// 체결 시 효과음 재생
+	useEffect(() => {
+		if (trades.length === 0) return;
+		const latest = trades[0];
+		if (latest.sequentialId !== prevTradeIdRef.current && prevTradeIdRef.current !== 0) {
+			playTradeSound(latest.askBid as 'BID' | 'ASK', latest.tradeVolume);
+		}
+		prevTradeIdRef.current = latest.sequentialId;
+	}, [trades, playTradeSound]);
+
+	// 체결 강도 계산: 매수 비율 (%)
+	let tradeIntensity: number | null = null;
+	let intensityClass = '';
+	if (ticker) {
+		const total = ticker.accAskVolume + ticker.accBidVolume;
+		if (total > 0) {
+			tradeIntensity = (ticker.accBidVolume / total) * 100;
+			intensityClass = tradeIntensity >= 50 ? 'up' : 'down';
+		}
+	}
+
+	return (
+		<div className='trade-history'>
+			<div className='trade-title'>
+				<span className='trade-title-left'>
+					<span>체결 내역</span>
+					<button
+						className={`sound-toggle ${muted ? 'muted' : 'active'}`}
+						onClick={toggleMute}
+						title={muted ? '효과음 켜기' : '효과음 끄기'}
+					>
+						{muted ? '🔇' : '🔊'}
+					</button>
+				</span>
+				{tradeIntensity !== null && (
+					<span className={`trade-intensity ${intensityClass}`}>
+						체결강도 {tradeIntensity.toFixed(1)}%
+					</span>
+				)}
+			</div>
+			<div className='trade-header'>
+				<span>체결가</span>
+				<span>체결량</span>
+				<span>시간</span>
+			</div>
+			<div className='trade-body'>
+				{trades.length === 0 ? (
+					<div className='trade-loading'>로딩 중...</div>
+				) : (
+					trades.map((trade, i) => (
+						<div
+							key={`${trade.sequentialId}-${i}`}
+							className={`trade-row ${trade.askBid === 'BID' ? 'buy' : 'sell'}`}
+						>
+							<span className='price'>{formatPrice(trade.tradePrice)}</span>
+							<span className='size'>{formatSize(trade.tradeVolume)}</span>
+							<span className='time'>{formatTime(trade.tradeTimestamp)}</span>
+						</div>
+					))
+				)}
+			</div>
+		</div>
+	);
+}

+ 307 - 0
app/component/crypto/TradingChart.tsx

@@ -0,0 +1,307 @@
+'use client';
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import useCandles from '@/hooks/useCandles';
+import './trading-chart.scss';
+
+type IntervalType = 'sec' | '1' | '3' | '5' | '10' | '15' | '30' | '60' | '240' | 'day' | 'week' | 'month';
+
+const INTERVALS: { label: string; value: IntervalType }[] = [
+	{ label: '1초', value: 'sec' },
+	{ label: '1분', value: '1' },
+	{ label: '3분', value: '3' },
+	{ label: '5분', value: '5' },
+	{ label: '15분', value: '15' },
+	{ label: '30분', value: '30' },
+	{ label: '1시간', value: '60' },
+	{ label: '4시간', value: '240' },
+	{ label: '1일', value: 'day' },
+	{ label: '1주', value: 'week' },
+	{ label: '1월', value: 'month' },
+];
+
+const MA_COLORS = {
+	ma5: '#E8D44D',
+	ma10: '#E88B4D',
+	ma20: '#4DA6E8',
+} as const;
+
+export default function TradingChart() {
+	const { selectedMarket } = useCryptoContext();
+	const [interval, setInterval] = useState<IntervalType>('15');
+	const [showMA, setShowMA] = useState({ ma5: true, ma10: true, ma20: true });
+	const [isFullscreen, setIsFullscreen] = useState(false);
+	const { candles, volumes, ma5, ma10, ma20, loading } = useCandles(selectedMarket, interval);
+
+	const wrapperRef = useRef<HTMLDivElement>(null);
+	const chartContainerRef = useRef<HTMLDivElement>(null);
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const chartRef = useRef<any>(null);
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const candleSeriesRef = useRef<any>(null);
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const volumeSeriesRef = useRef<any>(null);
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const ma5SeriesRef = useRef<any>(null);
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const ma10SeriesRef = useRef<any>(null);
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const ma20SeriesRef = useRef<any>(null);
+
+	const prevDataKeyRef = useRef('');
+	const prevCandleLenRef = useRef(0);
+
+	// 차트 초기화
+	useEffect(() => {
+		if (!chartContainerRef.current) return;
+
+		let disposed = false;
+		const container = chartContainerRef.current;
+
+		const initChart = async () => {
+			const { createChart, CandlestickSeries, HistogramSeries, LineSeries } = await import('lightweight-charts');
+			if (disposed) return;
+
+			if (chartRef.current) {
+				chartRef.current.remove();
+			}
+
+			const chart = createChart(container, {
+				width: container.clientWidth,
+				height: container.clientHeight || 500,
+				layout: {
+					background: { color: '#ffffff' },
+					textColor: '#333333',
+					fontSize: 12,
+				},
+				grid: {
+					vertLines: { color: '#f0f0f0' },
+					horzLines: { color: '#f0f0f0' },
+				},
+				crosshair: {
+					mode: 0,
+					vertLine: { color: '#9B9B9B', width: 1, style: 3, labelBackgroundColor: '#505050' },
+					horzLine: { color: '#9B9B9B', width: 1, style: 3, labelBackgroundColor: '#505050' },
+				},
+				rightPriceScale: {
+					borderColor: '#e0e0e0',
+					scaleMargins: { top: 0.1, bottom: 0.25 },
+				},
+				timeScale: {
+					borderColor: '#e0e0e0',
+					timeVisible: true,
+					secondsVisible: false,
+					rightOffset: 5,
+				},
+			});
+
+			const candleSeries = chart.addSeries(CandlestickSeries, {
+				upColor: '#ef5350',
+				downColor: '#42a5f5',
+				borderUpColor: '#ef5350',
+				borderDownColor: '#42a5f5',
+				wickUpColor: '#ef5350',
+				wickDownColor: '#42a5f5',
+			});
+
+			const volumeSeries = chart.addSeries(HistogramSeries, {
+				priceFormat: { type: 'volume' },
+				priceScaleId: 'volume',
+			});
+			chart.priceScale('volume').applyOptions({
+				scaleMargins: { top: 0.8, bottom: 0 },
+			});
+
+			const ma5Series = chart.addSeries(LineSeries, {
+				color: MA_COLORS.ma5,
+				lineWidth: 1,
+				priceLineVisible: false,
+				lastValueVisible: false,
+				crosshairMarkerVisible: false,
+			});
+
+			const ma10Series = chart.addSeries(LineSeries, {
+				color: MA_COLORS.ma10,
+				lineWidth: 1,
+				priceLineVisible: false,
+				lastValueVisible: false,
+				crosshairMarkerVisible: false,
+			});
+
+			const ma20Series = chart.addSeries(LineSeries, {
+				color: MA_COLORS.ma20,
+				lineWidth: 1,
+				priceLineVisible: false,
+				lastValueVisible: false,
+				crosshairMarkerVisible: false,
+			});
+
+			chartRef.current = chart;
+			candleSeriesRef.current = candleSeries;
+			volumeSeriesRef.current = volumeSeries;
+			ma5SeriesRef.current = ma5Series;
+			ma10SeriesRef.current = ma10Series;
+			ma20SeriesRef.current = ma20Series;
+
+			const resizeObserver = new ResizeObserver((entries) => {
+				if (entries[0]) {
+					const { width, height } = entries[0].contentRect;
+					chart.applyOptions({ width, height });
+				}
+			});
+			resizeObserver.observe(container);
+
+			return () => {
+				resizeObserver.disconnect();
+			};
+		};
+
+		const cleanup = initChart();
+
+		return () => {
+			disposed = true;
+			cleanup?.then((fn) => fn?.());
+			if (chartRef.current) {
+				chartRef.current.remove();
+				chartRef.current = null;
+			}
+		};
+	}, []);
+
+	// secondsVisible 동적 변경
+	useEffect(() => {
+		if (chartRef.current) {
+			chartRef.current.timeScale().applyOptions({
+				secondsVisible: interval === 'sec',
+			});
+		}
+	}, [interval]);
+
+	// 데이터 업데이트
+	useEffect(() => {
+		if (!candleSeriesRef.current || !volumeSeriesRef.current || candles.length === 0) return;
+
+		const dataKey = `${selectedMarket}-${interval}`;
+		const isNewData = dataKey !== prevDataKeyRef.current;
+		const lengthChanged = candles.length !== prevCandleLenRef.current;
+
+		if (isNewData || lengthChanged) {
+			// 마켓/인터벌 변경 또는 캔들 수 변경 시 전체 데이터 설정
+			candleSeriesRef.current.setData(candles);
+			volumeSeriesRef.current.setData(volumes);
+			if (isNewData && chartRef.current) {
+				chartRef.current.timeScale().fitContent();
+			}
+			prevDataKeyRef.current = dataKey;
+		} else {
+			// 마지막 캔들만 업데이트 (같은 시간대 close/high/low 변경)
+			try {
+				const lastCandle = candles[candles.length - 1];
+				const lastVolume = volumes[volumes.length - 1];
+				candleSeriesRef.current.update(lastCandle);
+				volumeSeriesRef.current.update(lastVolume);
+			} catch {
+				// 시간 순서 불일치 시 전체 재설정
+				candleSeriesRef.current.setData(candles);
+				volumeSeriesRef.current.setData(volumes);
+			}
+		}
+		prevCandleLenRef.current = candles.length;
+	}, [candles, volumes, selectedMarket, interval]);
+
+	// MA 데이터 업데이트
+	useEffect(() => {
+		if (ma5SeriesRef.current) {
+			ma5SeriesRef.current.setData(showMA.ma5 ? ma5 : []);
+		}
+		if (ma10SeriesRef.current) {
+			ma10SeriesRef.current.setData(showMA.ma10 ? ma10 : []);
+		}
+		if (ma20SeriesRef.current) {
+			ma20SeriesRef.current.setData(showMA.ma20 ? ma20 : []);
+		}
+	}, [ma5, ma10, ma20, showMA]);
+
+	const toggleMA = useCallback((key: 'ma5' | 'ma10' | 'ma20') => {
+		setShowMA((prev) => ({ ...prev, [key]: !prev[key] }));
+	}, []);
+
+	// 풀스크린 토글
+	const toggleFullscreen = useCallback(() => {
+		if (!wrapperRef.current) return;
+
+		if (!document.fullscreenElement) {
+			wrapperRef.current.requestFullscreen();
+		} else {
+			document.exitFullscreen();
+		}
+	}, []);
+
+	// 풀스크린 상태 동기화
+	useEffect(() => {
+		const handleChange = () => {
+			setIsFullscreen(!!document.fullscreenElement);
+		};
+		document.addEventListener('fullscreenchange', handleChange);
+		return () => document.removeEventListener('fullscreenchange', handleChange);
+	}, []);
+
+	return (
+		<div className={`trading-chart ${isFullscreen ? 'fullscreen' : ''}`} ref={wrapperRef}>
+			<div className='chart-toolbar'>
+				<div className='interval-buttons'>
+					{INTERVALS.map((item) => (
+						<button
+							key={item.value}
+							type='button'
+							className={`interval-btn ${interval === item.value ? 'active' : ''}`}
+							onClick={() => setInterval(item.value)}
+						>
+							{item.label}
+						</button>
+					))}
+				</div>
+				<div className='chart-toolbar-right'>
+					<div className='ma-buttons'>
+						<button
+							type='button'
+							className={`ma-btn ${showMA.ma5 ? 'active' : ''}`}
+							style={{ '--ma-color': MA_COLORS.ma5 } as React.CSSProperties}
+							onClick={() => toggleMA('ma5')}
+						>
+							MA5
+						</button>
+						<button
+							type='button'
+							className={`ma-btn ${showMA.ma10 ? 'active' : ''}`}
+							style={{ '--ma-color': MA_COLORS.ma10 } as React.CSSProperties}
+							onClick={() => toggleMA('ma10')}
+						>
+							MA10
+						</button>
+						<button
+							type='button'
+							className={`ma-btn ${showMA.ma20 ? 'active' : ''}`}
+							style={{ '--ma-color': MA_COLORS.ma20 } as React.CSSProperties}
+							onClick={() => toggleMA('ma20')}
+						>
+							MA20
+						</button>
+					</div>
+					<button
+						type='button'
+						className='fullscreen-btn'
+						onClick={toggleFullscreen}
+						title={isFullscreen ? '전체화면 종료' : '전체화면'}
+					>
+						{isFullscreen ? '⤓' : '⤢'}
+					</button>
+				</div>
+			</div>
+			<div className='chart-container' ref={chartContainerRef}>
+				{loading && <div className='chart-loading'>차트 로딩 중...</div>}
+			</div>
+		</div>
+	);
+}

+ 95 - 0
app/component/crypto/dashboard.scss

@@ -0,0 +1,95 @@
+.crypto-dashboard {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	background: #f8f8f8;
+
+	.resize-handle-h {
+		height: 5px;
+		background: #eee;
+		cursor: row-resize;
+		flex-shrink: 0;
+		transition: background 0.15s;
+		position: relative;
+
+		&::after {
+			content: '';
+			position: absolute;
+			left: 50%;
+			top: 50%;
+			transform: translate(-50%, -50%);
+			width: 30px;
+			height: 3px;
+			border-top: 1px solid #ccc;
+			border-bottom: 1px solid #ccc;
+		}
+
+		&:hover {
+			background: #d0d0d0;
+		}
+	}
+
+	.dashboard-bottom {
+		display: flex;
+		flex: 1;
+		min-height: 0;
+		border-top: 1px solid #eee;
+
+		.dashboard-orderbook {
+			overflow: hidden;
+			min-width: 0;
+		}
+
+		.dashboard-trades {
+			overflow: hidden;
+			min-width: 0;
+		}
+
+		.resize-handle-v {
+			width: 5px;
+			background: #eee;
+			cursor: col-resize;
+			flex-shrink: 0;
+			transition: background 0.15s;
+			position: relative;
+
+			&::after {
+				content: '';
+				position: absolute;
+				left: 50%;
+				top: 50%;
+				transform: translate(-50%, -50%);
+				width: 3px;
+				height: 30px;
+				border-left: 1px solid #ccc;
+				border-right: 1px solid #ccc;
+			}
+
+			&:hover {
+				background: #d0d0d0;
+			}
+		}
+	}
+}
+
+@media (max-width: 768px) {
+	.crypto-dashboard {
+		.dashboard-bottom {
+			flex-direction: column;
+
+			.dashboard-orderbook {
+				width: 100% !important;
+				max-height: 350px;
+			}
+
+			.dashboard-trades {
+				width: 100% !important;
+				max-height: 300px;
+			}
+
+			.resize-handle-v {
+				display: none;
+			}
+		}
+	}
+}

+ 140 - 0
app/component/crypto/market-header.scss

@@ -0,0 +1,140 @@
+.market-header {
+	display: flex;
+	align-items: center;
+	gap: 20px;
+	padding: 10px 16px;
+	background: #fff;
+	border-bottom: 1px solid #eee;
+	flex-wrap: wrap;
+	min-height: 48px;
+
+	.market-header-loading {
+		color: #999;
+		font-size: 0.875rem;
+	}
+
+	.market-title {
+		.symbol {
+			font-size: 1.25rem;
+			font-weight: 700;
+			color: #222;
+		}
+
+		.pair {
+			font-size: 0.875rem;
+			color: #999;
+			margin-left: 2px;
+		}
+	}
+
+	.market-price {
+		.current-price {
+			font-size: 1.375rem;
+			font-weight: 700;
+			font-variant-numeric: tabular-nums;
+		}
+	}
+
+	.market-change {
+		display: flex;
+		gap: 6px;
+		font-size: 0.875rem;
+		font-weight: 500;
+		font-variant-numeric: tabular-nums;
+	}
+
+	.market-stats {
+		display: flex;
+		gap: 16px;
+		margin-left: auto;
+
+		.stat {
+			display: flex;
+			flex-direction: column;
+			align-items: flex-end;
+			gap: 1px;
+
+			.label {
+				font-size: 0.688rem;
+				color: #999;
+			}
+
+			.value {
+				font-size: 0.813rem;
+				font-weight: 500;
+				font-variant-numeric: tabular-nums;
+				color: #333;
+			}
+		}
+
+		.volume-stat {
+			.volume-sparkline-wrapper {
+				position: relative;
+
+				.volume-sparkline {
+					flex-shrink: 0;
+					cursor: pointer;
+					border-radius: 3px;
+					transition: background 0.15s;
+
+					&:hover {
+						background: #f5f5f5;
+					}
+				}
+
+				.volume-popover {
+					position: absolute;
+					top: calc(100% + 6px);
+					right: 0;
+					background: #fff;
+					border: 1px solid #e0e0e0;
+					border-radius: 6px;
+					box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+					padding: 8px 12px;
+					white-space: nowrap;
+					z-index: 100;
+					display: flex;
+					flex-direction: column;
+					gap: 2px;
+
+					.volume-popover-label {
+						font-size: 0.625rem;
+						color: #999;
+					}
+
+					.volume-popover-value {
+						font-size: 0.813rem;
+						font-weight: 600;
+						font-variant-numeric: tabular-nums;
+						color: #333;
+					}
+				}
+			}
+		}
+	}
+
+	.up {
+		color: hsl(var(--crypto-up));
+	}
+
+	.down {
+		color: hsl(var(--crypto-down));
+	}
+
+	.neutral {
+		color: hsl(var(--crypto-neutral));
+	}
+}
+
+@media (max-width: 640px) {
+	.market-header {
+		gap: 8px;
+		padding: 8px 12px;
+
+		.market-stats {
+			width: 100%;
+			margin-left: 0;
+			justify-content: space-between;
+		}
+	}
+}

+ 195 - 0
app/component/crypto/orderbook.scss

@@ -0,0 +1,195 @@
+.orderbook {
+	display: flex;
+	flex-direction: column;
+	background: #fff;
+	height: 100%;
+
+	.orderbook-title {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		font-size: 0.813rem;
+		font-weight: 600;
+		padding: 8px 12px;
+		border-bottom: 1px solid #eee;
+		color: #333;
+
+		.orderbook-total {
+			display: flex;
+			gap: 10px;
+			font-size: 0.688rem;
+			font-weight: 400;
+
+			.total-ask {
+				color: hsl(var(--crypto-down));
+			}
+
+			.total-bid {
+				color: hsl(var(--crypto-up));
+			}
+		}
+	}
+
+	.orderbook-loading {
+		padding: 24px;
+		text-align: center;
+		color: #999;
+		font-size: 0.813rem;
+	}
+
+	.orderbook-body {
+		display: flex;
+		flex-direction: column;
+		flex: 1;
+		min-height: 0;
+		font-variant-numeric: tabular-nums;
+	}
+
+	// 매도 섹션: column-reverse로 낮은 가격이 아래(스프레드 근처)
+	.ask-section {
+		flex: 1;
+		min-height: 0;
+		overflow-y: auto;
+		display: flex;
+		flex-direction: column-reverse;
+
+		&::-webkit-scrollbar {
+			width: 0;
+		}
+	}
+
+	// 매수 섹션
+	.bid-section {
+		flex: 1;
+		min-height: 0;
+		overflow-y: auto;
+
+		&::-webkit-scrollbar {
+			width: 0;
+		}
+	}
+
+	.orderbook-row {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		position: relative;
+		padding: 3px 12px;
+		font-size: 0.75rem;
+		transition: background-color 0.1s;
+		flex-shrink: 0;
+
+		.bar {
+			position: absolute;
+			top: 0;
+			bottom: 0;
+			opacity: 0.12;
+		}
+
+		// 매도: bar 왼쪽에서 확장 (수량 쪽)
+		&.ask .bar {
+			left: 0;
+			background: hsl(var(--crypto-down));
+		}
+
+		// 매수: bar 오른쪽에서 확장 (수량 쪽)
+		&.bid .bar {
+			right: 0;
+			background: hsl(var(--crypto-up));
+		}
+
+		.price {
+			position: relative;
+			z-index: 1;
+			font-weight: 500;
+		}
+
+		&.ask .price {
+			color: hsl(var(--crypto-down));
+		}
+
+		&.bid .price {
+			color: hsl(var(--crypto-up));
+		}
+
+		.size {
+			position: relative;
+			z-index: 1;
+			color: #555;
+		}
+
+		// 체결 플래시 효과
+		&.flash-sell {
+			animation: flash-sell 0.5s ease-out;
+		}
+
+		&.flash-buy {
+			animation: flash-buy 0.5s ease-out;
+		}
+
+		// LTP 5m ago 가이드선
+		.ltp-guide {
+			position: absolute;
+			top: -1px;
+			left: 0;
+			right: 0;
+			height: 2px;
+			background: #E8B730;
+			z-index: 2;
+
+			&::after {
+				content: 'LTP 5m ago';
+				position: absolute;
+				right: 4px;
+				top: -15px;
+				font-size: 0.6rem;
+				font-weight: 600;
+				color: #fff;
+				background: #E8B730;
+				padding: 1px 5px;
+				border-radius: 3px;
+				white-space: nowrap;
+			}
+		}
+	}
+
+	.spread-row {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		padding: 5px 12px;
+		font-size: 0.75rem;
+		background: #f9f9f9;
+		border-top: 1px solid #eee;
+		border-bottom: 1px solid #eee;
+		flex-shrink: 0;
+
+		.spread-price {
+			font-weight: 600;
+			color: #333;
+		}
+
+		.spread-info {
+			color: #999;
+			font-size: 0.688rem;
+		}
+	}
+}
+
+@keyframes flash-sell {
+	0% {
+		background-color: hsla(var(--crypto-down), 0.3);
+	}
+	100% {
+		background-color: transparent;
+	}
+}
+
+@keyframes flash-buy {
+	0% {
+		background-color: hsla(var(--crypto-up), 0.3);
+	}
+	100% {
+		background-color: transparent;
+	}
+}

+ 164 - 0
app/component/crypto/sidebar.scss

@@ -0,0 +1,164 @@
+.crypto-sidebar {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	background: #fafafa;
+
+	.sidebar-tabs {
+		display: flex;
+		border-bottom: 1px solid #eee;
+
+		.tab {
+			flex: 1;
+			padding: 8px 0;
+			font-size: 0.75rem;
+			font-weight: 600;
+			color: #999;
+			background: transparent;
+			border: none;
+			border-bottom: 2px solid transparent;
+			cursor: pointer;
+			transition: color 0.15s, border-color 0.15s;
+
+			&:hover {
+				color: #666;
+			}
+
+			&.active {
+				color: #F7931A;
+				border-bottom-color: #F7931A;
+			}
+		}
+	}
+
+	.sidebar-search {
+		padding: 8px;
+		border-bottom: 1px solid #eee;
+
+		input {
+			width: 100%;
+			padding: 6px 10px;
+			font-size: 0.813rem;
+			border: 1px solid #ddd;
+			border-radius: 4px;
+			outline: none;
+
+			&:focus {
+				border-color: #F7931A;
+			}
+		}
+	}
+
+	.sidebar-list {
+		flex: 1;
+		overflow-y: auto;
+	}
+
+	.sidebar-item {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		width: 100%;
+		padding: 8px 12px;
+		border: 1px solid transparent;
+		border-bottom: 1px solid #f0f0f0;
+		background: transparent;
+		cursor: pointer;
+		text-align: left;
+		transition: background 0.15s;
+
+		&:hover {
+			background: #f0f0f0;
+		}
+
+		&.active {
+			background: #fff3e0;
+			border-left: 3px solid #F7931A;
+		}
+
+		.item-info {
+			display: flex;
+			flex-direction: column;
+			gap: 1px;
+
+			.item-kor-name {
+				font-weight: 600;
+				font-size: 0.813rem;
+				color: #222;
+			}
+
+			.item-symbol {
+				font-size: 0.688rem;
+				color: #999;
+			}
+		}
+
+		.item-price {
+			display: flex;
+			flex-direction: column;
+			align-items: flex-end;
+			gap: 1px;
+
+			.price {
+				font-size: 0.813rem;
+				font-weight: 600;
+				font-variant-numeric: tabular-nums;
+			}
+
+			.change {
+				font-size: 0.688rem;
+				font-variant-numeric: tabular-nums;
+			}
+
+			.up {
+				color: hsl(var(--crypto-up));
+			}
+
+			.down {
+				color: hsl(var(--crypto-down));
+			}
+
+			.neutral {
+				color: hsl(var(--crypto-neutral));
+			}
+		}
+	}
+
+	.sidebar-empty {
+		padding: 24px;
+		text-align: center;
+		color: #999;
+		font-size: 0.813rem;
+	}
+}
+
+// 시세 변동 보더 플래시 애니메이션
+@keyframes flash-border-up {
+	0% {
+		border-color: hsl(var(--crypto-up));
+		box-shadow: inset 0 0 0 1px hsl(var(--crypto-up) / 0.3);
+	}
+	100% {
+		border-color: transparent;
+		box-shadow: none;
+	}
+}
+
+@keyframes flash-border-down {
+	0% {
+		border-color: hsl(var(--crypto-down));
+		box-shadow: inset 0 0 0 1px hsl(var(--crypto-down) / 0.3);
+	}
+	100% {
+		border-color: transparent;
+		box-shadow: none;
+	}
+}
+
+.sidebar-item.flash-up {
+	animation: flash-border-up 0.6s ease-out;
+}
+
+.sidebar-item.flash-down {
+	animation: flash-border-down 0.6s ease-out;
+}

+ 125 - 0
app/component/crypto/trade-history.scss

@@ -0,0 +1,125 @@
+.trade-history {
+	display: flex;
+	flex-direction: column;
+	background: #fff;
+	height: 100%;
+
+	.trade-title {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		font-size: 0.813rem;
+		font-weight: 600;
+		padding: 8px 12px;
+		border-bottom: 1px solid #eee;
+		color: #333;
+
+		.trade-title-left {
+			display: flex;
+			align-items: center;
+			gap: 6px;
+		}
+
+		.sound-toggle {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			width: 22px;
+			height: 22px;
+			border: none;
+			border-radius: 4px;
+			background: transparent;
+			cursor: pointer;
+			font-size: 0.75rem;
+			padding: 0;
+			transition: background 0.15s;
+
+			&:hover {
+				background: #f0f0f0;
+			}
+
+			&.active {
+				color: #F7931A;
+			}
+
+			&.muted {
+				opacity: 0.5;
+			}
+		}
+
+		.trade-intensity {
+			font-size: 0.688rem;
+			font-weight: 600;
+			font-variant-numeric: tabular-nums;
+
+			&.up {
+				color: hsl(var(--crypto-up));
+			}
+
+			&.down {
+				color: hsl(var(--crypto-down));
+			}
+		}
+	}
+
+	.trade-loading {
+		padding: 24px;
+		text-align: center;
+		color: #999;
+		font-size: 0.813rem;
+	}
+
+	.trade-header {
+		display: flex;
+		justify-content: space-between;
+		padding: 4px 12px;
+		font-size: 0.688rem;
+		color: #999;
+		border-bottom: 1px solid #f5f5f5;
+
+		span:last-child {
+			text-align: right;
+		}
+	}
+
+	.trade-body {
+		flex: 1;
+		overflow-y: auto;
+		font-variant-numeric: tabular-nums;
+	}
+
+	.trade-row {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		padding: 3px 12px;
+		font-size: 0.75rem;
+		transition: background 0.1s;
+
+		&.buy .price {
+			color: hsl(var(--crypto-up));
+		}
+
+		&.sell .price {
+			color: hsl(var(--crypto-down));
+		}
+
+		.price {
+			font-weight: 500;
+			flex: 1;
+		}
+
+		.size {
+			color: #555;
+			flex: 1;
+			text-align: center;
+		}
+
+		.time {
+			color: #999;
+			font-size: 0.688rem;
+			flex-shrink: 0;
+			text-align: right;
+		}
+	}
+}

+ 135 - 0
app/component/crypto/trading-chart.scss

@@ -0,0 +1,135 @@
+.trading-chart {
+	display: flex;
+	flex-direction: column;
+	background: #fff;
+	height: 100%;
+
+	&.fullscreen {
+		position: fixed;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		z-index: 9999;
+	}
+
+	.chart-toolbar {
+		display: flex;
+		align-items: center;
+		padding: 6px 12px;
+		border-bottom: 1px solid #f0f0f0;
+		overflow-x: auto;
+		flex-wrap: nowrap;
+		gap: 8px;
+		flex-shrink: 0;
+
+		&::-webkit-scrollbar {
+			height: 0;
+		}
+
+		.interval-buttons {
+			display: flex;
+			gap: 2px;
+		}
+
+		.interval-btn {
+			padding: 4px 10px;
+			font-size: 0.75rem;
+			font-weight: 500;
+			color: #666;
+			background: transparent;
+			border: 1px solid transparent;
+			border-radius: 3px;
+			cursor: pointer;
+			white-space: nowrap;
+			transition: all 0.15s;
+
+			&:hover {
+				background: #f5f5f5;
+				color: #333;
+			}
+
+			&.active {
+				background: #F7931A;
+				color: #fff;
+				border-color: #F7931A;
+			}
+		}
+
+		.chart-toolbar-right {
+			display: flex;
+			align-items: center;
+			gap: 8px;
+			margin-left: auto;
+			flex-shrink: 0;
+		}
+
+		.ma-buttons {
+			display: flex;
+			gap: 4px;
+			flex-shrink: 0;
+		}
+
+		.ma-btn {
+			padding: 3px 8px;
+			font-size: 0.7rem;
+			font-weight: 600;
+			color: #999;
+			background: transparent;
+			border: 1px solid #ddd;
+			border-radius: 3px;
+			cursor: pointer;
+			white-space: nowrap;
+			transition: all 0.15s;
+
+			&:hover {
+				border-color: #bbb;
+				color: #666;
+			}
+
+			&.active {
+				color: var(--ma-color);
+				border-color: var(--ma-color);
+				background: rgba(0, 0, 0, 0.03);
+			}
+		}
+
+		.fullscreen-btn {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			width: 28px;
+			height: 28px;
+			border: 1px solid #ddd;
+			border-radius: 3px;
+			background: transparent;
+			cursor: pointer;
+			font-size: 1rem;
+			color: #666;
+			transition: all 0.15s;
+
+			&:hover {
+				border-color: #bbb;
+				background: #f5f5f5;
+				color: #333;
+			}
+		}
+	}
+
+	.chart-container {
+		position: relative;
+		width: 100%;
+		flex: 1;
+		min-height: 0;
+
+		.chart-loading {
+			position: absolute;
+			top: 50%;
+			left: 50%;
+			transform: translate(-50%, -50%);
+			color: #999;
+			font-size: 0.875rem;
+			z-index: 1;
+		}
+	}
+}

+ 6 - 0
app/globals.scss

@@ -45,6 +45,9 @@ body {
         --chart-4: 43 74% 66%;
         --chart-5: 27 87% 67%;
         --radius: 0.5rem;
+		--crypto-up: 0 70% 55%;
+		--crypto-down: 220 70% 55%;
+		--crypto-neutral: 0 0% 50%;
 	}
   	.dark {
         --background: 0 0% 3.9%;
@@ -71,6 +74,9 @@ body {
         --chart-3: 30 80% 55%;
         --chart-4: 280 65% 60%;
         --chart-5: 340 75% 55%;
+		--crypto-up: 0 70% 60%;
+		--crypto-down: 220 70% 60%;
+		--crypto-neutral: 0 0% 63.9%;
     }
 }
 

+ 21 - 104
app/page.tsx

@@ -1,106 +1,23 @@
-import Image from "next/image";
-import Layout from "@/app/component/Layout";
-import PopupModal from "@/app/component/PopupModal";
+import CryptoPageContent from '@/app/component/crypto/CryptoPageContent';
+import { fetchJson } from '@/lib/utils/server';
+import type { ResultDto } from '@/types/response/common';
+import type { TickerRestData } from '@/types/crypto';
 
-export default function Home() {
-    return (
-        <Layout>
-        <PopupModal position='main' />
-        <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
-            <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
-                <Image
-                    className="dark:invert"
-                    src="/next.svg"
-                    alt="Next.js logo"
-                    width={180}
-                    height={38}
-                    priority
-                />
-                <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
-                    <li className="mb-2">
-                        Get started by editing{" "}
-                        <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
-                            app/page.tsx
-                        </code>
-                        .
-                    </li>
-                    <li>Save and see your changes instantly.</li>
-                </ol>
+export default async function Home() {
+	let initialTickers: TickerRestData[] = [];
 
-                <div className="flex gap-4 items-center flex-col sm:flex-row">
-                    <a
-                        className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
-                        href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                    >
-                        <Image
-                            className="dark:invert"
-                            src="/vercel.svg"
-                            alt="Vercel logomark"
-                            width={20}
-                            height={20}
-                        />
-                        Deploy now
-                    </a>
-                    <a
-                        className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
-                        href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                    >
-                        Read our docs
-                    </a>
-                </div>
-            </main>
-            <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
-                <a
-                    className="flex items-center gap-2 hover:underline hover:underline-offset-4"
-                    href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-                    target="_blank"
-                    rel="noopener noreferrer"
-                >
-                    <Image
-                        aria-hidden
-                        src="/file.svg"
-                        alt="File icon"
-                        width={16}
-                        height={16}
-                    />
-                    Learn
-                </a>
-                <a
-                    className="flex items-center gap-2 hover:underline hover:underline-offset-4"
-                    href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-                    target="_blank"
-                    rel="noopener noreferrer"
-                >
-                    <Image
-                        aria-hidden
-                        src="/window.svg"
-                        alt="Window icon"
-                        width={16}
-                        height={16}
-                    />
-                    Examples
-                </a>
-                <a
-                    className="flex items-center gap-2 hover:underline hover:underline-offset-4"
-                    href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-                    target="_blank"
-                    rel="noopener noreferrer"
-                >
-                    <Image
-                        aria-hidden
-                        src="/globe.svg"
-                        alt="Globe icon"
-                        width={16}
-                        height={16}
-                    />
-                    Go to nextjs.org →
-                </a>
-            </footer>
-        </div>
-        </Layout>
-    );
-}
+	try {
+		const res: ResultDto<TickerRestData[]> = await fetchJson('/api/crypto/tickers?quote=KRW', {
+			method: 'GET',
+		});
+		if (res.success && res.data) {
+			initialTickers = res.data;
+		}
+	} catch {
+		// 백엔드 미연결 시 빈 배열
+	}
+
+	return (
+		<CryptoPageContent initialTickers={initialTickers} />
+	);
+}

+ 90 - 8
app/styles/common.module.scss

@@ -5,11 +5,11 @@
 
     // PC
     @media (min-width: 1124px) {
-        grid-template-areas: "header header" "main aside" "footer aside";
-        grid-template-columns: 1fr minmax(200px, 400px);
+        grid-template-areas: "header header header" "aside main chatAside" "aside footer chatAside";
+        grid-template-columns: minmax(200px, 280px) 1fr minmax(200px, 400px);
     }
 
-    // Table
+    // Tablet
     @media (max-width: 1125px) {
         grid-template-areas: "header" "main" "footer";
         grid-template-columns: 1fr;
@@ -21,11 +21,35 @@
         background: #f4f4f4;
         border-bottom: 1px solid #dedede;
 
+        // 햄버거 메뉴
+        .hamburger {
+            display: none;
+            align-items: center;
+            justify-content: center;
+            width: 40px;
+            height: 40px;
+            border: none;
+            background: transparent;
+            cursor: pointer;
+            font-size: 1.25rem;
+            color: #333;
+            flex-shrink: 0;
+
+            &:hover {
+                color: #000;
+            }
+
+            @media (max-width: 1125px) {
+                display: flex;
+            }
+        }
+
         // 로고
-        > a {
+        > a, > .logoLink {
             flex-basis: 137px;
             font-size: inherit;
             padding-right: 20px;
+			flex-shrink: 0;
         }
 
         // 메뉴
@@ -45,7 +69,44 @@
         }
     }
 
-    // 좌측 내용
+    // 좌측 사이드바 (코인 목록)
+    > .aside {
+        grid-area: aside;
+        overflow-y: auto;
+        transition: transform 0.3s ease;
+        background: #fff;
+
+        @media (min-width: 1124px) {
+            position: relative;
+            transform: translateX(0);
+            border-right: 1px solid #dedede;
+        }
+
+        // 모바일: 왼쪽에서 슬라이드
+        @media (max-width: 1125px) {
+            position: fixed;
+            top: 56px;
+            left: 0;
+            width: min(320px, 85vw);
+            height: calc(100vh - 56px);
+            z-index: 1000;
+            transform: translateX(-100%);
+            border-right: 1px solid #dedede;
+            box-shadow: none;
+        }
+    }
+
+    // 사이드바 열림 상태
+    &.sidebarOpen {
+        > .aside {
+            @media (max-width: 1125px) {
+                transform: translateX(0);
+                box-shadow: 4px 0 12px rgba(0, 0, 0, 0.15);
+            }
+        }
+    }
+
+    // 메인 내용
     > .main {
 		position: relative;
         grid-area: main;
@@ -53,9 +114,9 @@
         border-bottom: 1px solid #dedede;
     }
 
-    // 우측 내용
-    > .aside {
-        grid-area: aside;
+    // 우측 사이드바 (채팅)
+    > .chatAside {
+        grid-area: chatAside;
         transition: transform 0.3s ease;
 
         @media (min-width: 1124px) {
@@ -76,6 +137,27 @@
         }
     }
 
+    // 사이드바 오버레이 (모바일)
+    > .overlay {
+        display: none;
+
+        @media (max-width: 1125px) {
+            position: fixed;
+            top: 56px;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background: rgba(0, 0, 0, 0.4);
+            z-index: 999;
+        }
+    }
+
+    &.sidebarOpen > .overlay {
+        @media (max-width: 1125px) {
+            display: block;
+        }
+    }
+
     // 하단 내용
     > .footer {
         grid-area: footer;

+ 69 - 0
contexts/cryptoProvider.tsx

@@ -0,0 +1,69 @@
+'use client';
+
+import { createContext, useContext, useState, useCallback } from 'react';
+import type { TickerData, TickerMeta } from '@/types/crypto';
+
+interface CryptoContextType {
+	selectedMarket: string;
+	setSelectedMarket: (market: string) => void;
+	quoteMarket: string;
+	setQuoteMarket: (quote: string) => void;
+	tickers: Map<string, TickerData>;
+	setTickers: (tickers: Map<string, TickerData>) => void;
+	tickerMeta: Map<string, TickerMeta>;
+	setTickerMeta: (meta: Map<string, TickerMeta>) => void;
+}
+
+const CryptoContext = createContext<CryptoContextType>({
+	selectedMarket: 'KRW-BTC',
+	setSelectedMarket: () => {},
+	quoteMarket: 'KRW',
+	setQuoteMarket: () => {},
+	tickers: new Map(),
+	setTickers: () => {},
+	tickerMeta: new Map(),
+	setTickerMeta: () => {},
+});
+
+type Props = {
+	children: React.ReactNode;
+	initialMarket?: string;
+};
+
+export function CryptoProvider({ children, initialMarket = 'KRW-BTC' }: Props) {
+	const [selectedMarket, setSelectedMarketState] = useState(initialMarket);
+	const [quoteMarket, setQuoteMarketState] = useState('KRW');
+	const [tickers, setTickersState] = useState<Map<string, TickerData>>(new Map());
+	const [tickerMeta, setTickerMetaState] = useState<Map<string, TickerMeta>>(new Map());
+
+	const setSelectedMarket = useCallback((market: string) => {
+		setSelectedMarketState(market);
+	}, []);
+
+	const setQuoteMarket = useCallback((quote: string) => {
+		setQuoteMarketState(quote);
+	}, []);
+
+	const setTickers = useCallback((t: Map<string, TickerData>) => {
+		setTickersState(t);
+	}, []);
+
+	const setTickerMeta = useCallback((m: Map<string, TickerMeta>) => {
+		setTickerMetaState(m);
+	}, []);
+
+	return (
+		<CryptoContext.Provider value={{
+			selectedMarket, setSelectedMarket,
+			quoteMarket, setQuoteMarket,
+			tickers, setTickers,
+			tickerMeta, setTickerMeta,
+		}}>
+			{children}
+		</CryptoContext.Provider>
+	);
+}
+
+export function useCryptoContext() {
+	return useContext(CryptoContext);
+}

+ 235 - 0
hooks/useCandles.ts

@@ -0,0 +1,235 @@
+'use client';
+
+import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+import { fetchApi } from '@/lib/utils/client';
+import type { CandleData, CandleRestData, CandleBar, VolumeBar, MAData, TickerData } from '@/types/crypto';
+
+type IntervalType = 'sec' | '1' | '3' | '5' | '10' | '15' | '30' | '60' | '240' | 'day' | 'week' | 'month';
+
+const UP_COLOR = 'rgba(239, 83, 80, 0.5)';
+const DOWN_COLOR = 'rgba(66, 133, 244, 0.5)';
+
+function toUnixTime(dateStr: string): number {
+	return Math.floor(new Date(dateStr).getTime() / 1000);
+}
+
+function getEndpointForInterval(market: string, interval: IntervalType, count: number): string {
+	switch (interval) {
+		case 'sec':
+			return `/api/crypto/${market}/candles/seconds?count=${count}`;
+		case 'day':
+			return `/api/crypto/${market}/candles/days?count=${count}`;
+		case 'week':
+			return `/api/crypto/${market}/candles/weeks?count=${count}`;
+		case 'month':
+			return `/api/crypto/${market}/candles/months?count=${count}`;
+		default:
+			return `/api/crypto/${market}/candles/minutes/${interval}?count=${count}`;
+	}
+}
+
+export default function useCandles(market: string, interval: IntervalType = '1') {
+	const { cryptoConnection, cryptoConnected } = useSignalRContext();
+	const [candles, setCandles] = useState<CandleBar[]>([]);
+	const [volumes, setVolumes] = useState<VolumeBar[]>([]);
+	const [loading, setLoading] = useState(true);
+	const candlesRef = useRef<CandleBar[]>([]);
+	const volumesRef = useRef<VolumeBar[]>([]);
+	const tickerUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+	// REST 초기 로드
+	useEffect(() => {
+		if (!market) {
+			return;
+		}
+
+		setLoading(true);
+		candlesRef.current = [];
+		volumesRef.current = [];
+
+		const load = async () => {
+			try {
+				const endpoint = getEndpointForInterval(market, interval, 200);
+				const res = await fetchApi<CandleRestData[]>(endpoint);
+				if (res.success && res.data) {
+					const sorted = [...res.data].sort((a, b) =>
+						toUnixTime(a.candleDateTimeUtc) - toUnixTime(b.candleDateTimeUtc)
+					);
+
+					const bars: CandleBar[] = sorted.map((c) => ({
+						time: toUnixTime(c.candleDateTimeUtc),
+						open: c.openingPrice,
+						high: c.highPrice,
+						low: c.lowPrice,
+						close: c.tradePrice,
+					}));
+
+					const vols: VolumeBar[] = sorted.map((c) => ({
+						time: toUnixTime(c.candleDateTimeUtc),
+						value: c.candleAccTradeVolume,
+						color: c.tradePrice >= c.openingPrice ? UP_COLOR : DOWN_COLOR,
+					}));
+
+					candlesRef.current = bars;
+					volumesRef.current = vols;
+					setCandles(bars);
+					setVolumes(vols);
+				}
+			} catch (error) {
+				console.error('Failed to load candles:', error);
+			} finally {
+				setLoading(false);
+			}
+		};
+
+		load();
+	}, [market, interval]);
+
+	// SignalR 실시간 캔들 업데이트 (1분봉만)
+	const handleCandle = useCallback((data: CandleData) => {
+		if (data.market !== market || interval !== '1') {
+			return;
+		}
+
+		const time = toUnixTime(data.candleDateTimeUtc);
+		const bar: CandleBar = {
+			time,
+			open: data.openingPrice,
+			high: data.highPrice,
+			low: data.lowPrice,
+			close: data.tradePrice,
+		};
+
+		const vol: VolumeBar = {
+			time,
+			value: data.candleAccTradeVolume,
+			color: data.tradePrice >= data.openingPrice ? UP_COLOR : DOWN_COLOR,
+		};
+
+		const bars = [...candlesRef.current];
+		const vols = [...volumesRef.current];
+		const lastIdx = bars.length - 1;
+
+		if (lastIdx >= 0 && bars[lastIdx].time === time) {
+			bars[lastIdx] = bar;
+			vols[lastIdx] = vol;
+		} else {
+			bars.push(bar);
+			vols.push(vol);
+		}
+
+		candlesRef.current = bars;
+		volumesRef.current = vols;
+		setCandles(bars);
+		setVolumes(vols);
+	}, [market, interval]);
+
+	// SignalR 실시간 티커 → 마지막 캔들 close 업데이트 (모든 인터벌)
+	const scheduleTickerUpdate = useCallback(() => {
+		if (tickerUpdateTimerRef.current) {
+			return;
+		}
+		tickerUpdateTimerRef.current = setTimeout(() => {
+			setCandles([...candlesRef.current]);
+			setVolumes([...volumesRef.current]);
+			tickerUpdateTimerRef.current = null;
+		}, 250);
+	}, []);
+
+	const handleTicker = useCallback((ticker: TickerData) => {
+		if (ticker.market !== market || candlesRef.current.length === 0) {
+			return;
+		}
+
+		const bars = candlesRef.current;
+		const vols = volumesRef.current;
+		const lastIdx = bars.length - 1;
+		const last = bars[lastIdx];
+
+		if (interval === 'sec') {
+			// 초봉: 새로운 초가 되면 새 캔들 바 생성
+			const nowSec = Math.floor(ticker.timestamp / 1000);
+			if (nowSec !== last.time) {
+				bars.push({
+					time: nowSec,
+					open: ticker.tradePrice,
+					high: ticker.tradePrice,
+					low: ticker.tradePrice,
+					close: ticker.tradePrice,
+				});
+				vols.push({
+					time: nowSec,
+					value: 0,
+					color: UP_COLOR,
+				});
+			} else {
+				bars[lastIdx] = {
+					...last,
+					close: ticker.tradePrice,
+					high: Math.max(last.high, ticker.tradePrice),
+					low: Math.min(last.low, ticker.tradePrice),
+				};
+				vols[lastIdx] = {
+					...vols[lastIdx],
+					color: ticker.tradePrice >= last.open ? UP_COLOR : DOWN_COLOR,
+				};
+			}
+		} else {
+			// 분봉 이상: 마지막 캔들 close/high/low 업데이트
+			bars[lastIdx] = {
+				...last,
+				close: ticker.tradePrice,
+				high: Math.max(last.high, ticker.tradePrice),
+				low: Math.min(last.low, ticker.tradePrice),
+			};
+			vols[lastIdx] = {
+				...vols[lastIdx],
+				color: ticker.tradePrice >= last.open ? UP_COLOR : DOWN_COLOR,
+			};
+		}
+
+		scheduleTickerUpdate();
+	}, [market, interval, scheduleTickerUpdate]);
+
+	useEffect(() => {
+		if (!cryptoConnection || !cryptoConnected) {
+			return;
+		}
+
+		cryptoConnection.on('ReceiveCandle', handleCandle);
+		cryptoConnection.on('ReceiveTicker', handleTicker);
+
+		return () => {
+			cryptoConnection.off('ReceiveCandle', handleCandle);
+			cryptoConnection.off('ReceiveTicker', handleTicker);
+			if (tickerUpdateTimerRef.current) {
+				clearTimeout(tickerUpdateTimerRef.current);
+				tickerUpdateTimerRef.current = null;
+			}
+		};
+	}, [cryptoConnection, cryptoConnected, handleCandle, handleTicker]);
+
+	// MA 계산
+	const ma5 = useMemo(() => calcMA(candles, 5), [candles]);
+	const ma10 = useMemo(() => calcMA(candles, 10), [candles]);
+	const ma20 = useMemo(() => calcMA(candles, 20), [candles]);
+
+	return { candles, volumes, ma5, ma10, ma20, loading };
+}
+
+function calcMA(data: CandleBar[], period: number): MAData[] {
+	if (data.length < period) return [];
+	const result: MAData[] = [];
+	let sum = 0;
+	for (let i = 0; i < data.length; i++) {
+		sum += data[i].close;
+		if (i >= period) {
+			sum -= data[i - period].close;
+		}
+		if (i >= period - 1) {
+			result.push({ time: data[i].time, value: sum / period });
+		}
+	}
+	return result;
+}

+ 31 - 0
hooks/useMarketData.ts

@@ -0,0 +1,31 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+
+export default function useMarketData(market: string) {
+	const { cryptoConnection, cryptoConnected } = useSignalRContext();
+	const prevMarketRef = useRef<string | null>(null);
+
+	useEffect(() => {
+		if (!cryptoConnection || !cryptoConnected || !market) {
+			return;
+		}
+
+		// 이전 마켓 구독 해제
+		if (prevMarketRef.current && prevMarketRef.current !== market) {
+			cryptoConnection.invoke('UnsubscribeMarket', prevMarketRef.current).catch(() => {});
+		}
+
+		// 새 마켓 구독
+		cryptoConnection.invoke('SubscribeMarket', market).catch(console.error);
+		prevMarketRef.current = market;
+
+		return () => {
+			if (prevMarketRef.current) {
+				cryptoConnection.invoke('UnsubscribeMarket', prevMarketRef.current).catch(() => {});
+				prevMarketRef.current = null;
+			}
+		};
+	}, [cryptoConnection, cryptoConnected, market]);
+}

+ 61 - 0
hooks/useOrderbook.ts

@@ -0,0 +1,61 @@
+'use client';
+
+import { useEffect, useState, useCallback } from 'react';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+import { fetchApi } from '@/lib/utils/client';
+import type { OrderbookData, OrderbookRestData } from '@/types/crypto';
+
+export default function useOrderbook(market: string) {
+	const { cryptoConnection, cryptoConnected } = useSignalRContext();
+	const [orderbook, setOrderbook] = useState<OrderbookData | null>(null);
+
+	// REST 초기 로드
+	useEffect(() => {
+		if (!market) {
+			return;
+		}
+
+		const load = async () => {
+			try {
+				const res = await fetchApi<OrderbookRestData>(`/api/crypto/${market}/orderbook`);
+				if (res.success && res.data) {
+					setOrderbook({
+						market,
+						symbol: market.split('-')[1] || '',
+						totalAskSize: res.data.totalAskSize,
+						totalBidSize: res.data.totalBidSize,
+						units: res.data.units,
+						timestamp: res.data.timestamp,
+						level: res.data.level,
+						streamType: 'SNAPSHOT',
+					});
+				}
+			} catch (error) {
+				console.error('Failed to load orderbook:', error);
+			}
+		};
+
+		load();
+	}, [market]);
+
+	// SignalR 실시간 업데이트
+	const handleOrderbook = useCallback((data: OrderbookData) => {
+		if (data.market === market) {
+			setOrderbook(data);
+		}
+	}, [market]);
+
+	useEffect(() => {
+		if (!cryptoConnection || !cryptoConnected) {
+			return;
+		}
+
+		cryptoConnection.on('ReceiveOrderbook', handleOrderbook);
+
+		return () => {
+			cryptoConnection.off('ReceiveOrderbook', handleOrderbook);
+		};
+	}, [cryptoConnection, cryptoConnected, handleOrderbook]);
+
+	return orderbook;
+}

+ 158 - 0
hooks/useTickers.ts

@@ -0,0 +1,158 @@
+'use client';
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+import { fetchApi } from '@/lib/utils/client';
+import type { TickerData, TickerRestData, TickerMeta } from '@/types/crypto';
+
+function restToTickerData(t: TickerRestData): TickerData {
+	return {
+		market: t.market,
+		symbol: t.symbol,
+		openingPrice: t.openingPrice,
+		highPrice: t.highPrice,
+		lowPrice: t.lowPrice,
+		tradePrice: t.tradePrice,
+		prevClosingPrice: t.prevClosingPrice,
+		change: t.change,
+		changePrice: t.changePrice,
+		signedChangePrice: t.signedChangePrice,
+		changeRate: t.changeRate,
+		signedChangeRate: t.signedChangeRate,
+		tradeVolume: t.tradeVolume,
+		accTradeVolume: t.accTradeVolume,
+		accTradeVolume24h: t.accTradeVolume24h,
+		accTradePrice: t.accTradePrice,
+		accTradePrice24h: t.accTradePrice24h,
+		tradeDate: '',
+		tradeTime: '',
+		tradeTimestamp: 0,
+		askBid: '',
+		accAskVolume: 0,
+		accBidVolume: 0,
+		highest52WeekPrice: 0,
+		highest52WeekDate: '',
+		lowest52WeekPrice: 0,
+		lowest52WeekDate: '',
+		marketState: 'ACTIVE',
+		delistingDate: null,
+		marketWarning: '',
+		timestamp: 0,
+		streamType: '',
+	};
+}
+
+function extractMeta(t: TickerRestData): TickerMeta {
+	return {
+		korName: t.korName,
+		engName: t.engName,
+		logoImage: t.logoImage,
+	};
+}
+
+export default function useTickers(quoteMarket: string = 'KRW', initialTickers?: TickerRestData[]) {
+	const { cryptoConnection, cryptoConnected } = useSignalRContext();
+	const [tickers, setTickers] = useState<Map<string, TickerData>>(new Map());
+	const [meta, setMeta] = useState<Map<string, TickerMeta>>(new Map());
+	const tickersRef = useRef<Map<string, TickerData>>(new Map());
+	const metaRef = useRef<Map<string, TickerMeta>>(new Map());
+	const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+	const subscribedQuoteRef = useRef<string | null>(null);
+	const initializedRef = useRef(false);
+
+	// 초기 REST 데이터 로드
+	useEffect(() => {
+		// initialTickers는 KRW일 때만 사용 (서버에서 KRW로 패칭)
+		if (!initializedRef.current && initialTickers && initialTickers.length > 0 && quoteMarket === 'KRW') {
+			const tickerMap = new Map<string, TickerData>();
+			const metaMap = new Map<string, TickerMeta>();
+			for (const t of initialTickers) {
+				tickerMap.set(t.market, restToTickerData(t));
+				metaMap.set(t.market, extractMeta(t));
+			}
+			tickersRef.current = tickerMap;
+			metaRef.current = metaMap;
+			setTickers(new Map(tickerMap));
+			setMeta(new Map(metaMap));
+			initializedRef.current = true;
+		} else {
+			loadTickers(quoteMarket);
+		}
+	}, [quoteMarket]);
+
+	const loadTickers = async (quote: string) => {
+		try {
+			const res = await fetchApi<TickerRestData[]>(`/api/crypto/tickers?quote=${quote}`);
+			if (res.success && res.data) {
+				const tickerMap = new Map<string, TickerData>();
+				const metaMap = new Map<string, TickerMeta>();
+				for (const t of res.data) {
+					tickerMap.set(t.market, restToTickerData(t));
+					metaMap.set(t.market, extractMeta(t));
+				}
+				tickersRef.current = tickerMap;
+				metaRef.current = metaMap;
+				setTickers(new Map(tickerMap));
+				setMeta(new Map(metaMap));
+				initializedRef.current = true;
+			}
+		} catch (error) {
+			console.error('Failed to load tickers:', error);
+		}
+	};
+
+	// 배치 업데이트 (250ms 간격)
+	const scheduleUpdate = useCallback(() => {
+		if (updateTimerRef.current) {
+			return;
+		}
+		updateTimerRef.current = setTimeout(() => {
+			setTickers(new Map(tickersRef.current));
+			updateTimerRef.current = null;
+		}, 250);
+	}, []);
+
+	// SignalR 구독
+	useEffect(() => {
+		if (!cryptoConnection || !cryptoConnected) {
+			return;
+		}
+
+		const handleTicker = (ticker: TickerData) => {
+			tickersRef.current.set(ticker.market, ticker);
+			scheduleUpdate();
+		};
+
+		const handleTickers = (list: TickerData[]) => {
+			for (const ticker of list) {
+				tickersRef.current.set(ticker.market, ticker);
+			}
+			scheduleUpdate();
+		};
+
+		// 이전 구독 해제
+		if (subscribedQuoteRef.current && subscribedQuoteRef.current !== quoteMarket) {
+			cryptoConnection.invoke('UnsubscribeTickers', subscribedQuoteRef.current).catch(() => {});
+		}
+
+		cryptoConnection.on('ReceiveTicker', handleTicker);
+		cryptoConnection.on('ReceiveTickers', handleTickers);
+		cryptoConnection.invoke('SubscribeTickers', quoteMarket).catch(console.error);
+		subscribedQuoteRef.current = quoteMarket;
+
+		return () => {
+			cryptoConnection.off('ReceiveTicker', handleTicker);
+			cryptoConnection.off('ReceiveTickers', handleTickers);
+			if (subscribedQuoteRef.current) {
+				cryptoConnection.invoke('UnsubscribeTickers', subscribedQuoteRef.current).catch(() => {});
+				subscribedQuoteRef.current = null;
+			}
+			if (updateTimerRef.current) {
+				clearTimeout(updateTimerRef.current);
+				updateTimerRef.current = null;
+			}
+		};
+	}, [cryptoConnection, cryptoConnected, quoteMarket, scheduleUpdate]);
+
+	return { tickers, meta };
+}

+ 153 - 0
hooks/useTradeSound.ts

@@ -0,0 +1,153 @@
+'use client';
+
+import { useEffect, useRef, useCallback, useState } from 'react';
+
+/**
+ * bitFlyer Lightning 스타일 효과음
+ * sound-parlor.mp3 오디오 스프라이트 사용
+ *
+ * 스프라이트 구간 [startMs, durationMs]:
+ *   electricalMagicPoof [0, 761]      → 호가 제거
+ *   dropHeavyPlastic    [761, 574]    → 호가 추가
+ *   MagicDing           [1330, 1221]  → 소형 매도 체결
+ *   DescendingMagic     [2550, 1183]  → 대형 매도 체결
+ *   coinGameWinPack1    [3740, 1140]  → 소형 매수 체결
+ *   coinGameWinPack2    [4882, 1017]  → 대형 매수 체결
+ */
+
+// [startMs, durationMs]
+const SPRITES = {
+	orderbookRemove: [0, 761],
+	orderbookAdd: [761, 574],
+	sellSmall: [1330, 1221],
+	sellBig: [2550, 1183],
+	buySmall: [3740, 1140],
+	buyBig: [4882, 1017],
+} as const;
+
+// 대형 체결 기준 (이 수량 이상이면 Big 사운드)
+const BIG_TRADE_THRESHOLD = 1;
+
+// 호가 사운드 쓰로틀 간격 (ms) — 짧게 설정하여 bitFlyer처럼 연속 재생
+const OB_THROTTLE_ADD = 60;
+const OB_THROTTLE_REMOVE = 150;
+
+export default function useTradeSound() {
+	const audioCtxRef = useRef<AudioContext | null>(null);
+	const audioBufferRef = useRef<AudioBuffer | null>(null);
+	const [muted, setMuted] = useState(true);
+	const loadedRef = useRef(false);
+	const loadingRef = useRef(false);
+	const obThrottleRef = useRef<{ add: number; remove: number }>({ add: 0, remove: 0 });
+
+	// AudioContext 초기화
+	const ensureContext = useCallback(() => {
+		if (!audioCtxRef.current) {
+			audioCtxRef.current = new AudioContext();
+		}
+		if (audioCtxRef.current.state === 'suspended') {
+			audioCtxRef.current.resume();
+		}
+		return audioCtxRef.current;
+	}, []);
+
+	// MP3 스프라이트 파일 로드
+	const loadSound = useCallback(async () => {
+		if (loadedRef.current || loadingRef.current) return;
+		loadingRef.current = true;
+
+		try {
+			const ctx = ensureContext();
+			const response = await fetch('/sounds/sound-parlor.mp3');
+			const arrayBuffer = await response.arrayBuffer();
+			const decoded = await ctx.decodeAudioData(arrayBuffer);
+			audioBufferRef.current = decoded;
+			loadedRef.current = true;
+		} catch (err) {
+			console.error('Failed to load trade sound:', err);
+		} finally {
+			loadingRef.current = false;
+		}
+	}, [ensureContext]);
+
+	// 스프라이트 구간 재생
+	const playSprite = useCallback((startMs: number, durationMs: number, volume: number) => {
+		if (!audioBufferRef.current || !audioCtxRef.current) return;
+
+		const ctx = audioCtxRef.current;
+		if (ctx.state === 'suspended') {
+			ctx.resume();
+		}
+
+		const source = ctx.createBufferSource();
+		const gainNode = ctx.createGain();
+
+		source.buffer = audioBufferRef.current;
+		gainNode.gain.value = Math.min(Math.max(volume, 0.1), 1.0);
+
+		source.connect(gainNode);
+		gainNode.connect(ctx.destination);
+
+		const startSec = startMs / 1000;
+		const durationSec = durationMs / 1000;
+		source.start(0, startSec, durationSec);
+	}, []);
+
+	// 체결 사운드 재생
+	const playTradeSound = useCallback((side: 'BID' | 'ASK', tradeVolume: number = 0.5) => {
+		if (muted || !loadedRef.current) return;
+
+		const isBig = tradeVolume >= BIG_TRADE_THRESHOLD;
+		let sprite: readonly [number, number];
+
+		if (side === 'BID') {
+			sprite = isBig ? SPRITES.buyBig : SPRITES.buySmall;
+		} else {
+			sprite = isBig ? SPRITES.sellBig : SPRITES.sellSmall;
+		}
+
+		// 거래량 기반 볼륨 (0.3 ~ 0.8)
+		const vol = Math.min(0.3 + tradeVolume * 0.3, 0.8);
+		playSprite(sprite[0], sprite[1], vol);
+	}, [muted, playSprite]);
+
+	// 호가 사운드 재생 (짧은 쓰로틀 → bitFlyer 스타일 연속 딸깍)
+	const playOrderbookSound = useCallback((type: 'add' | 'remove') => {
+		if (muted || !loadedRef.current) return;
+
+		const now = Date.now();
+		const throttle = type === 'add' ? OB_THROTTLE_ADD : OB_THROTTLE_REMOVE;
+		if (now - obThrottleRef.current[type] < throttle) return;
+		obThrottleRef.current[type] = now;
+
+		// electricalMagicPoof (remove) 는 일단 비활성화
+		if (type === 'remove') return;
+
+		const sprite = SPRITES.orderbookAdd;
+		playSprite(sprite[0], sprite[1], 0.15);
+	}, [muted, playSprite]);
+
+	// 토글 함수
+	const toggleMute = useCallback(() => {
+		setMuted((prev) => {
+			if (prev) {
+				// 음소거 해제 시 사운드 로드
+				ensureContext();
+				loadSound();
+			}
+			return !prev;
+		});
+	}, [ensureContext, loadSound]);
+
+	// cleanup
+	useEffect(() => {
+		return () => {
+			if (audioCtxRef.current) {
+				audioCtxRef.current.close();
+				audioCtxRef.current = null;
+			}
+		};
+	}, []);
+
+	return { playTradeSound, playOrderbookSound, muted, toggleMute };
+}

+ 96 - 0
hooks/useTrades.ts

@@ -0,0 +1,96 @@
+'use client';
+
+import { useEffect, useState, useCallback, useRef } from 'react';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+import { fetchApi } from '@/lib/utils/client';
+import type { TradeData, TradeRestData } from '@/types/crypto';
+
+const MAX_TRADES = 100;
+
+export default function useTrades(market: string) {
+	const { cryptoConnection, cryptoConnected } = useSignalRContext();
+	const [trades, setTrades] = useState<TradeData[]>([]);
+	const tradesRef = useRef<TradeData[]>([]);
+	const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+	// REST 초기 로드
+	useEffect(() => {
+		if (!market) {
+			return;
+		}
+
+		setTrades([]);
+		tradesRef.current = [];
+
+		const load = async () => {
+			try {
+				const res = await fetchApi<TradeRestData[]>(`/api/crypto/${market}/trades?count=50`);
+				if (res.success && res.data) {
+					const mapped: TradeData[] = res.data.map((t) => ({
+						market,
+						symbol: market.split('-')[1] || '',
+						tradePrice: t.tradePrice,
+						tradeVolume: t.tradeVolume,
+						askBid: t.askBid,
+						prevClosingPrice: t.prevClosingPrice,
+						change: '',
+						changePrice: t.changePrice,
+						tradeDate: '',
+						tradeTime: '',
+						tradeTimestamp: t.timestamp,
+						sequentialId: t.sequentialId,
+						timestamp: t.timestamp,
+						streamType: 'SNAPSHOT',
+						bestAskPrice: 0,
+						bestAskSize: 0,
+						bestBidPrice: 0,
+						bestBidSize: 0,
+					}));
+					tradesRef.current = mapped;
+					setTrades(mapped);
+				}
+			} catch (error) {
+				console.error('Failed to load trades:', error);
+			}
+		};
+
+		load();
+	}, [market]);
+
+	// 배치 업데이트
+	const scheduleUpdate = useCallback(() => {
+		if (updateTimerRef.current) {
+			return;
+		}
+		updateTimerRef.current = setTimeout(() => {
+			setTrades([...tradesRef.current]);
+			updateTimerRef.current = null;
+		}, 200);
+	}, []);
+
+	// SignalR 실시간 업데이트
+	const handleTrade = useCallback((data: TradeData) => {
+		if (data.market === market) {
+			tradesRef.current = [data, ...tradesRef.current].slice(0, MAX_TRADES);
+			scheduleUpdate();
+		}
+	}, [market, scheduleUpdate]);
+
+	useEffect(() => {
+		if (!cryptoConnection || !cryptoConnected) {
+			return;
+		}
+
+		cryptoConnection.on('ReceiveTrade', handleTrade);
+
+		return () => {
+			cryptoConnection.off('ReceiveTrade', handleTrade);
+			if (updateTimerRef.current) {
+				clearTimeout(updateTimerRef.current);
+				updateTimerRef.current = null;
+			}
+		};
+	}, [cryptoConnection, cryptoConnected, handleTrade]);
+
+	return trades;
+}

+ 16 - 0
package-lock.json

@@ -27,6 +27,7 @@
                 "clsx": "^2.1.1",
                 "emoji-picker-react": "^4.12.2",
                 "framer-motion": "^12.6.3",
+                "lightweight-charts": "^5.1.0",
                 "lucide-react": "^0.469.0",
                 "next": "^15.3.0",
                 "postcss-loader": "^4.3.0",
@@ -14457,6 +14458,12 @@
             "license": "MIT",
             "peer": true
         },
+        "node_modules/fancy-canvas": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
+            "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
+            "license": "MIT"
+        },
         "node_modules/fast-deep-equal": {
             "version": "3.1.3",
             "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -15942,6 +15949,15 @@
                 "node": ">= 0.8.0"
             }
         },
+        "node_modules/lightweight-charts": {
+            "version": "5.1.0",
+            "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
+            "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
+            "license": "Apache-2.0",
+            "dependencies": {
+                "fancy-canvas": "2.1.0"
+            }
+        },
         "node_modules/lilconfig": {
             "version": "3.1.3",
             "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

+ 1 - 0
package.json

@@ -28,6 +28,7 @@
         "clsx": "^2.1.1",
         "emoji-picker-react": "^4.12.2",
         "framer-motion": "^12.6.3",
+        "lightweight-charts": "^5.1.0",
         "lucide-react": "^0.469.0",
         "next": "^15.3.0",
         "postcss-loader": "^4.3.0",

+ 20 - 0
public/resources/logo.svg

@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250" fill="none" viewBox="0 0 250 250">
+<!-- SVG created with Arrow, by QuiverAI (https://quiver.ai) -->
+  <path d="m58.71 124.9c0 14.25-11.7 25.8-26.86 25.8-14.24 0-25.94-11.55-25.94-25.8 0-13.9 11.32-26 25.57-26h0.37c14.88 0 26.86 11.47 26.86 26z" fill="url(#paint0_linear_201_1006)" stroke="#EAF9FC" stroke-miterlimit="10" stroke-width=".5"/>
+  <path d="m43.45 122.9c0.82-3.73-1.97-5.73-5.89-7.06l1.2-5.16-3.15-0.74-1.17 5.02c-0.82-0.21-1.67-0.41-2.51-0.61l1.18-5.05-3.09-0.75-1.24 5.17c-0.69-0.18-1.37-0.35-2.02-0.53l0.01-0.02-4.26-1.08-0.79 3.49s2.4 0.62 2.34 0.66c1.3 0.34 1.54 1.25 1.5 1.98l-1.37 5.91c0.09 0.02 0.2 0.06 0.32 0.11-0.1-0.03-0.21-0.06-0.33-0.09l-1.95 5.64c-0.18 0.43-0.64 1.08-1.67 0.8 0.05 0.06-2.34-0.65-2.34-0.65l-1.53 3.74 3.99 1.07c0.77 0.22 1.53 0.44 2.27 0.66l-1.24 5.23 3.09 0.83 1.24-5.17c0.86 0.28 1.68 0.53 2.48 0.75l-1.24 5.15 3.15-0.36 1.24-5.25c5.14 1.03 8.99 0.68 10.62-4.02 1.34-3.82-0.04-6.12-2.92-7.59 2.12-0.48 3.52-1.88 4.08-4.08zm-6.74 7.82c-0.95 3.83-7.62 1.65-9.65 1.01l1.66-6.91c2.03 0.57 9 1.85 7.99 5.9zm0.95-9.88c-0.87 3.5-6.45 1.58-8.17 1.08l1.5-6.3c1.73 0.5 7.6 1.53 6.67 5.22z" fill="#FFFEF9"/>
+  <path d="m83.29 116.8c-2.31 0-3.8 0.7-4.78 1.4l3.01-12.21-7.55 1.21-7.59 31.88c2.35 0.9 5.73 1.33 8.93 1.33 9.8 0 15.41-6.68 15.41-14.51 0-5.52-2.92-9.1-7.43-9.1zm-6.45 17.91c-0.79 0-1.45-0.09-2.06-0.27l2.53-11.15c0.9-1.37 2.16-2.12 3.75-2.12 2.45 0 3.47 1.99 3.47 4.38 0 4.55-2.92 9.16-7.69 9.16z" fill="#4D4D4D"/>
+  <path d="m102.9 106.3c-3.2 0-4.7 2.22-4.7 4.39 0 2.34 1.45 3.71 3.62 3.71h0.13c2.7 0 4.6-1.81 4.66-4.68 0-2.08-1.5-3.42-3.71-3.42z" fill="#4D4D4D"/>
+  <path d="m92.16 139.7h6.95l5.2-22.51h-6.4l-5.75 22.51z" fill="#4D4D4D"/>
+  <path d="m116.8 112 0.84-1.9-6.44 1.17-5.03 19.19c-0.48 1.85-0.66 3.14-0.66 4.47 0 3.12 2.31 5.29 6.92 5.29 1.9 0 4.07-0.22 5.31-0.73l1.24-5.59c-0.89 0.26-2.05 0.3-3.03 0.3-1.72 0-2.42-0.85-2.42-2.18 0-0.8 0.22-2.01 0.48-2.99l1.38-6.63h7.04l0.71-5.2h-6.55l0.21-5.2z" fill="#4D4D4D"/>
+  <path d="m140.3 106.3c-7.45 0-11.26 4.77-12.7 11.67l-5.38 21.71c-0.98 3.9-2.15 4.56-2.62 5.09l-0.3 3.4h0.75c5.65 0 8.13-4.15 9.46-10.41l3.43-15.39h7.76l1.44-5.2h-8.02l0.31-1.11c0.7-2.58 2.28-4.56 5.2-4.56 1.23 0 2.51 0.35 3.8 0.87l2.26-5.33c-1.23-0.47-3.26-0.74-5.39-0.74z" fill="#4D4D4D"/>
+  <path d="m154.5 116.6c-8.6 0-13.8 7.22-13.8 14.82 0 5.6 3.67 8.91 9.19 8.91 8.23 0 13.84-6.67 13.84-14.59 0-5.07-3.39-9.14-9.23-9.14zm-3.43 18.13c-2.53 0-3.76-1.86-3.76-4.52 0-3.73 2.17-8.49 6.02-8.49 2.87 0 3.81 2.35 3.81 4.46 0 4.16-2.35 8.55-6.07 8.55z" fill="#4D4D4D"/>
+  <path d="m178.9 117c-3.67 0-6.69 0.66-8.86 1.5l-4.65 21.19h6.4l4.2-17.61c1.06-0.36 2.19-0.4 3.26-0.4 1.39 0 3.15 0.31 4.18 0.83l2.12-4.76c-1.07-0.39-3.51-0.75-6.65-0.75z" fill="#4D4D4D"/>
+  <path d="m199.8 129.6c-0.94 4.26-2.43 5.37-4.55 5.37-2.03 0-2.83-1.41-2.83-3.17 0-1.07 0.22-2.36 0.53-3.69l2.4-10.91h-6.78l-2.82 11.9c-0.43 1.76-0.65 3.35-0.65 4.76 0 4.19 2.39 6.45 7.15 6.45 2.67 0 4.98-0.66 6.7-1.85 0.22 0.66 0.57 1.32 1 1.89l5.48-1.23c-0.48-1.45-0.78-3.39-0.78-5.2 0-1.64 0.26-3.02 0.82-5.41l2.35-11.31h-6.66l-1.36 12.4z" fill="#4D4D4D"/>
+  <path d="m237.8 116.7c-3.34 0-5.78 1.17-7.54 2.71-1.19-1.76-3-2.57-6.29-2.57-3.48 0-6.67 0.7-10.39 1.5l-4.81 21.61h6.62l3.9-17.73c0.94-0.31 1.88-0.49 3.01-0.49 2.44 0 3.14 1.45 3.14 3.21 0 0.8-0.17 1.78-0.48 3.11l-2.66 11.85h6.61l3.48-16.49c1.02-1.07 2.2-1.68 3.6-1.68 1.95 0 2.93 1.17 2.93 3.16 0 0.8-0.18 1.87-0.48 3.12l-2.78 11.89h6.62l2.96-13.4c0.17-0.85 0.3-1.79 0.3-2.68 0-4.19-2.35-7.12-7.74-7.12z" fill="#4D4D4D"/>
+  <defs>
+    <linearGradient id="paint0_linear_201_1006" x1="5.661" x2="58.96" y1="124.8" y2="124.8" gradientUnits="userSpaceOnUse">
+      <stop stop-color="#F38B16" offset="0"/>
+      <stop stop-color="#F29A19" offset="1"/>
+    </linearGradient>
+  </defs>
+</svg>

BIN
public/sounds/sound-eightbit.mp3


BIN
public/sounds/sound-parlor.mp3


+ 202 - 0
types/crypto.ts

@@ -0,0 +1,202 @@
+// SignalR Ticker 데이터
+export interface TickerData {
+	market: string;
+	symbol: string;
+	openingPrice: number;
+	highPrice: number;
+	lowPrice: number;
+	tradePrice: number;
+	prevClosingPrice: number;
+	change: string;
+	changePrice: number;
+	signedChangePrice: number;
+	changeRate: number;
+	signedChangeRate: number;
+	tradeVolume: number;
+	accTradeVolume: number;
+	accTradeVolume24h: number;
+	accTradePrice: number;
+	accTradePrice24h: number;
+	tradeDate: string;
+	tradeTime: string;
+	tradeTimestamp: number;
+	askBid: string;
+	accAskVolume: number;
+	accBidVolume: number;
+	highest52WeekPrice: number;
+	highest52WeekDate: string;
+	lowest52WeekPrice: number;
+	lowest52WeekDate: string;
+	marketState: string;
+	delistingDate: string | null;
+	marketWarning: string;
+	timestamp: number;
+	streamType: string;
+}
+
+// 코인 메타 정보 (korName, engName, logoImage — REST에서만 제공)
+export interface TickerMeta {
+	korName: string;
+	engName: string;
+	logoImage: string | null;
+}
+
+// REST 시세 응답 (GET /api/crypto/tickers) — 배열로 반환
+export interface TickerRestData {
+	market: string;
+	symbol: string;
+	korName: string;
+	engName: string;
+	logoImage: string | null;
+	openingPrice: number;
+	highPrice: number;
+	lowPrice: number;
+	tradePrice: number;
+	prevClosingPrice: number;
+	change: string;
+	changePrice: number;
+	signedChangePrice: number;
+	changeRate: number;
+	signedChangeRate: number;
+	tradeVolume: number;
+	accTradeVolume: number;
+	accTradeVolume24h: number;
+	accTradePrice: number;
+	accTradePrice24h: number;
+	isFeatured: boolean;
+	displayOrder: number;
+}
+
+// SignalR Trade 데이터
+export interface TradeData {
+	market: string;
+	symbol: string;
+	tradePrice: number;
+	tradeVolume: number;
+	askBid: string;
+	prevClosingPrice: number;
+	change: string;
+	changePrice: number;
+	tradeDate: string;
+	tradeTime: string;
+	tradeTimestamp: number;
+	sequentialId: number;
+	timestamp: number;
+	streamType: string;
+	bestAskPrice: number;
+	bestAskSize: number;
+	bestBidPrice: number;
+	bestBidSize: number;
+}
+
+// REST 체결 응답 항목 (GET /api/crypto/{market}/trades)
+export interface TradeRestData {
+	timestamp: number;
+	tradePrice: number;
+	tradeVolume: number;
+	prevClosingPrice: number;
+	changePrice: number;
+	askBid: string;
+	sequentialId: number;
+}
+
+// SignalR Orderbook 데이터
+export interface OrderbookData {
+	market: string;
+	symbol: string;
+	totalAskSize: number;
+	totalBidSize: number;
+	units: OrderbookUnitData[];
+	timestamp: number;
+	level: number;
+	streamType: string;
+}
+
+export interface OrderbookUnitData {
+	askPrice: number;
+	bidPrice: number;
+	askSize: number;
+	bidSize: number;
+}
+
+// REST 호가 응답 (GET /api/crypto/{market}/orderbook)
+export interface OrderbookRestData {
+	totalAskSize: number;
+	totalBidSize: number;
+	units: OrderbookUnitData[];
+	timestamp: number;
+	level: number;
+}
+
+// SignalR Candle 데이터
+export interface CandleData {
+	market: string;
+	symbol: string;
+	candleDateTimeUtc: string;
+	candleDateTimeKst: string;
+	openingPrice: number;
+	highPrice: number;
+	lowPrice: number;
+	tradePrice: number;
+	candleAccTradeVolume: number;
+	candleAccTradePrice: number;
+	timestamp: number;
+	streamType: string;
+}
+
+// REST 캔들 응답 항목 (GET /api/crypto/{market}/candles/*)
+export interface CandleRestData {
+	candleDateTimeUtc: string;
+	candleDateTimeKst: string;
+	openingPrice: number;
+	highPrice: number;
+	lowPrice: number;
+	tradePrice: number;
+	timestamp: number;
+	candleAccTradePrice: number;
+	candleAccTradeVolume: number;
+	unit?: number;
+}
+
+// REST 마켓 목록 응답 (GET /api/crypto/markets)
+export interface MarketInfo {
+	market: string;
+	koreanName: string;
+	englishName: string;
+	marketEvent: MarketEvent | null;
+}
+
+export interface MarketEvent {
+	warning: boolean;
+	caution: MarketCaution | null;
+}
+
+export interface MarketCaution {
+	priceFluctuations: boolean;
+	tradingVolumeSoaring: boolean;
+	depositAmountSoaring: boolean;
+	globalPriceDifferences: boolean;
+	concentrationOfSmallAccounts: boolean;
+}
+
+// lightweight-charts 호환 캔들 바
+export interface CandleBar {
+	time: number;
+	open: number;
+	high: number;
+	low: number;
+	close: number;
+}
+
+// lightweight-charts 호환 볼륨 바
+export interface VolumeBar {
+	time: number;
+	value: number;
+	color: string;
+}
+
+// lightweight-charts 호환 MA 라인 데이터
+export interface MAData {
+	time: number;
+	value: number;
+}