📚 목차
[React] useFunnel Hook 구현하기 - 쏟아지는 페이지 한 방에 관리하기
ByEunwoo
reactuseFunnel 도입 배경
회원가입이나 결제처럼 여러 단계를 거치는 페이지를 구현할 때, 기존 방식에는 불편함이 많다.
Toss에서 개발한 use-funnel에서 영감을 받아서 공부해보고 직접 구현해보기로 결심하였다.
- 페이지 분리 방식:
/signup/step1
,/signup/step2
처럼 라우트를 나누는 방법이다.
이 경우 각 단계마다 라우트를 추가해야 하고, 상태를 전달하기 위해 전역 상태나location.state
등을 사용해야 하는 번거로움이 있다. - 단일 페이지 조건부 렌더링:
step
상태값에 따라<Step1 />
,<Step2 />
를 조건부로 렌더링하는 방법이다.
이 경우 새로고침 시 단계가 초기화되고, 브라우저 뒤로가기나 앞으로가기가 원하는 대로 동작하지 않는다.
이러한 방식들은 다음과 같은 문제를 만든다.
- 브라우저 히스토리와 단계 진행이 일치하지 않아 UX가 어색하다.
- URL에 현재 단계 정보가 없어 새로고침하면 항상 첫 단계로 돌아간다.
- 단계 간 데이터 전달 방법이 일관되지 않아 유지보수가 어렵다.
이 문제를 해결하기 위해, URL과 단계 상태를 동기화하는 useFunnel
패턴을 도입하게 되었다.
useFunnel이란?
useFunnel
은 다단계 흐름을 URL 쿼리 파라미터와 동기화해 새로고침, 뒤로가기, 공유 등 다양한 상황에서도 안정적으로 동작하게 하는 훅이다.
핵심 아이디어는 다음과 같다.
- URL 쿼리(
?step=...
)에 현재 단계를 기록한다. setStep()
호출 시 URL 쿼리를 변경해 단계 전환을 한다.<Step name="...">
컴포넌트를 사용해 현재 단계와 일치하는 UI만 렌더링한다.- 이전 단계와 다음 단계 정보를 제공해 단계 이동을 간단하게 한다.
이 방식의 장점은 다음과 같다.
- 브라우저 히스토리와 단계 이동이 일치해 UX가 자연스럽다.
- URL에 단계 정보가 포함돼 새로고침이나 링크 공유 시에도 동일한 상태를 복원할 수 있다.
- 기존 쿼리 파라미터를 보존하므로 마케팅 태그나 검색 파라미터도 유지된다.
useFunnel 구현해보기
아래는 프로젝트 하면서 직접 구현한 useFunnel
코드이다.
해당 코드는 항상 정답이 아닌 참고용으로 코드를 참고하면 좋겠다.
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
export function useFunnel<T extends readonly string[]>(steps: T) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const stepQuery = searchParams.get('step');
const step = steps.includes(stepQuery || '') ? (stepQuery as T[number]) : steps[0];
const setStep = useCallback(
(nextStep: T[number]) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('step', nextStep);
navigate(`${location.pathname}?${newSearchParams.toString()}`, { replace: true });
},
[navigate, location.pathname, searchParams],
);
const Funnel = useCallback(({ children }: { children: React.ReactNode }) => <>{children}</>, []);
const Step = useCallback(
({ name, children }: { name: T[number]; children: React.ReactNode }) => {
return name === step ? <>{children}</> : null;
},
[step],
);
const currentStepIndex = useMemo(() => steps.indexOf(step), [step, steps]);
const beforeStep = useMemo(
() => (currentStepIndex > 0 ? (steps[currentStepIndex - 1] as T[number]) : null),
[currentStepIndex, steps],
);
const nextStep = useMemo(
() => (currentStepIndex < steps.length - 1 ? (steps[currentStepIndex + 1] as T[number]) : null),
[currentStepIndex, steps],
);
return useMemo(
() => ({
Funnel,
Step,
useStep: () => ({ step, setStep }),
beforeStep,
nextStep,
}),
[Step, step, setStep, beforeStep, nextStep],
);
}
useFunnel 사용법
const steps = ['verify', 'info', 'complete'] as const;
function SignupPage() {
const { Funnel, Step, useStep, beforeStep, nextStep } = useFunnel(steps);
const { step, setStep } = useStep();
return (
<Funnel>
<Step name='verify'>
<VerifyCodeForm onNext={() => setStep('info')} />
</Step>
<Step name='info'>
<InfoForm
onPrev={() => beforeStep && setStep(beforeStep)}
onNext={() => nextStep && setStep(nextStep)}
/>
</Step>
<Step name='complete'>
<SignupComplete />
</Step>
</Funnel>
);
}
코드의 특징
1. 타입 안전성
T extends readonly string[]
를 사용해 단계 목록을 리터럴 튜플로 받고,T[number]
로 가능한 단계명을 타입으로 제한한다.
2. URL 기반 상태 관리
useSearchParams
로 URL 쿼리를 읽어 현재 단계를 결정하고,navigate
를 통해 단계 전환 시 URL을 변경한다.
3. 기존 쿼리 보존
new URLSearchParams(searchParams)
로 복사 후 set하여, 기존 쿼리 파라미터를 보존한다.
4. 이전/다음 단계 계산
현재 단계 인덱스를 기반으로 이전 단계와 다음 단계를 쉽게 가져올 수 있다.
5. 컴포넌트 API 제공
<Funnel>
과<Step>
컴포넌트를 제공해 JSX에서 읽기 쉽고 명확하게 단계별 UI를 구성할 수 있다.
마무리
useFunnel
은 다단계 페이지의 상태를 URL과 동기화해 유지보수성과 사용자 경험을 동시에 높일 수 있는 패턴이다.
단계 이동이 브라우저 히스토리와 맞물려 동작하므로 뒤로가기나 앞으로가기가 자연스럽고, 새로고침이나 링크 공유 시에도 동일한 상태로 진입할 수 있다.
이번에 구현한 간단 버전은 기본적인 단계 전환과 URL 동기화를 처리하지만, 더 복잡한 시나리오에서는 단계별 상태 관리나 전환 검증 로직을 추가해 확장할 수 있다.