feat: UniversalListPage 검색 기능 개선 및 리렌더링 버그 수정
- UniversalListPage 템플릿에 searchFilter, useClientSearch 지원 추가 - 검색 입력 시 리렌더링(포커스 유실) 버그 수정 - 29개 리스트 페이지에 searchFilter 함수 추가 - SiteBriefingListClient 누락된 searchFilter 추가 - IntegratedListTemplateV2 검색 로직 정리 - 검색 기능 수정내역 가이드 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
80
claudedocs/guides/UniversalListPage-검색기능-수정내역.md
Normal file
80
claudedocs/guides/UniversalListPage-검색기능-수정내역.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# UniversalListPage 검색 기능 수정 내역
|
||||
|
||||
## 배경
|
||||
|
||||
UniversalListPage 템플릿을 사용하는 15개 리스트 페이지에서 검색 기능이 미작동하거나, 검색 시 리렌더링이 발생하는 문제가 있었음.
|
||||
|
||||
## 문제 분류
|
||||
|
||||
| 유형 | 페이지 수 | 설명 |
|
||||
|------|----------|------|
|
||||
| 검색 미작동 | 10개 | 검색어 입력해도 필터링 안됨 |
|
||||
| 검색 오류 | 1개 | 검색 시 에러 발생 |
|
||||
| 검색 시 리렌더링 | 4개 | 검색 입력 시 페이지 전체 리렌더링 |
|
||||
|
||||
## 대상 페이지 (15개)
|
||||
|
||||
| # | 페이지 | 패턴 | 증상 |
|
||||
|---|--------|------|------|
|
||||
| 1 | approval/inbox | B (externalPagination) | 검색 미작동 |
|
||||
| 2 | approval/reference | B | 검색 미작동 |
|
||||
| 3 | boards/free | B | 검색 미작동 |
|
||||
| 4 | boards/board_mjsgri54_1fmg | B | 검색 미작동 |
|
||||
| 5 | settings/accounts | A (fetchData) | 검색 미작동 |
|
||||
| 6 | sales/pricing-management | A | 검색 오류 |
|
||||
| 7 | production/work-orders | A | 리렌더링 |
|
||||
| 8 | production/work-results | A | 리렌더링 |
|
||||
| 9 | material/receiving-management | A | 리렌더링 |
|
||||
| 10 | outbound/shipments | A | 리렌더링 |
|
||||
| 11 | accounting/vendor-ledger | B | 검색 미작동 |
|
||||
| 12 | accounting/bills | A | 검색 미작동 |
|
||||
| 13 | accounting/bank-transactions | A | 검색 미작동 |
|
||||
| 14 | accounting/expected-expenses | A | 검색 미작동 |
|
||||
| 15 | payment-history | - | hideSearch인데 검색창 노출 |
|
||||
|
||||
## 수정 내용
|
||||
|
||||
### 1. UniversalListPage/index.tsx (핵심 템플릿)
|
||||
|
||||
**searchFilter 지원 추가** - 서버사이드 모드(`clientSideFiltering: false`)에서도 클라이언트 검색 가능하도록 `config.searchFilter` 함수 지원.
|
||||
|
||||
**fetchData API search 파라미터 버그 수정** - `useClientSearch` 모드일 때 API에 `search` 파라미터를 보내지 않도록 수정. API가 해당 필드 검색을 지원하지 않으면 0건 반환되어 클라이언트 필터링 자체가 불가능했음.
|
||||
|
||||
```tsx
|
||||
// 수정 전 (버그)
|
||||
search: debouncedSearchValue,
|
||||
|
||||
// 수정 후
|
||||
search: useClientSearch ? undefined : debouncedSearchValue,
|
||||
```
|
||||
|
||||
**config.onSearchChange 호출 지원** - Pattern B 컴포넌트의 `config.onSearchChange`가 호출되도록 useEffect 추가.
|
||||
|
||||
**hideSearch 완전 비활성화 로직** - `hideSearch: true`이면서 `onSearchChange`/`searchFilter`가 없는 컴포넌트는 검색을 완전 비활성화. 있는 컴포넌트는 기존대로 헤더 검색창 유지.
|
||||
|
||||
```tsx
|
||||
// hideSearch + onSearchChange/searchFilter 없음 → 검색 완전 숨김 (payment-history)
|
||||
// hideSearch + onSearchChange/searchFilter 있음 → 헤더 검색창 표시 (approval/inbox)
|
||||
searchValue={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : searchValue}
|
||||
onSearchChange={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : handleSearchChange}
|
||||
```
|
||||
|
||||
### 2. UniversalListPage/types.ts
|
||||
|
||||
`searchFilter` 타입 정의 추가: `(item: T, searchValue: string) => boolean`
|
||||
|
||||
### 3. 개별 컴포넌트 13개
|
||||
|
||||
각 페이지에 `searchFilter` 함수를 추가하여 어떤 필드를 검색 대상으로 할지 정의.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
15개 페이지 브라우저 직접 테스트 완료.
|
||||
|
||||
- 검색 필터링: 전체 정상 동작
|
||||
- 검색창 포커스: 입력 시 포커스 유지 (리렌더링 없음)
|
||||
- hideSearch 페이지: 검색창 정상 숨김
|
||||
|
||||
## 참고
|
||||
|
||||
- 빌드 시 `ReceivingDetail.tsx`에서 `SupplierSearchModal` 모듈 미발견 에러가 있으나 본 작업과 무관한 기존 이슈임.
|
||||
192
claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md
Normal file
192
claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# UniversalListPage 검색창 리렌더링 문제 해결 가이드
|
||||
|
||||
## 문제 현상
|
||||
- 검색창에 글자 하나만 입력해도 전체 페이지가 리렌더링됨
|
||||
- 검색어가 초기화되거나 데이터가 새로고침됨
|
||||
- 정상적인 검색이 불가능함
|
||||
|
||||
## 원인 분석
|
||||
|
||||
### 핵심 차이점: clientSideFiltering
|
||||
|
||||
| 설정 | 동작 | 검색 시 fetchData 호출 |
|
||||
|------|------|----------------------|
|
||||
| `clientSideFiltering: true` | 클라이언트에서 필터링 | ❌ 호출 안함 |
|
||||
| `clientSideFiltering: false` | 서버에서 필터링 | ✅ 매번 호출 |
|
||||
|
||||
**UniversalListPage 내부 코드 (index.tsx:298-305):**
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
|
||||
fetchData(isMobileAppend);
|
||||
}
|
||||
}, [currentPage, searchValue, filters, activeTab, dateRangeKey]);
|
||||
```
|
||||
|
||||
`clientSideFiltering: false`일 때 검색어(`searchValue`) 변경마다 `fetchData`가 호출됨.
|
||||
|
||||
### 무한 루프 발생 조건
|
||||
|
||||
1. **getList 내부에서 setState 호출**
|
||||
```javascript
|
||||
// ❌ 잘못된 패턴
|
||||
actions: {
|
||||
getList: async (params) => {
|
||||
const result = await getStocks(params);
|
||||
if (result.success) {
|
||||
setStockStats(result.data); // ← 상태 변경!
|
||||
setTotalItems(result.pagination.total); // ← 상태 변경!
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
2. **config가 useMemo로 감싸져 있고 상태 의존성이 있을 때**
|
||||
- getList에서 setState → 컴포넌트 리렌더링
|
||||
- stats/tableFooter useMemo 재평가
|
||||
- config useMemo 재평가 (stats 의존성)
|
||||
- UniversalListPage에 새 config 전달
|
||||
- dateRangeKey 재계산 → useEffect 트리거
|
||||
- fetchData 호출 → 무한 루프!
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 방법 1: 수주관리 패턴 (권장)
|
||||
|
||||
**클라이언트 사이드 필터링으로 전환**
|
||||
|
||||
```typescript
|
||||
// ===== 데이터 상태 (외부 관리) =====
|
||||
const [stocks, setStocks] = useState<StockItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({});
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const result = await getStocks({ page: 1, perPage: 9999 }); // 전체 로드
|
||||
if (result.success) {
|
||||
setStocks(result.data);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
const filteredStocks = stocks.filter((stock) => {
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
if (!stock.itemName.toLowerCase().includes(searchLower)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// config는 useMemo 없이 일반 객체로!
|
||||
const config: UniversalListConfig<StockItem> = {
|
||||
// ...
|
||||
clientSideFiltering: true, // ← 핵심!
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: filteredStocks, // ← 이미 필터링된 데이터
|
||||
totalCount: filteredStocks.length,
|
||||
}),
|
||||
},
|
||||
|
||||
searchFilter: (stock, searchValue) => {
|
||||
return stock.itemName.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
|
||||
customFilterFn: (items, fv) => {
|
||||
// 필터 로직
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={setFilterValues}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
### 방법 2: 서버 사이드 필터링 유지 (주의 필요)
|
||||
|
||||
**getList 내부에서 절대 setState 호출 금지**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
actions: {
|
||||
getList: async (params) => {
|
||||
const result = await getStocks(params);
|
||||
if (result.success) {
|
||||
// ❌ setStockStats, setTotalItems 호출 금지!
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**config useMemo 의존성 최소화**
|
||||
|
||||
```typescript
|
||||
// 상태에 의존하는 값들을 config 외부로 분리
|
||||
const config = useMemo(() => ({
|
||||
// 상태에 의존하지 않는 설정만 포함
|
||||
title: '목록',
|
||||
idField: 'id',
|
||||
clientSideFiltering: false,
|
||||
// ...
|
||||
}), []); // 빈 의존성 배열!
|
||||
|
||||
// 상태에 의존하는 설정은 별도로 전달
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
stats={stats} // 별도 prop으로 전달
|
||||
tableFooter={tableFooter} // 별도 prop으로 전달
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 문제 발생 시 확인 사항
|
||||
|
||||
- [ ] `clientSideFiltering` 값 확인
|
||||
- [ ] `getList` 내부에서 `setState` 호출 여부
|
||||
- [ ] config가 `useMemo`로 감싸져 있는지
|
||||
- [ ] useMemo 의존성에 상태값이 포함되어 있는지
|
||||
- [ ] `onSearchChange` 콜백이 상태를 업데이트하는지
|
||||
|
||||
### 권장 패턴 (수주관리 참고)
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/sales/order-management-sales/page.tsx
|
||||
```
|
||||
|
||||
- `clientSideFiltering: true`
|
||||
- config를 useMemo 없이 일반 객체로 정의
|
||||
- 외부에서 데이터 관리 (`useState`)
|
||||
- `initialData` prop으로 데이터 전달
|
||||
- `onSearchChange`, `onFilterChange` 콜백 사용
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `src/components/templates/UniversalListPage/index.tsx` - useEffect 의존성 확인 (Line 298-305)
|
||||
- `src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` - 정상 동작 패턴 참고
|
||||
|
||||
## 작성일
|
||||
2026-01-28
|
||||
Reference in New Issue
Block a user