useReducer를 이용해 복잡한 조건 필터링을 개선해보자🌀

gydotb·2025년 1월 17일
2

troubleshooting

목록 보기
1/2
post-thumbnail

들어가기 전에,

🔍 프로젝트 소개

유저가 선택한 조건에 따라 환경 데이터를 바탕으로 그래프를 그려주는 페이지를 구현하는 과정으로, 자세한 요구 사항은 아래와 같았다.

요구사항 / 구현 사항

  • time을 기준으로 환경 데이터를 받아 그래프를 표현할 것. 이 때, x축은 시간, y축은 각 데이터 칼럼의 값이다.
  • 사용자로 하여금 해당 조건들 각각에 대한 on-off view가 가능해야 함.
    (ex. A조건 포함, B조건 미포함 등 조건 선택이 가능해야 함)

개발 진행 과정

해당 개선 사항의 경우 점진적으로 trouble shooting을 진행했을 뿐더러 이런 개발 과정에서 기획 측면에서의 사용자 최적화된 그래프를 제공하는 과정을 거쳤기 때문에 시간의 흐름에 따라 단계별로 나누어 문제점 및 해결방안을 정리하였다.

결론만 보고 싶은 분들은 4차 : dropdown 말고, on-off로 바로 반영할 순 없을까? 여기로 가면 됩니다. 하지만 그냥 뻘짓 바라보는 기분으로 한 번 읽어주셔도 재밌을 겁니다…

1차: 그래프 표기 조건을 선택하는 건 http 요청이 필요하지 않아.

API를 통해 넘겨주어야 하는 조건의 경우, 해당 프로젝트에서는 시간 조건에 해당했다.

🕹️ API condition과 Render condition을 분리해서, 조건을 바꿀 때는 http 요청이 없도록 만들자!

정리하자면, 기존에는 filter라는 변수에 API 조건과 렌더 조건을 모두 포함시켰으나 이를 각각에 대해 filter, condFilter로 분리하여 state를 관리하는 것으로 변경하였다.

이러한 분리 과정을 통해 filter가 변경되면 API를 새롭게 요청하지만, condFilter의 경우에는 기존에 요청한 데이터를 가지고 데이터 칼럼만을 선택, UI만 리렌더시키고 API 요청 과정은 생략하도록 작성하였다.

type

type filterT = {
	period : string;
	timeUnit: Option; //{label, value}
	startDate: string;
	endDate: string;
}

type CondFilterT = {
	[조건 대분류명을 key 값으로 가짐] : Option[] // {label:string, value:string}[]
}

state

const [filter, setFilter] = useState<filterT>();
const [condFilter, setCondFilter] = useState<CondFilterT>();

⭐️ Result

http 요청에 있어 유의미한 개선 사항이 있었다. 기존에는 렌더링 조건 변경 시에도 모두 http 요청을 보냈다면, 이제는 오직 API 조건 변경 요청 시에만 http 요청을 보낸다.



2차 : 기존 그래프 분리가 필요해!

1차 리팩토링을 통해 http 요청 측면에서의 기능은 개선되었으나, 렌더링 조건을 통해 필터링한 데이터 각각에 대한 range가 너무 달라 사용자 입장에서 확인하기 어렵다는 피드백이 제기되었다. 대략 (-10, 30)과 (0, 3000), (0, 100) 등의 범위를 가진 그래프가 공존하고 있었기 때문에 해당 부분을 분명히 수정할 필요가 있었다.

% 값(0~100)을 가지는 조건에 대한 그래프와 다른 그래프(multi y-axis) 2개로 분리하는 방법도 시도해보았으나 이 방법 역시 categorizing이 제대로 되지 않는 등의 문제가 발생하였다.

그래서 이번에는 이렇게 수정해보기로 했다.

👾 기존 1개(or 2개)의 그래프를 range 혹은 분류에 따라 6개로 분리하면 어떨까?

기존의 대분류는 2개(outerCond, innerCond)에 지나지 않았는데, 이를 분리하여 기존의 condFilter state 내에 대분류를 6개로 만들고 조건을 선택하는 모달 내에서 6개의 state를 생성했다.

// 수정된 CondFilter의 type
type CondFilterT = {
	 temperCond: Option[];
	 radiCond: Option[];
	 humCond: Option[];
	 extCond: Option[];
	 humLackCond: Option[];
	 CO2Cond: Option[];
}
// Modal.tsx
const [temperCond, setTemperCond] = useState<Option[]>(condFilter.temperCond); 
const [radiCond, setRadiCond] = useState<Option[]>(condFilter.radiCond);
const [humCond, setHumCond] = useState<Option[]>(condFilter.humCond);
const [extCond, setExtCond] = useState<Option[]>(condFilter.extCond);
const [humlackCond, setHumLackCond] = useState<boolean>(
  condFilter.humlackCond ? true : false,
);
const [CO2Cond, setCO2Cond] = useState<boolean>(condFilter.CO2Cond ? true : false);

모달 내에서 6개의 state로 새롭게 분리된 이유는 제출 버튼을 눌렀을 때만 렌더를 발생시키기 위함이었다.

만일 condFilterT state를 그대로 사용한다면…?

condFilter가 선언된 컴포넌트는 (Layout, Routes 등의 컴포넌트를 제외하고) 해당 페이지의 거의 최상위 컴포넌트에 해당했다. 따라서 조건 컨트롤부(현재 모달) 및 각종 description UI, 그리고 그래프 컴포넌트까지 모두 해당 컴포넌트에 종속되어 있었기 때문에 모달 내에서 condFilter를 수정하는 것은 나머지 모든 컴포넌트를 렌더링 시키게 된다. 이를 원하지 않았기 때문에 별도로 state를 관리하게 되었다.

물론 memoization을 이용하면 또 다른 말이지만… 그 부분 역시 문제가 있어 일차적으로 부모 컴포넌트로부터의 렌더링 방지부터 시작했다.

⭐️ Result

6개의 그래프를 이용했을 때 훨씬 더 좋은 인사이트를 얻었다는 피드백을 받았다. 하지만 이 코드에도 문제점이 산발했으니, 그 중에서도 가장 큰 문제점은…



3차 : useState로 모두 처리하니 상태가 너무 많고 복잡해😵‍💫

state 6개만으로도 사실 좀 어지러운데, 실제로 handler 함수를 보면 정신 나가기 직전이었다. 심지어 humLackCondCO2Cond는 다른 state와는 다르게 boolean 값을 가지고 있었고, 이외에도 관리해야 하는 state가 3개가 넘었다.

핸들러는 둘째치고 이쯤 되니 submit 함수에서 조건문 안 조건문 안 조건문 안… 이 상태가 반복되었다. 아 진짜 누가 코드 이딴 식으로 짜지 ㅋㅋ

급하게 많은 state 관리하기, 복잡한 state 최적화 등의 검색어로 검색해본 결과,

🥨 useReducer라는게 있다던데… 이걸 이용하면?

여러 글을 읽어보며 느낀 개인적 감상은 useReducer는 많은 이벤트 핸들러 + 복잡한 useState 구조일 때 이용하면 효과가 좋은 느낌이라 생각했다. 물론 그 이벤트 핸들러가 어느 정도 통합 가능해야 하고, state 구조도 어느 정도 정형화되어 있어야겠지만…

실제로 아래에 적겠지만, useReducer를 사용하면 기존 핸들러를 획기적으로 통합하여 제공할 수 있었고, state 구조도 통일성이 높아 적용하기에는 최적의 코드이자 로직이라고 생각했다.

reducer function

참고로 dropdown selection을 통해 조건을 제공하고 있어 그 방식에 최적화시켰다.

/**
 * 그래프 렌더 조건을 관리하는 reducer 함수.
 * @param {condFilterT} filters
 * @param {actionT<MultiValue<Option>>} action
 * @returns {condFilterT}
 */
export const condFilterReducer = (
  filters: condFilterT[],
  action: actionT<MultiValue<Option>>,
): condFilterT[] => {
  switch (action.type) {
    case "mutivalue-option":
      return {
        ...filters,
        [action.payload.key]: action.payload.value as Option[],
      };
    default:
      return initialCondFilter;
  }
};

// 중략

다중 선택 부분을 multivalue-option이라는 type으로 설정했고, payload에 변형하고 싶은 key(위의 temperCond, radiCond 등)를 전달하여 해당 부분만 수정이 가능하도록 하였다.

⭐️ Result

우선 코드량이 확실히 감소했고, useReducer의 도입을 통해 UI와 로직의 분리가 어느 정도 가능했다. (사실 아직도 어디까지 분리시키는게 맞는지에 대해서는 잘 모르겠다… 여전히 공부 중인 부분) 그리고 state를 또 state로 재선언한다는 점이 걸렸는데, 이 부분이 해결되어 좋았다. 다만 이렇게 작동하게 된다면, 모든 condition state의 변경이 일어나게 되는데 여기서 한 가지 아이디어가 떠올랐다.



4차 : dropdown 말고, on-off로 바로 반영할 순 없을까?

dropdown을 도입하고 모달을 작성했던 건, 결국 개발 편의를 위해서였다. 하지만 사실 사용자 입장에서는 조건 셀렉을 모달에 들어감 → 필요 조건 선택 → 제출 → 변경을 위해 다시 모달에 들어감으로 진행하는 건 그리 편하지 않은 상황이었다.

하지만 useReducer를 이용해서 전체 state 관리가 쉽게 가능해졌고, 그 이전에 https 통신과 UI 렌더의 분리를 가능하게 만들었으므로 굳이 모달에 접근할 필요 없이 해당 페이지에서 버튼 클릭만을 통해 조건을 선택하는 방법을 제안하게 되었다.

🍙 클릭만으로 조건을 바로 반영할 수 있도록 수정하자!

Environment.page.tsx

const Environment = () => {
  /* 코드 중략 */
  const [condFilter, dispatchCondF] = useReducer(
    condFilterReducer,
    initialCondFilter,
  ); // 그래프 data 조건 reducer
  
  /* 코드 중략 */
  return(
    /* 중략 */
    <ConditionListGrid conds={condFilter} dispatchCondF={dispatchCondF} />
 /*...*/

가장 최상위 컴포넌트에 해당하는 page에서 condFilter state를 useReducer로 선언한다. 이 때, condFilterReducer는 리듀서 함수로 다른 파일에 작성하였으며 initialCondFilter는 초기 렌더링을 위한 초기값에 해당한다.

그리고 실제 조건을 컨트롤하는 컴포넌트인 ConditionListGrid.tsx에 state(condFilter)와 dispatch(dispatchCondF)를 넘겨주었다.

condFilterReducer.ts

/**
 * 그래프 렌더 조건을 관리하는 reducer 함수.
 * @param {condFilterT} conds
 * @param {actionT<MultiValue<Option>>} action
 * @returns {condFilterT}
 */
export const condFilterReducer = (
  conds: condFilterT[],
  action: actionT<Option>,
): condFilterT[] => {
  const temp = conds.find((c) => c.valueKey === action.payload.key);
  const index = conds.findIndex((c) => c.valueKey === action.payload.key);
  switch (action.type) {
    case "all": // title 단위 on-off
      /* 내부 계산 로직 */
      
    case "selection": // 내부 option on off
      /* 내부 계산 로직 */
      
    case "toggle": // toggle on off(주야, 감우)
      /* 내부 계산 로직 */
      
    default: // 기본
      return initialCondFilter;
  }
};

상세한 내부 계산은 제외하고 간단한 로직만을 작성하였다. action.type“all” | “selection” | “toggle” 에 해당하며 각각이 동작하는 바는 다음과 같다.

action type별 설명

  • all : 대분류별로 차트가 그려지므로(총 6개), 해당 대분류에 대한 차트 전체를 View에서 제거하는 경우이다.
  • selection : 대분류의 차트는 유지하되, 해당 그래프가 가지는 소분류들이 차트에 표기될지를 결정하는 경우이다.
  • toggle: 주야간상태 및 감우 상태는 차트 전반에서 annotation으로 제공되고 있으므로, 별도로 토글로 관리하여 전체 차트에서 visible 여부를 결정할 수 있도록 하였다. 해당 visible 여부를 관리하는 경우이다.

기존에 boolean 값으로 동작하던 extCondhumLackCond도 reducer를 통해 핸들러를 통일하고 로직을 정비함으로써 동일한 MultiValue<Option> 형태를 가질 수 있도록 수정할 수 있었다.

ConditionListGrid.tsx 내의 handler function

const handleLargerCond = (payload: { key: string }) => {
    // console.log(`${payload.key}에 대한 handleLargerCond`);
    dispatchCondF({
      type: "all",
      payload: payload,
    });
  };
  const handleSelection = (payload: { key: string; value: Option }) => {
    // console.log(`${payload.key}에 대한 handleSelection`);
    dispatchCondF({
      type: "selection",
      payload: payload,
    });
  };
  const handleToggle = (payload: { key: string }) => {
    // console.log(`${payload.key}에 대한 handleToggle`);
    dispatchCondF({
      type: "toggle",
      payload: payload,
    });
  };

실제 ConditionListGrid.tsx 내에서 UI를 제외한 로직은 핸들러 함수 3개로 줄일 수 있었다. 단, 이 부분에서 아무래도 조건으로 걸 사항이 많다 보니 dispatch를 통해 넘겨줄 action에 대해 많이 고민해야 했다.

📦 payload type

export type actionT<T> = {
  type: string;
  payload: { key: string; value?: T };
};

결정된 형태는 위와 같다. typeswitch를 통해 분리하는 경우에 대해 작성하였고, payload를 따로 지정하여 keyvalue 값을 전달한다. 현재 만든 state의 구조가 복잡하고, 대분류(즉, 여기선 key 값)에 접근하여 value를 바꿔야 하는 경우가 많기 때문에 별도로 key를 지정한 것이다.



마무리

사실 useReducer 도입 이외에도 다양한 방향으로 해당 코드를 개선해왔다. 추후에 작성할 react-query 측면에서도 그렇고 사용자 View에 대해서도 많은 고민과 수정을 거쳐서 완성되었지만 어째서 리팩토링은 끝이 없는지 모르겠다🫠

이번에 처음 useReducer를 써 보았는데, 확실히 간단한 state로 관리할 수 있는 경우에 도입하게 되면 오히려 코드의 가독성이 떨어진다는 느낌을 받았다. 이런 경우에는 차라리 가능하다면 custom hook으로 사용하거나 recoil, jotai, 혹은 useContext와 같이 전역 데이터 관리를 통해 개선하는 편이 나았다.

하지만 지금처럼 비슷한 핸들러를 조건만 바꿔 여러 번 호출하거나 state 구조가 중첩되어 복잡할 때useReducer를 적극 활용하면 코드 개선에 많은 도움이 될 것 같다!

profile
프론트엔드 개발이 좋다 왜냐하면 프론트엔드 개발이 좋기 때문이다.

0개의 댓글

관련 채용 정보