Skip to content

Conversation

@Dobbymin
Copy link
Owner

@Dobbymin Dobbymin commented Dec 4, 2025

📝 요약 (Summary)

ApexCharts를 활용하여 업비트 스타일의 인터랙티브 코인 차트를 구현했습니다. 캔들스틱(Candlestick) 차트와 거래량 Brush 차트를 동기화하여 사용자가 원하는 구간을 확대/축소하며 분석할 수 있도록 했으며, 다양한 타임프레임(1초/1분/5분/30분/1시간/주/달/년) 전환을 지원합니다. Upbit API의 모든 캔들 타입을 TypeScript 타입 안전성을 보장하며 활용할 수 있도록 개선하였으며, Zustand 기반 상태 관리를 통해 타임프레임 전환이 즉시 반영되도록 설계했습니다.

✅ 주요 변경 사항 (Key Changes)

  • ApexCharts 기반 차트 시스템 구현: Next.js SSR 환경에서 캔들스틱 + Brush 차트 동기화
  • 캔들 API 타입 시스템 구축: Union Type과 Discriminated Union을 활용한 타입 안전한 API 설계
  • Zustand Store 기반 타임프레임 관리: 전역 상태로 선택된 타임프레임 관리 및 컴포넌트 간 동기화
  • Custom Hook 패턴 적용: useCandleParams 훅으로 타임프레임 → API 파라미터 자동 변환, useChartData 훅으로 데이터
    변환 로직 분리
  • Upbit API Path 파라미터 올바른 처리: 분 단위 API의 /candles/minutes/{unit} 형식 적용

💻 상세 구현 내용 (Implementation Details)

1. Next.js SSR 이슈 해결

SSR 이슈 해결 (Window is not defined)

ApexCharts는 브라우저의 window 객체를 참조하지만, Next.js는 서버 사이드 렌더링(SSR)을 수행하기 때문에 에러가
발생합니다. next/dynamic을 사용하여 클라이언트 측에서만 로드하도록 설정했습니다.

// src/features/home/components/features/coin-chart/CoinChartDisplay.tsx
"use client";

import dynamic from "next/dynamic";

// ssr: false 옵션으로 서버 렌더링 제외
const ReactApexChart = dynamic(() => import("react-apexcharts"), { ssr: false });

export const CoinChartDisplay = () => {
  const { prices, volume, isLoading } = useCoinChartViewModel(50);

  return (
    <div className='w-full bg-surface-dark'>
      {/* 캔들스틱 차트 */}
      <ReactApexChart
        options={candleOptions}
        series={[{ name: "Price", data: prices }]}
        type='candlestick'
        height={300}
      />

      {/* 거래량 Brush 차트 */}
      <ReactApexChart
        options={barOptions}
        series={[{ name: "Volume", data: volume }]}
        type='bar'
        height={140}
      />
    </div>
  );
};

2. 차트 동기화: Candlestick + Brush 구현

사용자가 하단 차트(Brush)를 드래그하면 상단 차트(Main)가 확대되는 기능을 구현하기 위해 두 차트를 연결했습니다. 핵심은
idbrush.target을 일치시키는 것입니다.

메인 차트 옵션

const candleOptions: ApexOptions = {
  chart: {
    type: "candlestick",
    id: "candles", // [핵심 1] 메인 차트 ID
    toolbar: { autoSelected: "pan", show: false },
    zoom: { enabled: false }, // 자체 줌 비활성화
    background: "transparent",
  },
  theme: { mode: "dark" },
  plotOptions: {
    candlestick: {
      colors: {
        upward: "var(--increase)",   // 상승 (빨강)
        downward: "var(--decrease)", // 하락 (파랑)
      },
    },
  },
  xaxis: { type: "datetime" },
  yaxis: {
    labels: {
      formatter: (val) => Math.floor(val).toLocaleString(),
    },
  },
};

Brush 차트 옵션

const barOptions: ApexOptions = {
  chart: {
    type: "bar",
    id: "brush",
    brush: {
      enabled: true,
      target: "candles", // [핵심 2] 메인 차트 ID를 타겟으로 지정
    },
    selection: {
      enabled: true,
      // 초기 선택 범위: 최근 30개 데이터
      xaxis: {
        min: prices.length > 30 ? prices[prices.length - 30].x : undefined,
        max: prices.length > 0 ? prices[prices.length - 1].x : undefined,
      },
    },
  },
  plotOptions: {
    bar: {
      columnWidth: "100%",
      colors: {
        ranges: [{ from: 0, to: 1000000000, color: "#555" }], // 거래량 색상 통일
      },
    },
  },
  xaxis: { type: "datetime" },
};

3. 데이터 변환 로직 (Custom Hook 패턴)

업비트 API 원본 데이터 → ApexCharts 데이터 변환

업비트 API에서 받아오는 데이터(Snake Case)와 ApexCharts가 요구하는 데이터({ x, y })의 형태가 다릅니다. Custom Hook
useChartData를 통해 변환 로직을 분리했습니다.

// src/features/home/hooks/useChartData.ts
export const useChartData = (candleData?: CandleData[]) => {
  if (!candleData) 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 prices = sortedData.map((item) => ({
    x: new Date(item.candle_date_time_kst + "Z").getTime(), // KST → UTC 변환
    y: [item.opening_price, item.high_price, item.low_price, item.trade_price],
  }));

  // 거래량 데이터 변환
  const volume = sortedData.map((item) => ({
    x: new Date(item.candle_date_time_kst + "Z").getTime(),
    y: item.candle_acc_trade_volume.toFixed(3),
  }));

  return { prices, volume };
};

Timezone 이슈 해결 (KST vs UTC)

단순히 new Date(kstString)을 하면 브라우저가 로컬 시간으로 해석하고, ApexCharts는 이를 다시 UTC로 변환하여 표시하므로
시간이 9시간 밀리는 현상이 발생했습니다. KST 시간 문자열 뒤에 "Z"를 붙여 강제로 UTC로 인식시켰습니다.

// KST 시간을 UTC로 취급하여 차트에 주입 ("Z" 추가)
const parseKstToUtc = (kstString: string) => {
  return new Date(kstString + "Z").getTime();
};

4. 타입 안전한 캔들 API 설계

기존에는 1초 단위 데이터만 요청 가능했으나, Upbit API가 지원하는 모든 캔들 타입을 활용할 수 있도록 확장했습니다.

타입 정의 (src/entities/market/model/types/candle.type.ts)

// Discriminated Union으로 타입별 파라미터 분리
export type CandleParams =
  | SecondsCandleParams   // type: "seconds"
  | MinutesCandleParams   // type: "minutes" + unit (1~240)
  | OtherCandleParams;    // type: "days" | "weeks" | "months"

export type MinuteUnit = 1 | 3 | 5 | 10 | 15 | 30 | 60 | 240;

API 구현 (src/entities/market/model/apis/candle.api.ts)

// 분 캔들의 경우 unit을 Path 파라미터로 추가
if (type === "minutes") {
  const unit = (params as MinutesCandleParams).unit;
  endpoint = `${UPBIT_URL}/candles/minutes/${unit}`; // ✅ /candles/minutes/5
}

5. Zustand Store로 타임프레임 상태 관리

사용자가 선택한 타임프레임을 전역 상태로 관리하여 ChartControls ↔ CoinChartDisplay 간 데이터 흐름을 단순화했습니다.

Store 구조 (src/features/home/store/useChartStore.ts)

export type ChartTimeframe = "1초" | "1분" | "5분" | "30분" | "1시간" | "주" | "달" | "년";

export const useChartStore = create<ChartState>()(
  devtools(
    (set) => ({
      selectedTimeframe: "1초",
      setSelectedTimeframe: (timeframe: ChartTimeframe) =>
        set({ selectedTimeframe: timeframe }),
    }),
    { name: "ChartStore" }
  )
);

타임프레임 → API 파라미터 자동 변환

const timeframeToAPIParams = (timeframe: ChartTimeframe, market: string, count: number): CandleParams => {
  const baseParams = { market, count };

  switch (timeframe) {
    case "1초": return { ...baseParams, type: "seconds" };
    case "1분": return { ...baseParams, type: "minutes", unit: 1 };
    case "5분": return { ...baseParams, type: "minutes", unit: 5 };
    case "30분": return { ...baseParams, type: "minutes", unit: 30 };
    case "1시간": return { ...baseParams, type: "minutes", unit: 60 };
    case "주": return { ...baseParams, type: "weeks" };
    case "달": return { ...baseParams, type: "months" };
    case "년": return { ...baseParams, type: "days" };
  }
};

// Custom Hook으로 제공
export const useCandleParams = (market: string, count: number): CandleParams => {
  const selectedTimeframe = useChartStore((state) => state.selectedTimeframe);
  return timeframeToAPIParams(selectedTimeframe, market, count);
};

6. 컴포넌트 통합 및 ViewModel 패턴

ChartControls - 버튼 클릭 시 Store 업데이트

const selectedTimeframe = useChartStore((state) => state.selectedTimeframe);
const setSelectedTimeframe = useChartStore((state) => state.setSelectedTimeframe);

return (
  <TimeframeButton
    timeframe={timeframe}
    selectedTimeframe={selectedTimeframe}
    setSelectedTimeframe={setSelectedTimeframe}
  />
);

useCoinChartViewModel - 로직과 UI 분리, Store 구독하여 자동으로 올바른 API 요청

처음에는 컴포넌트 안에서 API 호출, 데이터 변환, 차트 옵션 설정을 모두 처리했습니다. 하지만 코드가 비대해지고 유지보수가
어려워져, 데이터 로직을 Custom Hook으로 분리했습니다. 이 훅은 API에서 데이터를 받아와 차트가 바로 사용할 수 있는
pricesvolume 형태로 가공해서 반환합니다.

export const useCoinChartViewModel = (count: number = 50) => {
  const { market } = useGetMarket();
  const currentMarket = formattedMarketName(market);

  // 🎯 현재 선택된 타임프레임에 따른 API 파라미터 자동 생성
  const candleParams = useCandleParams(currentMarket, count);

  const { data: candleData, isLoading, isError } = useGetCandle(candleParams);
  // ...
};

7. React Query 캐싱 최적화

타임프레임별로 독립적인 캐시 관리를 위해 queryKey에 타입 정보 포함:

// 분 캔들일 때만 unit을 queryKey에 포함
const unit = type === "minutes" ? (params as MinutesCandleParams).unit : undefined;
const queryKey = unit
  ? ["candle", market, count, type, unit]  // ["candle", "KRW-BTC", 50, "minutes", 5]
  : ["candle", market, count, type];       // ["candle", "KRW-BTC", 50, "seconds"]

🚀 트러블 슈팅 (Trouble Shooting)

1. Next.js SSR 환경에서 ApexCharts Window 에러

문제: ReferenceError: window is not defined

원인: ApexCharts가 브라우저 전용 라이브러리로 서버 환경에서 실행 불가

해결: next/dynamic으로 클라이언트 측에서만 로드

const ReactApexChart = dynamic(() => import("react-apexcharts"), { ssr: false });

2. 차트 시간축 9시간 밀림 현상

문제: 차트의 시간이 실제보다 9시간 늦게 표시됨

원인:

  • Upbit API는 KST 시간 문자열 반환 ("2025-12-03T23:43:14")
  • new Date(kstString) → 브라우저가 로컬 시간으로 해석
  • ApexCharts가 다시 UTC로 변환 → 이중 변환으로 시간 오류

해결: KST 문자열에 "Z" 추가하여 UTC로 강제 인식

const parseKstToUtc = (kstString: string) => {
  return new Date(kstString + "Z").getTime(); // "2025-12-03T23:43:14Z"
};

3. Upbit API Path 파라미터 이슈

문제: 분 단위 캔들 API가 동작하지 않음

원인:

  • ❌ 잘못된 형식: /candles/minutes?unit=5 (Query Parameter)
  • ✅ 올바른 형식: /candles/minutes/5 (Path Parameter)

해결: Upbit 공식 문서를 참고하여 엔드포인트 구성 방식 수정

// Before
const endpoint = `${UPBIT_URL}/candles/${type}`;
urlParams.append("unit", params.unit.toString()); // ❌

// After
let endpoint = `${UPBIT_URL}/candles/${type}`;
if (type === "minutes") {
  endpoint = `${UPBIT_URL}/candles/minutes/${unit}`; // ✅
}

4. TypeScript 타입 안전성 확보

문제: params.unit 접근 시 타입 에러 발생

원인: CandleParams는 Union Type이므로 직접 unit 프로퍼티 접근 불가

해결: Type Narrowing과 Type Assertion 활용

if (type === "minutes") {
  const unit = (params as MinutesCandleParams).unit; // ✅ 타입 단언
  endpoint = `${UPBIT_URL}/candles/minutes/${unit}`;
}

5. 컴포넌트 간 타입 불일치

문제: TimeframeButtonsetSelectedTimeframe 타입 에러

'(timeframe: ChartTimeframe) => void' 형식은
'(timeframe: string) => void' 형식에 할당할 수 없습니다.

해결: 컴포넌트 Props 타입을 ChartTimeframe으로 통일

// Before
type Props = {
  timeframe: string;
  setSelectedTimeframe: (timeframe: string) => void;
};

// After
type Props = {
  timeframe: ChartTimeframe;
  setSelectedTimeframe: (timeframe: ChartTimeframe) => void;
};

⚠️ 알려진 이슈 및 참고 사항 (Known Issues & Notes)

추후 개선 사항

  1. 타임프레임별 최적 데이터 개수 조정

    • 현재는 모든 타임프레임에서 동일한 count 사용
    • 주/달/년 단위는 더 많은 데이터 필요 (예: 200개)
  2. 캐싱 전략 고도화

    • 타임프레임 전환 시 이전 데이터 재활용 가능 여부 검토
    • Stale time 조정 (장기 타임프레임은 refetch 빈도 감소 가능)
  3. Loading State 개선

    • 타임프레임 전환 시 Skeleton UI 표시
    • Optimistic UI 적용 고려

기술적 참고 사항

  • Upbit API Rate Limit: 초당 최대 10회 (캔들 그룹 내 공유)
  • React Query refetchInterval: 현재 500ms로 설정 (실시간성 확보)
  • Zustand DevTools: Redux DevTools로 상태 변화 추적 가능

📸 스크린샷 (Screenshots)

2025-12-04.5.43.26.mov

#️⃣ 관련 이슈 (Related Issues)

@Dobbymin Dobbymin self-assigned this Dec 4, 2025
Copilot AI review requested due to automatic review settings December 4, 2025 08:43
@Dobbymin Dobbymin added the ✨ Feature 새로운 기능 추가 및 구현하는 경우 label Dec 4, 2025
@Dobbymin Dobbymin linked an issue Dec 4, 2025 that may be closed by this pull request
@vercel
Copy link

vercel bot commented Dec 4, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
dobbit Ready Ready Preview Comment Dec 4, 2025 8:54am

@gemini-code-assist
Copy link

Summary of Changes

Hello @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

  • 인터랙티브 코인 차트 구현: ApexCharts를 활용하여 업비트 스타일의 캔들스틱 및 거래량 브러시 차트를 구현하고 동기화했습니다.
  • 다양한 타임프레임 지원: 1초부터 년 단위까지 다양한 타임프레임 전환 기능을 추가했습니다.
  • 타입 안전한 Upbit API 연동: Union Type과 Discriminated Union을 사용하여 Upbit 캔들 API의 모든 타입을 타입 안전하게 처리하도록 개선했습니다.
  • Zustand 기반 상태 관리: 타임프레임 선택을 위한 전역 상태 관리를 Zustand 스토어로 구현하여 컴포넌트 간 동기화를 용이하게 했습니다.
  • Custom Hook을 통한 로직 분리: useCandleParamsuseChartData, useCoinChartViewModel 등의 커스텀 훅을 도입하여 데이터 변환 및 API 파라미터 관리 로직을 분리했습니다.
  • Next.js SSR 호환성 확보: ApexCharts의 window 객체 참조 문제를 next/dynamic을 사용하여 클라이언트 측에서만 로드하도록 해결했습니다.
  • 시간대(Timezone) 문제 해결: Upbit API의 KST 시간 문자열을 UTC로 강제 인식시켜 차트 시간 밀림 현상을 해결했습니다.
  • Upbit API Path 파라미터 처리: 분 단위 캔들 API의 Path 파라미터 형식을 올바르게 적용하여 API 호출 문제를 해결했습니다.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a 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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

refetchInterval을 500ms로 고정하면 '주', '달', '년'과 같이 데이터 변경 주기가 긴 타임프레임에서도 불필요한 API 요청이 너무 자주 발생합니다. 이는 Upbit API의 Rate Limit에 도달할 위험을 높이고 서버에 부담을 줍니다. 타임프레임(type)에 따라 refetchInterval 값을 동적으로 설정하는 것이 좋습니다. 예를 들어, 초/분 단위는 짧게, 그 외에는 더 길게 설정하거나 비활성화하는 것을 고려해 보세요.

Suggested change
refetchInterval: 500,
refetchInterval: type === "seconds" || type === "minutes" ? 1000 : false,

Comment on lines +47 to +54
getCandleParams: (market: string, count: number) =>
timeframeToAPIParams(
// state에서 현재 timeframe을 가져오기 위해서는 별도 처리 필요
// 이 함수는 직접 사용하지 않고, useCandleParams hook 사용 권장
"1초",
market,
count,
),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

getCandleParams 함수가 스토어의 현재 selectedTimeframe 상태를 사용하지 않고 "1초"로 하드코딩되어 있습니다. 주석으로 사용을 권장하지 않는다고 명시되어 있지만, 스토어에 잘못 구현된 메소드가 남아있는 것은 혼란을 야기하고 버그의 원인이 될 수 있습니다. useCandleParams 훅 사용을 강제할 것이라면, 혼동을 피하기 위해 이 메소드를 스토어에서 제거하는 것이 좋겠습니다.


// 분 캔들의 경우 unit을 Path 파라미터로 추가
if (type === "minutes") {
const unit = (params as MinutesCandleParams).unit;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

if (type === "minutes") 조건문 덕분에 이 블록 안에서 params의 타입이 MinutesCandleParams로 추론됩니다. 따라서 as MinutesCandleParams와 같은 타입 단언 없이 params.unit으로 직접 접근할 수 있습니다. 타입 단언을 제거하면 코드가 더 간결해지고 타입 안정성도 높아집니다.

Suggested change
const unit = (params as MinutesCandleParams).unit;
const unit = params.unit;

const { market, count, type } = params;

// 분 캔들일 때만 unit을 queryKey에 포함
const unit = type === "minutes" ? (params as MinutesCandleParams).unit : undefined;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

candle.api.ts 파일과 마찬가지로, type === "minutes" 조건으로 인해 params 타입이 MinutesCandleParams로 좁혀지므로 타입 단언이 필요하지 않습니다. 타입스크립트의 제어 흐름 분석을 활용하여 더 안전하고 간결한 코드를 작성할 수 있습니다.

Suggested change
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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

API로 요청하는 캔들 개수(count)가 50으로 하드코딩되어 있습니다. PR 설명에 언급해주신 것처럼, '주'/'달'/'년'과 같은 장기 타임프레임에서는 더 많은 데이터가 필요할 수 있습니다. 이 값을 타임프레임에 따라 동적으로 조절하거나 설정 가능하도록 변경하면 차트의 효용성이 더 높아질 것입니다.

Comment on lines +16 to +46
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" },
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

candleOptions 객체는 컴포넌트의 상태나 props에 의존하지 않는 정적인 값입니다. 컴포넌트 내부에 선언되면 렌더링될 때마다 새로 생성되어 불필요한 오버헤드를 유발할 수 있습니다. CoinChartDisplay 컴포넌트 외부로 옮겨서 한 번만 생성되도록 하면 성능을 개선할 수 있습니다. barOptionsprices에 의존하므로 내부에 두는 것이 맞습니다.


const volume = sortedData.map((item) => ({
x: parseKstToUtc(item.candle_date_time_kst),
y: item.candle_acc_trade_volume.toFixed(3),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

거래량(volume) 데이터를 변환할 때 toFixed(3)를 사용하여 숫자를 문자열로 바꾸고 있습니다. ApexCharts는 숫자 형태의 데이터도 잘 처리하며, 숫자 형식으로 유지하는 것이 데이터의 유연성 측면에서 더 좋습니다. 예를 들어, 나중에 차트 툴팁 등에서 추가적인 계산이 필요할 경우 문자열보다 숫자가 다루기 용이합니다. 포매팅은 차트 옵션의 formatter를 통해 UI단에서 처리하는 것을 권장합니다.

Suggested change
y: item.candle_acc_trade_volume.toFixed(3),
y: item.candle_acc_trade_volume,

Copy link

Copilot AI left a 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.

Comment on lines +1 to +12
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;
};
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +27
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 };
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
queryKey,
queryFn: () => candleAPI(params),

refetchInterval: 500,
Copy link

Copilot AI Dec 4, 2025

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.

Suggested change
refetchInterval: 500,
refetchInterval: 1000,

Copilot uses AI. Check for mistakes.

const volume = sortedData.map((item) => ({
x: parseKstToUtc(item.candle_date_time_kst),
y: item.candle_acc_trade_volume.toFixed(3),
Copy link

Copilot AI Dec 4, 2025

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.

Suggested change
y: item.candle_acc_trade_volume.toFixed(3),
y: item.candle_acc_trade_volume,

Copilot uses AI. Check for mistakes.
timestamp: number;
candle_acc_trade_price: number;
candle_acc_trade_volume: number;
unit: number;
Copy link

Copilot AI Dec 4, 2025

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.

Suggested change
unit: number;
unit?: number;

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
getCandleParams: (market: string, count: number) =>
timeframeToAPIParams(
// state에서 현재 timeframe을 가져오기 위해서는 별도 처리 필요
// 이 함수는 직접 사용하지 않고, useCandleParams hook 사용 권장
"1초",
market,
count,
),
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +86
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 },
};
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +13 to 112
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>
);
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
stroke: { color: "#0D47A1" },
// 초기 선택 범위: 데이터의 마지막 20% 구간
xaxis: {
min: prices.length > 30 ? prices[prices.length - 30].x : undefined,
Copy link

Copilot AI Dec 4, 2025

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.

Suggested change
min: prices.length > 30 ? prices[prices.length - 30].x : undefined,
min: prices.length > 0 ? prices[Math.floor(prices.length * 0.8)].x : undefined,

Copilot uses AI. Check for mistakes.
@Dobbymin Dobbymin merged commit aacb965 into main Dec 4, 2025
2 of 3 checks passed
@Dobbymin Dobbymin deleted the feat#15-coin-chart branch December 4, 2025 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능 추가 및 구현하는 경우

Projects

None yet

Development

Successfully merging this pull request may close these issues.

코인 차트 구현

2 participants