3. 데이터 파싱하고 시각화해서 정적페이지 만들어오세요~(제가 할수있을까요)
2025년 12월 9일
CSV 파싱 및 시각화
이제 csv 데이터를 연결하고 시각화를 진행해야 합니다.
1. CSV 파일 인코딩 변환
문제 상황
Next.js에서 파일을 읽을 때 한글이 깨져 보이는 문제가 발생했습니다. 아마 인코딩이 UTF-8이 아닌 EUC-KR 로 되어있어 문제가 생긴 것 같습니다.
# 파일 인코딩 확인 $ file -I data/crime_data.csv data/crime_data.csv: text/csv; charset=iso-8859-1
해결 방법
iconv 명령어를 사용하여 EUC-KR 인코딩을 UTF-8로 변환했습니다.
# EUC-KR → UTF-8 변환 iconv -f EUC-KR -t UTF-8 data/crime_data.csv > data/crime_data_utf8.csv # 원본 파일 백업 및 교체 cp data/crime_data.csv data/crime_data_backup_euckr.csv mv data/crime_data_utf8.csv data/crime_data.csv
변환 후에는 Next.js의 fs.readFileSync()에서 'utf-8' 옵션으로 정상적으로 읽을 수 있게 되었습니다.
2. TypeScript 타입 정의
타입 안전성을 보장하기 위해 먼저 모든 데이터 구조에 대한 타입을 정의했습니다. 직접 csv파일을 열고, 수작업으로 진행했습니다.

파일: types/crime.ts
기본 타입 정의
// 시간대 타입 export type TimeSlot = | '0시00분-02시59분' | '03시00분-05시59분' | '06시00분-08시59분' | '09시00분-11시59분' | '12시00분-14시59분' | '15시00분-17시59분' | '18시00분-20시59분' | '21시00분-23시59분' | '미상'; // 요일 타입 export type DayOfWeek = '일' | '월' | '화' | '수' | '목' | '금' | '토';
TimeSlot과 DayOfWeek는 리터럴 유니온 타입으로 정의하여 컴파일 타임에 유효한 값만 사용하도록 보장합니다.
범죄 데이터 레코드 타입
// 범죄 데이터 행 타입 export interface CrimeRecord { // 범죄 대분류 (예: 강력범죄, 절도범죄, 폭력범죄 등) categoryMajor: string; // 범죄 중분류 (예: 살인기수, 강도, 사기 등) categoryMiddle: string; // 시간대별 발생 건수 timeSlots: Record<TimeSlot, number>; // 요일별 발생 건수 daysOfWeek: Record<DayOfWeek, number>; // 전체 합계 total: number; }
Record<K, V> 유틸리티 타입을 사용하여 키-값 쌍의 타입을 명확하게 정의했습니다.
차트 데이터 타입
// 차트 데이터용 타입 export interface TimeSlotData { time: string; count: number; } export interface DayOfWeekData { day: DayOfWeek; count: number; } export interface CategoryMajorData { category: string; count: number; }
각 차트 컴포넌트가 사용할 데이터 구조를 별도로 정의하여 재사용성을 높였습니다.
대시보드 통계 타입
// 대시보드 통계 타입 export interface DashboardStats { // 전체 범죄 건수 totalCrimes: number; // 범죄 대분류별 합계 categoryMajorTotals: CategoryMajorData[]; // 시간대별 전체 합계 timeSlotTotals: TimeSlotData[]; // 요일별 전체 합계 dayOfWeekTotals: DayOfWeekData[]; // 가장 많이 발생한 범죄 topCrimes: Array<{ categoryMajor: string; categoryMiddle: string; total: number; }>; }
대시보드에서 사용할 모든 통계 데이터를 하나의 인터페이스로 묶어 관리합니다.
3. CSV 파서 구현
파일: lib/csvParser.ts
CSV 파일을 파싱하고 통계를 계산하는 유틸리티 함수들을 구현했습니다.
3.1 CSV 데이터 파싱 함수
import fs from 'fs'; import path from 'path'; import { CrimeRecord, TimeSlot, DayOfWeek, DashboardStats, TimeSlotData, DayOfWeekData, CategoryMajorData, } from '@/types/crime'; export function parseCrimeData(): CrimeRecord[] { // 1. 파일 경로 생성 const filePath = path.join(process.cwd(), 'data', 'crime_data.csv'); // 2. UTF-8 인코딩으로 파일 읽기 const fileContents = fs.readFileSync(filePath, 'utf-8'); // 3. 줄 단위로 분리 (줄바꿈 문자 정규화) // \r\n (Windows), \r (Mac), \n (Unix) 모두 처리 const lines = fileContents .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .split('\n') .map(line => line.trim()) .filter(line => line.length > 0); // 4. 첫 번째 줄에서 헤더 추출 (각 헤더의 공백 제거) const headers = lines[0].split(',').map(header => header.trim()); // 5. 헤더 인덱스 찾기 const categoryMajorIdx = headers.indexOf('범죄대분류'); const categoryMiddleIdx = headers.indexOf('범죄중분류'); // 6. 시간대 컬럼 인덱스 매핑 const timeSlotColumns: Array<{ slot: TimeSlot; idx: number }> = [ '0시00분-02시59분', '03시00분-05시59분', '06시00분-08시59분', '09시00분-11시59분', '12시00분-14시59분', '15시00분-17시59분', '18시00분-20시59분', '21시00분-23시59분', '미상', ].map((slot) => ({ slot: slot as TimeSlot, idx: headers.indexOf(slot), })); // 7. 요일 컬럼 인덱스 매핑 const daysOfWeek: DayOfWeek[] = ['일', '월', '화', '수', '목', '금', '토']; const dayColumns: Array<{ day: DayOfWeek; idx: number }> = daysOfWeek.map((day) => ({ day, idx: headers.indexOf(day), })); // 8. 데이터 행 파싱 const records: CrimeRecord[] = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]; if (!line) continue; // 빈 줄 스킵 // 각 값의 앞뒤 공백 제거 const values = line.split(',').map(value => value.trim()); const categoryMajor = values[categoryMajorIdx]; const categoryMiddle = values[categoryMiddleIdx]; // 시간대별 데이터 파싱 const timeSlots: Record<TimeSlot, number> = {} as Record<TimeSlot, number>; let timeSlotTotal = 0; for (const { slot, idx } of timeSlotColumns) { const count = parseInt(values[idx] || '0', 10); timeSlots[slot] = count; timeSlotTotal += count; } // 요일별 데이터 파싱 const daysOfWeekData: Record<DayOfWeek, number> = {} as Record<DayOfWeek, number>; let dayTotal = 0; for (const { day, idx } of dayColumns) { const count = parseInt(values[idx] || '0', 10); daysOfWeekData[day] = count; dayTotal += count; } // 레코드 생성 및 추가 records.push({ categoryMajor, categoryMiddle, timeSlots, daysOfWeek: daysOfWeekData, total: timeSlotTotal + dayTotal, }); } return records; }
주요 포인트:
process.cwd()를 사용하여 프로젝트 루트 기준 상대 경로 계산- 헤더 인덱스를 미리 계산하여 각 행을 효율적으로 파싱
- 타입 단언(
as)을 사용하여 타입 안전성 확보 - 빈 값 처리 (
values[idx] || '0') - 줄바꿈 문자 정규화 및 공백 제거 (안했다가 망할뻔)
3.2 실제 발생한 이슈: 줄바꿈 문자 처리 문제
대시보드를 구현하고 테스트하는 과정에서 토요일 데이터가 0으로 표시되는 버그를 발견했습니다. CSV 파일에는 분명히 토요일 데이터가 있었는데 차트에는 표시되지 않는 것이었습니다.
문제 진단
먼저 실제 데이터를 확인해보기 위해 디버깅 코드를 작성했습니다:
const headers = lines[0].split(','); console.log('Headers:', headers); console.log('토 인덱스:', headers.indexOf('토'));
출력 결과를 보니 문제를 발견했습니다:
Headers: [ '범죄대분류', '범죄중분류', '0시00분-02시59분', '03시00분-05시59분', // ... 중략 ... '금', '토\r' // ← 여기가 문제! ] 토 인덱스: -1 // '토'를 찾지 못함!
원인: CSV 파일의 마지막 컬럼인 "토"에 \r(캐리지 리턴) 문자가 포함되어 있어서, 실제 헤더는 '토\r'로 저장되어 있었습니다. 따라서 headers.indexOf('토')는 항상 -1을 반환하여 토요일 데이터를 찾을 수 없었습니다.
이는 Windows에서 생성된 CSV 파일이 \r\n으로 줄바꿈을 하거나, Excel 등에서 저장할 때 마지막 컬럼에 캐리지 리턴이 포함되는 경우에 발생하는 일반적인 문제입니다.
해결 방법
줄바꿈 문자를 정규화하고, 모든 헤더와 값에서 공백 문자를 제거하도록 코드를 수정했습니다:
// 수정 전 const lines = fileContents.trim().split('\n'); const headers = lines[0].split(','); const values = line.split(','); // 수정 후 const lines = fileContents .replace(/\r\n/g, '\n') // Windows 줄바꿈 처리 .replace(/\r/g, '\n') // Mac 줄바꿈 처리 .split('\n') .map(line => line.trim()) // 각 줄의 앞뒤 공백 제거 .filter(line => line.length > 0); const headers = lines[0].split(',').map(header => header.trim()); // 헤더 공백 제거 const values = line.split(',').map(value => value.trim()); // 값 공백 제거
수정 후 확인해보니:
Headers: [ '범죄대분류', '범죄중분류', // ... 중략 ... '금', '토' // 정상 ] 토 인덱스: 17 // 정상적으로 찾음!
CSV 파일 파싱 시에는 다음과 같은 사항을 주의해야 합니다:
- 줄바꿈 문자 정규화: 운영체제마다 다른 줄바꿈 문자(
\r\n,\r,\n) 처리 - 공백 문자 제거: CSV 생성 도구에 따라 헤더나 값에 공백이 포함될 수 있음
- 디버깅:
indexOf()결과가-1이면 헤더 이름이 정확히 일치하지 않는다는 신호 - 실제 데이터 확인: 코드가 예상대로 작동하는지 실제 값들을 콘솔로 출력하여 검증
이러한 경계 케이스 처리는 CSV 파싱의 중요한 부분입니다.
3.3 대시보드 통계 계산 함수
export function calculateDashboardStats(records: CrimeRecord[]): DashboardStats { // 1. 전체 범죄 건수 계산 const totalCrimes = records.reduce((sum, record) => sum + record.total, 0); // 2. 범죄 대분류별 합계 계산 const categoryMajorMap = new Map<string, number>(); records.forEach((record) => { const current = categoryMajorMap.get(record.categoryMajor) || 0; categoryMajorMap.set(record.categoryMajor, current + record.total); }); const categoryMajorTotals: CategoryMajorData[] = Array.from( categoryMajorMap.entries() ) .map(([category, count]) => ({ category, count })) .sort((a, b) => b.count - a.count); // 내림차순 정렬 // 3. 시간대별 전체 합계 계산 const timeSlotMap = new Map<string, number>(); records.forEach((record) => { Object.entries(record.timeSlots).forEach(([slot, count]) => { const current = timeSlotMap.get(slot) || 0; timeSlotMap.set(slot, current + count); }); }); const timeSlotTotals: TimeSlotData[] = [ '0시00분-02시59분', '03시00분-05시59분', '06시00분-08시59분', '09시00분-11시59분', '12시00분-14시59분', '15시00분-17시59분', '18시00분-20시59분', '21시00분-23시59분', '미상', ] .map((slot) => ({ time: slot, count: timeSlotMap.get(slot) || 0, })) .filter((item) => item.time !== '미상'); // 미상 데이터 제외 // 4. 요일별 전체 합계 계산 const dayMap = new Map<DayOfWeek, number>(); records.forEach((record) => { Object.entries(record.daysOfWeek).forEach(([day, count]) => { const current = dayMap.get(day as DayOfWeek) || 0; dayMap.set(day as DayOfWeek, current + count); }); }); const dayOrder: DayOfWeek[] = ['일', '월', '화', '수', '목', '금', '토']; const dayOfWeekTotals: DayOfWeekData[] = dayOrder.map((day) => ({ day, count: dayMap.get(day) || 0, })); // 5. 가장 많이 발생한 범죄 상위 10개 const topCrimes = records .map((record) => ({ categoryMajor: record.categoryMajor, categoryMiddle: record.categoryMiddle, total: record.total, })) .sort((a, b) => b.total - a.total) .slice(0, 10); return { totalCrimes, categoryMajorTotals, timeSlotTotals, dayOfWeekTotals, topCrimes, }; }
Map자료구조를 사용하여 그룹별 집계 수행- 함수형 프로그래밍 패러다임 적용? (map, filter, reduce)
- 데이터 정렬 및 필터링
3.4 헬퍼 함수들
// 범죄 중분류로 데이터 찾기 export function findCrimeByCategoryMiddle( records: CrimeRecord[], categoryMiddle: string ): CrimeRecord | undefined { return records.find( (record) => record.categoryMiddle === decodeURIComponent(categoryMiddle) ); } // 범죄 대분류로 데이터 찾기 export function findCrimesByCategoryMajor( records: CrimeRecord[], categoryMajor: string ): CrimeRecord[] { return records.filter((record) => record.categoryMajor === categoryMajor); }
URL 파라미터에서 받은 값을 디코딩하여 검색할 수 있도록 구현했습니다.
4. 차트 라이브러리 연동
Recharts 선택 이유
- React 친화적인 API
- TypeScript 지원
- 반응형 디자인 지원 (
ResponsiveContainer) - 다양한 차트 타입 제공
설치
npm install recharts
프로젝트 구조
차트 컴포넌트는 클라이언트 컴포넌트로 분리하여 구현했습니다:
components/
charts/
BarChart.tsx # 막대 차트
LineChart.tsx # 선 차트
PieChart.tsx # 파이 차트
각 컴포넌트는 "use client" 디렉티브를 사용하여 클라이언트 사이드에서만 렌더링됩니다.
5. 차트 컴포넌트 구현
5.1 막대 차트 (BarChart)
파일: components/charts/BarChart.tsx
"use client"; import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { DayOfWeekData, TimeSlotData } from '@/types/crime'; interface BarChartProps { data: DayOfWeekData[] | TimeSlotData[]; dataKey: string; xAxisKey: string; title?: string; } export default function BarChart({ data, dataKey, xAxisKey, title }: BarChartProps) { // 시간대 데이터인 경우 라벨 단축 const formattedData = data.map((item) => { if (xAxisKey === 'time') { const timeItem = item as TimeSlotData; // "0시00분-02시59분" -> "0시" 형태로 변환 const timeLabel = timeItem.time .replace('시00분-', '-') .replace('시59분', '시') .split('-')[0] + '시'; return { ...item, timeLabel }; } return item; }); return ( <div className="w-full h-full"> {title && ( <h3 className="text-lg font-semibold text-gray-800 mb-4">{title}</h3> )} <ResponsiveContainer width="100%" height="100%"> <RechartsBarChart data={formattedData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }} > <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey={xAxisKey === 'time' ? 'timeLabel' : xAxisKey} tick={{ fontSize: 12 }} /> <YAxis tick={{ fontSize: 12 }} /> <Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px' }} formatter={(value: number) => [value.toLocaleString(), '발생 건수']} /> <Legend /> <Bar dataKey={dataKey} fill="#1f2937" name="발생 건수" /> </RechartsBarChart> </ResponsiveContainer> </div> ); }
- 요일별 및 시간대별 데이터 모두 지원(혹시모르니)
- 시간대 라벨 자동 단축 처리
- 숫자 포맷팅 (
toLocaleString()) - 반응형 디자인 (
ResponsiveContainer)
5.2 선 차트 (LineChart)
파일: components/charts/LineChart.tsx
"use client"; import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { TimeSlotData } from '@/types/crime'; interface LineChartProps { data: TimeSlotData[]; title?: string; } export default function LineChart({ data, title }: LineChartProps) { // 시간대 라벨 단축 const formattedData = data.map((item) => { const timeLabel = item.time .replace('시00분-', '-') .replace('시59분', '시') .split('-')[0] + '시'; return { ...item, timeLabel }; }); return ( <div className="w-full h-full"> {title && ( <h3 className="text-lg font-semibold text-gray-800 mb-4">{title}</h3> )} <ResponsiveContainer width="100%" height="100%"> <RechartsLineChart data={formattedData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }} > <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey="timeLabel" tick={{ fontSize: 12 }} /> <YAxis tick={{ fontSize: 12 }} /> <Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px' }} formatter={(value: number) => [value.toLocaleString(), '발생 건수']} /> <Legend /> <Line type="monotone" dataKey="count" stroke="#1f2937" strokeWidth={2} name="발생 건수" dot={{ r: 4 }} activeDot={{ r: 6 }} /> </RechartsLineChart> </ResponsiveContainer> </div> ); }
- 시간대별 트렌드 시각화
- 부드러운 곡선 (
type="monotone") - 호버 시 확대되는 점 (
activeDot)
5.3 파이 차트 (PieChart)
파일: components/charts/PieChart.tsx
"use client"; import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'; import { CategoryMajorData } from '@/types/crime'; interface PieChartProps { data: CategoryMajorData[]; title?: string; } // 차트 색상 팔레트 const COLORS = [ '#1f2937', // gray-800 '#374151', // gray-700 '#4b5563', // gray-600 '#6b7280', // gray-500 '#9ca3af', // gray-400 '#d1d5db', // gray-300 '#e5e7eb', // gray-200 '#f3f4f6', // gray-100 ]; export default function PieChart({ data, title }: PieChartProps) { // 상위 7개만 표시하고 나머지는 "기타"로 합침 const displayData = data.length > 7 ? [ ...data.slice(0, 7), { category: '기타', count: data.slice(7).reduce((sum, item) => sum + item.count, 0), }, ] : data; const renderLabel = (entry: CategoryMajorData) => { const total = displayData.reduce((sum, item) => sum + item.count, 0); const percentage = ((entry.count / total) * 100).toFixed(1); return `${entry.category} (${percentage}%)`; }; return ( <div className="w-full h-full"> {title && ( <h3 className="text-lg font-semibold text-gray-800 mb-4">{title}</h3> )} <ResponsiveContainer width="100%" height="100%"> <RechartsPieChart> <Pie data={displayData} cx="50%" cy="50%" labelLine={false} label={renderLabel} outerRadius={80} fill="#8884d8" dataKey="count" nameKey="category" > {displayData.map((entry, index) => ( <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> ))} </Pie> <Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px' }} formatter={(value: number) => [value.toLocaleString(), '발생 건수']} /> <Legend verticalAlign="bottom" height={36} formatter={(value) => { const item = displayData.find((d) => d.category === value); if (!item) return value; const total = displayData.reduce((sum, d) => sum + d.count, 0); const percentage = ((item.count / total) * 100).toFixed(1); return `${value} (${percentage}%)`; }} /> </RechartsPieChart> </ResponsiveContainer> </div> ); }
- 상위 7개 항목만 표시, 나머지는 "기타"로 통합
- 각 조각에 비율(%) 표시
- 일관된 색상 팔레트 사용
- 범례에 비율 정보 포함
6. 대시보드 페이지 구현
파일: app/dashboard/page.tsx
Next.js의 서버 컴포넌트를 활용하여 서버 사이드에서 데이터를 파싱하고 통계를 계산합니다.
import { parseCrimeData, calculateDashboardStats } from '@/lib/csvParser'; import SearchBar from '@/components/SearchBar'; import CrimeList from '@/components/CrimeList'; import BarChart from '@/components/charts/BarChart'; import LineChart from '@/components/charts/LineChart'; import PieChart from '@/components/charts/PieChart'; export default function Dashboard() { // 1. CSV 데이터 파싱 (서버 사이드) const records = parseCrimeData(); // 2. 대시보드 통계 계산 const stats = calculateDashboardStats(records); // 3. 검색용 범죄 목록 (중분류만) const crimeList = records.map((record) => record.categoryMiddle); // 4. 범죄 목록 데이터 (테이블용) const crimeListData = records.map((record) => ({ categoryMajor: record.categoryMajor, categoryMiddle: record.categoryMiddle, total: record.total, })); return ( <div className="bg-gray-50"> <section className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <h1 className="text-3xl font-bold text-gray-900 mb-8">대시보드</h1> {/* 통계 요약 카드 */} <div className="grid grid-cols-1 gap-4 mb-8 sm:grid-cols-3"> <div className="bg-white rounded-lg shadow-md p-6"> <h3 className="text-sm font-medium text-gray-500 mb-2"> 전체 범죄 건수 </h3> <p className="text-3xl font-bold text-gray-900"> {stats.totalCrimes.toLocaleString()} </p> </div> <div className="bg-white rounded-lg shadow-md p-6"> <h3 className="text-sm font-medium text-gray-500 mb-2"> 범죄 유형 수 </h3> <p className="text-3xl font-bold text-gray-900"> {records.length} </p> </div> <div className="bg-white rounded-lg shadow-md p-6"> <h3 className="text-sm font-medium text-gray-500 mb-2"> 범죄 대분류 수 </h3> <p className="text-3xl font-bold text-gray-900"> {stats.categoryMajorTotals.length} </p> </div> </div> {/* 검색 바 */} <div className="mb-8"> <SearchBar crimeList={crimeList} /> </div> {/* 차트 섹션 */} <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 mb-8"> {/* 요일별 발생 현황 (Bar Chart) */} <div className="bg-white rounded-lg shadow-md p-6"> <h2 className="text-xl font-semibold text-gray-800 mb-4"> 요일별 발생 현황 </h2> <div className="h-64"> <BarChart data={stats.dayOfWeekTotals} dataKey="count" xAxisKey="day" /> </div> </div> {/* 시간대별 발생 현황 (Line Chart) */} <div className="bg-white rounded-lg shadow-md p-6"> <h2 className="text-xl font-semibold text-gray-800 mb-4"> 시간대별 발생 현황 </h2> <div className="h-64"> <LineChart data={stats.timeSlotTotals} /> </div> </div> </div> {/* 범죄 대분류 비중 (Pie Chart) */} <div className="bg-white rounded-lg shadow-md p-6 mb-8"> <h2 className="text-xl font-semibold text-gray-800 mb-4"> 범죄 대분류 비중 </h2> <div className="h-96"> <PieChart data={stats.categoryMajorTotals} /> </div> </div> {/* 범죄 목록 테이블 */} <div className="mb-8"> <CrimeList crimes={crimeListData} /> </div> </section> </div> ); }
- 서버 컴포넌트로 데이터 로딩 (SEO 최적화 될듯)
- 타입 안전한 데이터 전달
- 반응형 레이아웃 (Tailwind CSS)
- 컴포넌트 분리로 코드 재사용성 향상
검색 바 컴포넌트
파일: components/SearchBar.tsx
클라이언트 사이드에서 동작하는 검색 기능을 별도 컴포넌트로 분리했습니다.
"use client"; import { useState, FormEvent, useRef, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; interface SearchBarProps { crimeList: string[]; } export default function SearchBar({ crimeList }: SearchBarProps) { const [searchQuery, setSearchQuery] = useState(""); const [showAutocomplete, setShowAutocomplete] = useState(false); const searchContainerRef = useRef<HTMLDivElement>(null); const router = useRouter(); // 검색어에 따라 범죄 목록 필터링 const filteredCrimeList = crimeList.filter((crime) => crime.includes(searchQuery) ); // 검색 폼 제출 핸들러 const handleSearch = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); if (!searchQuery.trim()) return; if (filteredCrimeList.length === 1) { router.push(`/detail/${encodeURIComponent(filteredCrimeList[0])}`); return; } if (filteredCrimeList.length > 1) { router.push(`/detail/${encodeURIComponent(filteredCrimeList[0])}`); return; } alert(`"${searchQuery}"에 대한 검색 결과가 없습니다.`); }; // 외부 클릭 시 자동완성 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node) ) { setShowAutocomplete(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, []); // ... 렌더링 로직 }
지난 글에서 자세히 설명했으므로 생략
범죄 목록 테이블 컴포넌트
파일: components/CrimeList.tsx
데이터를 테이블 형태로 표시하는 컴포넌트입니다.
"use client"; import Link from "next/link"; interface CrimeListProps { crimes: Array<{ categoryMajor: string; categoryMiddle: string; total: number; }>; searchQuery?: string; onSearchQueryChange?: (query: string) => void; } export default function CrimeList({ crimes, searchQuery = "" }: CrimeListProps) { // 검색어 필터링 const filteredCrimes = searchQuery ? crimes.filter((crime) => crime.categoryMiddle.includes(searchQuery) || crime.categoryMajor.includes(searchQuery) ) : crimes; return ( <div className="bg-white rounded-lg shadow-md p-6"> <div className="flex items-center justify-between mb-4"> <h2 className="text-xl font-semibold text-gray-900">범죄 목록</h2> {searchQuery && ( <span className="text-sm text-gray-600 font-medium"> {filteredCrimes.length}개 결과 </span> )} </div> <div className="overflow-x-auto"> <table className="min-w-full divide-y divide-gray-200"> <thead className="bg-gray-50"> <tr> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 대분류 </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 중분류 </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 발생 건수 </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 상세보기 </th> </tr> </thead> <tbody className="bg-white divide-y divide-gray-200"> {filteredCrimes.map((crime, index) => ( <tr key={`${crime.categoryMajor}-${crime.categoryMiddle}-${index}`} className="hover:bg-gray-50"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> {crime.categoryMajor} </td> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> {crime.categoryMiddle} </td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600"> {crime.total.toLocaleString()} </td> <td className="px-6 py-4 whitespace-nowrap text-sm"> <Link href={`/detail/${encodeURIComponent(crime.categoryMiddle)}`} className="text-gray-900 hover:text-gray-700 font-medium" > 보기 → </Link> </td> </tr> ))} </tbody> </table> </div> </div> ); }
7. 결과 및 마무리
완성된 기능
CSV 데이터 파싱
- EUC-KR → UTF-8 인코딩 변환
- 타입 안전한 데이터 구조로 변환
타입 시스템
- 모든 데이터 구조에 대한 타입 정의
- 컴파일 타임 타입 체크
데이터 시각화
- 요일별 발생 현황 (Bar Chart)
- 시간대별 발생 현황 (Line Chart)
- 범죄 대분류 비중 (Pie Chart)
대시보드 기능
- 통계 요약 카드
- 검색 기능 (자동완성)
- 범죄 목록 테이블
성능 최적화
- 서버 컴포넌트 사용으로 클라이언트 번들 크기 감소
- 데이터 파싱을 서버 사이드에서 수행
- 컴포넌트 분리로 코드 스플리팅
향후 개선 사항
- 차트 선택할 수 있게?
- 데이터 필터링 기능
- 시간 범위 선택 기능
- 데이터 내보내기 기능
- 등...
이번 프로젝트를 통해 CSV 데이터 파싱부터 타입 정의, 차트 시각화까지의 전체 프로세스를 경험했습니다. TypeScript의 타입 시스템을 과하다 싶을 정도로 활용하여 안전하고 유지보수하기 쉬운 코드를 작성하려고 노력은했습니다.
리차트또한 사용자가 보기쉽게 필수적인 기능 외에도 ux에 도움이 되는 기능은 다 넣은 것 같습니다.
이제는 csv파일이든 json이든 데이터를 제공받으면, 가공하고 시각화하는 정도는 충분히 할 수 있을 것 같습니다. 이번에 파싱하다 줄바꿈 때문에 해멘것도 잊지 않아야 할 것 같습니다.
다음에는 이제 디테일 페이지도 시각화를 완료하고자 합니다.
기말고사 1주일남았는데 좀만 더 화이팅합시다..