효율적인 페이지 흐름 관리하기 위한 useFunnel 패턴 도입기

JangGwon·2024년 9월 2일

배경

현제 제가 진행하고 있는 인맥사무소 프로젝트에서는 회원가입, 모임 생성, 동호회 생성, 모임 신청, 동호회 가입다양한 곳에서 복잡한 입력폼을 다뤄야 합니다.

만약 이 모임 생성에 필요한 모든 입력란을 좁은 모바일 화면에 한꺼번에 담는다면 사용자들은 복잡하고 난잡한 화면을 마주하게 되고 당장 눈에 보이는 수많은 입력란에 피로감을 유발하는 등 유저 경험에 다소 아쉬울거라 생각했습니다.

그래서 저희는 입력폼들을 쪼개서 페이지 한 개당 한 개의 입력란만 들어가도록 하고 이렇게 생성된 다수의 페이지들을 페이지네이션 방식의 프로세스를 만들어 설계한다면 위에 언급한 문제들이 다소 해결될거라 생각했습니다.

하지만 이 방식을 사용한다면 한개의 설문지를 만들기 위해 생기는 다수의 페이지(컴포넌트)들을 어떻게 관리할지 고민이였고 그러다가 예전에 봤었던 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 에서의 퍼널 패턴이 적합해보였습니다.

그래서 퍼널 패턴이 무엇인지 어떤 장점 때문에 적합해보였는지 차근 차근 풀어보겠습니다.

퍼널 패턴이란 ?

퍼널이란?

퍼널은 마케팅 용어로 유저가 서비스에 들어와서 최종 목표지점에 이르는것을 유도하기 위해 사용되는 도구나 방법을 뜻하는데,

Toss/Slash에서 공개한 프론트엔드에서 패널 패턴 뜻 역시 퍼널 사용자 행동 흐름이 점진적으로 좁아지는 방식으로 사용자를 특정 목표에 도달하게 만드는 패턴으로, 설문조사 패턴에서 응집도, 추상화, 시각화 측면을 개선한 패턴이라고 합니다.

어떻게 응집도가 개선하였지

문제 상황

만약 위와 같이 다양한 상태를 수집하는 페이지들이 흩어져있으며, 페이지들은 router.push, navigate 로 페이지 흐름을 연결시킨 상황이라면 어떤 단점이 있을까요?

  • 페이지 흐름 파악 하기 위해서는 페이지 이곳 저곳 흩어보며 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 이동은 상위에서 관리하게 설계

이로써 장점은…

  • 페이지간 흐름을 파악하기 위해서는 페이지들을 방문해가며 추적할 필요없이 Funnel 컴포넌트안에서 쉽게 파악할 수 있습니다.
  • 콜백함수를 통해 하위 컴포넌트로 입력받은 데이터를 상위 컴포넌트에서 수집하면 전역 상태 관리를 사용하지 않다 그렇기에 어떤 UI에서 어떤 데이터를 수집하는지 한 눈에 파악하기 쉽다.

이러한 동작방식을 여러 곳에서 적용할 수 있도록 추상화한 것이 useFunnel훅으로, 그래서 저희 팀 역시 설문조사 페이지들을 효과적으로 관리하고 유지보수 하기 위해 해당 이 useFunnel훅을 프로젝트에 적용해보기로했습니다.

기능 구현해보기

Toss/slash 유튜브 영상에서 공개된 코드에서 기반으로 커스텀을 진행해봤습니다.

1. AppRouter에서의 Shallow Route기능 추가하기 (영상에 관련 설명이 나왔지만, 코드 부분이 없어 따로 구현했습니다.)

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를 이용한다면 쉽게 해결 할 수 있다는 정보를 입수해 적용해봤습니다.

해결 → HistoryAPi pushState 메소드 이용하기

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훅 반환 객체, 변수 정리

  • Funnel - Funnel패턴을 사용하기 위한 컨테이너 컴포넌트입니다.
  • setStep(nextStep : string) - 원하는 퍼널로 바꿀 수 있는 함수입니다.
  • pushStep() - 다음 퍼널로 이동하는 함수입니다
  • popStep() - 이전 퍼널로 이동하는 함수입니다.
  • step - 현재 위치 퍼널명(string)이 저장 되어있는 변수입니다.
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
}

추후 과제

  • Funnel 전환 애니메이션 구현 하기
  • 시각화

참고

https://www.youtube.com/watch?v=NwLWX2RNVcw&t=1s

https://f-lab.kr/insight/use-funnel-multi-step-form-20240628

0개의 댓글