React에서 검색 폼과 리스트 렌더링 최적화하기

React에서 검색 조건이 있는 리스트 화면을 구현할 때, 입력값 변경마다 API가 호출되는 문제를 방지하고 검색 버튼 클릭 시에만 요청하도록 설계하는 방법을 살펴봅니다.

문제 상황

검색 조건을 상태로 관리하고 useEffect로 감시하면 입력값이 바뀔 때마다 불필요한 API 호출이 발생합니다.

const [filterCriteria, setFilterCriteria] = useState({
  keyword: '',
  categoryCode: '',
});
const [resultItems, setResultItems] = useState([]);

const fetchItems = async () => {
  const response = await api.fetchList(filterCriteria);
  setResultItems(response.data);
};

// ❌ filterCriteria 변경 시다 호출됨
useEffect(() => {
  fetchItems();
}, [filterCriteria]);

해결 방안: 검색 조건 분리

입력용 상태와 실제 검색에 사용되는 상태를 분리합니다. 사용자가 입력하는 동안은 임시 상태에 저장하고, 검색 버튼 클릭 시에만 실제 검색 상태를 갱신합니다.

const SearchableList = () => {
  // 입력 필드용 임시 상태
  const [draftFilters, setDraftFilters] = useState({
    keyword: '',
    categoryCode: '',
  });
  
  // 실제 API 호출에 사용되는 상태
  const [activeFilters, setActiveFilters] = useState({
    keyword: '',
    categoryCode: '',
  });
  
  const [items, setItems] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  // activeFilters 변경 시에만 API 호출
  useEffect(() => {
    loadData();
  }, [activeFilters]);

  const loadData = async () => {
    setIsLoading(true);
    try {
      const result = await api.fetchList(activeFilters);
      setItems(result.data);
    } finally {
      setIsLoading(false);
    }
  };

  // 입력값 변경 (API 호출 없음)
  const updateKeyword = (val) => {
    setDraftFilters(prev => ({ ...prev, keyword: val }));
  };

  const updateCategory = (val) => {
    setDraftFilters(prev => ({ ...prev, categoryCode: val }));
  };

  // 검색 버튼 클릭 시 activeFilters 동기화
  const executeSearch = () => {
    setActiveFilters(draftFilters);
  };

  // 초기화: 입력값과 검색 조건 모두 리셋 후 API 호출
  const clearAndReload = () => {
    const emptyState = { keyword: '', categoryCode: '' };
    setDraftFilters(emptyState);
    setActiveFilters(emptyState); // 상태 변경으로 useEffect 트리거
  };

  return (
    <div>
      <input 
        value={draftFilters.keyword} 
        onChange={e => updateKeyword(e.target.value)} 
        placeholder="검색어"
      />
      <select 
        value={draftFilters.categoryCode}
        onChange={e => updateCategory(e.target.value)}
      >
        <option value="">전체</option>
        <option value="A">카테고리 A</option>
        <option value="B">카테고리 B</option>
      </select>
      <button onClick={executeSearch}>검색</button>
      <button onClick={clearAndReload}>초기화</button>
      
      {isLoading ? <p>로딩 중...</p> : (
        <ul>
          {items.map(item => <li key={item.id}>{item.name}</li>)}
        </ul>
      )}
    </div>
  );
};

커스텀 훅으로 추상화

재사용 가능한 형태로 로직을 분리합니다.

const useSearchableList = (fetcher) => {
  const [draft, setDraft] = useState({});
  const [query, setQuery] = useState({});
  const [data, setData] = useState([]);
  const [pending, setPending] = useState(false);

  useEffect(() => {
    let cancelled = false;
    setPending(true);
    fetcher(query).then(res => {
      if (!cancelled) setData(res);
    }).finally(() => {
      if (!cancelled) setPending(false);
    });
    return () => { cancelled = true; };
  }, [query]);

  const applySearch = () => setQuery(draft);
  
  const resetAll = () => {
    const empty = {};
    setDraft(empty);
    setQuery(empty);
  };

  const updateField = (key, value) => {
    setDraft(prev => ({ ...prev, [key]: value }));
  };

  return {
    draft,
    data,
    pending,
    updateField,
    applySearch,
    resetAll,
  };
};

// 사용 예시
const ProductList = () => {
  const {
    draft, data, pending,
    updateField, applySearch, resetAll
  } = useSearchableList(api.getProducts);

  return (
    <>
      <input 
        value={draft.productName || ''} 
        onChange={e => updateField('productName', e.target.value)}
      />
      <button onClick={applySearch}>조회</button>
      <button onClick={resetAll}>리셋</button>
      {pending ? <Spinner /> : <Table rows={data} />}
    </>
  );
};

핵심 정리

  • 상태 분리: 입력 상태(query string)와 검색 실행 상태를 별도로 관리
  • 명시적 트리거: 버튼 클릭으로만 검색 상태 변경 → 자연스럽게 API 호출 제어
  • 초기화 처리: 빈 객체로 양쪽 상태 동시 변경, useEffect로 일관된 흐름 유지
  • 불필요한 플래그 제거: 복잡한 reset 플래그 패턴 없이 단순한 상태 흐름으로 해결

태그: React useEffect State Management Custom Hook API Integration

7월 20일 04:30에 게시됨