이전에 했던 프로젝트에 통계 기능을 추가하기 앞서, 공공 데이터를 사용하여 데이터를 시각화하는 연습을 해보았다. 자바스크립트 차트 라이브러리로는 Chart.js가 간편하면서도 커스텀이 쉽고, 사용자가 많아 Chart.js의 리액트 버전인 react-chartjs-2를 사용하려고 했는데, React 차트 라이브러리로는 D3의 리액트 버전인 Recharts가 더 사용이 쉽고, 많이 사용된다고 하여 두 라이브러리를 모두 사용해 봤다.
1. 날씨 데이터를 이용한 시간대별 기온 데이터 시각화
1-1. 데이터 로드 및 가공
기상청의 단기예보 OpenAPI를 이용하여 초단기예보 데이터를 받아오고, 여기서 시간대별 기온 데이터만 추출했다.
export interface WeatherResponse {
pageNo: number;
numOfRows: number;
totalCount: number;
items: { item: WeatherItem[] };
}
export interface WeatherItem {
baseDate: string;
baseTime: string;
category: string;
fcstDate: string;
fcstTime: string;
fcstValue: string;
nx: number;
ny: number;
}
export class weatherAPI {
(...)
// 초단기예보조회
static getUltraSrtFcst() {
return weatherAPIinstance.get<WeatherResponse, WeatherResponse>(
`/getUltraSrtFcst`,
{
baseURL: import.meta.env.VITE_OPEN_API_ROOT,
params: {
serviceKey: import.meta.env.VITE_SERVICE_KEY,
pageNo: 1,
numOfRows: 60,
dataType: "JSON",
base_date: "20250425",
base_time: "0600", // 오전 6시 기준
nx: 55,
ny: 127,
},
},
);
}
}
기상청에서 제공하는 초단기예보 응답 데이터를 확인하면 시간대별로 각 날씨 정보가 나오는 것이 아니라 카테고리별(기온, 습도 강수량 등)로 날씨 정보가 순서대로 제공된다. 그래서 시간대별로 날씨 데이터를 가져오기 위해 데이터를 가공해 주었다.
import { WeatherItem } from "@/types";
export const category = {
T1H: "기온",
RN1: "1시간 강수량",
SKY: "하늘 상태",
UUU: "동서바람성분",
VVV: "남북바람성분",
REH: "습도",
PTY: "강수 형태",
LGT: "낙뢰",
VEC: "풍향",
WSD: "풍속",
};
export const unit = {
T1H: "℃",
RN1: "mm",
SKY: "",
UUU: "m/s",
VVV: "m/s",
REH: "%",
PTY: "",
LGT: "kA",
VEC: "deg",
WSD: "m/s",
};
export function getHourlyWeather(items: WeatherItem[]) {
const report: Map<string, { [key: string]: string }> = new Map();
items.forEach(a => {
const time = a.fcstTime.slice(0, -2);
if (!report.has(time)) report.set(time, {});
let value = a.fcstValue;
if (a.category === "SKY") value = getSkyStatus(a.fcstValue);
if (a.category === "PTY") value = getPTYText(Number(a.fcstValue));
const prev = report.get(time);
report.set(time, { ...prev, [a.category]: value });
});
return report;
};
function getSkyStatus(skyCode: string) {
if (skyCode === "1") return "맑음";
if (skyCode === "3") return "구름 많음";
if (skyCode === "4") return "흐림";
return "";
}
function getPTYText(pCode: number) {
const arr = [
"없음",
"비",
"비/눈",
"눈",
"빗방울",
"빗방울/눈날림",
"눈날림",
];
return arr[pCode];
}
getHourlyWeather를 호출하면 아래와 같이 시간별 날씨 데이터를 얻을 수 있다.
1-2. react-chartjs-2를 이용한 시각화
가공된 데이터에서 기온 정보만 추출하여 차트에 매핑했다.
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
Point,
ChartData,
} from "chart.js";
import { useEffect, useState } from "react";
import { Line } from "react-chartjs-2";
import { weatherAPI } from "@/api/instance";
import { getHourlyWeather } from "@/utils/transformData";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
);
export const options = {
responsive: true,
plugins: {
legend: {
position: "top" as const,
},
customCanvasBackgroundColor: {
color: "white",
},
},
scales: {
x: {
title: {
display: true,
text: "시간대",
},
},
y: {
title: {
display: true,
text: "기온",
rotation: 90,
},
min: 0,
max: 20,
ticks: {
stepSize: 5,
},
},
},
};
export default function Weather() {
const [chartData, setChartData] = useState<ChartData<
"line",
(number | Point | null)[],
unknown
> | null>(null);
const fetchData = async () => {
const res = await weatherAPI.getUltraSrtFcst();
const result = getHourlyWeather(res.items.item);
// 차트용 데이터 생성
const labels: string[] = [];
const data: number[] = [];
for (const [key, value] of result) {
labels.push(key + "시");
data.push(Number(value.T1H));
}
setChartData({
labels,
datasets: [
{
label: "시간대별 기온 (°C)",
data,
borderColor: "rgb(87, 210, 248)",
backgroundColor: "rgba(66, 155, 187, 0.2)",
},
],
});
};
useEffect(() => {
fetchData();
}, []);
return (
<div style={{ width: "800px", height: "400px" }}>
{chartData && <Line options={options} data={chartData} />}
</div>
);
}
Chart.js를 사용하면 트리셰이킹을 위해 사용하는 것들만 import하고 register해서 사용해야 한다. (참고)
register를 하지 않으면 차트가 제대로 동작하지 않는다.
결과물
Chart.js는 차트가 로드될 때 애니메이션이 기본으로 적용된다. legend를 클릭하면 그래프의 표시 여부를 변경할 수 있다. 한 차트에 여러 그래프가 있을 때 보기 편할 듯하다. 간단한 스타일 수정이 쉽고, 기본 스타일도 깔끔하다.
1-3. Recharts를 이용한 시각화
import { useEffect, useState } from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { weatherAPI } from "@/api/instance";
import { getHourlyWeather } from "@/utils/transformData";
interface Temperature {
time: string;
value: number;
}
export default function Weather() {
const [data, setData] = useState<Temperature[] | null>(null);
const fetchData = async () => {
const res = await weatherAPI.getUltraSrtFcst();
const result = getHourlyWeather(res.items.item);
// 차트용 데이터 생성
const temperatures: Temperature[] = [];
for (const [key, value] of result) {
temperatures.push({ time: key + "시", value: Number(value.T1H) });
}
setData(temperatures);
};
useEffect(() => {
fetchData();
}, []);
return (
<div style={{ width: "800px", height: "400px" }}>
{data && (
<ResponsiveContainer width="100%" height="100%">
<LineChart
width={500}
height={300}
data={data}
margin={{
top: 5,
right: 30,
left: 10,
bottom: 10,
}}
>
<CartesianGrid />
<XAxis
dataKey="time"
tick={{ fontSize: 12 }}
label={{
value: "시간대",
fontSize: 12,
position: "insideBottom",
}}
/>
<YAxis
tick={{ fontSize: 12 }}
label={{ value: "기온", fontSize: 12, position: "insideLeft" }}
/>
<Tooltip contentStyle={{ fontSize: 12 }} />
<Legend
verticalAlign="top"
height={28}
wrapperStyle={{ fontSize: 12 }}
/>
<Line
type="monotone"
name="시간대별 기온 (°C)"
dataKey="value"
stroke="rgb(87, 210, 248)"
strokeWidth={2.5}
fill="rgb(87, 210, 248)"
activeDot={{ r: 8 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
);
}
Chart.js에서 차트 관련 설정을 options props로 넘겨준 것과 다르게 차트 컴포넌트 내에 필요한 속성 컴포넌트를 추가하여 차트를 구성한다. 차트와 관련된 코드가 한 곳에 모여 있어 가독성이 좋다.
결과물
react-chartjs-2와 다르게 legend를 클릭해도 그래프가 사라지지 않는다. 이러한 효과를 주려면 Legend 컴포넌트에 onClick 핸들러를 지정하여 직접 구현해야 한다.
2. 연령대별 인구수 데이터 시각화
2-1. 데이터 로드 및 가공
KOSIS 국가통계포털에서 연령대별 인구 데이터를 받아와 시각화했다. KOSIS에서 제공하는 Open API는 사이트 내 조회 설정을 통해 필요한 데이터만 필터링해서 받을 수 있다. URL도 필터에 따라 생성해 줘서 파라미터를 직접 입력할 필요가 없다.
export interface PopulationItem {
C1_NM: string; // 지역
C2_NM: string; // 연령대
ITM_NM: string; // 성별
DT: string; // 인구수
}
export class populationAPI {
...
static getPopulations() {
return defaultInstance.get<PopulationItem[], PopulationItem[]>(
`kosis/openapi/Param/statisticsParameterData.do?method=getList&apiKey=${API_KEY}&itmId=T2+T3+T4+&objL1=00+&objL2=5+10+15+20+25+30+35+40+45+50+55+60+65+70+75+80+85+90+95+100+105+&objL3=&objL4=&objL5=&objL6=&objL7=&objL8=&format=json&jsonVD=Y&prdSe=Y&newEstPrdCnt=1&outputFields=OBJ_NM+NM+ITM_NM+LST_CHN_DE+&orgId=101&tblId=DT_1B04005N`,
);
}
}
주어진 데이터가 5세 단위여서 10세 단위로 재분류했다. 100세 이상은 90대 이상 값과 합쳤다.
export function getGroupedAgeData(arr: PopulationItem[]) {
return [
Number(arr[10].DT) + Number(arr[0].DT),
Number(arr[3].DT) + Number(arr[4].DT),
Number(arr[5].DT) + Number(arr[6].DT),
Number(arr[7].DT) + Number(arr[8].DT),
Number(arr[9].DT) + Number(arr[11].DT),
Number(arr[12].DT) + Number(arr[13].DT),
Number(arr[14].DT) + Number(arr[15].DT),
Number(arr[16].DT) + Number(arr[17].DT),
Number(arr[18].DT) + Number(arr[19].DT),
Number(arr[20].DT) + Number(arr[2].DT),
];
}
2-2. react-chartjs-2를 이용한 시각화
남녀 데이터를 각각 뽑아 Stacked Bar 차트로 그렸다.
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
ChartData,
} from "chart.js";
import { useEffect, useState } from "react";
import { Bar } from "react-chartjs-2";
import { populationAPI } from "@/api/instance";
import { PopulationItem } from "@/types";
import { getGroupedAgeData } from "@/utils/transformData";
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
);
export const options = {
responsive: true,
plugins: {
legend: {
display: true,
},
title: {
display: true,
text: "연령대별 인구수",
},
customCanvasBackgroundColor: {
color: "white",
},
},
scales: {
x: {
stacked: true,
title: {
display: true,
text: "연령대",
},
},
y: {
stacked: true,
ticks: {
callback: function (value: string | number) {
if (typeof value === "number") {
return value / 10000;
}
return value;
},
},
title: {
display: true,
text: "인구수 (단위: 만 명)",
},
},
},
};
export default function Population() {
const [chartData, setChartData] = useState<ChartData<
"bar",
number[],
string
> | null>(null);
const fetchData = async () => {
const res = await populationAPI.getPopulations();
const female = res.filter(
(item: PopulationItem) => item.ITM_NM === "여자인구수",
);
const male = res.filter(
(item: PopulationItem) => item.ITM_NM === "남자인구수",
);
const labels = [
"10대 미만",
"10대",
"20대",
"30대",
"40대",
"50대",
"60대",
"70대",
"80대",
"90대 이상",
];
const femaleData = getGroupedAgeData(female);
const maleData = getGroupedAgeData(male);
setChartData({
labels,
datasets: [
{
label: "여자",
data: femaleData,
borderColor: "rgb(255, 99, 132)",
borderWidth: 1.5,
backgroundColor: "rgb(255, 99, 132)",
},
{
label: "남자",
data: maleData,
borderColor: "rgb(54, 162, 235)",
borderWidth: 1.5,
backgroundColor: "rgb(54, 162, 235)",
},
],
});
};
useEffect(() => {
fetchData();
}, []);
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
height: "100%",
}}
>
Home
<div
style={{
width: "800px",
height: "400px",
margin: "0 auto",
background: "white",
}}
>
{chartData && <Bar options={options} data={chartData} />}
</div>
</div>
);
}
결과물
2-3. Recharts를 이용한 시각화
마찬가지로 Stacked Bar 차트로 그렸다.
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { populationAPI } from "@/api/instance";
import { PopulationItem } from "@/types";
import { getGroupedAgeData } from "@/utils/transformData";
interface Population {
age: string;
male: number;
female: number;
}
export default function Population() {
const [data, setData] = useState<Population[] | null>([]);
const fetchData = async () => {
const res = await populationAPI.getPopulations();
const female = res.filter(
(item: PopulationItem) => item.ITM_NM === "여자인구수",
);
const male = res.filter(
(item: PopulationItem) => item.ITM_NM === "남자인구수",
);
const labels = [
"10대 미만",
"10대",
"20대",
"30대",
"40대",
"50대",
"60대",
"70대",
"80대",
"90대 이상",
];
const femaleData = getGroupedAgeData(female);
const maleData = getGroupedAgeData(male);
const ageData: Population[] = [];
for (let i = 0; i < labels.length; i++) {
ageData.push({
age: labels[i],
female: femaleData[i],
male: maleData[i],
});
}
setData(ageData);
};
useEffect(() => {
fetchData();
}, []);
return (
<div style={{ width: "800px", height: "400px" }}>
{data && (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{
top: 5,
right: 30,
left: 10,
bottom: 20,
}}
>
<CartesianGrid />
<XAxis
dataKey="age"
tick={{ fontSize: 12 }}
label={{
value: "연령대",
fontSize: 12,
offset: -5,
position: "insideBottom",
}}
/>
<YAxis
tick={{ fontSize: 12 }}
tickCount={10}
tickFormatter={value => `${value / 10000}`}
label={{
value: "인구수 (단위: 만 명)",
fontSize: 12,
angle: -90,
position: "insideLeft",
}}
/>
<Legend
verticalAlign="top"
height={28}
wrapperStyle={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{ fontSize: 12 }}
formatter={(value: number) => value.toLocaleString()}
/>
<Bar
name="여자"
dataKey="female"
stackId="a"
fill="rgb(255, 99, 132)"
/>
<Bar
name="남자"
dataKey="male"
stackId="a"
fill="rgb(54, 162, 235)"
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
);
}
결과물
Recharts는 react-chartjs-2와 다르게 개별 bar에 대한 tooltip은 제공하지 않고, 합쳐진 tooltip을 제공한다. 또한 앞선 react-chartjs-2처럼 차트에 title과 같은 속성이 없어 차트 제목을 추가하려면 직접 HTML 태그로 추가해 주어야 한다.
3. react-chartjs-2와 Recharts 비교
react-chartjs-2 (Chart.js) | Recharts | |
코드 구조 | 단일 컴포넌트에 options props를 전달하여 커스텀 | 컴파운드 패턴으로, 차트 컴포넌트 내에 여러 기능별 컴포넌트를 추가 |
트리셰이킹 | register 필요 | 자동으로 적용됨 |
커스텀 | 쉬움 | 어려움 |
Chart.js 공식 문서가 가독성이 좋고 잘 되어 있어서 이를 훑어보고 react-chartjs-2 공식 문서를 보니 이해하기 쉬웠다. Recharts는 차트별 예제가 많고 다양해서 좋았다. Chart.js를 먼저 접한 탓인지 Chart.js가 조금 더 쉽게 느껴졌지만, Recharts가 JSX 구조라 더 직관적이기도 하고, 익숙해지기만 한다면 React와 더 잘 어울릴 것 같다는 생각이 들었다!
'Develop > React' 카테고리의 다른 글
[React] Sentry, ErrorBoundary로 오류 대응하기 (0) | 2024.10.10 |
---|---|
[React] useRef, scrollIntoView를 통한 스크롤 위치 이동 (0) | 2024.09.23 |
Zustand 사용해 보기 (+ Redux와 비교) (0) | 2024.06.27 |
[React] 라이브러리 없이 Toast 구현하기 (2) | 2023.11.02 |
[React] ios 환경에서 input, textarea 화면 확대 방지하기 (+ 웹 접근성) (0) | 2023.10.13 |