동일한 플로우 안(ex. 등록폼)의 다중 페이지 쉽게 관리하기

Chex·2023년 10월 13일
1

우아한테크코스

목록 보기
18/19
post-thumbnail

시작하며

현재 '집사의고민'이라는 프로젝트를 진행하고 있습니다.

저희 서비스에는 내 반려동물 정보를 등록하는 폼, 사료에 대한 리뷰를 등록하는 폼이 있어요. 한 페이지로 이루어진 폼이 아닌 여러 페이지로 구성된 폼이라는 점이 특징입니다.

기존 등록폼의 아쉬운점

반려동물정보 등록폼의 경우 이름, 나이, 견종 등 7개의 페이지로 구성되어있고 리뷰 등록폼의 경우 별점, 리뷰 작성 2개의 페이지로 구성되어있어요. Context API, useLocation, useOutletContext 등을 이용하여 페이지 간 데이터를 전달하는 방식으로 구현했었는데요.

이렇게 여러 페이지로 구성된 폼들을 관리하기란 여간 까다로운 일이 아니었습니다.

1. 가독성이 떨어지는 복잡한 코드
반려동물 정보 등록 폼의 경우 헤더에 진행상황을 숫자로 나타내기 위해 step(number타입)이라는 상태를 만들고 각 단계에 useOutletContextupdateCurrentStep을 전달해 각 단계에서 현재 step을 업데이트 해주는 식이었습니다. 이러한 구조로 깔끔하지 못한 코드가 나왔어요.

...
  return (
      <Template
        staticHeader={헤더컴포넌트}
        footer={false}
      >
        <ContentLayout>
          <Outlet
            context={{
              updateCurrentStep,
              updateIsValidStep,
            }}
          />
        </ContentLayout>
        {다음 버튼 컴포넌트}
      </Template>
    );
  };

2. 전체적인 플로우를 한눈에 파악하기 어려움
정보를 등록하는 플로우를 파악하기 위해서는 여러 파일들을 넘나들며 확인해야하는 불편함이 있었습니다.

3. 페이지 이동 시 작성중인 데이터가 유지되지 않음

사용자 피드백 中
"별점 수정이 가능했으면 좋겠어요 실수로 누르고 리뷰 작성하다가 수정하려고 뒤로 가기 했는데 작성한 내역이 사라져 있어서 불편해요!"

리뷰 등록 폼 실행화면 반려동물 등록 폼 실행화면

페이지 간 상태가 유지되지 않아서 폼 작성 중 페이지를 이동하면 작성 중인 데이터가 모두 날아가는 문제도 있었어요.

결국 위와 같은 문제점들을 해결하기 위해 퍼널패턴을 학습한 후 프로젝트에 적용해보았습니다.

등록 폼 개선하기

useFunnel & Funnel

// useFunnel.tsx
export const useFunnel = <Steps extends NonEmptyArray<string>>(
  steps: Steps,
  defaultStep: Steps[number],
) => {
  const [step, setStep] = useState<Steps[number]>(defaultStep);

  const FunnelComponent = useMemo(
    () =>
      Object.assign(
        (props: Omit<FunnelProps<Steps>, 'step' | 'steps'>) => (
          <Funnel<Steps> step={step} steps={steps} {...props} />
        ),
        { Step },
      ),
    [step],
  );

  return { Funnel: FunnelComponent, step, setStep };
};
// Funnel.tsx
export const Funnel = <Steps extends NonEmptyArray<string>>(props: FunnelProps<Steps>) => {
  const { steps, step, children } = props;
  const validChildren = Children.toArray(children)
    .filter(isValidElement<StepProps<Steps>>)
    .filter(({ props }) => steps.includes(props.name));

  const targetStep = validChildren.find(child => child.props.name === step);

  if (!targetStep) {
    throw new RuntimeError(
      { code: 'WRONG_URL_FORMAT' },
      `${step} 스텝 컴포넌트를 찾지 못했습니다.`,
    );
  }

  return targetStep;
};

각 step의 이름이 들어있는 steps 배열과 기본으로 보여줄 step을 받고 children(Step 컴포넌트들) 중 현재 Step컴포넌트(name으로 필터링)를 반환합니다.

useFunnel훅의 역할은 특정한 steps배열과 step을 가진 Funnel 컴포넌트를 생성하는 것입니다. 이러한 방법으로 Funnel 컴포넌트 생성 및 구성을 중앙화하여 관리함으로써 코드 일관성을 유지하고 사용자가 Funnel 컴포넌트를 더 쉽게 생성하고 속성을 설정하도록 하였습니다.

반려동물 등록 Funnel

// PetProfileAdditionFormFunnel.tsx
const PetProfileAdditionFormFunnel = () => {
  const { Funnel, step, setStep } = useFunnel(
    PET_PROFILE_ADDITION_STEP,
    PET_PROFILE_ADDITION_STEP[0],
  );
  
  // ...

  return (
    <Template
      staticHeader={() =>
        getPetProfileAdditionHeader({
          title: '반려동물 정보 등록',
          stepNum: PET_PROFILE_ADDITION_STEP.indexOf(step) + 1,
          totalStep: PET_PROFILE_ADDITION_STEP.length,
          onClickBackButton,
        })
      }
      footer={false}
    >
      <ContentLayout>
        <Funnel>
          <Funnel.Step name="NAME">
            <PetProfileNameAddition onNext={() => setStep(prev => 'AGE')} />
          </Funnel.Step>
          <Funnel.Step name="AGE">
            <PetProfileAgeAddition onNext={() => setStep(prev => 'BREED')} />
          </Funnel.Step>
          <Funnel.Step name="BREED">
            <PetProfileBreedAddition
              setIsMixedBreed={setIsMixedBreed}
              onNext={() => {
                if (isMixedBreed) setStep(prev => 'PET_SIZE');
                else setStep(prev => 'GENDER');
              }}
            />
          </Funnel.Step>
          <Funnel.Step name="PET_SIZE">
            <PetProfilePetSizeAddition onNext={() => setStep(prev => 'GENDER')} />
          </Funnel.Step>
          <Funnel.Step name="GENDER">
            <PetProfileGenderAddition onNext={() => setStep(prev => 'WEIGHT')} />
          </Funnel.Step>
          <Funnel.Step name="WEIGHT">
            <PetProfileWeightAddition onNext={() => setStep(prev => 'IMAGE_FILE')} />
          </Funnel.Step>
          <Funnel.Step name="IMAGE_FILE">
            <PetProfileImageAddition />
          </Funnel.Step>
        </Funnel>
      </ContentLayout>
    </Template>
  );
};

다음 버튼 클릭 시 다음 step으로 변경하여 다음 페이지로 이동할 수 있습니다.
위와 같은 구조로 각 단계에서 어떤 단계(페이지)로 이동하는지 한눈에 파악이 가능합니다.

사용자의 실수를 예방하는 작은 장치 적용하기

좋은 UI/UX를 가진 서비스는 사용자의 실수를 예상하고 이를 예방하는 서비스라고 생각합니다.

// useEasyNavigate.ts
const useEasyNavigate = () => {
  const navigate = useNavigate();

  const goHome = () => navigate(routerPath.home());

  const goBackSafely = () => {
    confirm(FORM_EXIT_CONFIRMATION_MESSAGE) && goBack();
  };
  
  // ...


  return { navigate, goBack, goBackSafely, ...};
};

그래서 사용자가 실수하기 어렵도록(?) goBackSafely함수를 만들어 등록폼 페이지를 벗어나는 경우 확인창을 띄우도록 했습니다.

확인창 띄우기 실행화면

등록폼 개선결과

리뷰 등록 폼 실행화면 반려동물 등록 폼 실행화면
  1. 가독성이 떨어지는 복잡한 코드를 개선하였습니다.
  • 진행상황을 나타내는 상태를 따로 만들어서 각 페이지에서 매번 업데이트해줄 필요 없이 최상단의 퍼널 페이지에서 steps 배열을 이용하여 헤더에 보여줄 수 있게 되었습니다.
    반려동물 정보 등록 헤더
  1. 여러 페이지로 이루어진 등록 폼의 전체적인 흐름을 한눈에 파악할 수 있게 되었습니다.
  • 특히 반려동물 등록폼에서 견종이 믹스견인 경우 사이즈 선택 페이지로, 믹스견이 아닌 경우 성별 선택 페이지로 이동하는 동적인 흐름도 파악하기 쉬워졌습니다.
  1. 사용자가 실수로 다른 페이지로 이동한 후 돌아와도 작성한 데이터가 유지되도록 하였습니다.
  • 리뷰 등록폼의 경우 퍼널 페이지에서 상태(reviewData)를 관리하도록 했습니다. 퍼널 페이지의 경우 등록 플로우가 끝날 때까지 unmount되지 않기 때문에 페이지 이동 시에도 상태가 날아가지 않고 유지됩니다.
  • 반려동물 등록폼의 경우 페이지 수가 많고 기존에 ContextAPI로 만들어둔 전역상태가 있었기 때문에 이를 사용했고 폼 내부의 인풋 컴포넌트들을 비제어 컴포넌트에서 전역 상태를 이용한 제어 컴포넌트로 변경하여 사용자가 이전에 작성한 데이터를 보여주도록 하였습니다.
profile
Fake It till you make It!

0개의 댓글

관련 채용 정보