4. 동적 라우트의 params는 Promise여야 한다.
2025년 12월 10일
정적 인터랙티브 대시보드
상세 페이지 구현
1. 상세 페이지 구현 목표
- URL 파라미터로 범죄 종류 받기 (
/detail/[category_middle]) - 해당 범죄의 시간대별/요일별 데이터 표시
- 전체 평균과의 비교 시각화
- 상세 수치 데이터 테이블
2. 데이터 연동 구현
2.1 기존 함수 활용
이미 구현된 CSV 파서의 함수를 활용했습니다:
parseCrimeData() // 전체 데이터 파싱 findCrimeByCategoryMiddle() // 특정 범죄 찾기
2.2 상세 페이지 기본 구조
// app/detail/[category_middle]/page.tsx import { parseCrimeData, findCrimeByCategoryMiddle } from '@/lib/csvParser'; import { notFound } from 'next/navigation'; export default function DetailPage({ params }: DetailPageProps) { // URL 파라미터에서 범죄 종류 추출 const categoryName = decodeURIComponent(params.category_middle); // 전체 데이터 파싱 const records = parseCrimeData(); // 해당 범죄 데이터 찾기 const crimeData = findCrimeByCategoryMiddle(records, categoryName); // 데이터가 없으면 404 페이지 if (!crimeData) { notFound(); } // ... 나머지 로직 }
2.3 데이터 포맷팅
차트에 사용할 데이터를 포맷팅했습니다:
// 시간대별 데이터 포맷팅 const timeSlotData: TimeSlotData[] = [ '0시00분-02시59분', '03시00분-05시59분', // ... 모든 시간대 ].map((slot) => ({ time: slot, count: crimeData.timeSlots[slot] || 0, })); // 요일별 데이터 포맷팅 const dayOrder: DayOfWeek[] = ['일', '월', '화', '수', '목', '금', '토']; const dayOfWeekData: DayOfWeekData[] = dayOrder.map((day) => ({ day, count: crimeData.daysOfWeek[day] || 0, }));
3. 전체 평균 계산 함수 추가
비교 차트에 사용할 전체 평균을 계산하는 함수를 추가했습니다.
3.1 함수 구현
// lib/csvParser.ts export function calculateAverageStats(records: CrimeRecord[]): { timeSlotAverages: TimeSlotData[]; dayOfWeekAverages: DayOfWeekData[]; } { const recordCount = records.length; // 시간대별 합계 계산 const timeSlotSum = new Map<string, number>(); records.forEach((record) => { Object.entries(record.timeSlots).forEach(([slot, count]) => { if (slot !== '미상') { const current = timeSlotSum.get(slot) || 0; timeSlotSum.set(slot, current + count); } }); }); // 평균 계산 const timeSlotAverages: TimeSlotData[] = timeSlotOrder.map((slot) => ({ time: slot, count: Math.round((timeSlotSum.get(slot) || 0) / recordCount), })); // 요일별도 동일한 방식으로 계산 // ... return { timeSlotAverages, dayOfWeekAverages, }; }
3.2 사용 예시
// 상세 페이지에서 const records = parseCrimeData(); const averageStats = calculateAverageStats(records); // 비교 차트에 전달 <ComparisonChart data={dayOfWeekData} // 해당 범죄 데이터 averageData={averageStats.dayOfWeekAverages} // 전체 평균 // ... />
4. 비교 차트 컴포넌트 구현
전체 평균과 개별 범죄를 동시에 보여주는 비교 차트 컴포넌트를 만들었습니다.
4.1 컴포넌트 구조
// components/charts/ComparisonChart.tsx "use client"; import { BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts'; interface ComparisonChartProps { data: DayOfWeekData[] | TimeSlotData[]; // 개별 범죄 데이터 averageData: DayOfWeekData[] | TimeSlotData[]; // 전체 평균 데이터 dataKey: string; xAxisKey: string; dataLabel: string; // 예: "사기" averageLabel: string; // 예: "전체 평균" } export default function ComparisonChart({ data, averageData, dataKey, xAxisKey, dataLabel, averageLabel, }: ComparisonChartProps) { // 두 데이터를 병합 const formattedData = data.map((item, index) => { const avgItem = averageData[index]; return { ...item, average: avgItem ? avgItem.count : 0, }; }); return ( <ResponsiveContainer width="100%" height="100%"> <BarChart data={formattedData}> <Bar dataKey={dataKey} fill="#1f2937" name={dataLabel} /> <Bar dataKey="average" fill="#9ca3af" name={averageLabel} /> {/* ... 기타 설정 */} </BarChart> </ResponsiveContainer> ); }
- 두 데이터를 하나의 배열로 병합
- 서로 다른 색상으로 구분 (진한 회색 vs 연한 회색)
- 범례에 의미 있는 라벨 표시
5. Next.js 16의 params Promise 이슈
5.1 문제: Next.js 16의 params Promise 이슈
구현 후 다음 에러가 발생했습니다:
Error: Route "/detail/[category_middle]" used `params.category_middle`.
`params` is a Promise and must be unwrapped with `await` or `React.use()`
before accessing its properties.
5.2 원인 분석
Next.js 16(및 15+)부터 동적 라우트의 params가 Promise로 변경되었습니다. 이를 반영하지 않아 발생한 문제입니다.
5.3 해결 방법
함수를 async로 만들고 params를 await로 처리하도록 변경했습니다:
// 에러 발생 interface DetailPageProps { params: { category_middle: string; }; } export default function DetailPage({ params }: DetailPageProps) { const categoryName = decodeURIComponent(params.category_middle); // ... } // 정상 작동 interface DetailPageProps { params: Promise<{ category_middle: string; }>; } export default async function DetailPage({ params }: DetailPageProps) { const { category_middle } = await params; const categoryName = decodeURIComponent(category_middle); // ... }
5.4 변경 사항
- 타입 정의:
params: { ... }→params: Promise<{ ... }> - 함수 선언:
function→async function - 파라미터 접근:
params.category_middle→await params후 destructuring
6. 결과
6.1 구현된 기능
URL 파라미터 기반 동적 라우팅
/detail/사기,/detail/절도등으로 접근 가능- 없는 범죄는 404 처리
데이터 시각화
- 요일별 비교 차트 (전체 평균 vs 해당 범죄)
- 시간대별 비교 차트 (전체 평균 vs 해당 범죄)
- 요일별 개별 막대 차트
- 시간대별 개별 라인 차트
상세 데이터 테이블
- 요일별 발생 건수 및 비율(%)
- 시간대별 발생 건수 및 비율(%)
사용자 경험 개선
- 대시보드로 돌아가기 링크
- 범죄 대분류 및 전체 발생 건수 표시
6.2 페이지 구조
상세 페이지 (/detail/[category_middle])
├── 헤더
│ ├── 범죄명
│ ├── 대분류 정보
│ └── 전체 발생 건수
├── 비교 차트 섹션
│ ├── 요일별 비교 (전체 평균 vs 해당 범죄)
│ └── 시간대별 비교 (전체 평균 vs 해당 범죄)
├── 개별 차트 섹션
│ ├── 요일별 발생 현황 (막대 차트)
│ └── 시간대별 발생 현황 (라인 차트)
└── 상세 데이터 테이블
├── 요일별 수치 (건수, 비율)
└── 시간대별 수치 (건수, 비율)
6.3 기술 포인트
- Next.js 16의 동적 라우팅:
params가 Promise로 변경됨 - TypeScript 비동기 타입: Promise 타입 정의와 await 처리
- 데이터 가공: CSV 데이터를 차트에 맞게 변환
- 컴포넌트 재사용: 기존 차트 컴포넌트 활용 및 새로운 비교 차트 추가
일단 모든 페이지가 이로써 구현은 완료되었습니다. 다만 컴포넌트?나 로직(날짜나 시간 매핑 로직)들이(유틸함수) 중에 중복된것이 몇개 있는 것 같아서 리팩터링이 약간 필요할 듯 합니다. 다음 글에서는 리팩터링을 한 이유와 방식에 대해서 설명하고자 합니다.