[Next Js] 단계별 페이지 이동 관리하기 (feat. 토스 funnel)

iberis2·2024년 7월 20일
4

NextJS

목록 보기
10/10

들어가기

토스 front accelerator 1기에 선정되어 멘토링 커리큘럼 목차를 보던 중 '퍼널 간 상태 관리'라는 주제를 발견했다.
퍼널이 뭔지 검색 중 유튜브에서 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 를 보게 됐고,

영상 속 리팩토링 전 코드가 이전에 구현했던 회원가입 페이지와 유사하여 이번 기회에 리팩토링을 진행해보았다.

퍼널이란 여러 페이지들을 통해 상태를 수집하고, 결과 페이지를 보여주는 형태의 페이지 패턴을 의미합니다.

0️⃣ 리팩토링 필요성

회원가입 단계 흐름

1. 약관 동의 페이지 
       ⇩
2. 본인 인증 페이지
       ⇩
3. 추가 정보를 입력할 것인지 확인 페이지
       ⇩
NO → 4. 회원가입 완료
YES → 
  3-1) 닉네임 프로필 입력 페이지
            ⇩
  3-2) 성별 생일 입력 페이지
            ⇩
  3-3) 주소 입력 페이지
            ⇩
  // ... 중간 단계 생략
            ⇩
  4. 회원가입 완료

기존에는 각 단계를 별도의 페이지로 구성했다.
때문에 회원단계 흐름을 파악하려면 router.push() 를 따라 여기저기 흩어져있는 UI 컴포넌트들을 찾아 쫓아다녀야했다.
이는 코드 유지 보수에 어려움을 초래했으며, 단계 추가나 삭제 시 복잡한 수정이 필요하게 했다.

페이지 구조

지금보면 참 부끄러운 😂 유지 보수, 확정성 등은 하나도 고려하지 못한 아키텍쳐였다.
(변명하자면 아키텍쳐가 뭔지조차 몰랐던 4개월차 햇병아리였다...🐣 )

변경 전

ㄴ app
  ㄴ signup
    ㄴ page.tsx // 1. 약관 동의 페이지 
    ㄴ verification
      ㄴ page.tsx // 2. 본인 인증 페이지
    ㄴ extra-info
      ㄴ page.tsx // 3. 추가 정보를 입력할 것인지 확인 페이지
      ㄴ nickname-profile
        ㄴ page.tsx // 4-1) 닉네임 프로필 입력 페이지
      ㄴ gender-birthday
         ㄴ page.tsx // 4-2) 성별 생일 입력 페이지
      ㄴ address
        ㄴ page.tsx // 4-3) 주소 입력 페이지

변경 후

ㄴ app
  ㄴ signup
    ㄴ page.tsx //  1. ~ 3. 약관 동의, 본인 인증, 추가 정보를 입력할 것인지 확인 페이지
    ㄴ extra-info
      ㄴ page.tsx // 4-1) ~ 3) 닉네임 프로필 입력 페이지, 성별 생일 입력 페이지, 주소 입력 페이지

데이터 관리

변경 전

각 단계의 데이터를 zustand로 관리하여 전역 상태로 저장했다.
그러나 데이터를 저장하는 곳, 사용하는 곳이 분산되어 있어 관리와 사용처 파악이 어려웠다.

변경 후

전역 store 를 없애고 상위 컴포넌트 페이지에서 state로 데이터를 관리하여 한 눈에 파악하기 쉽도록 했다.

1️⃣ 1단계 : 하나의 페이지에 관련 단계 페이지들 모으기

signup page.tsx 에 회원가입 단계 페이지 (1. ~ 4.) 컴포넌트들을 모으고,
extra-info page.tsx 에 추가 정보 입력 단계 페이지 (4-1. ~ 4-4.) 컴포넌트들을 모았다.
(extra-info page 도 구성은 동일하므로 코드는 생략)

그리고 각 단계를 steps: string[] 로 만들어서 step 과 컴포넌트가 일치하면 컴포넌트를 보여주도록 했다.

// signup.tsx
"use client";

const steps = ["TermsAgree", "Verification", "ExtraInfoPrompt", "SignUpSuccess"];

export default function SignUp() {
  const [stepLevel, setStepLevel] = useState(0);

  const onNext = () => {
    setStepLevel((prev) => prev + 1);
  };

  const onPrev = () => {
    setStepLevel((prev) => prev - 1);
  };

  return (
    <div>
      {steps[stepLevel] === "TermsAgree" && <TermsAgree onNext={onNext} />}
      {steps[stepLevel] === "Verification" && (
        <SignUpVerification onNext={onNext} onPrev={onPrev} />
      )}
      {steps[stepLevel] === "ExtraInfoPrompt" && <ExtraInfoPrompt onNext={onNext} />}
      {steps[stepLevel] === "SignUpSuccess" && <SignUpSuccess />}
    </div>
  );
}

2️⃣ 2단계 : 전역 store 대신 상위 컴포넌트에서 state 관리

변경 전

전역 데이터 저장소

// useSignUpStore.tsx
import { create } from "zustand";

interface SignUpState {
  terms: ITerms;
  setTerms: (term: ITerms) => void;
}

export const useSignUpStore = create<SignUpState>((set) => ({
  terms: {
	// ...생략
  },
  setTerms: (terms) => set(() => ({ terms })),
}));

전역 데이터 저장 및 사용

// TermsAgree.tsx : terms state 를 저장할 때
export default function TermsAgree() {
  const { setTerms } = useSignUpStore();
   
  // terms state 저장하는 로직
  
  return <></>;
}

// SignUpVerification.tsx : terms state 를 꺼내서 사용할 때
export default function SignUpVerification() {
  const { terms } = useSignUpStore();
   
  // terms state 사용하는 로직
  
  return <></>;
}

변경 후

<TermsAgree />, <SignUpVerification /><SignUp /> 에 모여있어서 useState로 관리할 수 있게 됐다.

// SignUp.tsx
const steps = ["TermsAgree", "Verification", "ExtraInfoPrompt", "SignUpSuccess"];

export default function SignUp() {
  const [stepLevel, setStepLevel] = useState(0);
  // 전역 스토어 대신 SignUp 에서 데이터 관리
  const [terms, setTerms] = useState<ITerms>(defaultTerms); 

  const onNext = (terms?: ITerms) => {
    // terms 저장
    if (terms) {
      setTerms(terms);
    }
    setStepLevel((prev) => prev + 1);
  };

  const onPrev = () => {
    setStepLevel((prev) => prev - 1);
  };

  return (
    <>
      {steps[stepLevel] === "TermsAgree" && <TermsAgree onNext={onNext} />}
      {steps[stepLevel] === "Verification" && ( {/* terms 사용 */}
        <SignUpVerification terms={terms} onNext={onNext} onPrev={onPrev} />
      )}
      {steps[stepLevel] === "ExtraInfoPrompt" && <ExtraInfoPrompt onNext={onNext} />}
      {steps[stepLevel] === "SignUpSuccess" && <SignUpSuccess />}
    </>
  );
}

3️⃣ ⭐️ 3단계 : Funnel 을 재사용할 수 있는 컴포넌트로 만들기

steps[stepLevel] === "현재 단계" && <컴포넌트 /> 로 반복되는 로직을 묶어서 재사용할 수 있도록 했다.

useContext로 <Funnel /> 에 현재 step 을 전달하고 <Funnel.Step >{children}</Funnel.Step> 에서 일치 여부를 확인해서 자식 컴포넌트를 보여주도록 했다.

  • <Step /><Funnel /> 에서 사용되는 컴포넌트이므로 compound component 패턴으로 묶어서 <Funnel.Step /> 으로 만들어줬다.

Funnel 사용법

완성된 <Funnel /> 을 먼저 미리 보면 다음과 같다.

// SignUp.tsx
const steps = ["TermsAgree", "Verification", "ExtraInfoPrompt", "SignUpSuccess"];

export default function SignUp() {
  const [level, setStepLevel] = useState(0);
  const [terms, setTerms] = useState<ITerms>(defaultTerms);

  const onNext = (terms?: ITerms) => {/* 생략 */};
  const onPrev = () => {/* 생략 */};

  return (
    <Funnel step={steps[level]}> {/* 현재 보여줘야 할 step 을 전달한다 */}
      
      {/* step === "TermsAgree" 이면 <TermsAgree /> 를 보여준다  */}
      <Funnel.Step name="TermsAgree"> 
        <TermsAgree onNext={onNext} />
      </Funnel.Step>
      
      {/* step === "Verification" 이면 <SignUpVerification /> 를 보여준다  */}
      <Funnel.Step name="Verification">
        <SignUpVerification terms={terms} onNext={onNext} onPrev={onPrev} />
      </Funnel.Step>
      
      <Funnel.Step name="ExtraInfoPrompt">
        <ExtraInfoPrompt onNext={onNext} />
      </Funnel.Step>
      
      <Funnel.Step name="SignUpSuccess">
        <SignUpSuccess />
      </Funnel.Step>
    </Funnel>
  );
}

Funnel.tsx

import { createContext } from "react";

import Step from "./Step";

export const FunnelContext = createContext<{ step?: string }>({});

interface FunnelProps {
  children: React.ReactNode;
  step: string;
}

function Funnel({ children, step }: FunnelProps) {
  return <FunnelContext.Provider value={{ step }}>{children}</FunnelContext.Provider>;
}

export default Object.assign(Funnel, { Step });

Funnel/Step.tsx

<Funnel /> 에서 받은 step 을 useContext 에서 꺼내서 name 과 일치하면 자식 컴포넌트를 보여준다.

import React, { useContext } from "react";

import { FunnelContext } from "..";

interface StepProps {
  children: React.ReactNode;
  name: string;
}

export default function Step({ children, name }: StepProps) {
  const context = useContext(FunnelContext);
  if (context?.step === name) {
    return <>{children}</>;
  }
  return null;
}

4️⃣ 4단계 : useFunnel 로 onNextStep, onPrevStep 분리하기

onNext(), onPrev(), step 은 <Funnel />에서 사용되는 공통 로직이므로 useFunnel 로 분리해줬다.

useFunnel.tsx

import { useCallback, useState } from "react";

interface UseFunnelProps {
  steps: string[];
}

const useFunnel = ({ steps }: UseFunnelProps) => {
  const [level, setStepLevel] = useState(0);

  const onNextStep = useCallback(() => {
    setStepLevel((prev) => {
      if (prev >= steps.length - 1) {
        return prev;
      }
      return prev + 1;
    });
  }, [steps]);

  const onPrevStep = useCallback(() => {
    setStepLevel((prev) => {
      if (prev <= 0) {
        return 0;
      }
      return prev - 1;
    });
  }, []);

  return {
    step: steps[level],
    onNextStep,
    onPrevStep,
  };
};

export default useFunnel;

SignUp.tsx

const steps = ["TermsAgree", "Verification", "ExtraInfoPrompt", "SignUpSuccess"];

export default function SignUp() {
  const { step, onNextStep, onPrevStep } = useFunnel({ steps });
  const [terms, setTerms] = useState<ITerms>(defaultTerms);

  const onNext = (terms?: ITerms) => {
    if (terms) {
      setTerms(terms);
    }
    onNextStep();
  };

  return (
    <Funnel step={step}>
      <Funnel.Step name="TermsAgree">
        <TermsAgree onNext={onNext} />
      </Funnel.Step>
      
      <Funnel.Step name="Verification">
        <SignUpVerification terms={terms} onNext={onNext} onPrev={onPrevStep} />
      </Funnel.Step>
      
      {/*... 생략 */}
    </Funnel>
  );
}

⭐️ 5️⃣ 5단계 : 브라우저 url 히스토리 쌓기

제일 고민했던 부분이다. 🧐

페이지 내에도 뒤로가기 버튼(←)다음 버튼 이 있어서 onPrev, onNext 로도 단계를 이동하면서 브라우저의 ⬅️ 뒤로가기, 앞으로가기➡️ 로도 단계 페이지를 이동할 수 있어야 했다.

이를 위해 searchParams 로 step 을 넣어서 페이지 이동시마다 바뀌는 step 으로 브라우저 히스토리를 쌓았다.

문제점

  1. 유튜브 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 에서 나왔던 router.push(url, undefined, { shallow: true }) 옵션은 Next.js app router 의 push 메서드에서는 shallow option 이 빠져있었다.
    router.replace() 를 사용했을 때에는 브라우저 히스토리가 쌓이지 않아서 브라우저의 ⬅️ 뒤로가기 버튼을 사용할 수 없었다.

  2. step 이 업데이트 된 이후 업데이트 된 step 으로 URL의 searchParams 도 업데이트하고 싶었으나, react batch 업데이트 때문에 state 가 비동기적으로 순차 업데이트 되지 않았다.
    → useEffect 불필요하게 많이 사용하는 걸 지양하고 싶어서 여러 방법을 시도해봤지만 결국 useEffect 로 처리했다. 😅

useFunnel.tsx

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";

const useFunnel = ({ steps }: UseFunnelProps) => {
 // ... 생략
  
  useEffect(() => { 
    // 브라우저 히스토리 쌓기 
    router.push(`${pathname}?step=${steps[level]}`);
  }, [level, steps, router, pathname]);

  useEffect(() => { 
    // 브라우저의 앞으로가기, 뒤로가기를 눌렀을 때 searchParams 의 변경에 따라 step도 이동
    const step = searchParams.get("step");
    if (step) {
      const index = steps.findIndex((s) => s === step);
      if (index !== -1) {
        setStepLevel(index);
      }
    }
  }, [searchParams, steps]);

  return {
    step: steps[level],
    onNextStep,
    onPrevStep,
  };
};

export default useFunnel;

마무리

이번 리팩토링을 통해 회원가입 퍼널을 더 효율적이고 유지 보수하기 쉬운 구조로 개선할 수 있었다.
관련 페이지와 상태(state)를 하나의 상위 컴포넌트에 모으고, Funnel 컴포넌트를 재사용 가능하게 만들어 코드를 더 간결하고 이해하기 쉽게 만들었다. 또한 브라우저 URL 히스토리를 관리하여 뒤로가기, 앞으로 가기 기능에도 영향이 없도록 유지했다.

이번 리팩토링 경험을 통해 더 나은 코드 구조와 상태 관리 방법을 배우게 되었고,
앞으로도 좋은 기술 블로그, 영상들을 공부하며 더 좋은 코드를 쓸 수 있는 개발자가 되도록 노력해야겠다. 🛠️


칭찬받은 후기



리팩토링해서 고객사에 전달드렸더니, 킹왕짱갓제너럴 내가 항상 존경하는 S 님이 PR 봐주셔서 넘 뿌듯했다 ☺️
후후 개발 넘 재밌어 🫶 리팩토링 재밌어 🫶🏻 일의 자아 효능감 짱 🫶🏻 ㅋㅋㅋㅋㅋ


레퍼런스 : 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기
@toss/use-funnel

profile
React, Next.js, TypeScript 로 개발 중인 프론트엔드 개발자

0개의 댓글