# HR API - React 동기화 계획 > **작성일**: 2025-12-09 > **수정일**: 2025-12-09 > **목적**: API와 React 프론트엔드 간 데이터 타입 동기화 > **원칙**: **API snake_case 유지** - React에서 camelCase 변환 처리 --- ## 📋 작업 요약 | 영역 | 작업 | 수정 필요 | |------|------|----------| | **Employee API** | 기존 snake_case 유지 | ❌ 불필요 | | **Attendance API** | 기존 snake_case 유지 | ❌ 불필요 | | **Department Tree API** | 기존 snake_case 유지 | ❌ 불필요 | | **React 프론트엔드** | 변환 유틸리티 적용 | ✅ 프론트엔드 | --- # Part 1: 백엔드 (API) - 변경 없음 ## 설계 원칙 - **API 응답은 snake_case 유지** (Laravel 표준) - **json_extra, json_details 구조 그대로 유지** - **기존 API 클라이언트 호환성 보장** ## 현재 API 응답 구조 ### Employee API 응답 ```json { "id": 1, "tenant_id": 1, "user_id": 10, "department_id": 5, "position_key": "DEVELOPER", "employment_type_key": "REGULAR", "employee_status": "active", "profile_photo_path": null, "json_extra": { "employee_code": "EMP001", "resident_number": "******-*******", "gender": "male", "address": "서울시 강남구", "salary": 50000000, "hire_date": "2023-01-15", "rank": "대리", "bank_account": { "bank": "국민", "account": "123-456-789", "holder": "홍길동" } }, "user": { "id": 10, "name": "홍길동", "email": "hong@example.com", "phone": "010-1234-5678", "is_active": true }, "department": { "id": 5, "name": "개발팀" }, "created_at": "2023-01-15T09:00:00.000000Z", "updated_at": "2024-12-09T10:30:00.000000Z" } ``` ### Attendance API 응답 ```json { "id": 1, "tenant_id": 1, "user_id": 10, "base_date": "2024-12-09", "status": "onTime", "json_details": { "check_in": "09:00:00", "check_out": "18:00:00", "work_minutes": 540, "overtime_minutes": 60, "late_minutes": 0, "gps_data": { "check_in": { "lat": 37.5665, "lng": 126.9780 } } }, "remarks": null, "user": { "id": 10, "name": "홍길동", "email": "hong@example.com" }, "created_at": "2024-12-09T09:00:00.000000Z", "updated_at": "2024-12-09T18:00:00.000000Z" } ``` ### Department Tree API 응답 ```json [ { "id": 1, "tenant_id": 1, "parent_id": null, "code": "DEV", "name": "개발본부", "description": "개발 조직", "is_active": true, "sort_order": 1, "children": [ { "id": 2, "tenant_id": 1, "parent_id": 1, "code": "DEV-FE", "name": "프론트엔드팀", "is_active": true, "sort_order": 1, "children": [] } ] } ] ``` --- # Part 2: 프론트엔드 (React) 수정사항 ## 변환 전략 React 프론트엔드에서 API 응답을 받아 내부 타입으로 변환합니다. ### 변환 유틸리티 위치 ``` react/src/lib/ ├── api/ │ └── transformers/ │ ├── employee.ts # Employee 변환 │ ├── attendance.ts # Attendance 변환 │ ├── department.ts # Department 변환 │ └── index.ts # 공통 유틸리티 ``` ## 1. 공통 변환 유틸리티 **파일**: `react/src/lib/api/transformers/index.ts` ```typescript /** * snake_case → camelCase 변환 */ export function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); } /** * 객체 키를 camelCase로 변환 (재귀) */ export function transformKeys(obj: unknown): T { if (obj === null || obj === undefined) { return obj as T; } if (Array.isArray(obj)) { return obj.map(item => transformKeys(item)) as T; } if (typeof obj === 'object') { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { const camelKey = toCamelCase(key); result[camelKey] = transformKeys(value); } return result as T; } return obj as T; } /** * ISO 문자열을 Date로 변환 */ export function parseDate(dateStr: string | null): Date | null { if (!dateStr) return null; return new Date(dateStr); } ``` ## 2. Employee 변환 **파일**: `react/src/lib/api/transformers/employee.ts` ```typescript import { transformKeys } from './index'; import type { Employee, EmployeeApiResponse } from '@/types/hr'; /** * API 응답 → React Employee 타입 변환 */ export function transformEmployee(data: EmployeeApiResponse): Employee { const base = transformKeys>(data); const jsonExtra = data.json_extra ?? {}; return { id: String(data.id), name: data.user?.name ?? '', email: data.user?.email ?? '', phone: data.user?.phone ?? null, residentNumber: jsonExtra.resident_number ?? null, salary: jsonExtra.salary ?? null, profileImage: data.profile_photo_path ?? null, employeeCode: jsonExtra.employee_code ?? null, gender: jsonExtra.gender ?? null, address: transformAddress(jsonExtra.address), bankAccount: transformBankAccount(jsonExtra.bank_account), hireDate: jsonExtra.hire_date ?? null, employmentType: mapEmploymentType(data.employment_type_key), rank: jsonExtra.rank ?? null, status: data.employee_status ?? 'active', departmentPositions: buildDepartmentPositions(data), userInfo: buildUserInfo(data), createdAt: data.created_at ?? null, updatedAt: data.updated_at ?? null, }; } function transformAddress(address: unknown): Employee['address'] { if (!address) return null; if (typeof address === 'string') { return { zipCode: '', address1: address, address2: '', }; } if (typeof address === 'object') { const addr = address as Record; return { zipCode: addr.zip_code ?? addr.zipCode ?? '', address1: addr.address1 ?? addr.address_1 ?? '', address2: addr.address2 ?? addr.address_2 ?? '', }; } return null; } function transformBankAccount(bankAccount: unknown): Employee['bankAccount'] { if (!bankAccount || typeof bankAccount !== 'object') return null; const ba = bankAccount as Record; return { bankName: ba.bank ?? ba.bankName ?? '', accountNumber: ba.account ?? ba.accountNumber ?? '', accountHolder: ba.holder ?? ba.accountHolder ?? '', }; } function mapEmploymentType(key: string | null): string | null { if (!key) return null; const map: Record = { REGULAR: 'regular', CONTRACT: 'contract', PARTTIME: 'parttime', INTERN: 'intern', }; return map[key] ?? key.toLowerCase(); } function buildDepartmentPositions(data: EmployeeApiResponse): Employee['departmentPositions'] { if (!data.department_id) return []; return [{ id: String(data.id), departmentId: String(data.department_id), departmentName: data.department?.name ?? '', positionId: data.position_key ?? '', positionName: data.position_key ?? '', }]; } function buildUserInfo(data: EmployeeApiResponse): Employee['userInfo'] { if (!data.user) return null; return { userId: data.user.user_id ?? data.user.email, role: 'user', // TODO: 실제 역할 정보 accountStatus: data.user.is_active ? 'active' : 'inactive', }; } /** * Employee 목록 변환 */ export function transformEmployeeList(data: EmployeeApiResponse[]): Employee[] { return data.map(transformEmployee); } ``` ## 3. Attendance 변환 **파일**: `react/src/lib/api/transformers/attendance.ts` ```typescript import type { Attendance, AttendanceApiResponse } from '@/types/hr'; /** * API 응답 → React Attendance 타입 변환 */ export function transformAttendance(data: AttendanceApiResponse): Attendance { const jsonDetails = data.json_details ?? {}; return { id: String(data.id), employeeId: String(data.user_id), employeeName: data.user?.name ?? '', department: '', // TODO: user.tenantProfile.department.name position: '', // TODO: user.tenantProfile.position_key rank: '', // TODO: user.tenantProfile.json_extra.rank baseDate: data.base_date, checkIn: jsonDetails.check_in ?? null, checkOut: jsonDetails.check_out ?? null, breakTime: jsonDetails.break_time ?? null, overtimeHours: formatOvertimeHours(jsonDetails.overtime_minutes), reason: buildReason(data), status: data.status, createdAt: data.created_at ?? null, updatedAt: data.updated_at ?? null, }; } function formatOvertimeHours(minutes: number | undefined): string | null { if (minutes === undefined || minutes === null) return null; const hours = Math.floor(minutes / 60); const mins = minutes % 60; return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`; } function buildReason(data: AttendanceApiResponse): Attendance['reason'] { if (!data.remarks) return null; const typeMap: Record = { vacation: 'vacationRequest', businessTrip: 'businessTripRequest', fieldWork: 'fieldWorkRequest', overtime: 'overtimeRequest', }; return { type: typeMap[data.status] ?? 'vacationRequest', label: data.remarks, documentId: null, }; } /** * Attendance 목록 변환 */ export function transformAttendanceList(data: AttendanceApiResponse[]): Attendance[] { return data.map(transformAttendance); } ``` ## 4. Department Tree 변환 **파일**: `react/src/lib/api/transformers/department.ts` ```typescript import type { DepartmentNode, DepartmentApiResponse } from '@/types/hr'; /** * API 응답 → React Department 타입 변환 (재귀) */ export function transformDepartmentTree( data: DepartmentApiResponse[], depth: number = 0 ): DepartmentNode[] { return data.map(dept => ({ id: dept.id, name: dept.name, parentId: dept.parent_id, depth: depth, children: dept.children ? transformDepartmentTree(dept.children, depth + 1) : [], })); } ``` ## 5. API 호출 래퍼 **파일**: `react/src/lib/api/hr.ts` ```typescript import { apiClient } from '@/lib/api/client'; import { transformEmployee, transformEmployeeList } from './transformers/employee'; import { transformAttendance, transformAttendanceList } from './transformers/attendance'; import { transformDepartmentTree } from './transformers/department'; // Employee API export async function getEmployees(params?: Record) { const response = await apiClient.get('/v1/employees', { params }); return transformEmployeeList(response.data.data); } export async function getEmployee(id: string) { const response = await apiClient.get(`/v1/employees/${id}`); return transformEmployee(response.data.data); } // Attendance API export async function getAttendances(params?: Record) { const response = await apiClient.get('/v1/attendances', { params }); return transformAttendanceList(response.data.data); } export async function getAttendance(id: string) { const response = await apiClient.get(`/v1/attendances/${id}`); return transformAttendance(response.data.data); } // Department API export async function getDepartmentTree(params?: Record) { const response = await apiClient.get('/v1/departments/tree', { params }); return transformDepartmentTree(response.data.data); } ``` --- # Part 3: React 타입 정의 **파일**: `react/src/types/hr.ts` ```typescript // ============================================================ // React 내부 타입 (camelCase) // ============================================================ export interface Employee { id: string; name: string; email: string; phone: string | null; residentNumber: string | null; salary: number | null; profileImage: string | null; employeeCode: string | null; gender: 'male' | 'female' | null; address: { zipCode: string; address1: string; address2: string; } | null; bankAccount: { bankName: string; accountNumber: string; accountHolder: string; } | null; hireDate: string | null; employmentType: 'regular' | 'contract' | 'parttime' | 'intern' | null; rank: string | null; status: 'active' | 'leave' | 'resigned'; departmentPositions: { id: string; departmentId: string; departmentName: string; positionId: string; positionName: string; }[]; userInfo: { userId: string; role: string; accountStatus: 'active' | 'inactive'; } | null; createdAt: string | null; updatedAt: string | null; } export interface Attendance { id: string; employeeId: string; employeeName: string; department: string; position: string; rank: string; baseDate: string; checkIn: string | null; checkOut: string | null; breakTime: string | null; overtimeHours: string | null; reason: { type: 'vacationRequest' | 'businessTripRequest' | 'fieldWorkRequest' | 'overtimeRequest'; label: string; documentId: string | null; } | null; status: string; createdAt: string | null; updatedAt: string | null; } export interface DepartmentNode { id: number; name: string; parentId: number | null; depth: number; children: DepartmentNode[]; } // ============================================================ // API 응답 타입 (snake_case) // ============================================================ export interface EmployeeApiResponse { id: number; tenant_id: number; user_id: number; department_id: number | null; position_key: string | null; employment_type_key: string | null; employee_status: string; profile_photo_path: string | null; json_extra: Record | null; user: { id: number; user_id?: string; name: string; email: string; phone: string | null; is_active: boolean; } | null; department: { id: number; name: string; } | null; created_at: string | null; updated_at: string | null; } export interface AttendanceApiResponse { id: number; tenant_id: number; user_id: number; base_date: string; status: string; json_details: Record | null; remarks: string | null; user: { id: number; name: string; email: string; } | null; created_at: string | null; updated_at: string | null; } export interface DepartmentApiResponse { id: number; tenant_id: number; parent_id: number | null; code: string | null; name: string; description: string | null; is_active: boolean; sort_order: number; children: DepartmentApiResponse[] | null; } ``` --- # Part 4: 작업 체크리스트 ## 프론트엔드 작업 - [ ] `react/src/lib/api/transformers/index.ts` - 공통 변환 유틸리티 - [ ] `react/src/lib/api/transformers/employee.ts` - Employee 변환 - [ ] `react/src/lib/api/transformers/attendance.ts` - Attendance 변환 - [ ] `react/src/lib/api/transformers/department.ts` - Department 변환 - [ ] `react/src/lib/api/hr.ts` - API 호출 래퍼 - [ ] `react/src/types/hr.ts` - 타입 정의 (내부용 + API 응답용) - [ ] 기존 API 호출 코드를 래퍼 함수로 교체 ## 백엔드 작업 - [x] ~~Resource 클래스 생성~~ → **취소** (기존 응답 유지) - [x] ~~Controller 수정~~ → **취소** - [x] ~~Service 수정~~ → **취소** --- # Part 5: 장단점 비교 ## 현재 접근법 (React 변환) **장점:** - API 하위 호환성 유지 (기존 클라이언트 영향 없음) - Laravel 표준 컨벤션 유지 (snake_case) - 백엔드 변경 불필요 **단점:** - React에서 변환 로직 필요 - 타입 이중 정의 (API 타입 + 내부 타입) ## 대안 접근법 (API camelCase 변환) **장점:** - React에서 변환 불필요 - 프론트엔드 코드 단순화 **단점:** - 기존 API 클라이언트 호환성 깨짐 - Laravel 표준과 불일치 - Resource 클래스 추가 유지보수 --- # Part 6: 참고 사항 ## 변환 시점 1. **API 호출 직후**: `transformXxx()` 함수로 즉시 변환 2. **React Query/SWR 사용 시**: fetcher 함수 내에서 변환 3. **Zustand/Redux 사용 시**: store에 저장 전 변환 ## 성능 고려 - 대량 데이터 변환 시 Web Worker 고려 - 변환 결과 캐싱 (React Query의 staleTime 활용) - 필요한 필드만 변환하는 최적화 가능 ## 테스트 전략 ```typescript // 변환 함수 단위 테스트 describe('transformEmployee', () => { it('should transform snake_case to camelCase', () => { const apiResponse = { employee_status: 'active' }; const result = transformEmployee(apiResponse); expect(result.status).toBe('active'); }); it('should handle null json_extra', () => { const apiResponse = { json_extra: null }; const result = transformEmployee(apiResponse); expect(result.address).toBeNull(); }); }); ```