logo

DowanKim

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로 만들고 paramsawait로 처리하도록 변경했습니다:

// 에러 발생 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 변경 사항

  1. 타입 정의: params: { ... }params: Promise<{ ... }>
  2. 함수 선언: functionasync function
  3. 파라미터 접근: params.category_middleawait params 후 destructuring

6. 결과

6.1 구현된 기능

URL 파라미터 기반 동적 라우팅

  • /detail/사기, /detail/절도 등으로 접근 가능
  • 없는 범죄는 404 처리

데이터 시각화

  • 요일별 비교 차트 (전체 평균 vs 해당 범죄)
  • 시간대별 비교 차트 (전체 평균 vs 해당 범죄)
  • 요일별 개별 막대 차트
  • 시간대별 개별 라인 차트

상세 데이터 테이블

  • 요일별 발생 건수 및 비율(%)
  • 시간대별 발생 건수 및 비율(%)

사용자 경험 개선

  • 대시보드로 돌아가기 링크
  • 범죄 대분류 및 전체 발생 건수 표시

6.2 페이지 구조

상세 페이지 (/detail/[category_middle])
├── 헤더
│   ├── 범죄명
│   ├── 대분류 정보
│   └── 전체 발생 건수
├── 비교 차트 섹션
│   ├── 요일별 비교 (전체 평균 vs 해당 범죄)
│   └── 시간대별 비교 (전체 평균 vs 해당 범죄)
├── 개별 차트 섹션
│   ├── 요일별 발생 현황 (막대 차트)
│   └── 시간대별 발생 현황 (라인 차트)
└── 상세 데이터 테이블
    ├── 요일별 수치 (건수, 비율)
    └── 시간대별 수치 (건수, 비율)

6.3 기술 포인트

  1. Next.js 16의 동적 라우팅: params가 Promise로 변경됨
  2. TypeScript 비동기 타입: Promise 타입 정의와 await 처리
  3. 데이터 가공: CSV 데이터를 차트에 맞게 변환
  4. 컴포넌트 재사용: 기존 차트 컴포넌트 활용 및 새로운 비교 차트 추가

일단 모든 페이지가 이로써 구현은 완료되었습니다. 다만 컴포넌트?나 로직(날짜나 시간 매핑 로직)들이(유틸함수) 중에 중복된것이 몇개 있는 것 같아서 리팩터링이 약간 필요할 듯 합니다. 다음 글에서는 리팩터링을 한 이유와 방식에 대해서 설명하고자 합니다.