Light & Freely & Easy๋ผ๋ ์ปจ์
์ ๊ฐ์ง EZBIT์
๋๋ค.
๋๊ตฌ๋ ๊ฐ๋ณ๊ฒ ์ด์ฉํ๋ ๋ชจ์ํฌ์๋ฅผ ๊ฒฝํํ์ธ์!
30,000,000์์ ์๋๋จธ๋๋ฅผ ๊ฐ์ง๊ณ ์์ํ ์ ์์ต๋๋ค.
ํฌ์ ํ์ด๋ฐ ์ก๊ธฐ ์ฐ์ต, ํ์ผ๋ฟ์ด ๋ง๋ค์ด์ฃผ๋ ํฌํธํด๋ฆฌ์ค ๊ฒฝํ ๋ฑ ๋ค์ํ ํฌ์๋ฅผ ์๋ํด๋ณด๋ฉด์ ๋น์ ์ ํฌ์ ๊ฐ๊ฐ์ ์ฌ๋ ค๋ณด์ธ์๐
- ๐๏ธ ๊ฐ๋ฐ ๊ธฐ๊ฐ
- ๐ค ์ฒดํ ๊ณ์
- ๐งย ์ฑ ๋ค์ด๋ก๋ ๋ฐ ์คํ
- ๐ ๊ฐ๋ฐ ํ๊ฒฝ
- โจ ์ฃผ์ ๊ธฐ๋ฅ
- ๐ ํ๋ก์ ํธ ๊ตฌ์กฐ
- ๐๏ธ ์์คํ ์ค๊ณ
- ๐คบ ์คํฌ ํฌ์ปค์ค
- โก ์ฑ๋ฅ ์ต์ ํ
- ๐ ํ๊ณ
- test123@example.com
- 123567as#
git clone https://github.com/window-ook/EZBIT.git
cd frontend
pnpm install
pnpm dev
cd backend
npm install
npm run dev
์
๋นํธ WebSocket์ผ๋ก ์ค์๊ฐ ํ์ฌ๊ฐ, ํธ๊ฐ, ์ฒด๊ฒฐ ๋ด์ญ ๋ฐ์ดํฐ๋ฅผ ์ ๊ณตํฉ๋๋ค.
Highcharts์ ์บ๋ค์คํฑ ์ฐจํธ๋ก ๋ค์ํ ๋ถ๋ด ์ฐจํธ๋ฅผ ์ ๊ณตํฉ๋๋ค.
์ฌ์ฉ์๋ ์์ฅ๊ฐ๋ก๋ง ์ฆ์ ๋งค์์ ๋งค๋ ์ฃผ๋ฌธํ ์ ์์ต๋๋ค.
์ธํ
๋ ์ต์
์ค ์ ํํด์, ์์ ์ด ๋ณด์ ํ ์ํ ์ด๋ด์ ์ํ๋ ๊ธ์ก๋งํผ ํฌํธํด๋ฆฌ์ค๋ฅผ ๋ง๋ค์ด๋๋ฆฝ๋๋ค.
๊ฐ ์ต์
์ ์ด 5๊ฐ์ ์ฝ์ธ์ผ๋ก 20%์ฉ ๋น์ค์ ๊ฐ์ต๋๋ค.
- ๋ผ์ด์ง ์คํ: ์ค์๊ฐ TOP 5
- ๋ฒ ์คํธ ์ ๋ฌ: 24์๊ฐ ๊ฑฐ๋๋๊ธ์ด ๊ฐ์ฅ ๋์ ์ฝ์ธ TOP 5
- ์์ด์ธํธ: ์๊ฐ์ด์ก TOP 5
๋ฌ๋ฌ, ์, ์์, ์ ๋ก ํ์จ ์ ๋ณด์ TOKEN POST์ ์ํฉ, ๊ธ๋ก๋ฒ ํ ํฝ ๊ธฐ์ฌ๋ฅผ ์ ๊ณตํฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ ํ๋ธ์์ ํซํ ๋นํธ์ฝ์ธ ๊ด๋ จ ์์์ ํด๋ฆญํ๋ฉด ์ ํ๋ธ๋ก ์ด๋ํ์ฌ ์์ฒญํ ์ ์์ต๋๋ค.
ํ์ฌ๊ฐ ๊ธฐ์ค ์ค์๊ฐ ํ๊ฐ์์ต, ์์ต๋ฅ , ๋๋ ์ฐจํธ ๊ธฐ๋ฐ ๋ณด๊ธฐ ์ฌ์ด ๋งค์ ๋น์ค ์ ๋ณด๋ฅผ ์ ๊ณตํฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ข
๋ชฉ๋ณ ์์ธ ์ ๋ณด๋ฅผ ํ
์ด๋ธ์์ ํ์ธํ ์ ์์ต๋๋ค.
backend/
โโโ server.js # Express.js + Socket.IO ์๋ฒ
โโโ ...
๐ฅ๏ธ frontend
โโโ actions/ # Server Actions (Next.js 15)
โ
โโโ components/
โ โโโ exchange # ๊ฑฐ๋์
โ โโโ my-assets # ๋ณด์ ์์ฐ
โ โโโ shadcn-ui # shadcn/ui ์ปดํฌ๋ํธ
โ โโโ ...
โ
โโโ hooks # ์ปค์คํ
ํ
โ โโโ socket # WebSocket ํต์
โ โโโ supabase # Supabase CRUD ์๋ฒ ์ก์
์ฌ์ฉ, ์ธ์ฆ ๊ด๋ จ
โ โโโ trends # ํธ๋ ๋ ํ์ด์ง ๋ฐ์ดํฐ
โ โโโ upbit # Upbit REST API ์กฐํ ์๋ฒ ์ก์
์ฌ์ฉ
โ
โโโ ...
๋ฐฑ์๋์ ํ๋ก ํธ์๋๋ ๋ ๋ฆฝ์ ์ธ ๋ฉํฐ๋ ํฌ๋ก ๊ฒฉ๋ฆฌํ์ต๋๋ค. ์ํธ ์์กด์ฑ์ด ์๊ณ , ๋ฐฑ์๋๋ ๊ฐ๋ฒผ์ด BFF์ด๊ธฐ ๋๋ฌธ์ด์ฃ .
ํ๋ก ํธ์๋์ ๋๋ ํ ๋ฆฌ๋ ๋ชจ๋ ์ญํ ๊ธฐ๋ฐ์ผ๋ก ๋ถ๋ฅ๋์ด ์์ต๋๋ค.
์ด ๋ฐฉ์์ ์ปดํฌ๋ํธ๋ ํ , ํจ์, ํ์ ๋ฑ ์ด๋ค ํ์ผ์ด๋ ์ง ์์น๊ฐ ์ง๊ด์ ์ด๊ธฐ ๋๋ฌธ์
์์ ์ฑ๊ณผ ํ์ฅ์ฑ ๋ฉด์์ ๋งค์ฐ ์ ๋ฆฌํ๊ณ , EZBIT ๊ท๋ชจ์ ์๋น์ค์ ์ ํฉํ ๊ตฌ์กฐ๋ผ๊ณ ์๊ฐํ์ต๋๋ค.
-- ์ฌ์ฉ์ ์ ๋ณด
users {
user_id: UUID PRIMARY KEY
holding_krw: NUMERIC DEFAULT 30000000
total_invested: NUMERIC DEFAULT 0
nickname: STRING
}
-- ๋ณด์ ์ข
๋ชฉ
holdings {
user_id: UUID FOREIGN KEY
market: STRING
total_bid_volume: NUMERIC
total_bid_amount: NUMERIC
avg_bid_price: NUMERIC
}
-- ๊ฑฐ๋ ๋ด์ญ
history {
id: UUID PRIMARY KEY
user_id: UUID FOREIGN KEY
market: STRING
order_type: BID | ASK
volume: NUMERIC
trade_price: NUMERIC
total_amount: NUMERIC
}์ด 3๊ฐ์ ํ ์ด๋ธ๋ก ๊ตฌ์ฑํ์ต๋๋ค.
๋ชจ๋ ํ ์ด๋ธ์ RLS(Row Level Security) ์ ์ฑ ์ ์ ์ฉํ์ฌ, ๋ชจ๋ CRUD์ ๋ํ ์ธ๋ถ์ ๋น์ธ๊ฐ ์์ฒญ์ ๋ฐฉ์ดํฉ๋๋ค.
DB ์กฐํ, ์ ๋นํธ API ๋ฆฌํ์คํธ
์ปดํฌ๋ํธ โ useQuery ์ปค์คํ
ํ
โ ์๋ฒ ์ก์
โ Supabase
์ปดํฌ๋ํธ โ useQuery ์ปค์คํ
ํ
โ ๋ผ์ฐํธ ํธ๋ค๋ฌ โ Upbit API
Websocket ํต์
์ปดํฌ๋ํธ โ useSocket ์ปค์คํ
ํ
โ Express.js ๋ฐฑ์๋ โ Upbit Websocket
DB ์์ฑ/์ญ์ /์ ๋ฐ์ดํธ, ์ธ์ฆ ๊ด๋ จ
์ปดํฌ๋ํธ โ useMutation ์ปค์คํ
ํ
โ ์๋ฒ ์ก์
โ Supabase
@frontend/app/exchange/page.tsx
<section className="flex flex-col md:flex-row justify-center gap-2">
{/* ์ค๋๋ถ ํ
์ด๋ธ */}
<ErrorBoundaryWrapper
featureName="์ค๋๋ถ ํ
์ด๋ธ"
message="์ค๋๋ถ ํ
์ด๋ธ ๋ก๋ฉ ์ค ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค."
>
<Suspense fallback={<LoadingSpinner size="2xl" />}>
<OrderbookTable />
</Suspense>
</ErrorBoundaryWrapper>
{/* ์ฃผ๋ฌธํ๊ธฐ ํผ */}
<ErrorBoundaryWrapper
featureName="์ฃผ๋ฌธํ๊ธฐ ํผ"
message="์ฃผ๋ฌธํ๊ธฐ ํผ ๋ก๋ฉ ์ค ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค."
>
<Suspense fallback={<LoadingSpinner size="2xl" />}>
<UserDataPrefetcher>
<OrderForm />
</UserDataPrefetcher>
</Suspense>
</ErrorBoundaryWrapper>
</section>`ErrorBoundaryWrapper`๋ ์๋ฌ๊ฐ ํฐ์ง ๊ธฐ๋ฅ๊ณผ, ๋ง์ถค ๋ฉ์ธ์ง๋ฅผ ๋ํ๋ด๋ ์ปค์คํ Wrapper Component์ ๋๋ค.
์ ์ธํ ํ๋ก๊ทธ๋๋ฐ์ ์ฌ์ฉํ์ฌ ํ๋ก์ ํธ ์ ์ฒด์ ์ผ๊ด๋ ์๋ฌ UI๋ฅผ ์ ๊ณตํ๊ธฐ ์ํด ๊ตฌํํ์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ Suspense๋ฅผ ์ฌ์ฉํ์ฌ ๋ก๋ฉ ์ํ ์ฒ๋ฆฌ๋ ์ ์ธํ ํ๋ก๊ทธ๋๋ฐ ๋ฐฉ์์ผ๋ก ๊ตฌํํ์ต๋๋ค.
@frontend/schema/**
/** ๋๋ค์ ๋ณ๊ฒฝ ํผ ์ ํจ์ฑ ๊ฒ์ฆ */
export const changeNickNameSchema = z.object({
nickname: z
.string()
.min(1, '๋๋ค์์ 1์ ์ด์์ด์ด์ผ ํฉ๋๋ค.')
.max(20, '๋๋ค์์ 20์ ์ดํ์ฌ์ผ ํฉ๋๋ค.')
.regex(/^[a-zA-Z0-9๊ฐ-ํฃ]+$/, 'ํน์๋ฌธ์๋ ์ฌ์ฉํ ์ ์์ต๋๋ค.')
.refine(
async (data) => {
const supabase = createBrowserSupabaseClient();
const { data: existingNickname } = await supabase
.from('users')
.select('nickname')
.eq('nickname', data)
.single();
return existingNickname === null;
},
{
message: '์ด๋ฏธ ์กด์ฌํ๋ ๋๋ค์์
๋๋ค.',
path: ['nickname'],
}
)
.refine((data) => data.trim() !== '', {
message: '๋๋ค์์ ๋น์ด์์ ์ ์์ต๋๋ค.',
path: ['nickname'],
}),
});
/** ๋งค์ ์ฃผ๋ฌธ ์ ํจ์ฑ ๊ฒ์ฆ */
export const bidSchema = z.object({
price: z
.number({ invalid_type_error: '๋งค์ ๊ฐ๊ฒฉ์ ์ซ์์ฌ์ผ ํฉ๋๋ค.' })
.min(0, { message: '๋งค์ ๊ฐ๊ฒฉ์ 0 ์ด์์ด์ด์ผ ํฉ๋๋ค.' }),
quantity: z
.number({ invalid_type_error: '์ฃผ๋ฌธ์๋์ ์ซ์์ฌ์ผ ํฉ๋๋ค.' })
.min(0, { message: '์ฃผ๋ฌธ์๋์ 0 ์ด์์ด์ด์ผ ํฉ๋๋ค.' }),
total: z
.number({ invalid_type_error: '์ฃผ๋ฌธ์ด์ก์ ์ซ์์ฌ์ผ ํฉ๋๋ค.' })
.min(5000, { message: '์ต์ 5,000์ ์ด์์ด์ด์ผ ํฉ๋๋ค.' }),
});
/** ๋งค๋ ์ฃผ๋ฌธ ์ ํจ์ฑ ๊ฒ์ฆ */
export const askSchema = z.object({
price: z
.number({ invalid_type_error: '๋งค๋ ๊ฐ๊ฒฉ์ ์ซ์์ฌ์ผ ํฉ๋๋ค.' })
.min(0, { message: '๋งค๋ ๊ฐ๊ฒฉ์ 0 ์ด์์ด์ด์ผ ํฉ๋๋ค.' }),
quantity: z
.number({ invalid_type_error: '์ฃผ๋ฌธ์๋์ ์ซ์์ฌ์ผ ํฉ๋๋ค.' })
.min(0, { message: '์ฃผ๋ฌธ์๋์ 0 ์ด์์ด์ด์ผ ํฉ๋๋ค.' }),
total: z.number({ invalid_type_error: '์ฃผ๋ฌธ์ด์ก์ ์ซ์์ฌ์ผ ํฉ๋๋ค.' }),
});
...zod์ ๋ฉ์๋ ์ฒด์ด๋์ผ๋ก ์ง๊ด์ ์ด๊ณ ํจ์จ์ ์ธ ์ ํจ์ฑ ๊ฒ์ฆ ๋ก์ง์ ๊ตฌํํ์ต๋๋ค.
React Hook Form์ useForm๊ณผ ํจ๊ป ์กฐํฉํ์ฌ ํผ์ ์ํ ๊ด๋ฆฌ์ ์ ํจ์ฑ ๊ฒ์ฆ, ์๋ฌ ํธ๋ค๋ง๊น์ง ๋ด๋นํฉ๋๋ค.
EZBIT์ BFF๋ก์, Express.js ๊ธฐ๋ฐ์ Socket.IO ํ๋ก์ ์๋ฒ๋ฅผ ๊ตฌํํ์ต๋๋ค.
- 3๊ณ์ธต ์บ์ฑ: ticker, orderbook, trade ๋ฐ์ดํฐ Map ๊ตฌ์กฐ ์บ์ฑ
- ์ค๋งํธ ๊ตฌ๋ : ํด๋ผ์ด์ธํธ๋ณ ์ ํ์ ๋ง์ผ ๊ตฌ๋ /ํด์
- ์๋ ์ฌ์ฐ๊ฒฐ: ์ต๋ 5ํ ์ฌ์๋, ๊ณ ์ 5์ด ์ง์ฐ
- ์ค๋ณต ๋ฐฉ์ง: sequential_id ๊ธฐ์ค ์ค๋ณต ์ฒดํฌ
- ์์ ๋ณด์ฅ: timestamp ๊ธฐ๋ฐ ๋ฐ์ดํฐ ์์ ์ ์ง
- ํ์ ๋ณ ๋ถ๋ฆฌ: ticker, orderbook, trade ์ด๋ฒคํธ ๋ ๋ฆฝ ์ฒ๋ฆฌ
- ๋ฉ๋ชจ๋ฆฌ ์ต์ ํ: ์ํ ๋ฒํผ๋ก trade ๋ฐ์ดํฐ 50๊ฐ ์ ํ
- ์ค๋กํ๋ง: 100ms ๋จ์ ์ ๋ฐ์ดํธ ์ ํ์ผ๋ก ์ฑ๋ฅ ํฅ์
- Connection Pool: ํด๋ผ์ด์ธํธ ์์ ๋ฐ๋ฅธ ์ง๋ฅํ ์ฐ๊ฒฐ ๊ด๋ฆฌ
- Graceful Shutdown: SIGTERM/SIGINT ์ ํธ ์ฒ๋ฆฌ๋ก ์์ ํ ์ข ๋ฃ
๊ทธ๋ฆฌ๊ณ ๋ฐ์์จ ๋ฐ์ดํฐ๋ฅผ TickerProvider ๋ด๋ถ์์ ๋ฉ๋ชจ์ด์ ์ด์ ์ ํตํด ์ต์ ํ๋ ์ํ๋ก ๊ด๋ฆฌํฉ๋๋ค.
- TickerProvider: ์ฝ์ธ ํ๊ตญ๋ช
, ์ ํํ ์ฝ์ธ, ๋ชจ๋ ์ฝ์ธ์ ์ค์๊ฐ ํ์ฌ๊ฐ ๋ฑ ์ํ ๊ณต์
- useMemo๋ก contextValue ์ต์ ํํ์ฌ ๋ถํ์ํ ๋ฆฌ๋ ๋๋ง ๋ฐฉ์ง
- useCallback ์ฐธ์กฐ ๋์ผ์ฑ ์ฒดํฌ๋ก setState ์ต์ ํ
- ๋ง์ผ ์ ํ ์ ์ด๊ธฐ ์ค๋๋ถ/์ฒด๊ฒฐ๋ด์ญ ๋ฐ์ดํฐ ๋ณ๋ ฌ ํ์นญ
React Query๋ก ํด๋ผ์ด์ธํธ์์ ์ธ๋ถ API์ Supabase DB์์ ํ์นญํ๋ ๋ฐ์ดํฐ๋ ์บ์ฑํ์ฌ ๊ด๋ฆฌํ์ต๋๋ค.
- ์บ์ฑ ์ ๋ต: ๊ธ๋ก๋ฒ QueryClient๋ก ๊ธฐ๋ณธ ์ต์ ์ค์
- ํ๋ฆฌํ์นญ: prefetchQuery, dehydrate, HydrationBoundary๋ฅผ ํตํด SSR ๋ฐ์ดํฐ ํ๋ฆฌํ์นญ
- ์ํ ๋๊ธฐํ ๋ฐ ๋๊ด์ ์ ๋ฐ์ดํธ: useMutation์ผ๋ก ์ด๋ฃจ์ด์ง๋ ๋งค์/๋งค๋ ์ฃผ๋ฌธ ์ฆ์ UI ๋ฐ์
- ์ปดํฌ๋ํธ ๋ฉ๋ชจ์ด์ ์ด์ : React.memo
- ๊ณ์ฐ ์ต์ ํ: useMemo
- ์ด๋ฒคํธ ํธ๋ค๋ฌ ์ต์ ํ: useCallback
- next/font/local ์ฌ์ฉ: Pretendard Variable + NEXON Gothic 3์ข (Light/Regular/Bold)
- Display Swap: ํฐํธ ๋ก๋ฉ ์ค ์์คํ ํฐํธ๋ก ๋์ฒดํ์ฌ FOUT ๋ฐฉ์ง(์์คํ ํฐํธ๊ฐ Fallback)
- Preload: ์ค์ ํฐํธ ์ฐ์ ๋ก๋ฉ์ผ๋ก CLS ์ต์ํ
- dynamic import: ๋น์ฅ ๋ณด์ด์ง ์๋ ์ปดํฌ๋ํธ๋
ssr: false์ ํจ๊ป ๋ ์ด์ง ๋ก๋
- ์บ์ฑ๊ณผ ์ ์ฉ๋ ํ์ฅ์CDN ์
๋ก๋,
.avifํ์ผ ์ฌ์ฉ
- ์ค๋ณต ์ ๋ฐ์ดํธ ๋ฐฉ์ง: timestamp + ๊ฐ ๋น๊ต
- ๋ฉ๋ชจ๋ฆฌ ํจ์จ์ ๊ตฌ๋ : Set ์๋ฃ๊ตฌ์กฐ ํ์ฉ
const currentMarkets = new Set(markets.map((m) => m.market));- ์ํ ๋ฒํผ: Trade ๋ฐ์ดํฐ ์ต๋ 50๊ฐ ์ ์ง (MAX_TRADES_COUNT = 50)
- TanStack Query: API ์๋ต ์บ์ฑ, ํ๋ฆฌ ํ์นญ
- WebSocket ๋ฐ์ดํฐ: ๋ฐฑ์๋ Map์ผ๋ก ํ๋ฆฌ์บ์ฑ
์๋ก์ด ์๋
์ ์ธํ ํ๋ก๊ทธ๋๋ฐ
์น์์ผ BFF ๊ตฌ์ถ๊ณผ ํด๋ผ์ด์ธํธ ํต์ ์ต์ ํ
์๋กญ๊ฒ ์๊ฒ ๋ ๊ฒ
- WebSocket ์ฐ๊ฒฐ ๊ด๋ฆฌ์ ๋ณต์ก์ฑ
- ํด๋ผ์ด์ธํธ ์์ ๋ฐ๋ฅธ ์ฐ๊ฒฐ ํ ๊ด๋ฆฌ, ์ค๋ณต ๋ฐฉ์ง, ์์ ๋ณด์ฅ, ๋ฉ๋ชจ๋ฆฌ ์ต์ ํ ๋ฑ ์ค์๊ฐ ํต์ ์์ ๊ณ ๋ คํด์ผ ํ ์์๋ค์ด ์๊ฐ๋ณด๋ค ๋ง์์ต๋๋ค.
- Context API์ ์ต์ ํ ํฌ์ธํธ
- useMemo์ useCallback์ ํ์ฉํ Provider ์ต์ ํ๊ฐ ๋๊ท๋ชจ ์ค์๊ฐ ๋ฐ์ดํฐ ์ ๋ฐ์ดํธ์์ ์ผ๋ง๋ ์ค์ํ์ง ์๊ฒ ๋์์ต๋๋ค.
- BFF ํจํด์ ์ค์ฉ์ฑ
- ๋จ์ํ API ํ๋ก์๋ฅผ ๋์ด์ ๋ฐ์ดํฐ ๊ฐ๊ณต, ์บ์ฑ, ์ฐ๊ฒฐ ๊ด๋ฆฌ๊น์ง ๋ด๋นํ๋ BFF์ ์ญํ ์ด ์ค์๊ฐ ์๋น์ค์์ ํ์์ ์์ ๊นจ๋ฌ์์ต๋๋ค.




