안녕하세요, 이상혁입니다

👉 알고 싶은 UI 전략, 퍼널(Funnel)

👉 알고 싶은 UI 전략, 퍼널(Funnel)

2025년 11월 14일

🤔 사실 퍼널(Funnel)을 알고 있었다?!

알고 싶은 UI 전략, 퍼널(Funnel) 썸네일

알고 싶은 UI 전략, 퍼널(Funnel) 썸네일

프론트엔드 기술 블로그나 컨퍼런스를 챙겨보면서 토스의 useFunnel 훅을 알게 되었다. 복잡한 핸드폰 가입 신청서 작성처럼 여러 단계로 나뉜 화면을 효과적으로 처리할 수 있는 솔루션이었다.

실무에서 다양한 프로젝트를 진행하며 사용자 신청 처리 화면을 자주 구현해야 했는데, useFunnel 하나로 모든 것을 처리할 수 있다는 점이 매력적이었다. Funnel의 코드를 분석하고 사내 요구사항에 맞춰 직접 구현해 적용했다.

유연한 요구사항 대응

가장 만족스러웠던 부분은 요구사항 변경에 대한 유연함이었다. 예를 들어 A → B → C 화면 플로우가 있을 때:

  • 중간에 D 기능이 추가되어야 하는 경우
  • A를 A-1, A-2 단계로 쪼개야 하는 경우
  • 로그인 상태에 따라 바로 B로 이동해야 하는 경우

이런 변경사항들을 해당 퍼널 컴포넌트만 추가, 삭제 또는 분기 처리하면 간단히 해결할 수 있었다. 소스 코드만으로 화면 이동 흐름을 파악하기 쉬웠고, 다양한 프로젝트에서 활용도가 높아 평생 기억해야 할 패턴이라고 생각한다.

깊이 있는 탐구

시간이 지나면서 퍼널(Funnel)이라는 용어가 다른 분야에서 먼저 사용되었다는 것을 알게 되었다. 전역 상태를 연동하는 경우도 있었는데, 토스에서는 왜 이런 확장 기능을 제공하지 않을까 하는 궁금증이 생겼다. useFunnel의 원리와 구현 과정을 깊이 있게 파악해보기로 했다.

🏁 퍼널(Funnel)의 정의

토스 SLASH의 '퍼널: 쏟아지는 페이지 한 방에 관리하기' 일부 장면 캡처

토스 SLASH의 ‘퍼널: 쏟아지는 페이지 한 방에 관리하기’ 일부 장면 캡처

퍼널(Funnel)은 “깔때기”라는 뜻으로, 주로 마케팅 용어로 활용된다. 고객이 상품을 인지하고 구매를 결정하기까지의 과정을 깔때기 모양으로 시각화한 개념이다.

깔때기 모양이 점점 좁아지는 것처럼, 고객의 여정도 단계를 거치며 점점 줄어든다. 넓은 입구는 초기 유입이 많은 단계를, 좁은 출구는 최종 전환에 도달하는 단계를 의미한다.

소프트웨어 분야에서는?

소프트웨어에서 말하는 퍼널(Funnel)은 사용자가 애플리케이션(앱, 웹 등)에서 최종 단계까지 도달하는 과정을 의미한다.

하지만 실제 개발 환경에서는 항상 일관된 방향으로만 화면을 처리할 수 없는 상황이 발생한다. 이때 코드의 복잡성을 줄이면서도 다양한 기술 스택과의 호환성을 확보하는 것이 중요한 과제가 되는데, useFunnel은 이러한 문제를 효과적으로 해결하는 솔루션이라고 생각한다.

🔃 그 당시 구현했던 Funnel 복기

그 당시 구현했던 Funnel 코드를 다시 살펴보면, 오픈소스로 제공되는 useFunnel을 참고하면서 여러 개발자들이 공부 목적으로 직접 구현한 코드들을 많이 봤다. 나도 직접 구현해보고 싶었지만 시간 제약이 있어서, 핵심 특징만 파악해서 사용할 수 있는 수준으로 클론(Clone)해서 구현했다.

클론한 Funnel를 TV 부품에 비유하여 설계한 장면

클론한 Funnel를 TV 부품에 비유하여 설계한 장면

각 코드들의 역할

// Funnel.tsx import { usePrevious } from "hooks"; import { AnimatePresence } from "framer-motion"; import { Children, cloneElement, isValidElement, PropsWithChildren, ReactElement, useMemo, } from "react"; import FunnelStep, { FunnelStepProps, StepArray, } from "components/funnel/FunnelStep"; export interface FunnelProps<Steps extends StepArray<string>> extends PropsWithChildren { readonly steps: Steps; readonly step: Steps[number]; } const Funnel = <T extends StepArray<string>>({ steps, step, children, }: FunnelProps<T>) => { const previous = usePrevious({ step }); const validSteps = Children.toArray(children) .filter(isValidElement) .filter(child => steps.includes((child.props as FunnelStepProps<T>).name ?? "") ) as ReactElement<JourneyStepProps<T>>[]; const direction = useMemo( () => steps.findIndex(findStep => findStep === previous?.step) < steps.findIndex(findStep => findStep === step) ? "NEXT" : "PREV", [previous?.step, steps, step] ); const element = validSteps.find(child => child.props.name === step); const currentStep = cloneElement(element ?? <></>, { ...element?.props, direction, }); if (currentStep === null) { throw new Error(`${step}의 단계 컴포넌트를 찾을 수 없습니다.`); } return ( <AnimatePresence mode="wait" custom={direction}> {currentStep} </AnimatePresence> ); }; Funnel.Step = FunnelStep; export default Funnel;

Funnel 컴포넌트는 TV의 메인보드와 전원부 같은 역할을 한다. 현재 단계, 유효한 화면 여부, 화면 전환 상태 등 Funnel의 주요 기능들을 중앙에서 관리한다.

// FunnelStep.tsx import { PropsWithChildren, useEffect } from "react"; import FunnelAnimation from "components/funnel/FunnelAnimation"; export type StepArray<T> = readonly T[]; export type StepOnNext<T> = (data?: T) => void; export type StepOnPrev = () => void; export interface FunnelStepProps<Steps extends StepArray<string>> extends PropsWithChildren { readonly name: Steps[number]; direction?: "PREV" | "NEXT"; } const FunnelStep = <T extends StepArray<string>>({ children, direction, }: FunnelStepProps<T>) => { useEffect(() => { setTimeout(() => { window.scrollTo(0, 0); }, 10); }, []); return <FunnelAnimation direction={direction}>{children}</FunnelAnimation>; }; export default FunnelStep;

FunnelStep 컴포넌트는 패널, 즉 화면 자체를 의미한다. 다만 화면 출력은 Funnel 컴포넌트가 담당하기 때문에, FunnelStep을 단독으로 사용하면 동작하지 않는다.

// FunnelAnimation.tsx import { AnimationProps, motion } from "framer-motion"; import { ReactNode } from "react"; const FUNNEL_VARIANTS = { initial: (direction: "NEXT" | "PREV") => ({ opacity: 1, x: direction === "NEXT" ? "100vw" : "-100vw", }), in: { opacity: 1, x: 0, }, out: (direction: "NEXT" | "PREV") => ({ opacity: 0.4, x: direction === "NEXT" ? "-100vw" : "100vw", }), }; export const FUNNEL_TRANSITION: AnimationProps["transition"] = { type: "tween", ease: "circInOut", duration: 0.2, }; export type FunnelAnimateDirection = "PREV" | "NEXT"; export interface FunnelAnimationProps { children: ReactNode; direction?: FunnelAnimateDirection; } const FunnelAnimation = ({ children, direction }: FunnelAnimationProps) => { return ( <motion.div initial="initial" animate="in" exit="out" variants={FUNNEL_VARIANTS} transition={FUNNEL_TRANSITION} custom={direction} style={{ height: "100%" }} > {children} </motion.div> ); }; export default FunnelAnimation;

FunnelAnimation 컴포넌트는 TV 뒤의 LED 라이트바처럼 몰입도를 높여주는 부가 기능이다. FunnelStep에 애니메이션 효과를 더해 사용자 경험을 향상시키지만, 필수 요소는 아니다.지 않다.

// useFunnel.tsx import { useState } from "react"; import { StepArray } from "components/funnel/FunnelStep"; export interface UseFunnelOptions<Steps extends StepArray<string>> { defaultStep?: Steps[number]; } const useFunnel = <Steps extends StepArray<string>>( steps: Steps, options?: UseFunnelOptions<Steps> ) => { const [step, setStep] = useState( options?.defaultStep ?? (steps[0] as Steps[number]) ); const currentStepIndex = steps.findIndex(name => name === step); return { step, currentStepIndex, setStep }; }; export default useFunnel;

useFunnel 훅은 리모컨 역할을 한다. 리모컨으로 TV를 조작하듯이, 이 훅을 통해 Funnel의 화면 이동을 쉽게 처리할 수 있다.

📺 공식 use-funnel과 함께 다시 확인하자

아래 자료들을 다시 한번 살펴보면서 토스측에서 useFunnel 관련 구현의 목표와 의도를 이해하고 내가 직접 구현한 부분과 비교하려고 한다.

영상이랑 글을 읽다보니까 use-funnel과 @use-funnel이 똑같은 라이브러리가 아니었다는 걸 알게 됐다. use-funnel의 주요 목적은 유지하면서, 데이터 상태 관리와 히스토리(History) 처리의 단점들을 보완해서 @use-funnel 라이브러리로 새롭게 출시된 거였다.

나는 use-funnel을 많이 참고했었는데, 사용하면서 폼(Form) 데이터의 상태 관리나 History 처리를 개선하면 어떨까 고민했던 적이 있다. 그런데 @use-funnel에서 그런 고민들을 이미 알고 있었던 것처럼 해결된 상태로 나오니까 신기했다.

useFunnel 훅에 Funnel 컴포넌트를 반환한다?

type UseFunnelResults<T> = { [key in keyof T]: { step: key; context: T[key]; history: FunnelHistory<T, T[key]>; }; }[keyof T] & { index: number; historySteps: { step: keyof T; context: T[keyof T] }[]; Render: FunnelRenderComponent<T>; }; function useFunnel<T>(options: UseFunnelOptions<T>): UseFunnelResults<T>; interface FunnelRenderComponent<T> extends React.ComponentType<FunnelRenderProps<T>> { with: FunnelRenderWithEvent<T>; overlay: FunnelRenderOverlay<T>; }

사실 use-funnel에서도 Hook 안에 컴포넌트를 반환하는 패턴으로 구현된 걸 봤지만, 당시에는 큰 관심이 없어서 그냥 넘어가고 내 스타일대로 구현했다. 내 입장에서는 생소한 패턴이었고, React에서 Hook은 로직을, 컴포넌트는 UI를 담당하는 식으로 관심사가 분리되어야 한다고 생각했기 때문이다.

// useFunnel.tsx 의 일부 내용 // https://github.com/toss/use-funnel/blob/main/packages/core/src/useFunnel.tsx export type UseFunnelResults = { Render: ((props: FunnelRenderComponentProps<TStepContextMap, TRouteOption>['steps']) => JSX.Element) & { with: typeof renderWith; overlay: typeof overlayRenderWith; }; } & FunnelStepByContextMap<TStepContextMap, TRouteOption>; export function createUseFunnel( useFunnelRouter: FunnelRouter<TRouteOption, TFunnelOption>, ) { return function useFunnel(options: UseFunnelOptions<TStepContextMap> & _TFunnelOption): UseFunnelResults<TStepContextMap, TRouteOption> { ... // (생략) ... const Render = useMemo(() => { return Object.assign( (props: FunnelRenderComponentProps<TStepContextMap, TRouteOption>['steps']) => { const currentStep = useStateStore(currentStepStoreRef); return <FunnelRender funnel={currentStep} steps={props} />; }, { with: renderWith, overlay: overlayRenderWith, }, ); }, [currentStepStoreRef]); return { ...step, Render, }; }; } // FunnelRender.tsx 의 일부 내용 // https://github.com/toss/use-funnel/blob/main/packages/core/src/FunnelRender.tsx export function FunnelRender( props: FunnelRenderComponentProps<TStepContextMap, TRouteOption>, ) { const { funnel, steps } = props; ... // (생략) ... return ( <Fragment> {renderEntires.map(([step, element]) => ( <Fragment key={step.toString()}>{element}</Fragment> ))} </Fragment> ); }

원본 @use-funnel 코드를 몇 시간 동안 들여다본 결과, 아래와 같은 특징들을 발견했다.

  • Hook에서 반환하는 컴포넌트(FunnelRender)는 UI를 그리지 않고, 순수 로직만 담긴 Wrapper 역할을 한다
  • FunnelRender 컴포넌트는 useFunnel의 상태값들에 의존하기 때문에, Hook에서 컴포넌트를 반환함으로써 응집도를 높이는 전략을 사용한다

어쨋든 JSX 문법만 보면 보통 UI 처리구나 하면서 UI와 로직의 일반적인 관점으로 볼 필요는 없었고 여기서는 강하게 결합된 로직과 로직을 이용하는 컴포넌트를 묶어, 다른 관점으로 처리한 것으로 해석됐다.

데이터 상태 관리를 Funnel 안에 넣었구나…

// 방법 1. 지역 상태 기반으로 Funnel 데이터 관리 type FormValues = { name: string; telephone: string; email?: string; }; const FUNNEL_STEPS = ["A 단계", "B 단계", "C 단계"] as const; function FunnelApp() { const [formValues, setFormValues] = useState<FormValues>({ name: "", telephone: "", email: "", }); const { step, currentStepIndex, setStep } = useFunnel(FUNNEL_STEPS); return ( <Funnel steps={FUNNEL_STEPS} step={step}> <Funnel.Step name="A 단계"> <A단계 onNext={({ name }) => setFormValues(prev => ({ ...prev, name }))} /> </Funnel.Step> <Funnel.Step name="B 단계"> <A단계 name={formValues.name} onNext={({ telephone }) => setFormValues(prev => ({ ...prev, telephone })) } /> </Funnel.Step> <Funnel.Step name="C 단계" /> </Funnel> ); } // 방법 2. RHF(React Hook Form)으로 Funnel 활용한 데이터 관리 function FunnelApp() { const methods = useForm<FormValues>({ defaultValues: { name: "", telephone: "", email: "", }, }); const { step, currentStepIndex, setStep } = useFunnel(FUNNEL_STEPS); const onSubmit = (data: FormValues) => { console.log("최종 제출 데이터:", data); }; return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <Funnel steps={FUNNEL_STEPS} step={step}> <Funnel.Step name="A 단계"> <A단계 onNext={() => { // name 필드 유효성 검사 methods.trigger("name").then(isValid => { if (isValid) { setStep("B 단계"); } }); }} /> </Funnel.Step> <Funnel.Step name="B 단계"> <B단계 onNext={() => { methods.trigger("telephone").then(isValid => { if (isValid) { setStep("C 단계"); } }); }} onPrev={() => setStep("A 단계")} /> </Funnel.Step> <Funnel.Step name="C 단계"> <C단계 onSubmit={methods.handleSubmit(onSubmit)} onPrev={() => setStep("B 단계")} /> </Funnel.Step> </Funnel> </form> </FormProvider> ); }

Funnel은 사용자 입력 정보를 여러 단계로 나눠 화면 단위로 제공하는 것이 주 목적이지만, 사용자 입력 정보를 관리하는 측면도 중요하다. 기존에는 use-funnel처럼 하나의 지역 상태를 가지고 각 단계별로 props로 넘겨 데이터를 공유하고, onNextonPrev 같은 이벤트가 발생했을 때 상태를 업데이트하는 방식으로 관리했다. 아니면 어떤 프로젝트에서는 RHF(React Hook Form) 라이브러리를 활용해 관리하기도 했다.

type FunnelProps<Steps, FormData> = { steps: Steps; step: Steps[number]; defaultFormValues?: FormData; children: ReactNode; }; const Funnel = <T extends StepArray<string>, V = Record<string, any>>({ steps, step, defaultFormValues, children, }: FunnelProps<T, V>) => { // ... 기존 로직 const [formValues, setFormValues] = useState<V>( defaultFormValues ?? ({} as V) ); const currentStep = cloneElement(element ?? <></>, { ...element?.props, direction, formValues, onFormValuesChange: setFormValues, }); // ... };

Funnel을 실제 프로젝트에 적용하면서 데이터 상태 관리를 외부 지역 상태로 관리하는 것보다, Funnel 컴포넌트 내에서 알아서 관리하게 하면 더 효율적이지 않을까 하는 생각을 여러 번 했다. Funnel 컴포넌트 안에서 Context 기반으로 관리할지, 아니면 지역 상태로 처리해서 Children API를 활용해 props로 자동 주입하게 할지 고민한 결과 후자를 선택했다. Context API는 Props Drilling이 심한 경우에 유용한데, 해당 상태들은 Funnel 내에서만 흐름이 이어지기 때문에 간단한 방식을 선택했다.

@use-funnel 같은 경우 자체 전역 상태 관리 로직을 만들어서 context 데이터들을 관리하고 있었다. 내가 개선한 방식과 비교했을 때 @use-funnel은 타입 안전성이 더 우수하고, 각 단계별로 필요한 데이터만 타입으로 명시해서 처리하기 때문에 상태 추적이 덜 복잡하겠다는 생각이 들었다. 다만 내 방식은 구현이 단순하고 직관적이라는 장점이 있다. 추가로, 자체 전역 상태 관리 로직을 보면 Ref 기반으로 Store를 생성하는데 React 컴포넌트의 라이프사이클과 무관하게 상태를 관리하는 포인트도 알게 되었다.

지금 생각해보면 히스토리 처리도 필요했다!

use-funnel에서 쿼리 파라미터 기반으로 단계들을 관리하고 URL로 해당 단계에 접근할 수 있는 형식을 소개한 걸 봤지만, 내가 했던 프로젝트에서는 적용하지 않았다. 아래처럼 사용자가 화면을 처리할 수 있게 UI를 구성했다.

  • 사용자가 Funnel 이동을 CTA Button[“이전”, “다음”]으로 움직일 수 있도록 처리
  • 사용자가 뒤로가기나 새로고침을 하면 “지금까지 진행했던 내용이 사라질 수 있습니다.” 같은 Alert로 움직임을 결정하도록 처리

우선 쿼리 파라미터 기반으로 하면 사용자가 URL로 입력해서 단계를 건너뛰고 화면에 접근할 수 있는 부분 때문에 사용을 원치 않았다. 물론 해당 단계에 접근했을 때 데이터 상태를 기반으로 접근 가능 여부를 검증하는 로직을 추가하면 되겠지만, 그 당시에는 각 단계별로 해당 로직을 넣어야 하는 부분이 더 복잡해지고 시간도 많이 투자해야 할 것 같아서 안 했다.

그리고 전반적으로 맡았던 프로젝트들은 사용자가 프로그램을 이용하는 데 자유성을 추구하는 것보다, 투표나 동의서처럼 올바른 규정에 맞게 프로그램을 이용하도록 유도하는 게 중요했기 때문이다.

안드로이드의 소프트키가 무엇인지 설명하는 사진

안드로이드의 소프트키가 무엇인지 설명하는 사진

그래도 추후에 @use-funnel처럼 자동 히스토리 관리하는 정도는 아니더라도, router.push("/page?step=A") 이 정도 수준이라도 관리할 수 있게 준비는 해뒀다. 이유는 우리가 아무리 CTA 버튼들을 제공해서 가이드를 주더라도, 생각보다 사용자들이 뒤로가기나 새로고침을 많이 이용하는 경우가 많았기 때문이다. 특히 안드로이드 기종을 사용하는 이용자들은 소프트키(내비게이션 바)에 의존적이라서 뒤로가기 버튼이 훨씬 익숙하다는 걸 뒤늦게 알았다.

👏 소감

언젠간 하겠지...

언젠간 하겠지…

서론에서도 이미 말했지만, Funnel UI를 알게 되면서 사내에서 정말 일 처리 속도와 유지보수 측면에서 많은 뽕(?)을 뽑았다고 말할 수 있을 정도로 도움이 되었던 코드 및 UI 기법이었다. 그리고 요즘 유명한 앱이나 모바일 웹 서비스들을 보면 Funnel UI 기반으로 처리하는 것 같아서, 이게 트렌드이자 표준이 될 수도 있겠다는 생각이 들었다.

일반적인 업무 방식을 빗대어 순수 UI 로직으로 구현해서 문제를 해결한 부분이 나한테 좋은 인사이트가 되었다. use-funnel처럼 유사한 코드를 내가 언젠가 직접 아이디어 내고 구현하는 날이 오지 않을까 싶다.