refactor: Medium 우선순위 코드 품질 개선

- API client: fetchFormData에 requireAuth 옵션 추가
- API client: HTTP 헬퍼 함수를 팩토리 함수로 통합 (api, authApi)
- useMediaQuery: 리스너 핸들러 useCallback 메모이제이션
- useCalendar: 반환 객체 useMemo 메모이제이션
- schedule.js: date.js의 extractDate/extractTime 재사용
- format.js: decodeHtmlEntities 순수 함수화 (SSR 호환)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 14:25:20 +09:00
parent 21639171e1
commit 116d41ff07
6 changed files with 115 additions and 74 deletions

View file

@ -640,12 +640,14 @@ closeConfirm: () => ...,
---
## 3. 중간 우선순위 (Medium)
## 3. 중간 우선순위 (Medium) ✅ 완료
### 3.1 API 에러 처리 불일치
### 3.1 API 에러 처리 불일치
**파일**: `api/client.js`
**상태**: ✅ 완료 - `fetchFormData``requireAuth` 옵션 추가
**문제**:
```javascript
// fetchAuthApi - 토큰 없으면 에러 발생
@ -688,10 +690,12 @@ export async function fetchFormData(endpoint, formData, method = 'POST', { requi
---
### 3.2 HTTP 헬퍼 함수 중복
### 3.2 HTTP 헬퍼 함수 중복
**파일**: `api/client.js`
**상태**: ✅ 완료 - `createMethodHelpers` 팩토리 함수로 통합, `api`/`authApi` 객체 export
**문제**:
```javascript
// 8개의 유사한 헬퍼 함수
@ -736,10 +740,12 @@ export const authApi = createMethodHelpers(fetchAuthApi);
---
### 3.3 useMediaQuery 리스너 메모이제이션
### 3.3 useMediaQuery 리스너 메모이제이션
**파일**: `hooks/useMediaQuery.js`
**상태**: ✅ 완료 - `useCallback`으로 핸들러 메모이제이션
**문제**:
```javascript
useEffect(() => {
@ -780,10 +786,12 @@ export function useMediaQuery(query) {
---
### 3.4 useCalendar 반환 객체 메모이제이션
### 3.4 useCalendar 반환 객체 메모이제이션
**파일**: `hooks/useCalendar.js`
**상태**: ✅ 완료 - `useMemo`로 반환 객체 메모이제이션
**문제**:
```javascript
return {
@ -825,10 +833,12 @@ return useMemo(() => ({
---
### 3.5 날짜/시간 추출 함수 중복
### 3.5 날짜/시간 추출 함수 중복
**파일**: `utils/date.js`, `utils/schedule.js`
**상태**: ✅ 완료 - `schedule.js`에서 `date.js``extractDate`, `extractTime` 재사용
**중복 함수**:
```javascript
// date.js
@ -858,10 +868,12 @@ export function getScheduleTime(schedule) {
---
### 3.6 decodeHtmlEntities DOM 조작
### 3.6 decodeHtmlEntities DOM 조작
**파일**: `utils/format.js`
**상태**: ✅ 완료 - 순수 함수로 변경 (정규식 + 매핑 객체), SSR 호환
**문제**:
```javascript
export function decodeHtmlEntities(text) {

View file

@ -87,11 +87,17 @@ export async function fetchAuthApi(endpoint, options = {}) {
* @param {string} endpoint - API 엔드포인트
* @param {FormData} formData - 전송할 FormData
* @param {string} method - HTTP 메서드 (기본: POST)
* @param {Object} options - 추가 옵션
* @param {boolean} options.requireAuth - 인증 필수 여부 (기본: true)
*/
export async function fetchFormData(endpoint, formData, method = 'POST') {
export async function fetchFormData(endpoint, formData, method = 'POST', { requireAuth = true } = {}) {
const token = useAuthStore.getState().token;
const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`;
if (requireAuth && !token) {
throw new ApiError('인증이 필요합니다.', 401);
}
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
@ -107,46 +113,43 @@ export async function fetchFormData(endpoint, formData, method = 'POST') {
}
/**
* GET 요청 헬퍼
* HTTP 메서드 헬퍼 생성기
*/
export const get = (endpoint) => fetchApi(endpoint);
export const authGet = (endpoint) => fetchAuthApi(endpoint);
/**
* POST 요청 헬퍼
*/
export const post = (endpoint, data) =>
fetchApi(endpoint, {
function createMethodHelpers(baseFetch) {
return {
get: (endpoint) => baseFetch(endpoint),
post: (endpoint, data) =>
baseFetch(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
export const authPost = (endpoint, data) =>
fetchAuthApi(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
/**
* PUT 요청 헬퍼
*/
export const put = (endpoint, data) =>
fetchApi(endpoint, {
}),
put: (endpoint, data) =>
baseFetch(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
export const authPut = (endpoint, data) =>
fetchAuthApi(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}),
del: (endpoint) => baseFetch(endpoint, { method: 'DELETE' }),
};
}
/**
* DELETE 요청 헬퍼
* 공개 API 헬퍼
* @example api.get('/albums'), api.post('/albums', data)
*/
export const del = (endpoint) =>
fetchApi(endpoint, { method: 'DELETE' });
export const api = createMethodHelpers(fetchApi);
export const authDel = (endpoint) =>
fetchAuthApi(endpoint, { method: 'DELETE' });
/**
* 인증 API 헬퍼
* @example authApi.get('/admin/stats'), authApi.post('/admin/schedules', data)
*/
export const authApi = createMethodHelpers(fetchAuthApi);
// 기존 호환성을 위한 개별 export (점진적 마이그레이션 후 삭제 예정)
export const get = api.get;
export const post = api.post;
export const put = api.put;
export const del = api.del;
export const authGet = authApi.get;
export const authPost = authApi.post;
export const authPut = authApi.put;
export const authDel = authApi.del;

View file

@ -89,7 +89,9 @@ export function useCalendar(initialDate = new Date()) {
[year, month]
);
return {
// 반환 객체 메모이제이션
return useMemo(
() => ({
...calendarData,
currentDate,
selectedDate,
@ -100,5 +102,17 @@ export function useCalendar(initialDate = new Date()) {
goToToday,
selectDate,
setSelectedDate,
};
}),
[
calendarData,
currentDate,
selectedDate,
canGoPrevMonth,
goToPrevMonth,
goToNextMonth,
goToMonth,
goToToday,
selectDate,
]
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
/**
* 미디어 쿼리
@ -13,14 +13,19 @@ export function useMediaQuery(query) {
return false;
});
// DOM 이벤트 리스너이므로 useEffect 사용 허용
// 핸들러 메모이제이션
const handler = useCallback((e) => {
setMatches(e.matches);
}, []);
// DOM 이벤트 리스너
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
setMatches(mediaQuery.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
}, [query, handler]);
return matches;
}

View file

@ -3,15 +3,30 @@
*/
/**
* HTML 엔티티 디코딩
* HTML 엔티티 매핑
*/
const HTML_ENTITIES = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
'&#x27;': "'",
'&nbsp;': ' ',
};
/**
* HTML 엔티티 디코딩 (순수 함수 - SSR 호환)
* @param {string} text - HTML 엔티티가 포함된 텍스트
* @returns {string} 디코딩된 텍스트
*/
export const decodeHtmlEntities = (text) => {
if (!text) return '';
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
return text.replace(
/&(?:amp|lt|gt|quot|#39|apos|#x27|nbsp);/g,
(match) => HTML_ENTITIES[match] || match
);
};
/**

View file

@ -1,6 +1,7 @@
/**
* 스케줄 관련 유틸리티 함수
*/
import { extractDate, extractTime } from './date';
/**
* 스케줄에서 카테고리 ID 추출
@ -32,9 +33,7 @@ export function getCategoryInfo(schedule) {
* @returns {string} YYYY-MM-DD 형식 날짜
*/
export function getScheduleDate(schedule) {
const datetime = schedule.datetime || schedule.date;
if (!datetime) return '';
return datetime.split(' ')[0].split('T')[0];
return schedule.date || extractDate(schedule.datetime);
}
/**
@ -46,14 +45,7 @@ export function getScheduleTime(schedule) {
if (schedule.time) {
return schedule.time.slice(0, 5);
}
const datetime = schedule.datetime;
if (datetime && (datetime.includes(' ') || datetime.includes('T'))) {
const timePart = datetime.includes(' ')
? datetime.split(' ')[1]
: datetime.split('T')[1];
return timePart?.slice(0, 5) || null;
}
return null;
return extractTime(schedule.datetime);
}
/**