판매자 센터의 메인에서 매출과 주문 수을 한눈에 파악할 수 있도록 하는 차트를 구현하고 싶어, 차트 라이브러리에 대해 알아보게 되었다.
Recharts, Nivo.rocks, Apexcharts, Chart.js 등 다양하게 차트를 구현할 수 있는 라이브러리가 있다.
내가 구현할 차트의 경우 아래 그림처럼 매출과 주문 건수와 같은 단순한 데이터이기 때문에 Chart.js만으로 충분히 구현 가능하다고 판단했다.
보통 Next.js나 React 프로젝트에서는 Chart.js를 직접 사용하기 보다는 react-chartjs-2
를 사용하게 된다.
react-chartjs-2
는 Chart.js를 React 프로젝트에서 쉽게 사용할 수 있도록 감싸주는 React용 래퍼 라이브러리이다.
<canvas>
요소를 사용해 차트를 렌더링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축)를 배열에 담아 전달해야 한다.
이를 위해서는 먼저 데이터를 가공해야 한다.
(현재 데이터 관계도는 위의 그림과 같다)
전체적인 흐름
데이터 가져오기: getOrderDataWithChartFormat
을 통해 Supabase로부터 데이터를 가져옴
데이터 가공: 데이터가 존재하면 processData
를 통해 월별로 매출과 주문 수를 집계
차트 형식으로 변환: getChartDataArrays
를 사용해 차트에 적합한 형식으로 데이터를 변환함
차트 출력: 변환된 데이터를 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 시작하기