본문 바로가기

Web.d

[React] For react States that represent only Valid State (w. Reducer)

반응형

내리 추가하는 상태에 따라 지수적으로 복잡도가 증가하고

코드의 유지보수가 힘들어지는 경우를 한 번씩은 경험해보았을 것이다.

 

그런 경험을 반복하지 않기 위해, 정말 간단하지만 중요한 이야기를 해보고자 한다.

 

혹은 Reducer에 익숙하지 않은 분들도 보면 도움이 될 것이다.

 


💠 Type 관점에서의 "유효한 상태 표현"

타입을 설계할 때는 어떤 값들을 포함하고, 어떤 값들을 제외할지 신중하게 생각해야 한다.

 

“유효한 상태”만을 표현하지 않고

“무효한 상태”를 함께 표현하는 타입은, 혼란을 초래하고 타입 체크 오류를 유발하게 된다.

 

알맞는 글을 불러와 나타내는 웹 페이지를 구현한다고 가정하고, 대표적인 다음 예시를 살펴보자.

페이지는 내용을 로드하고, 그 이후에 화면에 표시한다.

상태는 다음과 같이 설계한다.

 

interface State {
  text: string;
  isLoading: boolean;
  error?: string;
}

 

이를 통해 UI를 그리기 위해서는, State 객체의 필드를 전부 고려해서 상태 표시를 분기해야 한다.

 

function renderPage(state: State) {
  if (state.error) {
    // 에러 처리
    return
  }
  if (state.isLoading) {
    // 로딩
    return
  }
  // UI
  return
}

 

하지만 이는 분기 조건이 명확히 분리되어 있지 않다.

isLoading이 true이고, 동시에 error 값이 존재한다면? 로딩 중인 상태인지 오류가 발생한 상태인지 구분할 수가 없다는 것이다.

이처럼 State 설계는 무효한 상태, "무효한 경우의 수”가 포함되어 있는 상태이고, 이는 속성의 충돌을 야기하며 코드를 불분명하게 만든다.

상태를 핸들링하는 함수에서는 문제점이 더 분명하게 나타난다 !

 

(async function() {
  state.isLoading = true;
	
  try {
    const response = await fetch(~);
    if(!response.ok) {
      throw new Error(response.statusText);
    }
    
    state.text = await response.text();
    state.isLoading = false;
  } catch (e) {
    state.error = "" + e;
  }
})();

 

문제점이 있는가?

 

 

  1. 오류를 발생했을 때, 로딩을 멈추는 로직이 빠졌고,
  2. 에러를 최신화하지 않아 페이지 전환 중에 로딩 메시지 대신 과거의 오류 메시지를 보여주고,
  3. 로딩 중에 함수를 다시 호출하면 응답이 오는 순서에 따라 다른 페이지의 상태를 보여주게 된다.
  4. 그리고 이 문제점들이 “숨어있어” 찾기 힘들다. (이게 가장 큰 문제라고 생각한다.)

이처럼 무분별한 상태 타입 설계는 코드에 막대한 영향을 끼치게 된다.

{..., isLoading: true, error: “에러”} , {pageText: “”, ..., error: undefined} 등의 무효한 상태(경우의 수)가 property 수에 따라 지수적으로 증가하기 때문이다.

 

유효한 상태만을 표현하며 해결하기 위해서는 다음과 같이 태그된 유니온(구별된 유니온)과 유니온의 인터페이스가 아닌 인터페이스의 유니온을 사용하여 상태를 설계할 수 있다.

그에 따라 UI 함수와 핸들링 함수들은 어떻게 변화할 수 있는지는 직접 생각해 보면 좋을 것 같다.

 

interface RequestPending {
  state: "pending";
}
interface RequestError {
  state: "error";
  error: string;
}
interface RequestSuccess {
  state: "ok";
  pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;

 


🌐 switch case를 활용한 React Reducer

위와 같이 데이터를 패칭하는 상황을 가정하자.

React에서는 useState로써 데이터, 로딩 유무, 에러에 대한 각각의 상태를 정의하는 것이 전형적인 방법이다.

하지만 기능과 더 세밀한 UI가 요구될수록 상태가 늘어나고, 이에 대한 경우의 수가 발생하고, 상태 관리의 복잡도는 다시 지수적으로 증가한다.

When they get more complex, they can bloat your component’s code and make it difficult to scan.
- React

 

이 문제의 원인은 불필요한 관심사의 분리가 안되었다는 것이고,

“데이터 패칭이 잘 진행되는지”, “데이터 패칭이 에러 없이 완료되었는지”의 여부를 유효한 상태에 따라 추적하여 관리할 수 있어야 한다.

이에 대해 리액트는 🔗useReducer를 제시한다.

You can consolidate all the state update logic outside your component in a single function, called a reducer.
- React

 

Reducer를 이용해 유효한 상태만 표현하고 선언적인 코드를 지향한다.

State 로직을 Reducer로 추출하는 방법은 🔗공식문서에 잘 나와있으니, 참고하면 좋을 것 같다.

유효한 상태 정의에 대한 중요성은 충분히 설파된 것 같으니, 이제 적용을 해보도록 하자.

 


🔨 탭 이동에 따른 데이터 패칭

🔗SOPT 공식 홈페이지를 개발하며,

회원들의 활동 후기를 아카이빙 해놓은 페이지에서

탭 이동에 따라 각 파트별 아티클을 불러와 보여주는 기능이 요구되었다.

 

SeojinSeojin(Github)과 함께 개발하였습니다.

 

먼저, 컴포넌트가 가질 수 있는 상태를 정의한다.

TypeScript 를 사용하여 이 상태에 대한 Type 을 정의하는 것부터 시작한다.

가질 수 있는 상태는 4가지로, 아무런 요청이 발생하지 않는 IDLE 상태, 네이밍자체로 설명이 되는 LOADINGERROR 상태 그리고 데이터 패칭이 완료된 OK 상태가 있다.

ERROR 상태를 제외하고서는, data property으로써 무한 스크롤을 위해 페이지네이션 된 데이터들을 저장해 두고 UI로 나타낼 수 있게끔 설계한다.

 

정의된 4가지 상태는 dispatch 함수에 의해 전달된 Action 객체에 의해서만 변경된다.

그리고 상태를 변경시킬 수 있는 Action 객체에는 3가지 종류가 있다. 데이터 요청의 시작을 알리는 FETCH , 실패를 알리는 FAILED 그리고 성공을 알리는 SUCCESS.

성공을 알릴 때는 Action 객체에 데이터 패칭의 결과인 message 를 담아서 함께 보낸다.

또한 FETCH와 SUCCESS 동작에서 첫 페이지네이션의 패칭인지를 구분하여 data를 비워주거나, 쌓거나 하는 동작을 구분한다.

State 와 Action 코드는 아래와 같다.

 

export type State<T> =
  | {
      _TAG: 'IDLE';
      data: T[];
    }
  | {
      _TAG: 'LOADING';
      data: T[];
    }
  | {
      _TAG: 'ERROR';
      error: Error;
    }
  | {
      _TAG: 'OK';
      data: T[];
    };

export type Action<T> =
  | {
      _TAG: 'FETCH';
      isInitialFetching: boolean;
    }
  | {
      _TAG: 'FAILED';
      error: Error;
    }
  | {
      _TAG: 'SUCCESS';
      isInitialFetching: boolean;
      data: T[];
    };

 

다음은 reducer 를 정의한다.

reducer 는 prevState 와 Action 에 기반해 새로운 상태를 반환한다. prevState를 기준으로 상태 변화 과정에 대해 작성한다.

IDLE 상태가 전달받을 수 있는 Action 객체는 Fetch 뿐이고, 이는 Loading 상태를 반환한다.

Loading 상태가 전달받을 수 있는 Action 객체는 Failed 와 Success 이고, Failed 객체를 받는다면 Error 상태를 반환한다. 그리고 Success 객체를 받는다면 OK 상태를 반환한다.

OK 상태에서는 다시 요청을 하는 경우 Fetch 객체를 받을 수 있고 Loading 상태를 반환한다.

코드는 다음과 같다. (23/05/17 수정)

 

export function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {
  switch (action._TAG) {
    case 'FETCH':
      if (prevState._TAG === 'ERROR') throw new Error('Invalid action during error');
      return {
        _TAG: 'LOADING',
        data: action.isInitialFetching ? [] : prevState.data,
      };
    case 'FAILED':
      return {
        _TAG: 'ERROR',
        error: action.error,
      };
    case 'SUCCESS':
      if (prevState._TAG === 'ERROR') throw new Error('Invalid action during error');
      return {
        _TAG: 'OK',
        data: action.isInitialFetching ? action.data : [...prevState.data, ...action.data],
      };
    default:
      throw new Error('Unknown action type');
  }
}

 

작성한 Reducer를 🔗훅으로 분리하여 무한 스크롤과 같이 패칭한 데이터를 쌓을 수 있게 재사용이 가능하게끔 구현한다.

이때 패칭 함수를 감지하여 훅을 호출하기 때문에, 탭을 누르거나 하위의 Infinite Scroll이 감지될 때 Reducer가 실행된다.

 

먼저 Fetch Action을 전달해 IDLE 상태를 Loading 상태로 변경시킨다.

 

이후에는 await-to-js 라이브러리를 사용해 비동기 API 호출 함수에 대한 error와 response를 반환받는다.

반환되는 error 가 있거나, response 에서 반환되는 에러타입이 NOT_ERROR 가 아니라면 FAILED Action을 전달해 Loading 상태를 Error 상태로 변경한다.

 

마지막으로 response 가 정상적으로 반환되었다면 Success Action 을 전달해 Loading상태를 OK상태로 변경한다.

 

📝 글을 줄이며

유효한 상태만을 표현하는 상태를 지향하며 선언적으로 코드를 구현하는 것이 중요하다.

이에 더불어 로딩 로직과 에러 로직을 분리하는 React 18의 Suspense, Error Boundary API 등이 있고, 이렇게 유효한 상태를 기반으로, 데이터의 패칭을 추상화하여 다루는 라이브러리가 SWR, RQ이다.

 

패칭 이외에도 다양한 이벤트 핸들러가 퍼져 많은 state를 조작하다 보면, 그 경우의 수는 지수적으로 늘어나 디버깅에 치명적이기 때문에, 이와 같은 방법을 고려해야 한다.

실제로 state를 동시에 3개 이상 조작하다보면  (많은 리액트 입문자들이 경험하듯이) 머리를 뜯게 되지만, 유효한 상태를 추려보면 3~4가지 내외뿐인 경우가 많다.

상태를 설계할 때, 혹은 타입을 설계할 때는 어떤 값들을 포함하고, 어떤 값들을 제외할지 신중하게 머릿속으로 그려보고 진행해 보도록 하자.

References

- 이펙티브 타입스크립트 :: item 28

- react.dev :: extracting-state-logic-into-a-reducer

- https://github.com/sopt-makers/sopt.org-frontend

 

GitHub - sopt-makers/sopt.org-frontend: IT벤처창업동아리 SOPT 공식 홈페이지

IT벤처창업동아리 SOPT 공식 홈페이지. Contribute to sopt-makers/sopt.org-frontend development by creating an account on GitHub.

github.com

반응형