5. 리팩터링의 기준?
2025년 12월 11일
정적 인터랙티브 대시보드
리팩터링의 기준
코드 작성이 완성되고 리팩터링을 할 요소들이 몇개 보였습니다.
1. 리팩터링 배경
초기 구현에서는 다음 문제가 있었습니다:
- 동일한 데이터 변환 로직이 여러 파일에 중복
- 반복되는 UI 패턴이 하드코딩되어 있음
발견된 문제점
문제 1: 데이터 포맷팅 로직 중복
- 시간대 순서 배열이 여러 파일에 중복
- 시간대 라벨 변환 로직이 여러 곳에 분산
- 데이터 변환 로직이 페이지 컴포넌트 내부에 포함
문제 2: UI 컴포넌트 중복
- 동일한 스타일의 카드가 반복
- 차트 래퍼 코드가 여러 곳에 중복
- 스타일 변경 시 여러 곳을 수정해야 함
2. 데이터 포맷팅 함수 통합
2.1 발견된 중복 코드
시간대 순서 배열 중복
// app/detail/[category_middle]/page.tsx (리팩터링 전) const timeSlotData: TimeSlotData[] = [ '0시00분-02시59분', '03시00분-05시59분', // ... 8개 항목 반복 ].map((slot) => ({ time: slot, count: crimeData.timeSlots[slot] || 0, })); // lib/csvParser.ts (리팩터링 전) const timeSlotTotals: TimeSlotData[] = [ '0시00분-02시59분', '03시00분-05시59분', // ... 같은 배열이 또 반복 ].map((slot) => ({ time: slot, count: timeSlotMap.get(slot) || 0, }));
동일한 배열이 여러 파일에 존재했습니다.
시간대 라벨 변환 로직 중복
// components/charts/BarChart.tsx (리팩터링 전) const formattedData = data.map((item) => { if (xAxisKey === 'time') { const timeLabel = timeItem.time .replace('시00분-', '-') .replace('시59분', '시') .split('-')[0] + '시'; return { ...item, timeLabel }; } return item; }); // app/detail/[category_middle]/page.tsx (리팩터링 전) const timeLabel = timeSlot.time .replace('시00분-', '-') .replace('시59분', '시');
같은 변환 로직이 여러 곳에 중복되었습니다.
2.2 리팩터링 실행
Step 1: 유틸 함수 파일 생성
lib/dataFormatters.ts 파일을 생성했습니다:
// lib/dataFormatters.ts import { CrimeRecord, TimeSlotData, DayOfWeekData, TimeSlot, DayOfWeek } from '@/types/crime'; // 상수 정의 - 한 곳에서 관리 export const TIME_SLOT_ORDER: 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 const DAY_ORDER: DayOfWeek[] = ['일', '월', '화', '수', '목', '금', '토'];
상수를 한 곳에서 관리하면 순서 변경도 한 곳만 수정하면 됩니다.
Step 2: 포맷팅 함수 추출
/** * 시간대 라벨을 단축 형식으로 변환 * @param timeSlot - 시간대 문자열 * @param short - true면 "0시" 형식, false면 "0시-3시" 형식 */ export function formatTimeSlotLabel(timeSlot: string, short: boolean = false): string { if (timeSlot === '미상') return timeSlot; let formatted = timeSlot .replace('시00분-', '-') .replace('시59분', '시'); if (short) { formatted = formatted.split('-')[0] + '시'; } return formatted; }
단일 책임 원칙, 하나의 함수가 하나의 변환만 담당합니다.
Step 3: 데이터 변환 함수 추출
/** * CrimeRecord를 TimeSlotData[]로 변환 * @param record - 범죄 데이터 레코드 * @param excludeUnknown - 미상 데이터 제외 여부 (기본: true) */ export function convertCrimeRecordToTimeSlotData( record: CrimeRecord, excludeUnknown: boolean = true ): TimeSlotData[] { const slots = excludeUnknown ? TIME_SLOT_ORDER : [...TIME_SLOT_ORDER, '미상' as TimeSlot]; return slots.map((slot) => ({ time: slot, count: record.timeSlots[slot] || 0, })); } /** * CrimeRecord를 DayOfWeekData[]로 변환 */ export function convertCrimeRecordToDayOfWeekData(record: CrimeRecord): DayOfWeekData[] { return DAY_ORDER.map((day) => ({ day, count: record.daysOfWeek[day] || 0, })); }
페이지 컴포넌트의 데이터 변환 로직을 유틸 함수로 분리했습니다.
Step 4: 기존 파일에서 사용하도록 변경
csvParser.ts 수정:
// 리팩터링 전 const timeSlotTotals: TimeSlotData[] = [ '0시00분-02시59분', // ... 하드코딩된 배열 ].map((slot) => ({ time: slot, count: timeSlotMap.get(slot) || 0, })); // 리팩터링 후 import { TIME_SLOT_ORDER, DAY_ORDER } from './dataFormatters'; const timeSlotTotals: TimeSlotData[] = TIME_SLOT_ORDER.map((slot) => ({ time: slot, count: timeSlotMap.get(slot) || 0, }));
차트 컴포넌트 수정:
// 리팩터링 전 (BarChart.tsx) const timeLabel = timeItem.time .replace('시00분-', '-') .replace('시59분', '시') .split('-')[0] + '시'; // 리팩터링 후 import { formatTimeSlotLabel } from '@/lib/dataFormatters'; const timeLabel = formatTimeSlotLabel(timeItem.time, true);
2.3 리팩터링 결과
- 중복 코드 제거: 시간대/요일 배열을 한 곳에서 관리
- 유지보수성 향상: 수정 지점 축소
- 타입 안전성: 타입 정의로 일관성 확보
- 가독성 향상: 페이지 컴포넌트가 간결해짐
- 등..
3. 2단계: UI 컴포넌트 분리
3.1 발견된 중복 UI 패턴
통계 카드 패턴 중복
dashboard/page.tsx에서:
// 반복되는 통계 카드 (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>
동일한 구조가 3번 반복되었습니다.
차트 래퍼 패턴 중복
dashboard/page.tsx와 detail/page.tsx에서:
// 반복되는 차트 래퍼 (여러 곳에서 사용) <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={data} /> </div> </div> <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={data} /> </div> </div>
같은 구조가 여러 페이지에 반복되었습니다.
3.2 리팩터링 실행
Step 1: StatCard 컴포넌트 생성
// components/StatCard.tsx interface StatCardProps { label: string; value: string | number; } export default function StatCard({ label, value }: StatCardProps) { // 숫자는 자동으로 천 단위 구분자 적용 const displayValue = typeof value === 'number' ? value.toLocaleString() : value; return ( <div className="bg-white rounded-lg shadow-md p-6"> <h3 className="text-sm font-medium text-gray-500 mb-2">{label}</h3> <p className="text-3xl font-bold text-gray-900">{displayValue}</p> </div> ); }
- Props로
label과value만 받음 - 숫자는 자동 포맷팅
- 스타일은 컴포넌트 내부에서 일괄 관리
Step 2: ChartCard 컴포넌트 생성
// components/ChartCard.tsx import { ReactNode } from 'react'; interface ChartCardProps { title: string; children: ReactNode; description?: string; variant?: 'default' | 'comparison'; } export default function ChartCard({ title, children, description, variant = 'default' }: ChartCardProps) { // variant에 따라 다른 스타일 적용 const containerClass = variant === 'comparison' ? 'bg-gray-50 rounded-lg p-6' // 비교 차트용 (회색 배경) : 'bg-white rounded-lg shadow-md p-6'; // 일반 차트용 return ( <div className={containerClass}> <h3 className="text-lg font-semibold text-gray-800 mb-4">{title}</h3> {description && ( <p className="text-sm text-gray-500 mb-4">{description}</p> )} {children} </div> ); }
children으로 차트 컴포넌트를 받아 유연하게 구성variant로 스타일 변형 지원description은 선택적
Step 3: 페이지에서 컴포넌트 사용
Dashboard 페이지 수정:
// 리팩터링 전 <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> // 리팩터링 후 <StatCard label="전체 범죄 건수" value={stats.totalCrimes} />
// 리팩터링 전 <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} /> </div> </div> // 리팩터링 후 <ChartCard title="요일별 발생 현황"> <div className="h-64"> <BarChart data={stats.dayOfWeekTotals} /> </div> </ChartCard>
Detail 페이지 수정:
// 비교 차트는 variant="comparison" 사용 <ChartCard title={`요일별 비교 (전체 평균 vs ${categoryName})`} description={`전체 범죄 평균과 ${categoryName} 범죄의 요일별 발생 패턴을 비교합니다.`} variant="comparison" > <div className="h-64"> <ComparisonChart data={dayOfWeekData} averageData={averageStats.dayOfWeekAverages} // ... /> </div> </ChartCard>
3.3 리팩터링 결과
- 코드 간결화: Dashboard 페이지가 93줄 → 77줄
- 일관성: 동일한 UI 패턴이 통일된 컴포넌트로 관리
- 재사용성: 다른 페이지에서도 쉽게 사용 가능
- 유지보수성: 스타일 변경 시 컴포넌트 한 곳만 수정
- 등....
4. 리팩터링 결과 및 개선 효과
4.1 코드 메트릭 비교
Before (리팩터링 전)
- 중복된 시간대 배열: 3곳
- 중복된 라벨 변환 로직: 4곳
- 하드코딩된 통계 카드: 3개
- 하드코딩된 차트 래퍼: 6개
- Dashboard 페이지 라인 수: ~93줄
After (리팩터링 후)
- 상수/함수 중앙화: 1곳 (
dataFormatters.ts) - 재사용 가능한 컴포넌트: 2개 (
StatCard,ChartCard) - Dashboard 페이지 라인 수: 77줄
4.2 개선된 점
1. 유지보수성
// 리팩터링 전: 시간대 순서를 바꾸려면 3곳을 수정해야 함 // 파일1.ts const timeSlots = ['0시00분-02시59분', '03시00분-05시59분', ...]; // 파일2.ts const timeSlots = ['0시00분-02시59분', '03시00분-05시59분', ...]; // 파일3.ts const timeSlots = ['0시00분-02시59분', '03시00분-05시59분', ...]; // 리팩터링 후: 한 곳만 수정하면 됨 // dataFormatters.ts export const TIME_SLOT_ORDER: TimeSlot[] = [ // 여기만 수정하면 모든 곳에 반영됨 ];
2. 타입 안전성
- 상수와 함수가 타입 정의와 연결되어 컴파일 타임에 오류 발견 가능
- 잘못된 사용 시 TypeScript가 경고
3. 가독성
// 리팩터링 전 const timeLabel = timeSlot.time .replace('시00분-', '-') .replace('시59분', '시') .split('-')[0] + '시'; // 리팩터링 후 const timeLabel = formatTimeSlotLabel(timeSlot.time, true);
함수 이름으로 의도가 명확해집니다.
4. 테스트 용이성
- 유틸 함수와 컴포넌트를 독립적으로 테스트 가능
- 로직과 UI가 분리되어 단위 테스트 작성이 쉬움
5. 결과
5.1 리팩터링 원칙
-
DRY (Don't Repeat Yourself)
- 동일한 로직은 한 곳에만 작성
- 상수는 중앙에서 관리
-
단일 책임 원칙
- 함수/컴포넌트는 하나의 책임만 가짐
formatTimeSlotLabel은 라벨 변환만 담당
-
재사용성 고려
- 3번 이상 반복되면 컴포넌트/함수 추출 고려
- Props를 통해 유연성 확보
5.2 리팩터링 시 주의사항
-
점진적 리팩터링
- 한 번에 모든 것을 바꾸지 말고 단계별 진행
- 각 단계에서 빌드/테스트 확인
-
타입 안전성 유지
- TypeScript 타입 정의 활용
- 리팩터링 후 타입 체크
-
기존 동작 보장
- 기능 변경 없이 구조만 개선
- 사용자 경험 변화 없음
5.3 최종 구조
프로젝트 구조
├── lib/
│ ├── csvParser.ts # 데이터 파싱
│ └── dataFormatters.ts # 데이터 포맷팅 유틸 (새로 추가)
├── components/
│ ├── charts/ # 차트 컴포넌트들
│ ├── StatCard.tsx # 통계 카드 (새로 추가)
│ ├── ChartCard.tsx # 차트 래퍼 (새로 추가)
│ ├── SearchBar.tsx
│ └── CrimeList.tsx
└── app/
├── dashboard/
└── detail/
리팩터링의 기준은 사람마다 다 다르다고 생각하고, 팀이 있다면 팀의 규칙이 있을 것이라고 생각합니다.. 다만 그 근거가 합리적이어야 할 것입니다.
나름의 이유가 있고, 어느정도 해결이 된 리팩터링이었다고 생각합니다.