Files
sam-react-prod/claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md
유병철 a5578bf669 feat: UniversalListPage 검색 기능 개선 및 리렌더링 버그 수정
- UniversalListPage 템플릿에 searchFilter, useClientSearch 지원 추가
- 검색 입력 시 리렌더링(포커스 유실) 버그 수정
- 29개 리스트 페이지에 searchFilter 함수 추가
- SiteBriefingListClient 누락된 searchFilter 추가
- IntegratedListTemplateV2 검색 로직 정리
- 검색 기능 수정내역 가이드 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:07 +09:00

5.3 KiB

UniversalListPage 검색창 리렌더링 문제 해결 가이드

문제 현상

  • 검색창에 글자 하나만 입력해도 전체 페이지가 리렌더링됨
  • 검색어가 초기화되거나 데이터가 새로고침됨
  • 정상적인 검색이 불가능함

원인 분석

핵심 차이점: clientSideFiltering

설정 동작 검색 시 fetchData 호출
clientSideFiltering: true 클라이언트에서 필터링 호출 안함
clientSideFiltering: false 서버에서 필터링 매번 호출

UniversalListPage 내부 코드 (index.tsx:298-305):

useEffect(() => {
  if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
    fetchData(isMobileAppend);
  }
}, [currentPage, searchValue, filters, activeTab, dateRangeKey]);

clientSideFiltering: false일 때 검색어(searchValue) 변경마다 fetchData가 호출됨.

무한 루프 발생 조건

  1. getList 내부에서 setState 호출

    // ❌ 잘못된 패턴
    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: 수주관리 패턴 (권장)

클라이언트 사이드 필터링으로 전환

// ===== 데이터 상태 (외부 관리) =====
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 호출 금지

// ✅ 올바른 패턴
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 의존성 최소화

// 상태에 의존하는 값들을 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