Next.js에서 차트 구현하기 with Chart.js, react-chartjs-2

이지·2024년 11월 18일
0

Project

목록 보기
7/9
post-thumbnail

판매자 센터의 메인에서 매출과 주문 수을 한눈에 파악할 수 있도록 하는 차트를 구현하고 싶어, 차트 라이브러리에 대해 알아보게 되었다.

Recharts, Nivo.rocks, Apexcharts, Chart.js 등 다양하게 차트를 구현할 수 있는 라이브러리가 있다.

내가 구현할 차트의 경우 아래 그림처럼 매출과 주문 건수와 같은 단순한 데이터이기 때문에 Chart.js만으로 충분히 구현 가능하다고 판단했다.

Chart.js 시작하기

보통 Next.js나 React 프로젝트에서는 Chart.js를 직접 사용하기 보다는 react-chartjs-2를 사용하게 된다.

react-chartjs-2는 Chart.js를 React 프로젝트에서 쉽게 사용할 수 있도록 감싸주는 React용 래퍼 라이브러리이다.

  • Chart.js
    • JavaScript로 개발된 순수 차트 라이브러리
    • HTML5의 <canvas> 요소를 사용해 차트를 렌더링
    • 단독으로 React와 같이 쓰기엔 적합하지 않음
  • react-chartjs-2
    • Chart.js를 React 환경에 맞춰 React 컴포넌트로 감싸서 제공해주는 라이브러리
    • Chart.js의 모든 기능을 지원하면서 React 컴포넌트의 형태로 사용이 가능
    • 커스터마이징 시 Chart.js의 문서를 참조해야 해서 react-chartjs-2만의 고유 문서로는 상대적으로 부족할 수 있음

Chart.js & react-chartjs-2 설치

react-chartjs-2 공식문서

npm install --save chart.js react-chartjs-2
pnpm add chart.js react-chartjs-2
yarn add chart.js react-chartjs-2

⛏️ 차트에 삽입할 데이터 가공하기

차트를 사용하기 전에, 차트에 데이터를 어떻게 전달해야 하는지 확인해보자.

아래는 기본적인 Line 차트를 구현하는 코드 예제이다.

import React from 'react';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import faker from 'faker';

ChartJS.register(
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend
);

export const options = {
  responsive: true,
  plugins: {
    legend: {
      position: 'top' as const,
    },
    title: {
      display: true,
      text: 'Chart.js Line Chart',
    },
  },
};

const labels = ['January', 'February', 'March', 'April', 'May', 'June', 'July'];

export const data = {
  labels,
  datasets: [
    {
      label: 'Dataset 1',
      data: labels.map(() => faker.datatype.number({ min: -1000, max: 1000 })),
      borderColor: 'rgb(255, 99, 132)',
      backgroundColor: 'rgba(255, 99, 132, 0.5)',
    },
    {
      label: 'Dataset 2',
      data: labels.map(() => faker.datatype.number({ min: -1000, max: 1000 })),
      borderColor: 'rgb(53, 162, 235)',
      backgroundColor: 'rgba(53, 162, 235, 0.5)',
    },
  ],
};

export function App() {
  return <Line options={options} data={data} />;
}

각각의 labels(x축)에 맞는 데이터(y축)를 배열에 담아 전달해야 한다.

이를 위해서는 먼저 데이터를 가공해야 한다.

  • 필요한 데이터는 다음과 같다.
    • 주문 내역이 있는 판매자의 상품들의 가격을 모두 합한 금액상품들의 전체 개수
    • 최근 6개월간의 데이터만 필요
데이터 관계를 나타낸 그림

(현재 데이터 관계도는 위의 그림과 같다)

전체적인 흐름

  1. 데이터 가져오기: getOrderDataWithChartFormat을 통해 Supabase로부터 데이터를 가져옴

  2. 데이터 가공: 데이터가 존재하면 processData를 통해 월별로 매출과 주문 수를 집계

  3. 차트 형식으로 변환: getChartDataArrays를 사용해 차트에 적합한 형식으로 데이터를 변환함

  4. 차트 출력: 변환된 데이터를 Line | Bar 차트에 전달하여 시각화

getOrderDataWithChartFormat(server action)

'use server';
...
export const getOrderDataWithChartFormat = async () => {
  const supabase = createClient();

  const res = await getUserInfo();
  const userId = res.user?.id;

  // 6개월 전 날짜를 계산해, 데이터 필터링 조건으로 사용
  const sixMonthsAgo = new Date();
  sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
  
  const { status, data, error } = await supabase
    .from("order_item")
    .select(`price, product!inner(), order(created_at)`)
    .eq("product.seller_id", userId!) // 현재 사용자가 판매자인 상품의 주문만 필터링
    .gte("order.created_at", sixMonthsAgo.toISOString()); // gte: 지정된 값보다 크거나 같은 값의 데이터 필터링, toISOString(): 날짜와 시간을 국제 표준 형식(ISO 8601)으로 변환

  if (error) {
    console.error("get order_item error", error);
    return { status, message: ERROR_MESSAGE.serverError };
  }
  
  // 데이터가 없는 경우(판매자의 상품을 구매 내역 X): 6개월간의 초기 데이터를 생성해 차트에서 사용될 형식으로 변환
  if (!data || data.length === 0) {
    const transformNoData = getRecentMonthsData(); // 최근 6개월의 기본 데이터 생성
    const chartNoDataArrays = getChartDataArrays(transformNoData); // 차트에서 사용하기 위한 데이터 배열로 변환
    return { status, message: "주문이 없습니다.", data: chartNoDataArrays };
  }
  // 데이터가 있는 경우
  const transformData = processData(data); // 주문 데이터를 월별 합계로 변환
  const chartDataArrays = getChartDataArrays(transformData); // 차트 데이터 형식으로 변환

  return {
    status,
    message: "주문데이터를 차트 포맷으로 변경하는 데 성공했습니다.",
    data: chartDataArrays,
  };
};

(24.11.26 수정됨) product에 !inner 키워드 추가
참고 포스팅 -> Supabase에서 필터링 오류를 피하는 !inner 사용법
transformOrderData.ts

type RecentMonthsDataType = {
  [month: number]: {
    totalPrice: number;
    totalCount: number;
  };
};
// 현재 날짜 기준으로 최근 6개월의 키를 가진 객체 생성
export const getRecentMonthsData = () => {
  const recentMonthsData: RecentMonthsDataType = {};
  const currentDate = new Date();

  for (let i = 0; i < 6; i++) {
	  // 현재 월에서 i만큼 이전 월을 계산하여 month에 저장
    const month =
      new Date(
        currentDate.getFullYear(),
        currentDate.getMonth() - i,
        1
      ).getMonth() + 1;
    recentMonthsData[month] = { totalPrice: 0, totalCount: 0 }; // 초기 매출과 주문수 값을 0으로 설정하여 recentMonthsData 객체에 저장
  }
  return recentMonthsData;
};

type OrderItemDataType = {
  price: number;
  order: { created_at: string } | null;
};
// 데이터 가공 함수 - 데이터 배열을 월별 매출과 주문수로 집계
export const processData = (data: OrderItemDataType[]) => {
  const recentMonthsData = getRecentMonthsData(); // 최근 6개월 데이터 초기화 객체 생성

  data.forEach((item: any) => {
    const date = new Date(item.order.created_at); // 주문 생성 날짜를 Date 객체로 변환
    const month = date.getMonth() + 1; // 월을 1~12 범위로 계산하여 저장

    // 현재 월이 recentMonthsData에 있는 경우 해당 월의 데이터를 업데이트
    if (recentMonthsData[month]) {
      recentMonthsData[month].totalPrice += item.price; // 매출 합산
      recentMonthsData[month].totalCount += 1; // 주문수 증가
    }
  });

  return recentMonthsData;
};
/**
 * processData 실행 예시:
 * {
 *   '6': { totalPrice: 0, totalCount: 0 },
 *   '7': { totalPrice: 0, totalCount: 0 },
 *   '8': { totalPrice: 0, totalCount: 0 },
 *   '9': { totalPrice: 0, totalCount: 0 },
 *   '10': { totalPrice: 60000, totalCount: 7 },
 *   '11': { totalPrice: 28000, totalCount: 4 }
 * }
 */
// 차트 데이터 배열 생성 함수 - 객체 데이터를 차트용 배열로 변환
export const getChartDataArrays = (recentMonthsData: RecentMonthsDataType) => {
  const labels = []; // 차트의 x축 라벨 (월)
  const totalPriceData = []; // 매출 데이터 배열
  const totalCountData = []; // 주문수 데이터 배열

  // recentMonthsData 객체를 순회하며 필요한 데이터 추출
  for (const [month, data] of Object.entries(recentMonthsData)) {
    labels.push(month); // labels 배열에 월 추가
    totalPriceData.push(data.totalPrice); // totalPriceData 배열에 전체 매출 추가
    totalCountData.push(data.totalCount); // totalCountData 배열에 전체 주문수 추가
  }

  return { labels, totalPriceData, totalCountData };
};
/**
 * getChartDataArrays 실행 예시:
 * {
 *   labels: [ '6', '7', '8', '9', '10', '11' ],
 *   totalPriceData: [ 0, 0, 0, 0, 60000, 28000 ],
 *   totalCountData: [ 0, 0, 0, 0, 7, 4 ]
 * }
 */

📈 차트 생성

Chart.ts

"use client";

import React from "react";
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
  BarElement,
  Scale,
  Tick,
  TooltipItem,
  ChartOptions,
} from "chart.js";
import { Bar, Line } from "react-chartjs-2";

ChartJS.register(
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  BarElement,
  Title,
  Tooltip,
  Legend
);

// 차트 기본 속성 타입
interface ChartProp {
  chartTitle: string; // 차트 제목
  labels: string[]; // 차트의 x축 레이블
}

// LineChart에 사용할 속성 타입
interface LineChartProps extends ChartProp {
  totalPriceData: number[]; // 월별 매출 데이터
}

// 매출 라인 차트 컴포넌트
export function LineChart({
  chartTitle,
  labels,
  totalPriceData,
}: LineChartProps) {
  const options: ChartOptions<"line"> = {
    responsive: true, // 반응형 지원
    interaction: {
      mode: "index", // x축 상의 모든 데이터 포인트에 대해 툴팁을 표시
      intersect: false, // 마우스가 포인트에 위치하지 않아도 툴팁이 보이도록 설정
    },
    plugins: {
      legend: {
        position: "top" as const, // 범례 위치 설정
        // 범례(legend): 차트에서 데이터 항목의 의미를 나타내는 설명
        // 해당 차트에서는 dataset의 label("2024")이 해당함!
      },
      title: {
        display: true, // 제목 표시 여부
        align: "start", // 제목 정렬 설정
        text: chartTitle, // 차트 제목
        font: { family: "Pretendard", size: 20, weight: 500 }, // 제목 폰트 설정
        color: "black", // 제목 색상
      },
      tooltip: {
        callbacks: {
          // 툴팁 제목에 '월'을 추가
          title: (context: TooltipItem<"line">[]) => {
            return context[0].label + "월";
          },
          // 툴팁 데이터에 '원'을 추가
          label: (context: TooltipItem<"line">) => {
            return context.formattedValue + "원";
          },
        },
        backgroundColor: "#797979", // 툴팁 배경 색상
      },
    },
    scales: {
      x: {
        // x축 레이블에 '월' 추가
        afterTickToLabelConversion: function (scaleInstance: Scale) {
          scaleInstance.ticks.forEach((tick: Tick) => {
            if (tick.label) {
              tick.label += "월";
            }
          });
        },
      },
      y: {
        // y축 최대값을 1.5배로 확대하여 여백 추가
        // 데이터의 최대값이 y축의 최대값이 되면 차트가 딱 맞춰지게 되는데 이부분에 여백을 주어 보기 편하게 만들었다.
        afterDataLimits: (scale: Scale) => {
          scale.max = scale.max * 1.5;
        },
      },
    },
  };
  const data = {
    labels,
    datasets: [
      {
        label: "2024", // 데이터셋 레이블
        data: totalPriceData, // 매출 데이터
        borderColor: "#3b82f6", // 라인 색상
        backgroundColor: "#3b82f6", // 포인트 색상
      },
    ],
  };

  return <Line options={options} data={data} />;
}

// BarChart에 사용할 속성 타입
interface BarChartProps extends ChartProp {
  totalCountData: number[]; // 월별 주문수 데이터
}

// 주문수 바 차트 컴포넌트
export function BarChart({
  chartTitle,
  labels,
  totalCountData,
}: BarChartProps) {
  const options: ChartOptions<"bar"> = {
    responsive: true, // 반응형 지원
    interaction: {
      mode: "index", // x축 상의 모든 데이터 포인트에 대해 툴팁 표시
      intersect: false, // 마우스가 포인트에 위치하지 않아도 툴팁이 보이도록 설정
    },
    plugins: {
      legend: {
        position: "top" as const, // 범례 위치 설정
      },
      title: {
        display: true, // 제목 표시 여부
        align: "start", // 제목 정렬 설정
        text: chartTitle, // 차트 제목
        font: {
          family: "Pretendard",
          size: 20,
          weight: 500,
        }, // 제목 폰트 설정
        color: "black", // 제목 색상
      },
      tooltip: {
        callbacks: {
          // 툴팁 제목에 '월'을 추가
          title: (context: TooltipItem<"bar">[]) => {
            return context[0].label + "월";
          },
          // 툴팁 데이터에 '건'을 추가
          label: (context: TooltipItem<"bar">) => {
            return context.formattedValue + "건";
          },
        },
        backgroundColor: "#797979", // 툴팁 배경 색상
      },
    },
    scales: {
      x: {
        // x축 레이블에 '월' 추가
        afterTickToLabelConversion: function (scaleInstance: Scale) {
          scaleInstance.ticks.forEach((tick: Tick) => {
            if (tick.label) {
              tick.label += "월";
            }
          });
        },
      },
      y: {
        // y축 최대값을 1.5배로 확대하여 여백 추가
        afterDataLimits: (scale: Scale) => {
          scale.max = scale.max * 1.5;
        },
      },
    },
  };
  const data = {
    labels,
    datasets: [
      {
        label: "2024", // 데이터셋 레이블
        data: totalCountData, // 주문수 데이터
        borderColor: "#3b82f6", // 바 테두리 색상
        backgroundColor: "#3b82f6", // 바 색상
      },
    ],
  };
  return <Bar options={options} data={data} />;
}

차트를 사용할 땐 다음과 같이 사용할 수 있다.

import { BarChart, LineChart } from "@/components/sellercenter/Chart";
import { getOrderDataWithChartFormat } from "@/api/chartApis";
...

export default async function SellerCenter() {
  const { status, message, data } = await getOrderDataWithChartFormat();
  if ((status >= 400 && status < 500) || !data) {
    throw new Error(message);
  }
  return (
    <div className="px-4 py-4 space-y-10">
      <div className="flex gap-10">
        <div className="w-1/2 px-2 py-2 border rounded-sm">
          <LineChart
            chartTitle="매출현황"
            labels={data.labels}
            totalPriceData={data.totalPriceData}
          />
        </div>
        <div className="w-1/2 px-2 py-2 border rounded-sm">
          <BarChart
            chartTitle="주문건수"
            labels={data.labels}
            totalCountData={data.totalCountData}
          />
        </div>
      </div>
      ...
	)
}

결과

완성된 차트 모습은 다음과 같다 🎉
(사실 작년과 비교하는 형식을 만들고 싶었으나 작년 데이터를 아직 만들지 못해서 올해의 데이터만 가지고 진행했다.)


차트에 마우스를 올리면 툴팁은 아래 사진처럼 나타난다.

처음에는 어려운 도전처럼 느껴졌던 차트 구현이지만, 한 번 해보니 생각보다 그렇게까지 어려운 작업은 아니었다. (물론 간단한 차트여서 그렇게 느꼈을수도 있다...)

다른 유형의 차트나 복잡한 데이터를 표현해보고 싶어져서 도입할 데이터에 대한 고민을 좀 더 해보기로!


참고

Line Chart | Chart.js
Chart.js 공식문서 한국어 버전
[React] 리액트 그래프/차트 라이브러리 모음
[라이브러리] Chart.js 시작하기

0개의 댓글