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시간 변동률
+ 현재가
+ 변동률
- {/*
- | BTC/USD |
- 68,450.25 |
- +2.15% |
-
-
- | ETH/USD |
- 3,550.78 |
- +1.80% |
-
*/}
- {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 (
-