diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f3e42ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: lint & Build Test + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint Code + run: pnpm lint + + - name: Build Test + run: pnpm run build diff --git a/.gitignore b/.gitignore index 4c84356..cd57444 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ next-env.d.ts # local env files .env.local -/.vscode \ No newline at end of file +/.vscode +/docs \ No newline at end of file diff --git a/package.json b/package.json index fc2f479..b59e0b8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-query": "^5.90.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.553.0", @@ -26,6 +27,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@tanstack/react-query-devtools": "^5.90.2", "@trivago/prettier-plugin-sort-imports": "^6.0.0", "@types/node": "^20", "@types/react": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f70b1ff..03908ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.2)(react@19.2.0) + '@tanstack/react-query': + specifier: ^5.90.7 + version: 5.90.7(react@19.2.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -54,6 +57,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.17 + '@tanstack/react-query-devtools': + specifier: ^5.90.2 + version: 5.90.2(@tanstack/react-query@5.90.7(react@19.2.0))(react@19.2.0) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.0 version: 6.0.0(prettier@3.6.2) @@ -893,6 +899,23 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@tanstack/query-core@5.90.7': + resolution: {integrity: sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==} + + '@tanstack/query-devtools@5.90.1': + resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} + + '@tanstack/react-query-devtools@5.90.2': + resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.7': + resolution: {integrity: sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==} + peerDependencies: + react: ^18 || ^19 + '@trivago/prettier-plugin-sort-imports@6.0.0': resolution: {integrity: sha512-Xarx55ow0R8oC7ViL5fPmDsg1EBa1dVhyZFVbFXNtPPJyW2w9bJADIla8YFSaNG9N06XfcklA9O9vmw4noNxkQ==} engines: {node: '>= 20'} @@ -3209,6 +3232,21 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@tanstack/query-core@5.90.7': {} + + '@tanstack/query-devtools@5.90.1': {} + + '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.7(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/query-devtools': 5.90.1 + '@tanstack/react-query': 5.90.7(react@19.2.0) + react: 19.2.0 + + '@tanstack/react-query@5.90.7(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.7 + react: 19.2.0 + '@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.6.2)': dependencies: '@babel/generator': 7.28.5 diff --git a/src/app/api/market/route.ts b/src/app/api/market/route.ts new file mode 100644 index 0000000..35b5f78 --- /dev/null +++ b/src/app/api/market/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; + +import { marketInfoHandler } from "@/entities"; + +export async function GET() { + try { + const data = await marketInfoHandler(); + return NextResponse.json({ status: "success", data }); + } catch (error) { + console.error("Market API error:", error); + return NextResponse.json({ status: "error", error: "Failed to fetch market data" }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 4c12fe8..69532b2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,9 +47,13 @@ --color-text-light: var(--text-light); --color-negative: var(--negative); --color-positive: var(--positive); + --color-increase: var(--increase); + --color-decrease: var(--decrease); } :root { + --decrease: #1375ec; + --increase: #dd3c44; --positive: #00b15d; --negative: #ff4136; --text-dark: #e0e0e0; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6d91bae..3fd0b11 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ -import { Geist_Mono, Noto_Sans, Roboto } from "next/font/google"; +import { Noto_Sans, Roboto } from "next/font/google"; +import { AppProvider } from "@/shared"; import { AppLayout } from "@/widgets"; import type { Metadata } from "next"; @@ -28,7 +29,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); diff --git a/src/entities/index.ts b/src/entities/index.ts index e69de29..674239a 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -0,0 +1 @@ +export * from "./market"; diff --git a/src/entities/market/handler/index.ts b/src/entities/market/handler/index.ts new file mode 100644 index 0000000..60f9984 --- /dev/null +++ b/src/entities/market/handler/index.ts @@ -0,0 +1 @@ +export * from "./market-info.handler"; diff --git a/src/entities/market/handler/market-info.handler.ts b/src/entities/market/handler/market-info.handler.ts new file mode 100644 index 0000000..f6205c7 --- /dev/null +++ b/src/entities/market/handler/market-info.handler.ts @@ -0,0 +1,32 @@ +import { marketAllAPI, tickerAPI } from "../model"; + +export const marketInfoHandler = async () => { + const marketData = await marketAllAPI(); + + const krwMarketMap = new Map(); + marketData.forEach((item) => { + if (item.market.startsWith("KRW-")) { + krwMarketMap.set(item.market, { korean_name: item.korean_name }); + } + }); + + const krwMarketCodes = Array.from(krwMarketMap.keys()); + if (krwMarketCodes.length === 0) { + return []; + } + + const tickerData = await tickerAPI(krwMarketCodes); + + const result = tickerData.map((ticker) => { + const marketInfo = krwMarketMap.get(ticker.market); + + return { + market: ticker.market, + koreanName: marketInfo?.korean_name || "", + tradePrice: ticker.trade_price, + changeRate: parseFloat((ticker.signed_change_rate * 100).toFixed(2)), // 퍼센트로 변환 & 소수 둘째자리 + }; + }); + + return result; +}; diff --git a/src/entities/market/index.ts b/src/entities/market/index.ts new file mode 100644 index 0000000..97dec78 --- /dev/null +++ b/src/entities/market/index.ts @@ -0,0 +1,2 @@ +export * from "./handler"; +export * from "./model"; diff --git a/src/entities/market/model/apis/index.ts b/src/entities/market/model/apis/index.ts new file mode 100644 index 0000000..d62e325 --- /dev/null +++ b/src/entities/market/model/apis/index.ts @@ -0,0 +1,2 @@ +export * from "./market-all.api"; +export * from "./ticker.api"; diff --git a/src/features/home/apis/market-all.api.ts b/src/entities/market/model/apis/market-all.api.ts similarity index 98% rename from src/features/home/apis/market-all.api.ts rename to src/entities/market/model/apis/market-all.api.ts index f8d7f26..8d74e49 100644 --- a/src/features/home/apis/market-all.api.ts +++ b/src/entities/market/model/apis/market-all.api.ts @@ -8,7 +8,7 @@ export interface MarketAllItem { } export const marketAllAPI = async (): Promise => { - const response = await fetch(`${UPBIT_URL}/market/all?isDetails=true`, { + const response = await fetch(`${UPBIT_URL}/market/all?isDetails=false`, { next: { revalidate: 60 }, // 60초 캐시 (서버 컴포넌트 환경에서) }); diff --git a/src/features/home/apis/ticker.api.ts b/src/entities/market/model/apis/ticker.api.ts similarity index 100% rename from src/features/home/apis/ticker.api.ts rename to src/entities/market/model/apis/ticker.api.ts diff --git a/src/entities/market/model/index.ts b/src/entities/market/model/index.ts new file mode 100644 index 0000000..1063c8d --- /dev/null +++ b/src/entities/market/model/index.ts @@ -0,0 +1,2 @@ +export * from "./apis"; +export * from "./types"; diff --git a/src/entities/market/model/types/index.ts b/src/entities/market/model/types/index.ts new file mode 100644 index 0000000..0406812 --- /dev/null +++ b/src/entities/market/model/types/index.ts @@ -0,0 +1 @@ +export * from "./market-info.type"; diff --git a/src/entities/market/model/types/market-info.type.ts b/src/entities/market/model/types/market-info.type.ts new file mode 100644 index 0000000..1ffa380 --- /dev/null +++ b/src/entities/market/model/types/market-info.type.ts @@ -0,0 +1,6 @@ +export type MarketInfoType = { + market: string; + koreanName: string; + tradePrice: number; + changeRate: number; +}; diff --git a/src/features/home/apis/index.ts b/src/features/home/apis/index.ts index d62e325..ce385f9 100644 --- a/src/features/home/apis/index.ts +++ b/src/features/home/apis/index.ts @@ -1,2 +1 @@ -export * from "./market-all.api"; -export * from "./ticker.api"; +export * from "./market.api"; diff --git a/src/features/home/apis/market.api.ts b/src/features/home/apis/market.api.ts new file mode 100644 index 0000000..7432d33 --- /dev/null +++ b/src/features/home/apis/market.api.ts @@ -0,0 +1,12 @@ +import { MarketInfoType } from "@/entities"; +import { APIResponse } from "@/shared"; + +export const marketAPI = async (): Promise => { + const response = await fetch("/api/market", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Market API failed: ${response.status} ${response.statusText}`); + } + const json: APIResponse = await response.json(); + + return json.data; +}; diff --git a/src/features/home/components/features/coin-info/CoinInfoTable.tsx b/src/features/home/components/features/coin-info/CoinInfoTable.tsx index d620960..a693cd0 100644 --- a/src/features/home/components/features/coin-info/CoinInfoTable.tsx +++ b/src/features/home/components/features/coin-info/CoinInfoTable.tsx @@ -1,39 +1,34 @@ -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared"; +"use client"; -import { marketAllAPI, tickerAPI } from "../../../apis"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn } from "@/shared"; -export const CoinInfoTable = async () => { - const marketData = await marketAllAPI(); - const marketList = marketData.map((item) => item.market); - // console.log(marketData); - const tickerData = await tickerAPI(marketList); +import { useGetMarketInfo } from "../../../hooks"; + +export const CoinInfoTable = () => { + const { data: marketInfoData } = useGetMarketInfo(); + const rateColor = (rate: number) => { + return rate >= 0 ? "text-increase" : "text-decrease"; + }; return (
마켓 - 가격 - 24시간 변동률 + 현재가 + 변동률 - {/* - - - - - - - - - */} - {tickerData.map((ticker) => ( + {marketInfoData?.map((ticker) => ( - {ticker.market} - {ticker.trade_price.toLocaleString()} - = 0 ? "text-positive" : "text-destructive"}`}> - {(ticker.signed_change_rate * 100).toFixed(2)}% + +

{ticker.koreanName}

+

{ticker.market}

+
+ {Number(ticker.tradePrice).toLocaleString("ko-KR")} + + {ticker.changeRate.toFixed(2)}%
))} diff --git a/src/features/home/hooks/index.ts b/src/features/home/hooks/index.ts new file mode 100644 index 0000000..beb905f --- /dev/null +++ b/src/features/home/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useGetMarketInfo"; diff --git a/src/features/home/hooks/useGetMarketInfo.ts b/src/features/home/hooks/useGetMarketInfo.ts new file mode 100644 index 0000000..c6931ef --- /dev/null +++ b/src/features/home/hooks/useGetMarketInfo.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; + +import { marketAPI } from "../apis"; + +export const useGetMarketInfo = () => { + return useQuery({ + queryKey: ["market-info"], + queryFn: marketAPI, + refetchInterval: 10000, // 10초 폴링 + }); +}; diff --git a/src/features/home/ui/CoinInfoSection.tsx b/src/features/home/ui/CoinInfoSection.tsx index 61e4ccb..7c806d7 100644 --- a/src/features/home/ui/CoinInfoSection.tsx +++ b/src/features/home/ui/CoinInfoSection.tsx @@ -2,7 +2,7 @@ import { CoinInfoTable, CoinSearchInput } from "../components"; export const CoinInfoSection = () => { return ( -
BTC/USD68,450.25+2.15%
ETH/USD3,550.78+1.80%