React Native에서 Context API와 useReducer로 구조적인 상태 관리하기

들어가며

ReactNative에서의 Best Practice 상태관리는 어떤 방식일까? 다양한 상태 관리 라이브러리들이 존재하지만, 실제 프로젝트에서는 어떤 방식이 가장 효율적일지 고민하게 된다.

스타트업 환경에서 개발자들은 빠른 속도로 다양한 기능을 구현해야 하는 상황에 자주 직면한다. 적은 인원에서 많은 기능들을 구현할 때마다 상태 관리 방식을 고민하는 시간은 제한된 리소스를 가진 팀에게 큰 부담이 된다.

필자는 1년정도 reactnative를 접해보며, 여러 상태관리 라이브러리들을 사용해보았지만 아래에서 소개할 패턴이 가장 범용적으로 best practice에 가깝게 사용되었다. react나 next.js에서도 똑같이 사용 가능하다는 점도 큰 장점이다.

전역상태관리는 zustand, 부분상태관리는 useContext의 조합으로 효율적인 상태 관리가 가능하다. 이 글에서는 화면 단위의 상태 관리를 깔끔하게 해결할 수 있는 방법을 다룬다.

특히, 복잡한 상태 관리 라이브러리 없이도 예측 가능하고 유지보수하기 쉬운 패턴을 찾고 있다면 주목하자.

이런 고민이 있다면, 이 글이 도움이 될 것이며, 적어도 나에게는 아래의 욕구를 충족시켜주었다.

  • 일관된 코드 패턴으로 상태를 관리하고 싶다
  • 프로젝트 전체에서 통일된 방식으로 상태 관리 로직을 구현하고 싶다
  • React Native의 베스트 프랙티스에 맞는 상태 관리 방식을 도입하고 싶다
  • 외부 라이브러리에 의존하지 않으면서도 효율적인 방법을 찾고 싶다
  • 팀원들이 쉽게 이해하고 유지보수할 수 있는 패턴이 필요하다
  • 많은 화면의 상태 관리를 적은 인원으로 효율적으로 처리해야 할 때 도움이 된다

우스갯소리지만, 초반부에 소개할 Boilerplate 코드와 피그마의 View Design만 Cursor에게 넘겨주고 디버깅에 문제가 없는 80% 이상의 완성도의 코드들이 만들어졌다. 특히, 화면 단위의 상태 관리가 필요하지만 Redux 같은 무거운 전역 상태 관리 도구는 부담스러운 경우라면, React에 이미 내장된 useReducerContext API를 활용한 Flux 패턴이 좋은 대안이 될 수 있으며, Flux 패턴부터 가볍게 훑고 가자.

Flux 패턴이란?

Flux 패턴 다이어그램

Flux는 Facebook에서 개발한 단방향 데이터 흐름 아키텍처로, 예측 가능한 상태 관리를 가능하게 한다. 이 패턴은 Action → Dispatcher → Store → View 순서로 데이터가 흐르며, 변경된 상태가 다시 UI에 반영되는 구조를 가진다.

React에서는 useReducer와 Context API를 활용해 간단하게 Flux 패턴을 구현할 수 있다. 이 방식은 전역 상태 관리 라이브러리 없이도 깔끔한 상태 업데이트를 가능하게 하며, 디버깅과 유지보수가 쉬운 코드 구조를 만든다.

다음으로, 이 패턴을 활용한 화면 단위 상태 관리 방법을 살펴보자.

Context API + useReducer로 Flux 구현하기

React의 내장 기능만으로도 Flux 패턴을 구현할 수 있다. 먼저 화면 단위 상태 관리를 위한 기본 구조를 살펴보자.

  • 아래 보일러플레이트 코드는 화면별 상태 관리를 위한 기본 틀이다.
  • 실제 프로젝트에서는 ScreenName을 실제 화면 이름(ex. LoginScreenProvider)으로 대체하여 사용한다.
import React, { createContext, useContext, useReducer } from "react";

/**
 * _ScreenName_ 에서 사용하는 상태값입니다.
 * Reducer 를 통해 정의합니다.
 */
export type _ScreenName_ScreenState = {};

/**
 * _ScreenName_ 에서 사용하는 전체 Context 입니다.
 * 상태값에서 유도된 값과 상태값을 조작하는 함수들을 추가로 넘겨줍니다.
 */
type _ScreenName_ContextType = _ScreenName_ScreenState & {};

/** */
const initial_ScreenName_ScreenState: _ScreenName_ScreenState = {};

/** */
const _ScreenName_ScreenContext = createContext<_ScreenName_ContextType | undefined>(undefined);

/** */
export function use_ScreenName_Context() {
  const context = useContext(_ScreenName_ScreenContext);
  if (context === undefined) {
    throw new Error("_ScreenName_ 콘텍스트가 제공되지 않았습니다.");
  }
  return context;
}

/** */
function _ScreenName_ScreenStateReducer(
  state: _ScreenName_ScreenState,
  updatedState: Partial<_ScreenName_ScreenState>,
) {
  return { ...state, ...updatedState };
}

/** */
export function _ScreenName_ScreenProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(_ScreenName_ScreenStateReducer, initial_ScreenName_ScreenState);

  /** */
  function updateState() {
    dispatch({ type: "", payload: {} });
  }

  const value: _ScreenName_ContextType = {
    ...state,
  };

  return <_ScreenName_ScreenContext.Provider value={value}>{children}</_ScreenName_ScreenContext.Provider>;
}

여기서 주목할 점은 Context 생성 시 undefined를 명시적으로 포함시켜 타입 안전성을 보장한다는 것이다. 이는 Provider 외부에서 Context를 사용할 때 발생할 수 있는 오류를 미리 방지하고, 개발자에게 명확한 오류 메시지를 제공한다. 또한 타입 체크 후에는 IDE에서 완벽한 자동 완성을 지원받을 수 있어 개발 생산성이 향상된다.

또한 Reducer 함수의 구현 방식도 주목할 만하다. 복잡한 액션 타입 없이 Partial<State> 타입을 활용하여 상태의 일부만 업데이트할 수 있게 하여 코드를 간결하게 유지하면서도 타입 안전성을 보장한다. 스프레드 연산자를 통해 상태 불변성도 자연스럽게 유지된다.

실제 예시: 프로필 설정 화면 구현하기

이제 실제 프로필 설정 화면을 예로 들어 어떻게 이 패턴을 적용할 수 있는지 살펴보자.

1. 상태 정의하기

필요성 및 장점

  • 화면에서 관리해야 할 데이터를 한 곳에서 정의하여 일관성을 유지하고, 이후 상태 업데이트를 쉽게 만들 수 있다.
  • 상태의 구조를 명확하게 정의하여 유지보수가 쉽고 초기 상태를 별도로 선언하여 상태 초기화가 간단하다.
/**
 * ProfileSetup 화면에서 사용하는 상태값입니다.
 * Reducer를 통해 정의합니다.
 */

type ProfileSetupModalType = "TERMS_AGREEMENT" | "PROFILE_IMAGE_UPLOAD" | undefined;

export type ProfileSetupScreenState = {
  nickname: string;
  bio: string;
  profileImage?: string;
  modalVisible: boolean;
  modalType: ProfileSetupModalType;
};

/** 초기 상태 설정 */
const initialProfileSetupScreenState: ProfileSetupScreenState = {
  nickname: "",
  bio: "",
  isSubmitting: false,
  modalType: undefined,
};

2. Context 및 Hook 정의하기

  • 여러 컴포넌트에서 상태를 공유할 때 useContext를 활용하면 props를 여러 단계에 걸쳐 전달하는 번거로움을 줄일 수 있다.
  • useContext를 사용하면 필요한 곳에서만 상태를 쉽게 접근할 수 있고 UI 코드와 상태 관리 코드를 분리할 수 있어 가독성이 향상된다.
import React, { createContext, useContext, useReducer } from "react";

/**
 * ProfileSetup 화면에서 사용하는 전체 Context입니다.
 * 상태값에서 유도된 값과 상태값을 조작하는 함수들을 추가로 넘겨줍니다.
 */
type ProfileSetupContextType = ProfileSetupScreenState & {
  isValid: boolean;
  updateNickname: (nickname: string) => void;
  updateBio: (bio: string) => void;
  uploadProfileImage: () => Promise<void>;
  submitProfile: () => Promise<void>;
  showModal: (type: ProfileSetupModalType) => void;
  closeModal: () => void;
};

/** Context 생성 */
const ProfileSetupScreenContext = createContext<ProfileSetupContextType | undefined>(undefined);

/** Context 사용을 위한 Hook */
export function useProfileSetupContext() {
  const context = useContext(ProfileSetupScreenContext);
  if (context === undefined) {
    throw new Error("ProfileSetup 콘텍스트가 제공되지 않았습니다.");
  }
  return context;
}

3. Reducer 및 Provider 컴포넌트 구현하기

이제 상태를 업데이트하는 Reducer와 이를 제공하는 Provider를 구현한다.

  • React의 useReducer를 활용하여 상태를 일관된 방식으로 업데이트할 수 있도록 한다.
  • 상태 변경 로직을 하나의 함수로 모아 관리하면 유지보수가 쉬워진다.
  • 상태 변경 로직을 한 곳에서 관리하므로 코드가 일관되게 유지하고
  • 새로운 상태 업데이트가 필요할 때 간단하게 추가할 수 있다.
/**
 * 상태를 변경하는 Reducer 함수
 */
function profileSetupScreenStateReducer(
  state: ProfileSetupScreenState,
  updatedState: Partial<ProfileSetupScreenState>,
) {
  return { ...state, ...updatedState };
}

/**
 * ProfileSetup 화면에서 사용되는 상태와 함수를 제공합니다.
 */
export function ProfileSetupScreenProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(profileSetupScreenStateReducer, initialProfileSetupScreenState);

  function showModal(type: ProfileSetupModalType) {
    dispatch({ modalVisible: true, modalType: type });
  }

  function closeModal() {
    dispatch({ modalVisible: false, modalType: undefined });
  }

  const value: ProfileSetupContextType = {
    ...state,
    isValid: state.nickname.length >= 2 && state.nickname.length <= 20,
    updateNickname: (nickname: string) => dispatch({ nickname }),
    updateBio: (bio: string) => dispatch({ bio }),
    uploadProfileImage: async () => {
      const imageUrl = "업로드된 이미지 URL";
      dispatch({ profileImage: imageUrl });
    },
    submitProfile: async () => {
      if (!state.isValid) return;
      dispatch({ isSubmitting: true });
    },
    showModal,
    closeModal,
  };

  return <ProfileSetupScreenContext.Provider value={value}>{children}</ProfileSetupScreenContext.Provider>;
}

4. 화면 컴포넌트에서 Context 사용하기

  • Provider를 통해 제공된 상태를 화면 컴포넌트에서 쉽게 사용하도록 한다.
  • 상태를 직접 변경하는 대신 Context에서 제공하는 함수를 호출해 상태를 관리한다.
  • 컴포넌트 내부에서 불필요한 상태 선언 없이 useContext를 통해 상태를 직접 가져올 수 있다.
  • UI 코드와 상태 변경 로직을 분리하여 가독성이 높아진다.
import { Button, Modal, Text, View } from "react-native";

import { useProfileSetupContext } from "./ProfileSetupContextProvider";

export function ProfileSetupScreen() {
  return (
    <ProfileSetupScreenProvider>
      <ProfileSetupContainer />
    </ProfileSetupScreenProvider>
  );
}

function ProfileSetupContainer() {
  const { nickname, updateNickname, showModal, closeModal, modalVisible, modalType } = useProfileSetupContext();

  return (
    <View style={{ padding: 20 }}>
      <Text>닉네임 설정</Text>
      <TextInput value={nickname} onChangeText={updateNickname} placeholder='닉네임을 입력하세요' />

      <Button title='이용약관 보기' onPress={() => showModal("TERMS_AGREEMENT")} />
      <Button title='프로필 이미지 업로드' onPress={() => showModal("PROFILE_IMAGE_UPLOAD")} />

      <ProfileSetupModal modalVisible={modalVisible} modalType={modalType} closeModal={closeModal} />
    </View>
  );
}

function ProfileSetupModal({
  modalVisible,
  modalType,
  closeModal,
}: {
  modalVisible: boolean;
  modalType: ProfileSetupModalType;
  closeModal: () => void;
}) {
  if (!modalVisible) return null;

  return (
    <Modal transparent animationType='slide' visible={modalVisible}>
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0,0,0,0.5)" }}>
        <View style={{ width: 300, padding: 20, backgroundColor: "white", borderRadius: 10 }}>
          {modalType === "TERMS_AGREEMENT" && <Text>이용약관 동의 내용을 표시합니다.</Text>}
          {modalType === "PROFILE_IMAGE_UPLOAD" && <Text>프로필 이미지 업로드 기능을 제공합니다.</Text>}

          <Button title='닫기' onPress={closeModal} />
        </View>
      </View>
    </Modal>
  );
}

이 코드에서는 useProfileSetupContext 훅을 통해 상태와 액션을 한 번에 가져와 사용한다. 이는 컴포넌트 코드를 간결하게 유지하면서도 필요한 모든 상태와 함수에 접근할 수 있게 해준다. 또한 타입 정의를 통해 IDE의 자동 완성 기능을 최대한 활용할 수 있어 개발 생산성이 향상된다.

단계별 페이지에서의 Context API 활용 사례

회원가입, 설문조사, 결제 과정과 같이 여러 페이지에 걸쳐 진행되는 단계별 프로세스에서는 Context API를 활용한 상태 관리가 효과적이다. 각 단계별로 독립적인 상태를 가지면서도, 전체 프로세스에서 데이터를 공유해야 하기 때문이다.

아래는 그중에서도 설문조사 폼을 예로 들어 어떻게 이 패턴을 적용할 수 있는지 살펴보자.

1. 상태 정의하기

먼저 설문조사 폼에서 필요한 상태를 정의한다:

/**
 * 설문조사 단계를 정의합니다.
 */
type SurveyStep = "INTRO" | "PERSONAL_INFO" | "QUESTIONS" | "REVIEW" | "COMPLETE";

/**
 * 설문조사 단계 순서를 정의합니다.
 */
export const surveyStepOrder: SurveyStep[] = ["INTRO", "PERSONAL_INFO", "QUESTIONS", "REVIEW", "COMPLETE"];

/**
 * 개인정보 입력값 타입
 */
type PersonalInfo = {
  name: string;
  age: string;
  gender?: "MALE" | "FEMALE" | "OTHER";
  occupation?: string;
};

/**
 * 설문 응답 타입
 */
type SurveyAnswers = {
  [key: string]: string | string[];
};

/**
 * 설문조사 화면에서 사용되는 상태값 타입.
 */
export type SurveyScreenState = {
  step: SurveyStep;
  personalInfo: PersonalInfo;
  answers: SurveyAnswers;
  currentQuestionIndex: number;
  isSubmitting: boolean;
  modalVisible: boolean;
  modalType?: "CONFIRM_EXIT" | "SUBMIT_SUCCESS";
};

/**
 * 초기 상태 설정
 */
const initialSurveyScreenState: SurveyScreenState = {
  step: "INTRO",
  personalInfo: {
    name: "",
    age: "",
  },
  answers: {},
  currentQuestionIndex: 0,
  isSubmitting: false,
  modalVisible: false,
};

2. Reducer 함수 정의하기

상태 업데이트를 위한 Reducer 함수를 정의한다:

/**
 * 설문조사 화면 상태 업데이트를 위한 Reducer 함수
 */
function surveyScreenStateReducer(
  state: SurveyScreenState,
  updatedState: Partial<SurveyScreenState>,
): SurveyScreenState {
  return { ...state, ...updatedState };
}

3. Context 및 Hook 정의하기

상태와 함께 사용할 함수들을 포함한 Context를 정의한다:

/**
 * 설문조사 화면에서 사용하는 전체 Context 타입
 */
type SurveyContextType = SurveyScreenState & {
  // 파생 상태
  isPersonalInfoComplete: boolean;
  isAllQuestionsAnswered: boolean;
  totalQuestions: number;

  // 함수들
  goToStep: (step: SurveyStep) => void;
  goNextStep: () => void;
  goPrevStep: () => void;

  updatePersonalInfo: (info: Partial<PersonalInfo>) => void;
  updateAnswer: (questionId: string, answer: string | string[]) => void;

  goToNextQuestion: () => void;
  goToPrevQuestion: () => void;

  handleBackButton: () => boolean;
  submitSurvey: () => Promise<void>;

  setModalVisible: (visible: boolean, type?: "CONFIRM_EXIT" | "SUBMIT_SUCCESS") => void;
};

/** Context 생성 */
const SurveyScreenContext = createContext<SurveyContextType | undefined>(undefined);

/** Context 사용을 위한 Hook */
export function useSurveyContext() {
  const context = useContext(SurveyScreenContext);
  if (context === undefined) {
    throw new Error("설문조사 콘텍스트가 제공되지 않았습니다.");
  }
  return context;
}

4. Provider 컴포넌트 구현하기

상태와 함수들을 제공할 Provider 컴포넌트를 구현한다:

/**
 * 설문조사 화면에서 사용되는 상태와 함수를 제공합니다.
 */
export function SurveyScreenProvider({ navigation, children }: { navigation: any; children: React.ReactNode }) {
  // 설문 문항 데이터 (실제로는 API에서 가져올 수 있음)
  const questions = useMemo(
    () => [
      { id: "q1", text: "이 서비스를 어떻게 알게 되셨나요?", type: "SINGLE_CHOICE" },
      { id: "q2", text: "서비스 사용 빈도는 어떻게 되나요?", type: "SINGLE_CHOICE" },
      { id: "q3", text: "가장 유용하다고 생각하는 기능은 무엇인가요?", type: "MULTIPLE_CHOICE" },
      { id: "q4", text: "개선되었으면 하는 점이 있다면 자유롭게 작성해주세요.", type: "TEXT" },
    ],
    [],
  );

  const [state, dispatch] = useReducer(surveyScreenStateReducer, initialSurveyScreenState);

  // 파생 상태들
  const isPersonalInfoComplete = useMemo(() => {
    const { name, age, gender } = state.personalInfo;
    return name.trim() !== "" && age.trim() !== "" && gender !== undefined;
  }, [state.personalInfo]);

  const isAllQuestionsAnswered = useMemo(() => {
    return questions.every((q) => state.answers[q.id] !== undefined);
  }, [questions, state.answers]);

  const totalQuestions = questions.length;

  // 단계 이동 함수들
  function goToStep(step: SurveyStep) {
    dispatch({ step });
  }

  function goNextStep() {
    const currentIndex = surveyStepOrder.indexOf(state.step);
    if (currentIndex < surveyStepOrder.length - 1) {
      dispatch({ step: surveyStepOrder[currentIndex + 1] });
    }
  }

  function goPrevStep() {
    const currentIndex = surveyStepOrder.indexOf(state.step);
    if (currentIndex > 0) {
      dispatch({ step: surveyStepOrder[currentIndex - 1] });
    }
  }

  // 데이터 업데이트 함수들
  function updatePersonalInfo(info: Partial<PersonalInfo>) {
    dispatch({
      personalInfo: { ...state.personalInfo, ...info },
    });
  }

  function updateAnswer(questionId: string, answer: string | string[]) {
    dispatch({
      answers: { ...state.answers, [questionId]: answer },
    });
  }

  // 질문 이동 함수들
  function goToNextQuestion() {
    if (state.currentQuestionIndex < totalQuestions - 1) {
      dispatch({ currentQuestionIndex: state.currentQuestionIndex + 1 });
    } else if (state.currentQuestionIndex === totalQuestions - 1) {
      goToStep("REVIEW");
    }
  }

  function goToPrevQuestion() {
    if (state.currentQuestionIndex > 0) {
      dispatch({ currentQuestionIndex: state.currentQuestionIndex - 1 });
    } else if (state.currentQuestionIndex === 0) {
      goToStep("PERSONAL_INFO");
    }
  }

  // 뒤로가기 버튼 처리
  const handleBackButton = useCallback(() => {
    if (state.step === "INTRO") {
      return false; // 앱 종료 허용
    } else if (state.step === "COMPLETE") {
      return true; // 뒤로가기 무시
    } else {
      setModalVisible(true, "CONFIRM_EXIT");
      return true; // 뒤로가기 가로채기
    }
  }, [state.step]);

  // 모달 관련 함수
  function setModalVisible(visible: boolean, type?: "CONFIRM_EXIT" | "SUBMIT_SUCCESS") {
    dispatch({
      modalVisible: visible,
      modalType: type,
    });
  }

  // 설문 제출 함수
  async function submitSurvey() {
    try {
      dispatch({ isSubmitting: true });

      // API 호출 로직 (실제로는 여기서 서버에 데이터를 전송)
      await new Promise((resolve) => setTimeout(resolve, 1000));

      dispatch({ isSubmitting: false });
      goToStep("COMPLETE");
    } catch (error) {
      dispatch({ isSubmitting: false });
      // 에러 처리 로직
    }
  }

  const value: SurveyContextType = {
    ...state,
    isPersonalInfoComplete,
    isAllQuestionsAnswered,
    totalQuestions,

    goToStep,
    goNextStep,
    goPrevStep,

    updatePersonalInfo,
    updateAnswer,

    goToNextQuestion,
    goToPrevQuestion,

    handleBackButton,
    submitSurvey,

    setModalVisible,
  };

  return <SurveyScreenContext.Provider value={value}>{children}</SurveyScreenContext.Provider>;
}

이 패턴의 장점

  1. 예측 가능한 상태 변화: Reducer를 통해 상태 변화가 일어나므로 예측 가능하고 디버깅이 쉽다.
  2. 화면 단위 캡슐화: 화면별로 상태와 로직을 캡슐화하여 관리할 수 있다.
  3. 코드 재사용성: Hook을 통해 상태와 로직을 쉽게 재사용할 수 있다.
  4. 테스트 용이성: 상태 로직이 분리되어 있어 테스트하기 쉽다.
  5. 불필요한 리렌더링 방지: Context를 적절히 분리하면 불필요한 리렌더링을 방지할 수 있다.
  6. 타입 안전성: TypeScript와 함께 사용하면 타입 오류를 컴파일 시점에 발견할 수 있다.
  7. 명확한 에러 처리: Context 사용 시 Provider 없이 사용하는 경우 명확한 오류 메시지를 제공한다.

특히 타입 정의와 useContext 훅을 통한 getter/setter 패턴은 코드의 가독성과 유지보수성을 크게 향상시킨다. 이는 팀 단위 개발에서 더욱 중요한 요소가 된다.

Zustand와 Context API의 차이점

React의 Context API와 Zustand는 각각 다른 특성과 사용 사례를 가지고 있다:

상태 지속성의 차이

  • Context API: 해당 Context가 속한 화면이 언마운트되면 상태가 초기화된다.
  • Zustand: 앱이 종료되기 전까지 상태가 유지된다.

적절한 사용 사례

Zustand 사용이 적합한 경우:

  1. 여러 화면에서 공유되는 데이터- 사용자 프로필 정보 (닉네임, 아바타, 권한 등) - 포인트나 코인과 같은 잔액 정보 - 알림 상태나 메시지 카운트 - 앱 전체 테마나 설정값

  2. 화면 전환 후에도 유지되어야 하는 데이터- 장바구니 정보 - 검색 필터나 정렬 설정 - 작성 중인 게시글 임시 저장 데이터

  3. 전역적인 UI 상태- 모달 표시 여부 - 로딩 상태 - 토스트 메시지 큐

Context API 사용이 적합한 경우:

  1. 화면 단위의 복잡한 상태 관리- 폼 입력 데이터와 유효성 검사 상태 - 화면 내 탭이나 스텝 관리 - 화면 내 여러 컴포넌트가 공유하는 임시 상태

  2. 다중 인스턴스가 필요한 컴포넌트- 댓글 목록의 각 댓글 아이템 - 상품 목록의 각 상품 카드

    • 동일한 형태의 여러 모달 창

실제 활용 전략: 계층적 상태 관리

가장 효과적인 방법은 Zustand와 Context API를 계층적으로 조합하는 것이다:

  1. Zustand로 전역 상태 관리- Context는 일시적인 상태 관리에 적합하며, 상태를 오래 유지하려면 Redux나 Zustand 같은 전역 상태 관리 라이브러리를 고려하는 것이 좋다.

  2. Context API로 화면별 상태 관리- Zustand의 전역 상태를 가져와서 화면에 맞게 가공 - 화면 내부에서만 필요한 추가 상태 관리 - 예: 폼 상태, 화면 내 UI 상태, 임시 데이터 등

Context 상태를 유지하려면?

만약 Provider가 언마운트되더라도 상태를 유지하고 싶다면 다음 방법을 사용해야 한다:

  1. 전역 상태 관리 도구 사용 (Redux, Zustand)- Context는 일시적인 상태 관리에 적합하며, 상태를 오래 유지하려면 Redux나 Zustand 같은 전역 상태 관리 라이브러리를 고려하는 것이 좋다.

  2. 로컬 스토리지 활용- 중요한 상태는 AsyncStorage(React Native) 또는 localStorage(웹)에 저장하여 앱 재시작 시에도 복원할 수 있다. - Context Provider가 마운트될 때 저장된 상태를 불러오고, 상태가 변경될 때마다 저장하는 방식으로 구현할 수 있다.

  3. 상위 레벨 Provider 사용- 애플리케이션 최상위 레벨에 Provider를 배치하여 언마운트되지 않도록 한다. - 이 방법은 전역 상태 관리와 유사하지만, Context API의 성능 이슈가 발생할 수 있다.

보일러플레이트 코드의 실용적 활용

이 글에서 소개한 보일러플레이트 코드는 다양한 상황에서 유연하게 활용할 수 있다:

  1. 스타트업 환경에서의 빠른 개발- 스타트업에서는 많은 수의 피처(기능)들을 빠르고 정확하게 개발해야 하는 상황이 많다. - 이 보일러플레이트를 활용하면 새로운 화면마다 일관된 상태 관리 패턴을 적용할 수 있어 개발 속도가 크게 향상된다. - 팀원 간 코드 이해도와 유지보수성도 높아진다.

  2. 다양한 화면 유형에 맞춤 적용- 단순한 정보 표시 화면부터 복잡한 폼 입력 화면까지 다양한 유형의 화면에 적용 가능하다. - 화면의 복잡도에 따라 상태 구조와 액션을 확장하거나 축소할 수 있다.

  3. 기존 코드베이스와의 통합- 이미 Redux나 MobX 등을 사용하는 프로젝트에서도 화면 단위 상태 관리를 위해 이 패턴을 도입할 수 있다. - 전역 상태와 화면 로컬 상태를 명확히 구분하여 관리할 수 있다.

  4. 테스트 용이성- 상태 로직이 UI와 분리되어 있어 단위 테스트 작성이 용이하다. - Reducer 함수는 순수 함수이므로 입력에 대한 출력을 예측하기 쉽다.

이 보일러플레이트 패턴은 특히 빠른 개발 사이클이 요구되는 스타트업 환경에서 큰 가치를 발휘한다. 새로운 기능을 추가할 때마다 상태 관리 구조를 고민할 필요 없이, 검증된 패턴을 바로 적용하여 개발 시간을 단축할 수 있다. 또한 일관된 코드 스타일을 유지할 수 있어 팀 협업에도 큰 도움이 된다.

언마운트와 상태 관리의 관계

React 컴포넌트 생명주기에서 언마운트(Unmount)는 컴포넌트가 DOM에서 제거되는 과정을 의미한다. 이는 다음과 같은 상황에서 발생한다:

  1. 화면 전환 시 (다른 페이지로 이동)
  2. 조건부 렌더링에서 조건이 변경될 때
  3. 리스트에서 항목이 제거될 때

언마운트가 상태 관리에 미치는 영향은 사용하는 상태 관리 도구에 따라 크게 달라진다:

Context API와 언마운트

Context API를 사용할 때, Provider 컴포넌트가 언마운트되면 해당 Context에 저장된 모든 상태가 메모리에서 완전히 제거된다. 이는 다음과 같은 결과를 가져온다:

function ProfileScreen() {
  return (
    <ProfileContextProvider>
      {" "}
      {/* 이 Provider가 언마운트되면 */}
      <ProfileContent /> {/* 내부 상태가 모두 초기화됨 */}
    </ProfileContextProvider>
  );
}

예를 들어, 프로필 설정 화면에서 사용자가 닉네임을 입력하고 다른 화면으로 이동했다가 다시 돌아오면, 이전에 입력했던 닉네임은 모두 사라지고 초기 상태로 돌아간다. 이는 화면 단위로 독립적인 상태 관리가 필요할 때 유용하다.

Zustand와 언마운트

반면 Zustand는 컴포넌트의 생명주기와 독립적으로 상태를 관리한다:

// 앱 어디서나 접근 가능한 전역 상태
const useProfileStore = create((set) => ({
  nickname: "",
  updateNickname: (name) => set({ nickname: name }),
}));

function ProfileScreen() {
  // 이 컴포넌트가 언마운트되어도 상태는 유지됨
  const { nickname, updateNickname } = useProfileStore();

  return <TextInput value={nickname} onChangeText={updateNickname} />;
}

사용자가 프로필 화면을 벗어났다가 다시 돌아와도, 이전에 입력한 닉네임이 그대로 유지된다. 이는 화면 간 상태 공유지속적인 상태 유지가 필요한 경우에 적합하다.

이처럼 동일한 화면의 여러 인스턴스가 존재할 수 있는 경우, Context API의 언마운트 시 상태 초기화 특성이 오히려 장점이 된다. 각 인스턴스는 자체적인 상태를 가지며, 다른 인스턴스의 상태에 영향을 받지 않는다.

언제 어떤 방식을 선택해야 할까?

상태의 지속성 요구사항에 따라 선택하는 것이 좋다:

  1. Context API 선택 시기:

    • 화면별로 독립적인 상태가 필요할 때
    • 동일한 화면의 여러 인스턴스가 존재할 때
    • 화면을 벗어나면 상태가 초기화되어도 괜찮을 때
  2. Zustand 선택 시기:

    • 화면 간에 상태를 공유해야 할 때
    • 앱 전체에서 일관된 상태가 필요할 때
    • 화면을 벗어나도 상태가 유지되어야 할 때

결론

React의 useReducer와 Context API를 활용한 화면별 상태 관리 패턴은 복잡한 UI를 체계적으로 관리하고, 컴포넌트 간 상태 공유를 쉽게 할 수 있는 효과적인 방법이다.

이 패턴은 예측 가능한 상태 변화, 명확한 타입 정의, 최적화된 렌더링, 유지보수성을 고려한 코드 구조를 제공하여 React Native뿐만 아니라 Next.js, 웹 애플리케이션에도 유용하게 적용할 수 있다.

특히 다음과 같은 경우에 적합하다:

  • 화면 단위로 독립적인 상태 관리가 필요한 경우
  • 여러 컴포넌트가 동일한 상태를 공유해야 하는 경우
  • 상태 변화를 예측 가능하게 관리하고 싶은 경우
  • 빠른 개발 사이클이 요구되는 환경에서 일관된 코드 스타일을 유지하고 싶은 경우

이 패턴을 적용하면 불필요한 전역 상태 관리 라이브러리 없이도 효율적인 상태 관리가 가능하며, 유지보수성과 코드의 가독성을 높일 수 있다. React Native에서 상태 관리로 고민해본 개발자들에게 도움이 되길 바란다.

Copyright © Dohyeong Lee - Dohyeong Lee
All Rights Reserved.