(React) 페이지 관리와 퍼널
서론
Coko 프로젝트를 진행하며, 여러 단계의 모달이 순차적으로 나타나야 하는 복잡한 UI FLow를 마주했습니다. 이번 글에서는 useState를 이용한 조건부 렌더링의 한계를 어떻게 극복하고, useFunnel이라는 커스텀 훅을 통해 어떻게 선언적이고 확장 가능한 구조를 구축했는지 그 과정을 공유하고자 합니다.
모달이 많아지면 어떡하지?
Coko 서비스의 핵심 로직 중 하나는 사용자의 퀴즈 풀이 경험입니다.
비 로그인 사용자도 2문제까지는 체험이 가능하지만, 그 이후에는 로그인을 유도하는 모달을 띄워야 했습니다.

여기서 끝이 아닙니다. '로그인 창으로' 버튼을 누르면, 기존 모달이 닫히고 새로운 '로그인 모달'이 나타나야 합니다.

처음에는 단일 컴포넌트 내에서 boolean 상태를 두어 두 모달을 조건부 렌더링하는 방식으로 접근했습니다.
export default function GoToLogin({ isActive }: GoToLoginProps) { // ... const [goLogin, setGoLogin] = useState<boolean>(false); // ... return ( <Modal isShow={isShow}> {goLogin ? ( <Login /* ... */ /> ) : ( <FlexContainer> {/* 로그인 유도 UI */} </FlexContainer> )} </Modal> ); }
컴포넌트의 복잡성의 증대
여기에 기획상으로 퀴즈가 끝날 떄, 모든 정답을 맞추었을 때 등 추가적으로 보여줄 모달이 추가되었습니다.
기존 모달 렌더링 컴포넌트에 추가적인 조건문을 추가하는 방법도 있지만 상태 관리의 복잡성이나 추가적인 기획마다 일일히 명령형 코드를 작성해야 한다는 단점이 있어 이를 극복하고자 했습니다.
Funnel 패턴
이러한 명령형 방식의 한계를 극복하기 위해, UI 플로우를 선언적으로 관리할 수 있는 방법에 대해 고민하기 시작했습니다. 그러던 중 이전에 시청했던 유튜브의 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 영상이 떠올랐습니다.
영상에서 소개된 Funnel 패턴의 핵심은, 정해진 경로를 가진 여러 페이지(스텝)를 하나의 컴포넌트에서 선언적으로 관리하는 것이었습니다.
마치 깔때기(Funnel)처럼 사용자가 정해진 입구로 들어와 순차적인 단계를 거쳐 최종 목적지에 도달하게 만드는 아이디어는 저희가 겪고 있던 문제의 완벽한 해결책처럼 보였습니다.
Toss의 useFunnel 훅을 참고하여, 우리 프로젝트의 환경과 요구사항에 맞게 최소한의 기능만을 담은 useFunnel 커스텀 훅을 직접 구현하기로 결정했습니다.
type StepProps = { name: string; children: ReactNode; }; interface FunnelProps { children: ReactNode[]; } interface FunnelComponent extends FC<FunnelProps> { Step: FC<StepProps>; } type UseFunnelReturn<T> = { step: T; setStep: (step: T) => void; Funnel: FunnelComponent; }; const useFunnel = <T,>(defaultStep: T): UseFunnelReturn<T> => { const [step, setStep] = useState<T>(defaultStep); const Step: FC<StepProps> = ({ children }) => { return children; }; const Funnel: FunnelComponent = ({ children }) => { const currentStep = children.find( (child: ReactNode) => isValidElement(child) && (child.props as StepProps).name === step ); return currentStep || null; }; Funnel.Step = Step; return { step, setStep, Funnel }; };
useFunnel 훅은 setStep 함수와 Funnel 컴포넌트를 반환합니다. Funnel 컴포넌트는 그 자체로 렌더링 로직을 가지며, Step 컴포넌트를 자식으로 받아 현재 step 상태와 일치하는 Step만을 렌더링하는 책임을 가집니다.
결과
useFunnel 훅을 도입한 결과, 복잡했던 퀴즈 결과 처리 로직은 아래와 같이 직관적이고 선언적인 코드로 탈바꿈했습니다.
const STEPS = ['총결과', '파트클리어'] as const; const { Funnel, setStep } = useFunnel(STEPS[0]); return ( <Funnel> <Funnel.Step name="총결과"> <TotalResults onNext={setStep} /* ... */ /> </Funnel.Step> <Funnel.Step name="파트클리어"> <PartClear onNext={() => navigate('/learn')} /> </Funnel.Step> </Funnel> );
이제 각 스텝에 해당하는 컴포넌트(TotalResults, PartClear)는 자신의 UI와 로직에만 집중할 수 있습니다. 전체적인 플로우는 부모 컴포넌트에서 Funnel과 Step의 조합으로 한눈에 파악할 수 있게 되었습니다. 새로운 스텝이 추가되더라도 Funnel.Step을 하나 더 선언하기만 하면 되니, 확장성 또한 크게 향상되었습니다.
다시 생각해보니 funnel이 아닌것같다
useFunnel 훅을 도입하여 저는 훨씬 선언적이고 직관적인 코드를 얻을 수 있었습니다.
모달 관리 로직을 중앙화하고 재사용성을 높여, 개발자 경험(DX)을 크게 향상시킨 성공적인 리팩토링이었습니다.
하지만 프로젝트를 마무리하며 스스로에게 한 가지 질문을 던졌습니다. "이것이 정말 Funnel(깔때기) 패턴이 맞는가?"
Funnel 패턴은 정해진 순서에 따라 사용자의 흐름을 하나의 경로로 유도하는 것을 의미합니다. (예: 회원가입 1단계 → 2단계 → 3단계) 하지만 제가 구현한 useFunnel 훅은 setCurrentStep 함수를 통해 어떤 순서로든 자유롭게 모달을 띄울 수 있습니다. 퀴즈 결과에 따라 '결과 모달'이 뜰 수도 있고, 로그인이 안 되어 있다면 '로그인 유도 모달'이 먼저 뜰 수도 있었죠.
따라서 제가 구현한 것은 엄밀한 의미의 'Funnel'이라기보다는, 하나의 상태(currentStep)를 기반으로 렌더링할 컴포넌트를 결정하는 '조건부 렌더러(Switch-case 같은)' 에 더 가깝다는 결론을 내렸습니다....
하지만 이름은 다르지만, 이 패턴을 통해 제가 얻고자 했던 '복잡한 useState 상태를 하나의 선언적인 컴포넌트로 관리한다' 는 핵심 목표는 성공적으로 달성할 수 있었습니다.