Files
sam-docs/plans/hr-api-react-sync-plan.md
hskwon 5d1190a0d3 docs: plans 폴더 추가 및 HR API 규칙 문서 정리
- plans/ 폴더 신규 생성 (개발 계획 임시 문서용)
- hr-api-react-sync-plan.md를 specs → plans로 이동
- INDEX.md 업데이트 (폴더 구조, 워크플로우)
- rules/ HR API 규칙 문서 추가 (employee, attendance, department-tree)
- pricing API 요청 문서 업데이트
2025-12-09 14:44:39 +09:00

16 KiB

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 응답

{
  "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 응답

{
  "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 응답

[
  {
    "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

/**
 * snake_case → camelCase 변환
 */
export function toCamelCase(str: string): string {
  return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}

/**
 * 객체 키를 camelCase로 변환 (재귀)
 */
export function transformKeys<T>(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<string, unknown> = {};
    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

import { transformKeys } from './index';
import type { Employee, EmployeeApiResponse } from '@/types/hr';

/**
 * API 응답 → React Employee 타입 변환
 */
export function transformEmployee(data: EmployeeApiResponse): Employee {
  const base = transformKeys<Record<string, unknown>>(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<string, string>;
    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<string, string>;
  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<string, string> = {
    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

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<string, string> = {
    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

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

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<string, unknown>) {
  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<string, unknown>) {
  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<string, unknown>) {
  const response = await apiClient.get('/v1/departments/tree', { params });
  return transformDepartmentTree(response.data.data);
}

Part 3: React 타입 정의

파일: react/src/types/hr.ts

// ============================================================
// 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<string, unknown> | 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<string, unknown> | 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 호출 코드를 래퍼 함수로 교체

백엔드 작업

  • Resource 클래스 생성취소 (기존 응답 유지)
  • Controller 수정취소
  • 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 활용)
  • 필요한 필드만 변환하는 최적화 가능

테스트 전략

// 변환 함수 단위 테스트
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();
  });
});