복잡한 퍼널 관리하기 (React Context API + Custom Hook)

2024. 9. 13. 22:02프로젝트

회고 서비스 레이어는 사용자가 데이터를 직접 입력하거나 선택하는 플로우가 대부분이라 퍼널 형식의 UI/UX가 많아요. 대표적으로 스페이스 생성, 회고 생성, 회고 작성의 단계에서 퍼널이 사용되는데, 팀원들 모두 퍼널을 어떻게 구현할지에 대해 고민이 많았어요. 저는 회고 생성 플로우를 구현했는데, 개인적으로 처음 회고 생성 플로우를 개발할 때가 레이어를 개발하면서 가장 많은 고민을 했던 시기가 아닌가 생각이 듭니다. 레이어의 회고 생성은 평탄한 단계들을 순차적으로 진행하는 것이 아니라, 한 뎁스 더 들어가서 퍼널 안의 퍼널이 또 존재하는 형태예요. 이런 복잡한 플로우때문에 코드 구조를 설계하며 많은 고민을 거쳤는데, 우선 기본적인 퍼널을 어떻게 훅의 형태로 정리하고 사용하였는지를 풀어보겠습니다.

퍼널 설계를 어떻게 했을까요?

퍼널을 구현하기 위한 기능 정의를 먼저 하자면,

  • 각 단계마다 앞, 뒤로 이동할 수 있는 함수가 필요해요.

  • 현재 사용자가 위치한 단계에 해당하는 컴포넌트를 띄워야 해요.

따라서 해당 기능들을 제공하는 훅을 아래와 같이 작성했습니다.

import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";

type UseMultiStepForm<T extends string> = {
  steps: readonly T[]; // readonly 타입의 단계들을 배열 형태로 받습니다
  redirectPath?: string;
};

export const useMultiStepForm = <T extends string>({ steps, redirectPath }: UseMultiStepForm<T>) => {
  const navigate = useNavigate();

  // 전체 단계 개수
  const totalStepsCnt = useMemo(() => steps.length, [steps]);
  const [currentStepIndex, setCurrentStepIndex] = useState(0);
  const currentStep: T = useMemo(() => steps[currentStepIndex], [currentStepIndex, steps]);

  // 다음 단계로 이동
  const goNext = useCallback(() => {
    if (isLastStep) {
      if (redirectPath) {
        navigate(redirectPath, { replace: true });
      }
      return;
    }
    setCurrentStepIndex((i) => i + 1);
  }, [currentStep, totalStepsCnt, steps, redirectPath]);

  // 이전 단계로 이동
  const goPrev = useCallback(() => {
    if (currentStepIndex === 0) {
      navigate(-1);
      return;
    }
    setCurrentStepIndex((i) => i - 1);
  }, [currentStepIndex]);

  // 특정 단계로 이동
  const goTo = useCallback(
    (targetStep: T) => {
      const targetIndex = steps.indexOf(targetStep);
      setCurrentStepIndex(targetIndex);
    },
    [steps, setCurrentStepIndex],
  );

  // 마지막 단계인지를 판단
  const isLastStep = useMemo(() => currentStepIndex === totalStepsCnt - 1, [currentStepIndex, totalStepsCnt]);

  return useMemo(
    () => ({
      totalStepsCnt,
      currentStep,
      currentStepIndex,
      goNext,
      goPrev,
      goTo,
      isLastStep,
    }),
    [totalStepsCnt, currentStep, currentStepIndex, goNext, goPrev, goTo, isLastStep],
  );
};

이렇게 퍼널을 관리하는 데에 필요한 함수나 값들을 훅 안에 정리하여 필요할 때마다 꺼내서 쓸 수 있도록 모듈화 작업을 진행했어요. 동작 프로세스를 간단히 요약하자면,

  • 퍼널의 단계들을 담는 steps 배열을 훅에 전달합니다. 이때 배열 내 원소들의 순서는 곧 퍼널 단계의 순서입니다.

  • 현재 사용자가 어느 단계에 있는지를 currentStepIndex에 담아두고, 앞으로 이동하거나 뒤로 이동할 때마다 각각 1을 더하고 빼주었습니다.

  • 이에 따라 goNext, goPrev를 호출하면 currentStepsteps 중 해당하는 단계로 업데이트됩니다.

  • 상위 페이지 컴포넌트에서 각 단계에 해당하는 컴포넌트들을 currentStep 값에 따라 분기처리하여 렌더링하면 됩니다.

사소할 수 있지만 해당 훅을 작성하면서 고민했던 부분을 하나 소개드리자면, '꼭 각 단계의 이름을 부여해야하는가?'였어요. 개발자들이 가장 수고스럽게 여기는게 '이름 짓기'인 만큼, 굳이 각 단계의 이름을 정의해서 배열로 넘겨야 하는지에 대한 고민을 했었어요. 이렇게 하지 않더라도, 인덱스, 즉 숫자로 단계를 관리하면 이름을 따로 지을 필요도 없고, 몇번째 단계에 어떤 컴포넌트가 해당하는지도 빠르게 파악할 수 있으니까요. 그런데 훅 안에 정의된 메소드를 보면 알 수 있듯이, goTo처럼 특정 단계로 곧장 이동해야하는 경우에는 확실히 이름이 있어야 편하겠다는 생각을 했어요. goTo(3)보다는, goTo('dueDate')가 훨씬 직관적이니까요. 또 배열을 as const로 단언해 타입을 좁히면 currentStep이 배열의 원소로 추론되어 자동완성되니 작성할 때도 큰 불편함을 느끼지 못했습니다.

훅은 이렇게 작성을 끝내고, 컴포넌트마다 훅이 제공하는 메서드, 상태 값들을 props로 일일히 넘겨주기에는 반복작업이 많아 Context API를 사용했어요. 단계 컴포넌트들을 Provider로 감싸 퍼널 관련 메서드와 상태들을 하위로 전달합니다.

const PAGE_STEPS = ["start", "mainInfo", "customTemplate", "dueDate"] as const;

type UseMultiStepFormContextState<T extends (typeof PAGE_STEPS)[number]> = ReturnType<typeof useMultiStepForm<T>>;

type RetrospectCreateContextState = UseMultiStepFormContextState<(typeof PAGE_STEPS)[number]>

export const RetrospectCreateContext = createContext<RetrospectCreateContextState>({} as RetrospectCreateContextState);

export function RetrospectCreate() {

  const pageState = useMultiStepForm({
    steps: PAGE_STEPS,
  });

  return (
      <RetrospectCreateContext.Provider value={{ ...pageState }}>
        <form
          onSubmit={(e) => {
            e.preventDefault();
            /* 입력 데이터 POST */
          }}
        >
          {pageState.currentStep === "start" && <Start />}
          {pageState.currentStep === "mainInfo" && <MainInfo />}
          {pageState.currentStep === "customTemplate" && <CustomTemplate />}
          {pageState.currentStep === "dueDate" && <DueDate />}
        </form>
      </RetrospectCreateContext.Provider>
  );
}

useMultiStepForm가 반환하는 값들을 pageState로 받아요. 그리고 하위 단계별 컴포넌트들은 이 값들을 useContext를 통해 읽을 수 있어요.

export function MainInfo() {
  const { goNext } = useContext(RetrospectCreateContext);
  const [retroCreateData, setRetroCreateData] = useAtom(retrospectCreateAtom);

  const handleDataSave = () => {
    setRetroCreateData(데이터 저장);
    goNext();
  };

  return (
      {/* .. (생략).. */}
      <ButtonProvider>
        <ButtonProvider.Primary onClick={handleDataSave}>
          다음
        </ButtonProvider.Primary>
      </ButtonProvider>
  );
}

이렇게 단계 컴포넌트 내부에서 Context를 통해 메소드를 받아 호출만 하면 내부에서는 별다른 코드 작성 없이 퍼널을 진행시킬 수 있습니다.

퍼널 안 퍼널

앞서 회고 생성 플로우가 복잡해 많은 고민을 했다고 언급을 했었는데요, 이 플로우를 간단히 설명드리자면,

우선 회고 생성 퍼널은 크게 다음과 같은 단계들로 이루어져요.

단계 1. 회고 이름, 한 줄 설명 입력

단계 2. 템플릿 질문 수정 단계

단계 3. 회고 마감일 지정 단계

왼쪽부터 단계 1, 2, 3

단계 1부터 3까지 순차적으로 흘러가면 그렇게 복잡하지 않은 평범한 퍼널이라고 할 수 있는데, 문제는 단계 2에서 또 하나의 플로우가 더 추가된다는 것이었어요. 단계 2는

a. 템플릿의 원본 질문들을 확인하는 화면,

b. 질문을 수정하는 화면`과 질문을 수정한 후,

c. 수정된 질문들을 최종 확인하는 화면

왼쪽부터 단계 2-a, 2-b, 2-c

으로 구성되어있어요. 질문 수정을 하는 경우 두 개의 화면을 추가적으로 보여준 후에 단계 3으로 넘어가야 하는 플로우인거죠. a. 템플릿의 원본 질문들을 확인하는 화면c. 수정된 질문들을 최종 확인하는 화면이 텍스트 내용이나 약간의 차이만을 제외하면 거의 동일한 화면이니 처음 구조를 고민했을 때는 a와 c를 구분하지 말고 하나의 컴포넌트로 구성하되 분기처리를 하자는 위험한 생각을 했었습니다. 이렇게 하면 b를 중간에 띄웠다가 b단계를 마치고 나면 다시 a로 돌아와 약간의 분기처리만 해주면 되니까요. 당시 MVP 출시를 목표로 했던 중간발표 시점이 코앞이었던지라, 고민할 시간이 많이 없어 바로 이런 코드를 스파게티를 만들어내기 시작했었습니다. 그치만 페이지 컴포넌트 내부에서 분기처리를 해주자니 컴포넌트가 너무 복잡해졌고, 약간의 디자인 변경이 생길 때마다 하나의 컴포넌트 내부에서 건드려야 할 부분들이 계속해서 많아졌어요. 아.. 간단하게 처리하려다가 오히려 컴포넌트가 금방 괴상한 컴포넌트가 돼버리겠다는 생각이 들어 급하게 리팩토링을 진행했습니다.

차분하게 다시 설계를 해보자는 마음으로 피그마를 다시 들여다봤어요. 보다보니 생김새만 약간 다른 화면일 뿐이지 결국 이것도 하나의 작은 퍼널, 즉 퍼널 안의 퍼널로 해석할 수 있겠다는 생각을 했어요. 퍼널 관리를 훅으로 만들어두길 참 잘했다는 생각을 하며 곧바로 만들어두었던 훅을 다시 꺼내 사용했습니다.

// 👉 기존 상위 뎁스의 단계들(1, 2, 3)
const PAGE_STEPS = ["start", "mainInfo", "customTemplate", "dueDate"] as const;
// 👉 단계 2 내부의 단계들
const CUSTOM_TEMPLATE_STEPS = ["confirmDefaultTemplate", "editQuestions", "confirmEditTemplate"] as const;

type UseMultiStepFormContextState<T extends (typeof CUSTOM_TEMPLATE_STEPS)[number] | (typeof PAGE_STEPS)[number]> = ReturnType<
  typeof useMultiStepForm<T>
>;

type RetrospectCreateContextState = UseMultiStepFormContextState<(typeof PAGE_STEPS)[number]>;

type CustomTemplateContextState = UseMultiStepFormContextState<(typeof CUSTOM_TEMPLATE_STEPS)[number]>;

export const RetrospectCreateContext = createContext<RetrospectCreateContextState>({} as RetrospectCreateContextState);

export const CustomTemplateContext = createContext<CustomTemplateContextState>({} as CustomTemplateContextState);

export function RetrospectCreate() {
  // 👉 기존 상위 뎁스의 단계들(1, 2, 3)을 관리하기 위한 퍼널
  const pageState = useMultiStepForm({
    steps: PAGE_STEPS,
  });

  // 👉 단계 2 내부의 단계들을 위한 퍼널
  const customTemplateState = useMultiStepForm({
    steps: CUSTOM_TEMPLATE_STEPS,
  });

  return (
    <RetrospectCreateContext.Provider value={{ ...pageState }}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          // 입력 데이터 POST
        }}
      >
        {pageState.currentStep === "start" && <Start />}
        {pageState.currentStep === "mainInfo" && <MainInfo />}
        {pageState.currentStep === "customTemplate" && (
          // 👉 단계 2를 위한 퍼널 로직을 뿌리기 위해 Provider를 한 번 더 감싸요
          <CustomTemplateContext.Provider value={customTemplateState}>
            <CustomTemplate />
          </CustomTemplateContext.Provider>
        )}
        {pageState.currentStep === "dueDate" && <DueDate />}
      </form>
    </RetrospectCreateContext.Provider>
  );
}

아래는 단계 2에 해당하는 컴포넌트예요.

export function CustomTemplate() {
  const pageContext = useContext(RetrospectCreateContext);
  const customContext = useContext(CustomTemplateContext);

  return (
    <>
      {customContext.currentStep === "confirmDefaultTemplate" && <ConfirmDefaultTemplate goEdit={customContext.goNext} />}
      {customContext.currentStep === "editQuestions" && (
        <FullModal>
          <EditQuestions goNext={customContext.goNext} goPrev={customContext.goPrev} />
        </FullModal>
      )}
      {customContext.currentStep === "confirmEditTemplate" && <ConfirmEditTemplate goNext={pageContext.goNext} goPrev={customContext.goPrev} />}
    </>
  );
}

단계 2에 해당하는 CustomTemplate은 내부적으로 a, b, c단계로 다시 나눠지기 때문에 page 단위의 Context와 별도로, CustomTemplateContext를 통해 전달받은 퍼널 메소드들을 사용해요. 상위 뎁스의 퍼널을 이동시키고 싶다면 pageContext를 사용하고, 현재 퍼널, 즉 하위 뎁스의 퍼널을 이동시키고 싶다면 currentContext를 사용합니다. 다만 이 단계에선 pageContext를 사용할지, currentContext를 사용할지 각 단계 컴포넌트 내부에 작성하지 않고, 위 컴포넌트에서 결정해 작성해줬어요. 마지막 c단계에 해당하는 ConfirmEditTemplate의 경우, 다음 버튼을 눌렀을 때 pageContextgoNext가 아닌 pageContextgoNext를 호출해야 하는데, 컴포넌트 내부에서는 자기 자신이 마지막 단계에 해당하는지 알 길이 없으니까요. 물론 그냥 내부에서 pageContext를 바로 사용해버려도 되지만, 만약 단계가 끝에 새롭게 추가라도 되면 CustomTemplate 한 곳에서 수정하면 될 것을 내부 컴포넌트까지 넘나들며 수정 및 확인 과정을 거쳐야 합니다.

아쉬운 점들, 남아있는 과제

  • 현재 단계 컴포넌트들을 상위에서 form 태그로 감싸고 있어요. 그치만 내부의 input 들과 연결되어있지 않아 form의 event를 제대로 활용하고 있지 못합니다.

  • 아직은 데이터를 각 단계별 컴포넌트 안에서 전역 상태를 통해 처리해주고 있어요. 때문에 어떤 데이터를 어떤 컴포넌트에서 입력받고 있는지 한 눈에 파악하기가 어려워요.

퍼널이 많은 UI/UX로 사용되고 있는 만큼, 퍼널을 개발하는 방법은 다양하게 존재하는 것 같아요. 대표적으로 많은 분들이 알고 계시는 토스의 useFunnel이 있는데, 저희 팀은 해당 훅의 내부 코드를 보며 리팩토링의 방향성을 잡으려고 해요. 또 form을 제대로 관리하기 위해 React Hook Form도 살펴보며 리팩토링을 할 예정입니다.