토스 front accelerator 1기에 선정되어 멘토링 커리큘럼 목차를 보던 중 '퍼널 간 상태 관리'라는 주제를 발견했다.
퍼널이 뭔지 검색 중 유튜브에서 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 를 보게 됐고,
영상 속 리팩토링 전 코드가 이전에 구현했던 회원가입 페이지와 유사하여 이번 기회에 리팩토링을 진행해보았다.

퍼널이란 여러 페이지들을 통해 상태를 수집하고, 결과 페이지를 보여주는 형태의 페이지 패턴을 의미합니다.
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로 데이터를 관리하여 한 눈에 파악하기 쉽도록 했다.
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>
);
}
// 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 />}
</>
);
}
steps[stepLevel] === "현재 단계" && <컴포넌트 /> 로 반복되는 로직을 묶어서 재사용할 수 있도록 했다.
useContext로 <Funnel /> 에 현재 step 을 전달하고 <Funnel.Step >{children}</Funnel.Step> 에서 일치 여부를 확인해서 자식 컴포넌트를 보여주도록 했다.
<Step />은 <Funnel /> 에서 사용되는 컴포넌트이므로 compound component 패턴으로 묶어서 <Funnel.Step /> 으로 만들어줬다.완성된 <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>
);
}
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 을 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;
}
onNext(), onPrev(), step 은 <Funnel />에서 사용되는 공통 로직이므로 useFunnel 로 분리해줬다.
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;
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>
);
}
제일 고민했던 부분이다. 🧐

페이지 내에도 뒤로가기 버튼(←)과 다음 버튼 이 있어서 onPrev, onNext 로도 단계를 이동하면서 브라우저의 ⬅️ 뒤로가기, 앞으로가기➡️ 로도 단계 페이지를 이동할 수 있어야 했다.
이를 위해 searchParams 로 step 을 넣어서 페이지 이동시마다 바뀌는 step 으로 브라우저 히스토리를 쌓았다.
유튜브 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 에서 나왔던 router.push(url, undefined, { shallow: true }) 옵션은 Next.js app router 의 push 메서드에서는 shallow option 이 빠져있었다.
router.replace() 를 사용했을 때에는 브라우저 히스토리가 쌓이지 않아서 브라우저의 ⬅️ 뒤로가기 버튼을 사용할 수 없었다.
step 이 업데이트 된 이후 업데이트 된 step 으로 URL의 searchParams 도 업데이트하고 싶었으나, react batch 업데이트 때문에 state 가 비동기적으로 순차 업데이트 되지 않았다.
→ useEffect 불필요하게 많이 사용하는 걸 지양하고 싶어서 여러 방법을 시도해봤지만 결국 useEffect 로 처리했다. 😅
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