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:
유병철
2026-01-29 14:50:45 +09:00
parent 099700758c
commit a5578bf669
31 changed files with 570 additions and 79 deletions

View 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` 모듈 미발견 에러가 있으나 본 작업과 무관한 기존 이슈임.

View 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