-
Notifications
You must be signed in to change notification settings - Fork 0
코인 차트 기능 구현 #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
코인 차트 기능 구현 #16
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Summary of ChangesHello @Dobbymin, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 Pull Request는 ApexCharts를 활용하여 인터랙티브한 코인 차트 기능을 구현합니다. 사용자가 다양한 타임프레임으로 캔들스틱 및 거래량 차트를 조회하고, 브러시 기능을 통해 특정 구간을 확대/축소할 수 있도록 합니다. Next.js 환경에서의 SSR 이슈, 시간대 처리, 그리고 Upbit API와의 타입 안전한 연동 등 여러 기술적 난관을 해결하며 견고한 차트 시스템을 구축했습니다. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
ApexCharts를 활용하여 업비트 스타일의 인터랙티브 코인 차트를 구현하신 점이 인상적입니다. 캔들스틱 차트와 브러시 차트의 동기화, 다양한 타임프레임 지원, 타입 안전성을 고려한 API 연동 등 기능적으로 완성도가 매우 높습니다. 특히 Zustand를 이용한 상태 관리와 커스텀 훅을 통한 로직 분리 (ViewModel 패턴)는 코드의 구조를 명확하고 유지보수하기 쉽게 만들어주었습니다. 코드의 성능과 안정성을 더욱 높일 수 있는 몇 가지 개선점을 제안드립니다. 전반적으로 훌륭한 작업입니다!
| queryKey, | ||
| queryFn: () => candleAPI(params), | ||
|
|
||
| refetchInterval: 500, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
refetchInterval을 500ms로 고정하면 '주', '달', '년'과 같이 데이터 변경 주기가 긴 타임프레임에서도 불필요한 API 요청이 너무 자주 발생합니다. 이는 Upbit API의 Rate Limit에 도달할 위험을 높이고 서버에 부담을 줍니다. 타임프레임(type)에 따라 refetchInterval 값을 동적으로 설정하는 것이 좋습니다. 예를 들어, 초/분 단위는 짧게, 그 외에는 더 길게 설정하거나 비활성화하는 것을 고려해 보세요.
| refetchInterval: 500, | |
| refetchInterval: type === "seconds" || type === "minutes" ? 1000 : false, |
| getCandleParams: (market: string, count: number) => | ||
| timeframeToAPIParams( | ||
| // state에서 현재 timeframe을 가져오기 위해서는 별도 처리 필요 | ||
| // 이 함수는 직접 사용하지 않고, useCandleParams hook 사용 권장 | ||
| "1초", | ||
| market, | ||
| count, | ||
| ), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
|
||
| // 분 캔들의 경우 unit을 Path 파라미터로 추가 | ||
| if (type === "minutes") { | ||
| const unit = (params as MinutesCandleParams).unit; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| const { market, count, type } = params; | ||
|
|
||
| // 분 캔들일 때만 unit을 queryKey에 포함 | ||
| const unit = type === "minutes" ? (params as MinutesCandleParams).unit : undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
candle.api.ts 파일과 마찬가지로, type === "minutes" 조건으로 인해 params 타입이 MinutesCandleParams로 좁혀지므로 타입 단언이 필요하지 않습니다. 타입스크립트의 제어 흐름 분석을 활용하여 더 안전하고 간결한 코드를 작성할 수 있습니다.
| const unit = type === "minutes" ? (params as MinutesCandleParams).unit : undefined; | |
| const unit = type === "minutes" ? params.unit : undefined; |
| pan: true, | ||
| reset: true, | ||
| export const CoinChartDisplay = () => { | ||
| const { prices, volume, isLoading } = useCoinChartViewModel(50); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| const candleOptions: ApexOptions = { | ||
| chart: { | ||
| type: "candlestick", | ||
| id: "candles", | ||
| toolbar: { autoSelected: "pan", show: false }, | ||
| zoom: { enabled: false }, | ||
| background: "transparent", | ||
| }, | ||
| theme: { mode: "dark" }, | ||
| plotOptions: { | ||
| candlestick: { | ||
| colors: { | ||
| upward: "var(--increase)", | ||
| downward: "var(--decrease)", // 하락 (초록/파랑) | ||
| }, | ||
| }, | ||
| }, | ||
| zoom: { enabled: true }, | ||
| background: "transparent", | ||
| }, | ||
| grid: { | ||
| show: true, | ||
| borderColor: "#2d2d2d", | ||
| strokeDashArray: 0, | ||
| position: "back", | ||
| xaxis: { | ||
| lines: { | ||
| show: true, | ||
| }, | ||
| type: "datetime", | ||
| axisBorder: { show: false }, | ||
| axisTicks: { show: false }, | ||
| labels: { show: true }, // 메인 차트 X축 라벨 보이기 | ||
| }, | ||
| yaxis: { | ||
| lines: { | ||
| show: true, | ||
| }, | ||
| }, | ||
| }, | ||
| plotOptions: { | ||
| bar: { | ||
| columnWidth: "50%", | ||
| colors: { | ||
| ranges: [{ from: 0, to: 300, color: "#4a9eff" }], | ||
| }, | ||
| }, | ||
| candlestick: { | ||
| colors: { | ||
| upward: "#ef5350", | ||
| downward: "#26a69a", | ||
| }, | ||
| wick: { | ||
| useFillColor: true, | ||
| tooltip: { enabled: true }, | ||
| labels: { | ||
| formatter: (val) => Math.floor(val).toLocaleString(), | ||
| }, | ||
| }, | ||
| }, | ||
| xaxis: { | ||
| type: "datetime" as const, | ||
| labels: { | ||
| style: { | ||
| colors: "#9ca3af", | ||
| fontSize: "11px", | ||
| grid: { borderColor: "#333" }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
|
||
| const volume = sortedData.map((item) => ({ | ||
| x: parseKstToUtc(item.candle_date_time_kst), | ||
| y: item.candle_acc_trade_volume.toFixed(3), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
거래량(volume) 데이터를 변환할 때 toFixed(3)를 사용하여 숫자를 문자열로 바꾸고 있습니다. ApexCharts는 숫자 형태의 데이터도 잘 처리하며, 숫자 형식으로 유지하는 것이 데이터의 유연성 측면에서 더 좋습니다. 예를 들어, 나중에 차트 툴팁 등에서 추가적인 계산이 필요할 경우 문자열보다 숫자가 다루기 용이합니다. 포매팅은 차트 옵션의 formatter를 통해 UI단에서 처리하는 것을 권장합니다.
| y: item.candle_acc_trade_volume.toFixed(3), | |
| y: item.candle_acc_trade_volume, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements an interactive cryptocurrency chart feature using ApexCharts, styled after Upbit's trading interface. The implementation includes synchronized candlestick and brush (volume) charts with support for multiple timeframes (1초/1분/5분/30분/1시간/주/달/년). The feature utilizes TypeScript discriminated unions for type-safe API parameter handling, Zustand for global timeframe state management, and React Query for data fetching with real-time polling.
Key changes include:
- Type-safe candle API integration with support for all Upbit candle types (seconds/minutes/days/weeks/months)
- ApexCharts implementation with SSR compatibility using Next.js dynamic imports
- Zustand-based timeframe selection with automatic API parameter conversion
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
src/features/home/utils/formatted-market-name.ts |
Utility to convert market format from display format (BTC/KRW) to Upbit API format (KRW-BTC) |
src/features/home/utils/index.ts |
Barrel export for utilities |
src/features/home/store/useChartStore.ts |
Zustand store for managing selected timeframe and converting to API parameters |
src/features/home/store/index.ts |
Barrel export for store and hooks |
src/features/home/hooks/useChartViewModel.ts |
View model hook combining market data, API params, and chart data transformation |
src/features/home/hooks/useChartData.ts |
Data transformation logic converting API response to ApexCharts format |
src/features/home/hooks/index.ts |
Updated exports to include new chart hooks |
src/features/home/components/features/coin-chart/CoinChartDisplay.tsx |
Main chart component with synchronized candlestick and brush charts, replacing dummy data with live API data |
src/features/home/components/features/coin-chart/ChartControls.tsx |
Timeframe selection controls using Zustand store |
src/features/home/components/common/button/TimeframeButton.tsx |
Timeframe button with type-safe ChartTimeframe typing |
src/entities/market/model/types/chart.type.ts |
Type definitions for candle data and chart series |
src/entities/market/model/types/candle.type.ts |
Discriminated union types for different candle API parameters |
src/entities/market/model/types/index.ts |
Updated exports to include new types |
src/entities/market/model/hooks/useGetCandle.ts |
React Query hook for fetching candle data with 500ms polling |
src/entities/market/model/hooks/index.ts |
Updated exports to include candle hook |
src/entities/market/model/apis/candle.api.ts |
API client for Upbit candle endpoints with path parameter handling for minute candles |
src/entities/market/model/apis/index.ts |
Updated exports to include candle API |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export const formattedMarketName = (market: string | undefined): string => { | ||
| if (!market) { | ||
| return "KRW-BTC"; // 기본값 설정 | ||
| } | ||
|
|
||
| if (market.includes("/")) { | ||
| const [coin, currency] = market.split("/"); | ||
| return `${currency}-${coin}`; | ||
| } | ||
|
|
||
| return market; | ||
| }; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function name formattedMarketName doesn't follow typical JavaScript naming conventions. Function names that return a formatted value should either be verbs (e.g., formatMarketName) or clearly indicate they transform data (e.g., toUpbitMarketFormat). Consider renaming to formatMarketName or convertToUpbitMarketFormat for better clarity.
| export const useChartData = (candleData: CandleData[] | undefined): ChartSeriesData => { | ||
| // 1. 방어 코드: 데이터가 없으면 빈 배열 반환 | ||
| if (!candleData || candleData.length === 0) { | ||
| return { prices: [], volume: [] }; | ||
| } | ||
|
|
||
| const sortedData = [...candleData].sort( | ||
| (a, b) => new Date(a.candle_date_time_kst).getTime() - new Date(b.candle_date_time_kst).getTime(), | ||
| ); | ||
|
|
||
| const parseKstToUtc = (kstString: string) => { | ||
| return new Date(kstString + "Z").getTime(); | ||
| }; | ||
|
|
||
| const prices = sortedData.map((item) => ({ | ||
| x: parseKstToUtc(item.candle_date_time_kst), | ||
| y: [item.opening_price, item.high_price, item.low_price, item.trade_price], | ||
| })); | ||
|
|
||
| const volume = sortedData.map((item) => ({ | ||
| x: parseKstToUtc(item.candle_date_time_kst), | ||
| y: item.candle_acc_trade_volume.toFixed(3), | ||
| })); | ||
|
|
||
| return { prices, volume }; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The useChartData function performs data transformation but doesn't use any React hooks. This is a pure utility function and should not be prefixed with "use" as this violates React hook naming conventions and may confuse developers. Consider renaming it to transformChartData or formatCandleDataForChart to better reflect its purpose as a data transformation utility.
| queryKey, | ||
| queryFn: () => candleAPI(params), | ||
|
|
||
| refetchInterval: 500, |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The refetch interval of 500ms (line 18) is very aggressive and could lead to rate limiting issues with the Upbit API. According to the PR description, Upbit API has a rate limit of "초당 최대 10회" (maximum 10 requests per second). With a 500ms interval, you're making 2 requests per second per market/timeframe combination, which could quickly exceed limits when multiple users or components make requests. Consider increasing to 1000ms or implementing exponential backoff.
| refetchInterval: 500, | |
| refetchInterval: 1000, |
|
|
||
| const volume = sortedData.map((item) => ({ | ||
| x: parseKstToUtc(item.candle_date_time_kst), | ||
| y: item.candle_acc_trade_volume.toFixed(3), |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Converting volume to a fixed decimal string (.toFixed(3)) may cause precision loss for coins with very high or very low trading volumes. Additionally, the y field in ChartDataPoint is typed as number | number[] | string, but ApexCharts bar charts expect numeric values. Consider keeping volume as a number rather than converting it to a string, or ensure the chart library properly handles string values for bar charts.
| y: item.candle_acc_trade_volume.toFixed(3), | |
| y: item.candle_acc_trade_volume, |
| timestamp: number; | ||
| candle_acc_trade_price: number; | ||
| candle_acc_trade_volume: number; | ||
| unit: number; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CandleData type includes a unit field that may not be present in all API responses. According to Upbit API documentation, the unit field is only returned for minute candles, not for seconds/days/weeks/months candles. This could cause runtime errors when accessing unit on non-minute candle data. Consider making this field optional (unit?: number) or using discriminated unions for different candle response types.
| unit: number; | |
| unit?: number; |
| getCandleParams: (market: string, count: number) => | ||
| timeframeToAPIParams( | ||
| // state에서 현재 timeframe을 가져오기 위해서는 별도 처리 필요 | ||
| // 이 함수는 직접 사용하지 않고, useCandleParams hook 사용 권장 | ||
| "1초", | ||
| market, | ||
| count, | ||
| ), |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getCandleParams method in the Zustand store always returns hardcoded "1초" timeframe parameters (line 51), making it non-functional. Since the comment on lines 49-50 already indicates this function shouldn't be used directly and recommends useCandleParams hook instead, consider removing this method from the store interface entirely to prevent confusion and potential misuse.
| const candleOptions: ApexOptions = { | ||
| chart: { | ||
| type: "candlestick", | ||
| id: "candles", | ||
| toolbar: { autoSelected: "pan", show: false }, | ||
| zoom: { enabled: false }, | ||
| background: "transparent", | ||
| }, | ||
| theme: { mode: "dark" }, | ||
| plotOptions: { | ||
| candlestick: { | ||
| colors: { | ||
| upward: "var(--increase)", | ||
| downward: "var(--decrease)", // 하락 (초록/파랑) | ||
| }, | ||
| }, | ||
| }, | ||
| zoom: { enabled: true }, | ||
| background: "transparent", | ||
| }, | ||
| grid: { | ||
| show: true, | ||
| borderColor: "#2d2d2d", | ||
| strokeDashArray: 0, | ||
| position: "back", | ||
| xaxis: { | ||
| lines: { | ||
| show: true, | ||
| }, | ||
| type: "datetime", | ||
| axisBorder: { show: false }, | ||
| axisTicks: { show: false }, | ||
| labels: { show: true }, // 메인 차트 X축 라벨 보이기 | ||
| }, | ||
| yaxis: { | ||
| lines: { | ||
| show: true, | ||
| }, | ||
| }, | ||
| }, | ||
| plotOptions: { | ||
| bar: { | ||
| columnWidth: "50%", | ||
| colors: { | ||
| ranges: [{ from: 0, to: 300, color: "#4a9eff" }], | ||
| }, | ||
| }, | ||
| candlestick: { | ||
| colors: { | ||
| upward: "#ef5350", | ||
| downward: "#26a69a", | ||
| }, | ||
| wick: { | ||
| useFillColor: true, | ||
| tooltip: { enabled: true }, | ||
| labels: { | ||
| formatter: (val) => Math.floor(val).toLocaleString(), | ||
| }, | ||
| }, | ||
| }, | ||
| xaxis: { | ||
| type: "datetime" as const, | ||
| labels: { | ||
| style: { | ||
| colors: "#9ca3af", | ||
| fontSize: "11px", | ||
| grid: { borderColor: "#333" }, | ||
| }; | ||
|
|
||
| const barOptions: ApexOptions = { | ||
| chart: { | ||
| type: "bar", | ||
| id: "brush", | ||
| brush: { | ||
| enabled: true, | ||
| target: "candles", | ||
| }, | ||
| datetimeFormatter: { | ||
| year: "yyyy", | ||
| month: "MMM 'yy", | ||
| day: "dd MMM", | ||
| hour: "HH:mm", | ||
| selection: { | ||
| enabled: true, | ||
| fill: { color: "#ccc", opacity: 0.4 }, | ||
| stroke: { color: "#0D47A1" }, | ||
| // 초기 선택 범위: 데이터의 마지막 20% 구간 | ||
| xaxis: { | ||
| min: prices.length > 30 ? prices[prices.length - 30].x : undefined, | ||
| max: prices.length > 0 ? prices[prices.length - 1].x : undefined, | ||
| }, | ||
| }, | ||
| background: "transparent", | ||
| }, | ||
| axisBorder: { | ||
| show: true, | ||
| color: "#2d2d2d", | ||
| }, | ||
| axisTicks: { | ||
| show: true, | ||
| color: "#2d2d2d", | ||
| }, | ||
| }, | ||
| yaxis: [ | ||
| { | ||
| seriesName: "BTC/KRW", | ||
| opposite: true, | ||
| tooltip: { enabled: false }, | ||
| labels: { | ||
| style: { | ||
| colors: "#9ca3af", | ||
| fontSize: "11px", | ||
| theme: { mode: "dark" }, | ||
| dataLabels: { enabled: false }, | ||
| plotOptions: { | ||
| bar: { | ||
| columnWidth: "100%", | ||
| colors: { | ||
| ranges: [{ from: 0, to: 1000000000, color: "#555" }], // 거래량 색상 통일 | ||
| }, | ||
| formatter: (value) => { | ||
| return `${(value / 1000000).toFixed(1)}M`; | ||
| }, | ||
| }, | ||
| axisBorder: { | ||
| show: true, | ||
| color: "#2d2d2d", | ||
| }, | ||
| }, | ||
| { | ||
| seriesName: "Volume", | ||
| show: false, | ||
| min: 0, | ||
| max: 400, | ||
| }, | ||
| ], | ||
| tooltip: { | ||
| enabled: true, | ||
| shared: false, | ||
| theme: "dark", | ||
| style: { | ||
| fontSize: "12px", | ||
| stroke: { width: 0 }, | ||
| xaxis: { | ||
| type: "datetime", | ||
| tooltip: { enabled: false }, | ||
| axisBorder: { offsetX: 13 }, | ||
| }, | ||
| custom: function ({ seriesIndex, dataPointIndex, w }) { | ||
| const data = w.globals.initialSeries[seriesIndex].data[dataPointIndex]; | ||
| if (seriesIndex === 0 && data?.y) { | ||
| const [open, high, low, close] = data.y; | ||
| const change = close - open; | ||
| const changePercent = ((change / open) * 100).toFixed(2); | ||
| const color = change >= 0 ? "#ef5350" : "#26a69a"; | ||
| yaxis: { labels: { show: false } }, | ||
| grid: { show: false }, | ||
| }; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The chart options are recreated on every render, which could cause performance issues since ApexCharts may unnecessarily re-render even when data hasn't changed. Consider memoizing candleOptions and barOptions using useMemo with appropriate dependencies (e.g., prices.length for brush chart selection range) to prevent unnecessary recalculations and improve performance.
| export const CoinChartDisplay = () => { | ||
| const { prices, volume, isLoading } = useCoinChartViewModel(50); | ||
|
|
||
| const candleOptions: ApexOptions = { | ||
| chart: { | ||
| type: "candlestick", | ||
| id: "candles", | ||
| toolbar: { autoSelected: "pan", show: false }, | ||
| zoom: { enabled: false }, | ||
| background: "transparent", | ||
| }, | ||
| theme: { mode: "dark" }, | ||
| plotOptions: { | ||
| candlestick: { | ||
| colors: { | ||
| upward: "var(--increase)", | ||
| downward: "var(--decrease)", // 하락 (초록/파랑) | ||
| }, | ||
| }, | ||
| }, | ||
| zoom: { enabled: true }, | ||
| background: "transparent", | ||
| }, | ||
| grid: { | ||
| show: true, | ||
| borderColor: "#2d2d2d", | ||
| strokeDashArray: 0, | ||
| position: "back", | ||
| xaxis: { | ||
| lines: { | ||
| show: true, | ||
| }, | ||
| type: "datetime", | ||
| axisBorder: { show: false }, | ||
| axisTicks: { show: false }, | ||
| labels: { show: true }, // 메인 차트 X축 라벨 보이기 | ||
| }, | ||
| yaxis: { | ||
| lines: { | ||
| show: true, | ||
| }, | ||
| }, | ||
| }, | ||
| plotOptions: { | ||
| bar: { | ||
| columnWidth: "50%", | ||
| colors: { | ||
| ranges: [{ from: 0, to: 300, color: "#4a9eff" }], | ||
| }, | ||
| }, | ||
| candlestick: { | ||
| colors: { | ||
| upward: "#ef5350", | ||
| downward: "#26a69a", | ||
| }, | ||
| wick: { | ||
| useFillColor: true, | ||
| tooltip: { enabled: true }, | ||
| labels: { | ||
| formatter: (val) => Math.floor(val).toLocaleString(), | ||
| }, | ||
| }, | ||
| }, | ||
| xaxis: { | ||
| type: "datetime" as const, | ||
| labels: { | ||
| style: { | ||
| colors: "#9ca3af", | ||
| fontSize: "11px", | ||
| grid: { borderColor: "#333" }, | ||
| }; | ||
|
|
||
| const barOptions: ApexOptions = { | ||
| chart: { | ||
| type: "bar", | ||
| id: "brush", | ||
| brush: { | ||
| enabled: true, | ||
| target: "candles", | ||
| }, | ||
| datetimeFormatter: { | ||
| year: "yyyy", | ||
| month: "MMM 'yy", | ||
| day: "dd MMM", | ||
| hour: "HH:mm", | ||
| selection: { | ||
| enabled: true, | ||
| fill: { color: "#ccc", opacity: 0.4 }, | ||
| stroke: { color: "#0D47A1" }, | ||
| // 초기 선택 범위: 데이터의 마지막 20% 구간 | ||
| xaxis: { | ||
| min: prices.length > 30 ? prices[prices.length - 30].x : undefined, | ||
| max: prices.length > 0 ? prices[prices.length - 1].x : undefined, | ||
| }, | ||
| }, | ||
| background: "transparent", | ||
| }, | ||
| axisBorder: { | ||
| show: true, | ||
| color: "#2d2d2d", | ||
| }, | ||
| axisTicks: { | ||
| show: true, | ||
| color: "#2d2d2d", | ||
| }, | ||
| }, | ||
| yaxis: [ | ||
| { | ||
| seriesName: "BTC/KRW", | ||
| opposite: true, | ||
| tooltip: { enabled: false }, | ||
| labels: { | ||
| style: { | ||
| colors: "#9ca3af", | ||
| fontSize: "11px", | ||
| theme: { mode: "dark" }, | ||
| dataLabels: { enabled: false }, | ||
| plotOptions: { | ||
| bar: { | ||
| columnWidth: "100%", | ||
| colors: { | ||
| ranges: [{ from: 0, to: 1000000000, color: "#555" }], // 거래량 색상 통일 | ||
| }, | ||
| formatter: (value) => { | ||
| return `${(value / 1000000).toFixed(1)}M`; | ||
| }, | ||
| }, | ||
| axisBorder: { | ||
| show: true, | ||
| color: "#2d2d2d", | ||
| }, | ||
| }, | ||
| { | ||
| seriesName: "Volume", | ||
| show: false, | ||
| min: 0, | ||
| max: 400, | ||
| }, | ||
| ], | ||
| tooltip: { | ||
| enabled: true, | ||
| shared: false, | ||
| theme: "dark", | ||
| style: { | ||
| fontSize: "12px", | ||
| stroke: { width: 0 }, | ||
| xaxis: { | ||
| type: "datetime", | ||
| tooltip: { enabled: false }, | ||
| axisBorder: { offsetX: 13 }, | ||
| }, | ||
| custom: function ({ seriesIndex, dataPointIndex, w }) { | ||
| const data = w.globals.initialSeries[seriesIndex].data[dataPointIndex]; | ||
| if (seriesIndex === 0 && data?.y) { | ||
| const [open, high, low, close] = data.y; | ||
| const change = close - open; | ||
| const changePercent = ((change / open) * 100).toFixed(2); | ||
| const color = change >= 0 ? "#ef5350" : "#26a69a"; | ||
| yaxis: { labels: { show: false } }, | ||
| grid: { show: false }, | ||
| }; | ||
|
|
||
| return ` | ||
| <div style="padding: 12px; background: #1f2937; color: white; border-radius: 6px; border: 1px solid #374151;"> | ||
| <div style="font-weight: bold; margin-bottom: 8px; color: ${color};">BTC/KRW</div> | ||
| <div style="display: flex; justify-content: space-between; margin-bottom: 4px;"> | ||
| <span style="color: #9ca3af; margin-right: 20px;">시가:</span> | ||
| <span>₩${open.toLocaleString()}</span> | ||
| </div> | ||
| <div style="display: flex; justify-content: space-between; margin-bottom: 4px;"> | ||
| <span style="color: #9ca3af; margin-right: 20px;">고가:</span> | ||
| <span>₩${high.toLocaleString()}</span> | ||
| </div> | ||
| <div style="display: flex; justify-content: space-between; margin-bottom: 4px;"> | ||
| <span style="color: #9ca3af; margin-right: 20px;">저가:</span> | ||
| <span>₩${low.toLocaleString()}</span> | ||
| </div> | ||
| <div style="display: flex; justify-content: space-between; margin-bottom: 8px;"> | ||
| <span style="color: #9ca3af; margin-right: 20px;">종가:</span> | ||
| <span style="color: ${color}; font-weight: bold;">₩${close.toLocaleString()}</span> | ||
| </div> | ||
| <div style="border-top: 1px solid #374151; padding-top: 8px; color: ${color};"> | ||
| ${change >= 0 ? "▲" : "▼"} ${Math.abs(change).toLocaleString()} (${changePercent}%) | ||
| </div> | ||
| </div> | ||
| `; | ||
| } | ||
| return ""; | ||
| }, | ||
| }, | ||
| legend: { | ||
| show: false, | ||
| }, | ||
| theme: { | ||
| mode: "dark", | ||
| }, | ||
| annotations: { | ||
| xaxis: [], | ||
| yaxis: [], | ||
| }, | ||
| }; | ||
| if (isLoading) return <Skeleton className='h-[492px] w-full' />; | ||
|
|
||
| export const CoinChartDisplay = () => { | ||
| return ( | ||
| <div className='w-full bg-surface-dark p-4'> | ||
| <ReactApexChart options={options} series={series} type='candlestick' height={500} /> | ||
| <div className='w-full bg-surface-dark'> | ||
| <div id='chart-candlestick'> | ||
| <ReactApexChart | ||
| options={candleOptions} | ||
| series={[{ name: "Price", data: prices }]} | ||
| type='candlestick' | ||
| height={300} | ||
| width='100%' | ||
| /> | ||
| </div> | ||
|
|
||
| <div id='chart-bar'> | ||
| <ReactApexChart | ||
| options={barOptions} | ||
| series={[{ name: "Volume", data: volume }]} | ||
| type='bar' | ||
| height={140} | ||
| width='100%' | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no handling for the isError state returned from useGetCandle. If the API call fails, users will see either stale data or loading skeleton indefinitely. Consider adding an error state UI to inform users when chart data fails to load, similar to how isLoading is handled on line 88.
| stroke: { color: "#0D47A1" }, | ||
| // 초기 선택 범위: 데이터의 마지막 20% 구간 | ||
| xaxis: { | ||
| min: prices.length > 30 ? prices[prices.length - 30].x : undefined, |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on line 60 states "초기 선택 범위: 데이터의 마지막 20% 구간" (Initial selection range: last 20% of data), but the actual implementation selects the last 30 items (prices.length - 30), which is not 20% but a fixed count. Either update the comment to accurately reflect "마지막 30개 데이터" or change the implementation to use prices.length * 0.8 for an actual 20% selection.
| min: prices.length > 30 ? prices[prices.length - 30].x : undefined, | |
| min: prices.length > 0 ? prices[Math.floor(prices.length * 0.8)].x : undefined, |
📝 요약 (Summary)
ApexCharts를 활용하여 업비트 스타일의 인터랙티브 코인 차트를 구현했습니다. 캔들스틱(Candlestick) 차트와 거래량 Brush 차트를 동기화하여 사용자가 원하는 구간을 확대/축소하며 분석할 수 있도록 했으며, 다양한 타임프레임(1초/1분/5분/30분/1시간/주/달/년) 전환을 지원합니다. Upbit API의 모든 캔들 타입을 TypeScript 타입 안전성을 보장하며 활용할 수 있도록 개선하였으며, Zustand 기반 상태 관리를 통해 타임프레임 전환이 즉시 반영되도록 설계했습니다.
✅ 주요 변경 사항 (Key Changes)
useCandleParams훅으로 타임프레임 → API 파라미터 자동 변환,useChartData훅으로 데이터변환 로직 분리
/candles/minutes/{unit}형식 적용💻 상세 구현 내용 (Implementation Details)
1. Next.js SSR 이슈 해결
SSR 이슈 해결 (Window is not defined)
ApexCharts는 브라우저의
window객체를 참조하지만, Next.js는 서버 사이드 렌더링(SSR)을 수행하기 때문에 에러가발생합니다.
next/dynamic을 사용하여 클라이언트 측에서만 로드하도록 설정했습니다.2. 차트 동기화: Candlestick + Brush 구현
사용자가 하단 차트(Brush)를 드래그하면 상단 차트(Main)가 확대되는 기능을 구현하기 위해 두 차트를 연결했습니다. 핵심은
id와brush.target을 일치시키는 것입니다.메인 차트 옵션
Brush 차트 옵션
3. 데이터 변환 로직 (Custom Hook 패턴)
업비트 API 원본 데이터 → ApexCharts 데이터 변환
업비트 API에서 받아오는 데이터(Snake Case)와 ApexCharts가 요구하는 데이터(
{ x, y })의 형태가 다릅니다. Custom HookuseChartData를 통해 변환 로직을 분리했습니다.Timezone 이슈 해결 (KST vs UTC)
단순히
new Date(kstString)을 하면 브라우저가 로컬 시간으로 해석하고, ApexCharts는 이를 다시 UTC로 변환하여 표시하므로시간이 9시간 밀리는 현상이 발생했습니다. KST 시간 문자열 뒤에 "Z"를 붙여 강제로 UTC로 인식시켰습니다.
4. 타입 안전한 캔들 API 설계
기존에는 1초 단위 데이터만 요청 가능했으나, Upbit API가 지원하는 모든 캔들 타입을 활용할 수 있도록 확장했습니다.
타입 정의 (
src/entities/market/model/types/candle.type.ts)API 구현 (
src/entities/market/model/apis/candle.api.ts)5. Zustand Store로 타임프레임 상태 관리
사용자가 선택한 타임프레임을 전역 상태로 관리하여 ChartControls ↔ CoinChartDisplay 간 데이터 흐름을 단순화했습니다.
Store 구조 (
src/features/home/store/useChartStore.ts)타임프레임 → API 파라미터 자동 변환
6. 컴포넌트 통합 및 ViewModel 패턴
ChartControls - 버튼 클릭 시 Store 업데이트
useCoinChartViewModel - 로직과 UI 분리, Store 구독하여 자동으로 올바른 API 요청
처음에는 컴포넌트 안에서 API 호출, 데이터 변환, 차트 옵션 설정을 모두 처리했습니다. 하지만 코드가 비대해지고 유지보수가
어려워져, 데이터 로직을 Custom Hook으로 분리했습니다. 이 훅은 API에서 데이터를 받아와 차트가 바로 사용할 수 있는
prices와volume형태로 가공해서 반환합니다.7. React Query 캐싱 최적화
타임프레임별로 독립적인 캐시 관리를 위해 queryKey에 타입 정보 포함:
🚀 트러블 슈팅 (Trouble Shooting)
1. Next.js SSR 환경에서 ApexCharts Window 에러
문제:
ReferenceError: window is not defined원인: ApexCharts가 브라우저 전용 라이브러리로 서버 환경에서 실행 불가
해결:
next/dynamic으로 클라이언트 측에서만 로드2. 차트 시간축 9시간 밀림 현상
문제: 차트의 시간이 실제보다 9시간 늦게 표시됨
원인:
"2025-12-03T23:43:14")new Date(kstString)→ 브라우저가 로컬 시간으로 해석해결: KST 문자열에 "Z" 추가하여 UTC로 강제 인식
3. Upbit API Path 파라미터 이슈
문제: 분 단위 캔들 API가 동작하지 않음
원인:
/candles/minutes?unit=5(Query Parameter)/candles/minutes/5(Path Parameter)해결: Upbit 공식 문서를 참고하여 엔드포인트 구성 방식 수정
4. TypeScript 타입 안전성 확보
문제:
params.unit접근 시 타입 에러 발생원인:
CandleParams는 Union Type이므로 직접unit프로퍼티 접근 불가해결: Type Narrowing과 Type Assertion 활용
5. 컴포넌트 간 타입 불일치
문제:
TimeframeButton의setSelectedTimeframe타입 에러해결: 컴포넌트 Props 타입을
ChartTimeframe으로 통일추후 개선 사항
타임프레임별 최적 데이터 개수 조정
count사용캐싱 전략 고도화
Loading State 개선
기술적 참고 사항
📸 스크린샷 (Screenshots)
2025-12-04.5.43.26.mov
#️⃣ 관련 이슈 (Related Issues)