Skip to content

상태관리 migration (redux → react context api)

YUNHO edited this page Jan 29, 2023 · 2 revisions

1. FE에서 상태 관리란?

1-1. 프론트엔드 개발자의 반복적인 작업

프론트엔드 개발의 대부분의 작업은 원격 서버의 상태를 UI로 표현하는 일입니다. 이 일을 하기 위해 우리는 먼저 로컬 스토어 상태를 정의하고 각 화면의 컴포넌트들이 상태에 따라 어떻게 반응할 지(react) 정의합니다. 그리고 적절한 시점에 원격 서버의 데이터를 fetch 하여 로컬 스토어 상태를 초기화하고 사용자 액션을 받아서 원격상태를 추가/수정/삭제합니다. 그리고 원격상태가 변경될 때마다 그에 맞게 로컬 스토어 상태도 동기화시키는 작업을 빼먹지 말아야 겠지요. 우리 대부분은 늘 이런 일들을 합니다. 이 중 특별히 귀찮은 작업 중 하나가 데이터의 추가/수정/삭제가 발생할 때마다 원격의 상태와 로컬의 상태를 동기화시키는 일이죠. 이 작업에 대한 현기증을 누구나 한번쯤은 느끼셨을 것입니다. 출처: https://min9nim.vercel.app/2020-10-05-swr-intro2/

1-2.상태를 구분해보자.

서버 상태

  • 원격 서버의 상태

로컬 상태

  • 전역 상태: 프로젝트 전체에 영향을 주는 상태(theme, modal 등등)
  • 지역 상태: 다른 컴포넌트와 공유하지 않은 상태(input, form 등등)
  • 컴포넌트 간 상태: 여러 컴포넌트에서 서로 쓰는 상태(전역 상태보다 좁은 범위)
  • 기타: 브라우저에서 관리되는 url 상태

출처: [리액트 상태 관리 가이드](https://www.stevy.dev/react-state-management-guide/)

2. Redux를 사용하지 않은 이유

2-1. 전역상태가 생각보다 없다!

프로젝트 전체에서 사용하는 전역 상태는 생각보다 없었습니다. 저희는 redux와 redux thunk를 활용해서 상태관리를 했었는데 특정 컴포넌트간 공유하는 지역 상태는 많지만 전역적으로 관리해야하는 상태는 적다는 걸 알았습니다.

전역적으로 관리해야하는 상태보다는 서버에서 데이터를 요청해야하는 일이 많았습니다. 로컬 상태 관리가 아닌 서버 상태와 동기화해야하는 일이 많았습니다.

2-2. 서버 상태 반영하기

원격 서버의 상태를 어떻게 하면 UI로 적절하게 반영할 수 있을까고민하다가 FE팀은 이를 해결하기 위해 다음과 같은 로직 고려했습니다.

서버상태를 추가/수정/삭제하는 작업(CRUD) 이후 서버의 최신화된 상태를 다시 받아오기

댓글 목록을 예로 들어보겠습니다. 댓글 수정하는 작업을 하기 위해서는

  • 댓글 목록을 받아옴
  • 특정 댓글을 선택
  • 선택한 댓글을 수정 후 수정 요청
  • 수정 내용을 반영

추가/수정/삭제 요청 → 서버에서 성공 메세지 받기 (별도의 데이터는 받지 않음) → 다시 get 요청해서 목록 전체 받기

3. Context API + custom hooks + useAxios

3-1. context API를 사용한 이유

계층적인 컴포넌트구조에서 상태에 따른 렌더링을 이해하기 좋다고 생각했습니다.

여러 라이브러리들이 내부적으로는 context API를 통해 만들어져 있다고 알고 있습니다. (recoil, react-redux, styled-component)

구체적인 사용 방법

context API의 Provider는 props drilling을 해결하는 역할만 할 뿐, 직접적인 상태관리는 custom hooks를 통해 관리했습니다.

custom hooks는 두 가지 객체를 반환합니다.

  • action 객체: 상태를 변경하는 함수 모음
  • state 객체: 상태 변수 모음

불필요한 렌더링을 방지하기 위해 action과 state를 사용할 수 있는 useContext를 각각 만들어서 export하고 있습니다.

구체적인 예시는 아래와 같습니다.

src/context/EssentialForm/EssentialForm.Provider.jsx

import React, { createContext, useContext } from 'react';
import PropTypes from 'prop-types';

import useEssentialForm from './useEssentialForm';

const EssentialFormStateContext = createContext();
const EssentialFormActionContext = createContext();

EssentialFormProvider.propTypes = {
  children: PropTypes.element.isRequired,
};

export default function EssentialFormProvider({ children }) {
  const [states, actions] = useEssentialForm();
  return (
    <EssentialFormActionContext.Provider value={actions}>
      <EssentialFormStateContext.Provider value={states}>
        {children}
      </EssentialFormStateContext.Provider>
    </EssentialFormActionContext.Provider>
  );
}

export function useEssentialFormsState() {
  const value = useContext(EssentialFormStateContext);
  if (value === undefined) {
    throw new Error('useEssentialFormsState should be used within EssentialFormProvider');
  }
  return value;
}

export function useEssentialFormsAction() {
  const value = useContext(EssentialFormActionContext);
  if (value === undefined) {
    throw new Error('useEssentialFormsAction should be used within EssentialFormProvider');
  }
  return value;
}

src/context/EssentialForm/useEssentialForm.js

const useEssentialForm = () => {
// 생략
  const actions = useMemo(
    () => ({
      onChangeHandler,
      onChangeHandlerWithSelect,
      submitHandler,
      onClickCheckDuplicateNickname,
      isTargetSatisfyValidate,
      handleClickNextButton,
      handleClickPrevButton,
      handleClickLayout,
      closeEssentialModal,
      onChangeFile,
    }),
    [
      onChangeHandler,
      onChangeHandlerWithSelect,
      submitHandler,
      onClickCheckDuplicateNickname,
      isTargetSatisfyValidate,
      handleClickNextButton,
      handleClickPrevButton,
      handleClickLayout,
      closeEssentialModal,
      onChangeFile,
    ],
  );

  const states = useMemo(
    () => ({
      layoutRef,
      inputValues,
      validateError,
      satisfyAllValidates,
      isNicknameDuplicate,
      imageFile,
    }),
    [layoutRef, inputValues, validateError, satisfyAllValidates, isNicknameDuplicate, imageFile],
  );

  return [states, actions];
};

3-2. useAxios

저희 프로젝트에서는 api 요청에 관한 로직을 useAxios라는 custom hooks로 관리했습니다. useAxios는 axios 인스턴스와 즉시 실행 여부(immediate)에 따라 두 개의 핵심 메서드가 동작하는 hooks입니다.

  • getExecution: immediate가 true일 때, 컴포넌트가 마운트되는 시점에서 axios 인스턴스를 실행하는 함수입니다.
  • notGetExecution: immediate가 false일 때, 특정 동작이 실행되는 시점에서 axios 인스턴스를 실행하는 함수입니다.

getExecution과 notGetExecution 로직은 동일하지만 로딩 상태를 알리는 방식 다릅니다.

  • getExecution: API 상태에 따라 isLoading, responseData, error라는 useAxios의 내부 state를 변경합니다. (state는 useAxios가 호출된 컴포넌트에서 사용됩니다.)
  • notGetExecution: 토스트 알람으로 API 상태를 표시합니다. (useAxios의 내부 state를 변경하지 않습니다.)

위의 설명만 봐서는 구체적인 로직을 파악하기 어려울 것 같아 예시를 준비했습니다.

GET : api/team/:teamId : 팀 게시글에 대한 상세 정보 요청

export default function TeamPost() {
  const { teamId: stringTeamId } = useParams();
  const teamId = Number(stringTeamId);

  const { state, forceRefetch } = useAxios({
    axiosInstance : teamApi.GET_TEAM_DETAIL,
    axiosConfig: { id: teamId },
    responseDataKey: 'targetTeam',
  });
  const { responseData, isLoading, error } = state;

  if (isLoading) return <Spinner withLogo isFullPage />;

  if (error) {
    return (
      <Callback
        errorStatus={error.httpStatus}
        errorMessage={error.message}
        forceRefetch={forceRefetch}
      />
    );
  }
  return (
    <S.Container>
      <BackButton />
      <TeamPostView targetTeam={responseData} />
    </S.Container>
  );
}

PATCH: api/team/:teamId 팀 게시글에 대한 정보 수정 요청

export default function EditTeamPostDetail({targetTeam}) {
  // 생략

  // 수정 요청 api hooks
  const { notGetExecution } = useAxios({
    axiosInstance: teamApi.EDIT_TEAM_POST,
    immediate: false,
    axiosConfig: { id: teamId },
  });
  // 생략

  // 수정 요청
  const submitCallback = async (submitData) => {
		// 생략
    const parsedSubmitData = teamEditRequestParser(submitData);
    await notGetExecution({
      newConfig: { data: parsedSubmitData },
      successMessage: API_MESSAGE.SUCCESS_EDIT_TEAM,
    });
  };
  // 생략

  return (
    <EditTeamPostView 
      inputValues={inputValues} 
      submitCallback={submitCallback} 
      // 생략
    />
  );
}

useAxios

// 생략
const useAxios = ({ axiosInstance, axiosConfig, immediate = true }) => {
  const notifyDispatch = useToastNotificationAction();
  const navigate = useNavigate();
  const [state, dispatch] = useReducer(reducer, {
    isLoading: true,
    responseData: null,
    error: null,
  });
  const [trigger, setTrigger] = useState(Date.now());
  const [controller, setController] = useState();

  // 생략

  /**
   * 새로운 axios config와 함께 api호출을 실행하는 함수
   * @param {Object} newConfig axios instance에 넘겨줄 새로운 axios config
   */
  const getExecution = async (newConfig) => {
    dispatch({ type: LOADING_TYPE });
    try {
      const ctrl = new AbortController();
      setController(ctrl);
      const { data: responseData } = await axiosInstance({
        ...axiosConfig,
        ...newConfig,
        signal: ctrl.signal,
      });
      dispatch({ type: SUCCESS_TYPE, responseData });
    } catch (error) {
      console.error(error);
      handleExiredToken(error.httpStatus);
      dispatch({
        type: ERROR_TYPE,
        error: {
          httpStatus: error.httpStatus,
          message: error.message,
        },
      });
    }
  };

  const notGetExecution = async ({ newConfig, successMessage = '요청 성공!', seconds = 1500 }) => {
    let isOverStandard = true;
    setTimeout(() => {
      if (isOverStandard) notifyNewMessage(notifyDispatch, API_MESSAGE.LOADING, TOAST_TYPE.Info);
    }, seconds);
    try {
      const ctrl = new AbortController();
      setController(ctrl);
      const response = await axiosInstance({
        ...axiosConfig,
        ...newConfig,
        signal: ctrl.signal,
      });
      // const message = response?.message;
      notifyNewMessage(notifyDispatch, successMessage, TOAST_TYPE.Success);
      return response;
    } catch (error) {
      handleExiredToken(error.httpStatus);
      notifyNewMessage(notifyDispatch, error.message, TOAST_TYPE.Error);
    } finally {
      isOverStandard = false;
    }
    return null;
  };

  useEffect(() => {
    if (immediate) {
      getExecution();
    }
    return () => controller && controller.abort();
  }, [trigger]);

  return { state, getExecution, notGetExecution, forceRefetch, resetState };
};

export default useAxios;

4. 어려웠던 점

4-2. 최적화

context API 사용에 따른 불필요한 렌더링을 자주 발생했습니다. useMemo를 통해 어느 정도 불필요한 렌더링은 방지했지만 개선할 필요가 있습니다.

4-2. 적절한 추상화

선언적인 프로그래밍과 적절한 추상화 모두 다른사람이 알아보기 쉬워야한다고 생각합니다. useAxios에서 getExecution와 notGetExecution은 하나의 execution함수로 통일한 뒤, 호출하는 API의 메서드에 따라 구분하는 건 어떨까하는 아쉬움이 남습니다.

4-3. 로딩 처리

notGetExecution의 경우 “처리중”이라는 메시지를 보여줄 타이밍을 제어하기 어려웠습니다. notGetExecution 함수가 호출되자마자 “처리중”메시지를 보여준다면 곧바로 오는 API요청과 거의 동시에 보여 어색했습니다.

이를 해결하기 위해 flag변수와 setTimeout을 활용했습니다.

  • isOverStandard라는 변수를 true로 선언
  • 설정한 시간이 지났다면 setTimout의 callback함수를 실행
  • api요청(성공이든 실패든)이 끝난 뒤 finally에서 isOverStandard를 false로 변경
  • setTimeout에서 설정한 시간이 지났는데 api요청이 오지 않았다면 isOverStandard는 여전히 true
const notGetExecution = async ({ newConfig, successMessage = '요청 성공!', seconds = 1500 }) => {
    let isOverStandard = true;
    setTimeout(() => {
      if (isOverStandard) notifyNewMessage(notifyDispatch, API_MESSAGE.LOADING, TOAST_TYPE.Info);
    }, seconds);
    try {
		   // 생략
    } catch (error) {
		   // 생략
    } finally {
      isOverStandard = false;
    }
    // 생략
  };

4-4. memory leek과 요청 취소

컴포넌트가 마운트되어 API가 호출된 상황에서 API 응답이 오는 시간이 길어졌을 때, 그 사이에 컴포넌트가 unmont되었을 때 react는 아래와 같은 경고문을 보여줬습니다.

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup

컴포넌트가 unmount되었음에도 API호출에 대한 응답이 이루어졌고, 상태(변수)를 변경했기 때문에 발생한 문제였습니다. 이를 해결하기 위해 컴포넌트가 unmount가 되었음에도 API호출이 응답을 받지 못했다면 API호출을 취소해야 했습니다.

Abortcontroller에 대한 개념을 알고 있었고, https://axios-http.com/docs/cancellation 의 공식문서에 사용법이 나와있어 어렵지 않게 적용할 수 있었습니다.

Clone this wiki locally