useFunnel 커스텀 훅 구현

Hunjin·2026년 1월 23일

이번 프로젝트에서 온보딩 페이지를 구현하면서 가장 고민이 되었던 부분은 여러 단계로 이루어진 폼의 상태를 어떻게 관리할 것인가였습니다. 각 단계마다 페이지를 분리할 수도 있었지만, 사용자 경험과 상태 응집도를 고려했을 때 하나의 페이지 안에서 흐름을 관리하는 방식이 더 적합하다고 판단했습니다.

이에 따라 이번 온보딩 페이지에서는 퍼널(Funnel) 구조를 도입하고
이를 관리하기 위한 커스텀 훅 useFunnel을 직접 구현했습니다.

저희의 서비스에서는 총 4단계, 15개의 조건부 입력 필드가 존재합니다.

useFunnel 훅의 역할

useFunnel은 다음과 같은 책임을 가집니다.

  • 퍼널 단계(step)의 순서 관리
  • 현재 단계 상태 관리
  • 다음/이전 단계 이동 로직 제공
  • 브라우저 뒤로 가기(popstate) 대응
  • 현재 단계에 해당하는 UI만 렌더링

즉, 온보딩 흐름 전체를 하나의 훅에서 응집도 있게 관리하기 위한 구조입니다.

그러면 useFunnel 커스텀 훅을 어떻게 구현했는지 알아보도록 하겠습니다.


훅 시그니처 설계

const useFunnel = (steps: readonly string[], completePath: string)
  • steps: 퍼널을 구성하는 단계 목록
    순서가 중요한 구조이므로 배열 기반으로 관리하였습니다.
  • completePath: 마지막 단계가 완료된 후 이동할 경로

퍼널의 구조와 종료 지점을 외부에서 주입받도록 설계해,
온보딩 외에도 재사용 가능하도록 했습니다.


브라우저 히스토리 초기화

useEffect(() => {
  if (!window.history.state?.step) {
    window.history.replaceState({ step: steps[0] }, '');
  }
}, [steps]);

페이지 새로고침이나 직접 접근 시 history.state가 비어 있을 수 있기 때문에
최초 진입 시 현재 step을 브라우저 히스토리에 초기화하는 작업을 진행합니다.
이를 통해 UI와 히스토리 간에 불일치를 방지합니다.


브라우저 뒤로 가기

useEffect(() => {
  const handlePopState = (event: PopStateEvent) => {
    if (event.state?.step) {
      setCurrentStep(event.state.step);
    } else {
      setCurrentStep(steps[0] ?? '');
    }
  };
  window.addEventListener('popstate', handlePopState);
  return () => window.removeEventListener('popstate', handlePopState);
}, [steps]);

popstate가 필요할까요?
이번 온보딩은 외부의 접근을 막기 위해 URL을 고정된 상태로 진행하게 됩니다.
그래서 URL을 변경하지 않고 window.history.pushState를 통해 단계를 이동합니다.

하지만 해당 방식에는 문제가 있었습니다.
브라우저 히스토리는 변경되지만 react 상태는 자동으로 바뀌지 않았습니다.
즉, 사용자가 브라우저의 뒤로 가기 버튼을 눌렀을 때 히스토리는 이전 상태로 이동하지만,
UI는 여전히 이전 step을 가리키고 있는 문제가 발생하여 이를 해결하기 위해 popstate 이벤트를 직접 처리해야 합니다.

popstate 이벤트란?
popstate는 브라우저 히스토리가 변경될 때 발생하는 이벤트입니다.

  • 뒤로 가기
  • 앞으로 가기
  • history.back(), history.forward() 호출

단, pushState 자체로는 발생하지 않고
이미 쌓인 히스토리를 이동할 때만 발생합니다.


다음 단계 / 이전 단계 이동

const goToNextStep = () => {
  const nextStep = steps[currentStepIndex + 1];
  if (nextStep) {
    window.history.pushState({ step: nextStep }, '');
    setCurrentStep(nextStep);
  } else {
    navigate(completePath);
  }
};
  • 다음 step이 존재하면 해당 step으로 이동
  • 더 이상 step이 없을 경우 퍼널이 종료도어 설정 페이지로 이동

Funnel 구조

const Funnel = ({ children }: FunnelProps) => {
  const targetStep = Children.toArray(children).find((child) => {
    if (!isValidElement<StepProps>(child)) {
      return false;
    }
    return child.props.name === currentStep;
  });
  return targetStep;
};

Funnel 컴포넌트는

<Funnel>
  <Step name="intro">...</Step>
  <Step name="profile">...</Step>
  <Step name="terms">...</Step>
</Funnel>

해당 구조를 바탕으로 현재 step에 해당하는 Step만 렌더링합니다.


이를 통해 조건문 기반 렌더링을 제거하고
UI 구조 자체가 퍼널의 흐름을 그대로 드러내도록 설계할 수 있었습니다.

profile
프론트 개발을 해보아요👨🏻‍💻

0개의 댓글