현제 제가 진행하고 있는 인맥사무소 프로젝트에서는 회원가입, 모임 생성, 동호회 생성, 모임 신청, 동호회 가입 등 다양한 곳에서 복잡한 입력폼을 다뤄야 합니다.
만약 이 모임 생성에 필요한 모든 입력란을 좁은 모바일 화면에 한꺼번에 담는다면 사용자들은 복잡하고 난잡한 화면을 마주하게 되고 당장 눈에 보이는 수많은 입력란에 피로감을 유발하는 등 유저 경험에 다소 아쉬울거라 생각했습니다.
그래서 저희는 입력폼들을 쪼개서 페이지 한 개당 한 개의 입력란만 들어가도록 하고 이렇게 생성된 다수의 페이지들을 페이지네이션 방식의 프로세스를 만들어 설계한다면 위에 언급한 문제들이 다소 해결될거라 생각했습니다.
하지만 이 방식을 사용한다면 한개의 설문지를 만들기 위해 생기는 다수의 페이지(컴포넌트)들을 어떻게 관리할지 고민이였고 그러다가 예전에 봤었던 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 에서의 퍼널 패턴이 적합해보였습니다.
그래서 퍼널 패턴이 무엇인지 어떤 장점 때문에 적합해보였는지 차근 차근 풀어보겠습니다.
퍼널이란?
퍼널은 마케팅 용어로 유저가 서비스에 들어와서 최종 목표지점에 이르는것을 유도하기 위해 사용되는 도구나 방법을 뜻하는데,
Toss/Slash에서 공개한 프론트엔드에서 패널 패턴 뜻 역시 퍼널 사용자 행동 흐름이 점진적으로 좁아지는 방식으로 사용자를 특정 목표에 도달하게 만드는 패턴으로, 설문조사 패턴에서 응집도, 추상화, 시각화 측면을 개선한 패턴이라고 합니다.
문제 상황

만약 위와 같이 다양한 상태를 수집하는 페이지들이 흩어져있으며, 페이지들은 router.push, navigate 로 페이지 흐름을 연결시킨 상황이라면 어떤 단점이 있을까요?
const [registerData,setRegisterData] = useState({A:1, B:2, C:3, D:4})
<Funnel>
<Funnel.Step name="funnel1">
<AComponent onNext=((data)=>{
setDsetRegisterData((prev)=>{...prev,A :data}))
setStep("funnel2")
})/>
</div>
</Funnel.Step>
<Funnel.Step name="funnel2">
<BComponent onNext=((data)=>{
setDsetRegisterData((prev)=>{...prev,B :data}))
setStep("funnel3")
})/>
</Funnel.Step>
<Funnel>
이렇게 연관된 페이지(스탭)를 한 곳에 응집하였고 UI 관련은 하위 컴포넌트, Step 이동은 상위에서 관리하게 설계
이로써 장점은…
이러한 동작방식을 여러 곳에서 적용할 수 있도록 추상화한 것이 useFunnel훅으로, 그래서 저희 팀 역시 설문조사 페이지들을 효과적으로 관리하고 유지보수 하기 위해 해당 이 useFunnel훅을 프로젝트에 적용해보기로했습니다.
Toss/slash 유튜브 영상에서 공개된 코드에서 기반으로 커스텀을 진행해봤습니다.
Shallow Route는 SPA 환경에서 새로 고침 없이 URL 만 변경할 수 있게 하는 작업입니다.
그렇다면 저희는 Shallow Route가 왜 필요다고 생각했을까요?
우선 모임 생성 퍼널은 기획상 아래와같이 7개 스탭으로 이루어져있습니다.
모임 제목 - 모임 카테고리 선택- 활동시간 기입 - 모임 위치 선택 - 모집 인원수 설정 - 상세 내용작성 - 완료 이렇게 말이죠…
만약 사용자가 활동시간 스탭에서 뒤로가기 버튼을 눌렀지만 모임 생성 페이지가 단일 URL로 되어있는 경우 모임 카테고리 스탭으로 이동하는것이 아닌 메인 홈 페이지로 이동하게 됩니다. 왜죠? 스탭간 이동기록이 남아있지 히스토리에 남아있지 않으니깐요..
이렇듯 단일 URL을 사용하는 경우 step 사이에 뒤로가기, 앞으로가기 지원이 안 되는 불편함이 존재합니다.
그래서 스탭이 바뀔 때마다 Shallow Route를 이용하여 URL을 변경시키며 히스토리를 저장하여 뒤로가기, 앞으로가기를 지원하려 했습니다만…
Next AppRouter에서는 PageRouter와 달리 Shallow Route를 지원하지 않습니다.
그렇다고 Shallow Route를 이용하기 위해 프로젝트를 PageRoute방식으로 변경시킬 만큼 큰 이점을 가져주지 않을거라 판단했고 해당 문제는 https://github.com/vercel/next.js/discussions/4811에서 historyApi를 이용한다면 쉽게 해결 할 수 있다는 정보를 입수해 적용해봤습니다.
const shallowRoute = (nextFunnel: string) => {
if (pathName) {
window.history.pushState(null, "", `${pathName}?step=${nextFunnel}`)
}
}
페이지 이동을 시키는 방법이 여러가지가 있지만, 가장 신경써야할 부분은 url을 변경 할 때, 새로고침이 일어나면 안되는 문제였습니다.
왜냐하면.. 사용자가 입력한 정보들을 상태에 저장중에 새로고침이 일어난다면 상태 값이 초기화되기 때문입니다.
그래서 history.pushState 메소드를 이용해 해결했습니다. 해당 메소드를 이용한다면 저희가 새로고침을 하지 않고도 원하는 URL을 변경가능하게해줍니다!
useEffect(() => {
const handlePopState = () => {
const params = new URLSearchParams(window.location.search)
const newStep = params.get("step")
if (newStep && steps.includes(newStep)) {
setStep(newStep)
} else {
setStep(defaultStep)
}
}
window.addEventListener("popstate", handlePopState)
return () => {
window.removeEventListener("popstate", handlePopState)
}
}, [steps, defaultStep])
아 물론 뒤로가기 이후 Url의 step과 웹페이지에 step을 동기화 시키기 위해 popstate 이벤트를 추가해야합니다.!
// 매개변수
useFunnel([퍼널 리스트] : string[], Default 퍼널 : string)
// useFunnel훅 선언 예시
const { Funnel, setStep, pushStep, popStep, step } = useFunnel(["funnel1", "funnel2", "funnel3"],"funnel1")
useFunnel훅 반환 객체, 변수 정리
return (
<Funnel>
<Funnel.Step name="funnel1">
<p>지금은 {step} Funnel</p>
<button
onClick={() => setStep("funnel2")}>
다음 퍼널로
</button>
</div>
</Funnel.Step>
<Funnel.Step name="funnel2">
<p>지금은 {step} Funnel</p>
<button
onClick={() => pushStep()}>
다음 퍼널로
</button>
</Funnel.Step>
<Funnel.Step name="funnel3">
<p>지금은 {step} Funnel</p>
<button
onClick={() => popStep()}>
이전 퍼널로
</button>
</Funnel.Step>
</Funnel>
)
const Funnel = ({ step, children }: FunnelProps) => {
const targetStep = Children.toArray(children).find(
(childStep) => (childStep as ReactElement<StepProps>).props.name === step
) as ReactElement<StepProps> | undefined;
return targetStep ? <>{targetStep.props.children}</> : null;
};
const useFunnel = (steps: string[], defaultStep: string = steps[0]) => {
const [step, setStep] = useState(defaultStep)
const searchParams = useSearchParams()
const pathName = usePathname()
const stepName = searchParams.get("step")
useEffect(() => {
if (!stepName || !steps.includes(stepName)) {
setStep(defaultStep)
} else {
setStep(stepName)
}
}, [stepName, steps, defaultStep])
useEffect(() => {
const handlePopState = () => {
const params = new URLSearchParams(window.location.search)
const newStep = params.get("step")
if (newStep && steps.includes(newStep)) {
setStep(newStep)
} else {
setStep(defaultStep)
}
}
window.addEventListener("popstate", handlePopState)
return () => {
window.removeEventListener("popstate", handlePopState)
}
}, [steps, defaultStep])
const shallowRoute = (nextFunnel: string) => {
if (pathName) {
window.history.pushState(null, "", `${pathName}?step=${nextFunnel}`)
}
}
const pushStep = () => {
if (step === steps[steps.length - 1]) {
return
}
const nowIndex = steps.indexOf(step)
setFunnel(steps[nowIndex + 1])
}
const popStep = () => {
if (step === steps[0]) {
return
}
const nowIndex = steps.indexOf(step)
setFunnel(steps[nowIndex - 1])
}
const setFunnel = (nextFunnel: string) => {
shallowRoute(nextFunnel)
setStep(nextFunnel)
}
const FunnelComponent = Object.assign(
function RouteFunnel({ children }: Omit<FunnelProps, "step">) {
return <Funnel step={step}>{children}</Funnel>
},
{ Step }
)
return {
Funnel: FunnelComponent,
setStep: setFunnel,
pushStep,
popStep,
step
} as const
}
https://www.youtube.com/watch?v=NwLWX2RNVcw&t=1s
https://f-lab.kr/insight/use-funnel-multi-step-form-20240628