Taeseong Blog

useReducer 익숙해지기

2025-08-21

이 글은 reducer 패턴에 대해 알고 있다는 전제를 두고 작성했습니다.

들어가며

리액트를 배우다 보면 useReducer 훅을 한 번쯤 보셨을 겁니다. 저도 학습은 했지만 실제로는 잘 쓰지 않았습니다. 대부분의 경우 useState만으로 충분했고, 더 복잡하면 전역 상태 관리 도구를 썼으니까요.

그런데 최근 회사에서 특정 화면을 개발하면서 useState만으로 상태를 관리했다가, 코드가 개판이 되어가는 경험을 했습니다. 그래서 useReducer로 리팩토링을 했고, 확실히 코드가 깔끔해지고 확장성도 좋아져 그 과정을 정리해 공유합니다.

주문 리스트
필터 모달

회사에서 작업 목록 화면을 개발했는데, 생각보다 관리해야 할 상태가 많았습니다. 특히 필터 모달만 보더라도 다양한 조건이 필요하고, 리스트 페이지 자체에서도 정렬과 페이지네이션을 위한 상태가 추가됩니다.

“이 많은 상태를 어떻게 효율적으로 관리할까?”라는 고민이 들었습니다.

useState로 관리하기

처음에는 무지성으로 setState로 관리를 하려했는데요, 금세 이런 꼴이 나더라고요.

export const useFilterState = () => {
  const [periodType, setPeriodType] = useState();
  const [sortBy, setSortBy] = useState();
  const [selectedStatus, setSelectedStatus] = useState();

  const [selectedCompanies, setSelectedCompanies] = useState<SelectedCompany[]>(/* ... */);
  const [selectedDate, setSelectedDate] = useState<DateRange | undefined>(/* ... */);

  const [selectedMaker, setSelectedMaker] = useState<SelectedMaker | null>(/* ... */);
  const [selectedModelInit, setSelectedModelInit] = useState<SelectedModelInit | null>(/* ... */);
  const [selectedModel, setSelectedModel] = useState<SelectedModel | null>(/* ... */);

  const [vehicleClassification, setVehicleClassification] = useState();

  // ... 실제론 더 있음 ^^*,,
};

더 큰 문제는 이 훅을 가져다 쓰는 컴포넌트입니다. 상태와 setter만으로 많은 코드 라인수를 차지하고 있죠.

function TaskFilterModal() {
  const {
    selectedStatus,
    companySearchTerm,
    selectedCompanies,
    selectedDate,
    selectedMaker,
    selectedModelInit,
    selectedModel,
    makerType,
    vehicleClassification,
    setSortBy,
    setExternalLinkStatus,
    setCpoType,
    setSelectedStatus,
    setCompanySearchTerm,
    setSelectedCompanies,
    setSelectedDate,
    setSelectedMaker,
    setSelectedModelInit,
    setSelectedModel,
    setMakerType,
    setVehicleClassification,
    // ... 실제론 더 있음 ^^*,,22
  } = useFilterState(filters, companies || []);

  return (
    <VehicleModelSection
      selectedMaker={selectedMaker}
      selectedModelInit={selectedModelInit}
      selectedModel={selectedModel}
      onMakerChange={setSelectedMaker}
      onModelInitChange={setSelectedModelInit}
      onModelChange={setSelectedModel}
    />
    // ... 다른 폼들
  );
}

상태도 많고, setter도 많고, 보는 순간 짜증이 나는 코드가 되어버리죠. 코드를 처음 보는 사람은 Alt+클릭으로 무한 포탈을 돌며 이해해야 하는 상황이죠. 그래서 reducer 패턴을 적용하기로 했습니다.

reducer 패턴 적용하기

reducer 패턴을 적용한 후에는 다음처럼 리팩토링 되었는데요.

export const InitialFilterState = {
  companySearchTerm: '',
  dateFilterType: undefined,
  status: [],
  selectedCompanies: [],
  selectedDate: undefined,
  selectedMaker: null,
  selectedModelInit: null,
  selectedModel: null,
  makerType: 'ALL',
  vehicleClassification: null,
  externalLinkStatus: ExternalLinkStatus.ALL,
  cpoType: CpoType.ALL,
  isRework: null,
  isPriority: null,
};

export function filterReducer(state: FilterState, action: Action): FilterState {
  switch (action.type) {
    case 'RESET':
      return { ...InitialFilterState, ...action.payload };
    case 'SET':
      return { ...state, ...action.payload };
    default:
      return state;
  }
}

export function useFilterState({ saved }: UseFilterStateArgs) {
  const initial = useMemo(() => saved ?? InitialFilterState, [saved]);
  const [state, dispatch] = useReducer(filterReducer, initial);

  return { state, dispatch };
}

사용하는 컴포넌트의 props도 전보다 명확해졌습니다.

<VehicleTypeSection
  vehicleClassification={state.vehicleClassification}
  onVehicleClassificationChange={(value) => {
    dispatch({ type: 'SET', payload: { vehicleClassification: value } });
  }}
  makerType={state.makerType}
  onMakerTypeChange={(value) => {
    dispatch({ type: 'SET', payload: { makerType: value } });
  }}
/>
// ... 다른 폼들

여기서 고민이 들더군요.

  • 지금처럼 하나의 SET 액션으로 통합할 것인가?
  • 아니면 액션을 세분화해서 관리할 것인가?

저는 액션을 세분화했을 때의 장점이 더 많다고 느껴 후자를 선택하였는데요, 코드 예시와 함께 이야기해보겠습니다.

  1. 타입스크립트 자동완성 지원

타입스크립트를 사용한다면 액션 타입을 세분화하는 것만으로도 DX(Developer Experience)가 확실히 좋아집니다.

예를 들어 dateFilterType을 바꾸는 액션을 dispatch한다고 할 때, dispatch({ type: "SET_DA..." })까지만 입력해도 SET_DATE_FILTER_TYPE 이 자동완성으로 뜹니다. IDE가 실수를 줄여주고, 작성 속도도 훨씬 빨라집니다.

  1. 코드 가독성 향상

세분화된 액션은 props만 보더라도 어떤 로직인지 바로 이해할 수 있다는 장점이 있습니다.

예를 들어, 선택된 회사를 추가하거나 제거하는 로직을 작성한다고 해봅시다.

(1) 하나의 SET 액션으로 관리하는 경우

<CompanySection
  selectedCompanies={state.selectedCompanies}
  onCompanySelect={(value) => {
    if (!state.selectedCompanies.some((company) => company.companyId === value.companyId)) {
      dispatch({
        type: 'SET',
        payload: {
          selectedCompanies: [...state.selectedCompanies, value],
        },
      });
    }
  }}
  onCompanyRemove={(value) => {
    dispatch({
      type: 'SET',
      payload: {
        selectedCompanies: state.selectedCompanies.filter((company) => company.companyId !== value),
      },
    });
  }}
/>

props만 보고는 어떤 동작을 하는지 바로 이해하기 어렵습니다. payload 안의 로직을 따라가면서 읽어야 하니까요.

(2) 액션을 세분화한 경우

<CompanySection
  onCompanySelect={(value) => {
    if (!state.selectedCompanies.some((company) => company.companyId === value.companyId)) {
      dispatch({
        type: 'ADD_SELECTED_COMPANIES',
        payload: value,
      });
    }
  }}
  onCompanyRemove={(value) => {
    dispatch({
      type: 'REMOVE_SELECTED_COMPANIES',
      payload: value,
    });
  }}
/>

이 방식은 액션 이름만 봐도 의도가 명확합니다. ADD_SELECTED_COMPANIES → 회사를 추가하는 액션 REMOVE_SELECTED_COMPANIES → 회사를 제거하는 액션

props만 훑어봐도 기능을 이해할 수 있고, 유지보수 시 로직을 빠르게 파악할 수 있습니다.

물론 reducer의 switch문이 길어지는 단점은 있습니다. 하지만 자동완성 지원, 가독성 향상, 명확한 의도 표현이라는 장점이 훨씬 큽니다. 작은 프로젝트에서는 큰 차이가 없어 보일 수 있지만, 팀 단위 개발에서는 진가가 드러납니다.

극단적으로 말하자면, 프론트엔드 개발을 모르는 사람이 봐도 읽을 수 있을테니까요.

나가며

아무튼 그렇습니다.