[Frontend] 퍼널(Funnel) 구조와 @use-funnel 활용 가이드

YuminPark·2026년 5월 10일

Frontend

목록 보기
16/17

회원가입 화면을 만들다 보면 어느 순간 이런 상황을 마주칩니다. 이메일 입력, 비밀번호 입력, 추가 정보 입력처럼 화면이 여러 단계로 나뉘는데, 각 단계마다 필요한 데이터가 다르고 뒤로 가기를 누르면 이전 입력값이 날아가고, 어떤 경로로 현재 화면에 왔는지 추적하기도 어렵습니다. 단순히 step 번호 하나로 관리하기엔 금방 한계가 옵니다.

이 글에서는 이런 멀티 스텝 UI를 다루는 패턴인 퍼널 구조와, 이를 React에서 타입 안전하게 구현할 수 있도록 도와주는 @use-funnel 라이브러리를 소개합니다.

참고 자료


퍼널 구조

퍼널(Funnel)이란 사용자가 여러 단계를 순서대로 거치면서 데이터를 입력하거나 선택하는 UI 흐름을 의미합니다.
회원가입, 결제, 온보딩 설문처럼 화면이 단계별로 이어지는 멀티 스텝 UI가 대표적입니다.

단순히 useState로 현재 스텝 번호만 관리하면 아래와 같은 문제가 생깁니다.

  • 타입 불안정 : step A → B로 전환할 때 B에서 필요한 필드만 넘기면 될 것 같지만, 이전 context도 신경 써야 하는 등 타입 관리가 복잡해집니다.
  • 히스토리 관리 부재 : 브라우저 뒤로 가기 시 이전 step의 입력값 복원을 별도로 구현해야 합니다.
  • step 전환 시 상태 격리 어려움 : 특정 step에서만 필요한 값을 명확히 분리하기 어렵습니다.

퍼널 구조는 이 문제들을 세 가지 개념으로 분리해서 해결합니다.

  • step : 현재 어떤 화면(단계)에 있는지
  • context : 해당 step에서 필요한 상태 데이터 (step마다 타입이 다릅니다)
  • history : 사용자가 거쳐온 step과 각 step의 context 전체 기록

@use-funnel

토스(Toss)에서 만든 오픈소스 React 라이브러리로, step별 context 타입을 정의해 잘못된 상태 접근을 컴파일 타임에 차단하고, history와 state를 함께 관리해 뒤로 가기/앞으로 가기를 자동 처리합니다.

활용 방법

설치

사용하는 라우터 환경에 맞는 패키지를 설치합니다.

npm install @use-funnel/browser           # 브라우저 히스토리 API (Next.js App Router 포함)
npm install @use-funnel/next              # Next.js Page Router
npm install @use-funnel/react-router      # react-router
npm install @use-funnel/react-router-dom  # react-router-dom
npm install @use-funnel/react-navigation-native  # React Native

참고 : Next.js App Router는 전용 어댑터가 아직 없어 @use-funnel/browser를 사용합니다. 단, 브라우저 히스토리 상태에 의존하므로 서버에서 하이드레이션할 수 없어 클라이언트에서만 렌더링되도록 처리가 필요합니다.


1. context 타입 정의

@use-funnel을 쓸 때 가장 먼저 할 일은 각 step의 context 타입을 정의하는 것입니다.
"이 화면에 진입했을 때 어떤 데이터가 반드시 있어야 하는가" 를 타입으로 표현한다고 생각하면 됩니다.

아래는 3단계 회원가입 흐름의 예시입니다.
step이 진행될수록 필수(required) 필드가 하나씩 늘어나는 구조입니다.

// 1단계: 아무것도 입력되지 않은 상태 — email, password 모두 optional
type EmailInput = { email?: string; password?: string }
 
// 2단계: 이메일이 입력된 이후 — email은 반드시 있어야 함
type PasswordInput = { email: string; password?: string }
 
// 3단계: 이메일과 비밀번호 모두 입력된 이후 — 둘 다 필수
type OtherInfoInput = { email: string; password: string; other?: unknown }

이 구조 덕분에 PasswordInput step의 컴포넌트 안에서 context.email을 참조하면 TypeScript가 이를 string으로 추론합니다.
앞 단계를 거치지 않고 이 화면에 도달하는 것이 타입 레벨에서 불가능해집니다.

또한 잘못된 context로 step을 전환하려 하면 런타임이 아닌 컴파일 타임에 에러가 납니다.

// 필수 필드 b를 빠뜨리면 컴파일 에러
funnel.history.push("B", {});
// ^ '{}' is not assignable to type '{ a: string; b: string; c?: string; }'.
 
// 필수 필드를 모두 포함하면 정상
funnel.history.push("B", { b: "required value" });

2. useFunnel() 초기화

타입 정의가 끝났으면 useFunnel()로 퍼널을 초기화합니다.
제네릭에 { step이름: context타입 } 형태의 매핑을 전달하고, initial로 시작 step을 지정합니다. id는 한 컴포넌트 안에 퍼널이 여러 개 있을 때 구분하기 위한 고유 식별자로, 라우터 히스토리 상태의 key로도 사용됩니다.

import { useFunnel } from '@use-funnel/browser';
 
const funnel = useFunnel<{
  EmailInput: EmailInput;
  PasswordInput: PasswordInput;
  OtherInfoInput: OtherInfoInput;
}>({
  id: 'my-funnel-app',  // 퍼널 구분용 고유 ID (필수)
  initial: {
    step: 'EmailInput', // 시작 step 이름 (필수)
  },
});

useFunnel()이 반환하는 funnel 객체로 현재 step 확인, context 접근, step 전환을 모두 처리합니다.

useFunnel() 옵션 :

옵션필수 여부설명
idO퍼널을 구분하는 고유 식별자
initial.stepO시작 step 이름
initial.contextO시작 step의 초기 context
stepsXstep별 guard, parse 함수 등 추가 옵션

useFunnel()이 반환하는 값 :

설명
funnel.step현재 step 이름
funnel.context현재 step의 context (step별로 타입이 좁혀짐)
funnel.historystep 전환 메서드 모음
funnel.indexhistorySteps 배열에서 현재 step의 인덱스
funnel.historySteps지금까지의 { step, context }[] 전체 기록
funnel.Renderstep별 렌더링 컴포넌트

funnel.history 메서드 :

메서드설명
history.push(step, context)다음 step으로 이동, 히스토리 스택에 추가
history.replace(step, context)히스토리 스택에 추가하지 않고 현재 step 교체
history.go(index)historySteps 배열의 특정 인덱스로 이동
history.back()이전 step으로 이동

pushreplace의 두 번째 인자는 객체뿐만 아니라 이전 context를 받아 다음 context를 반환하는 함수 형태도 지원합니다.
이전 step의 데이터를 그대로 이어받아야 할 때 유용합니다.

// 객체로 전달
history.push('StartDateInput', { school });
 
// 함수로 전달 — 이전 context를 스프레드해서 새 필드만 추가
history.push('EnterJoinDate', (prev) => ({ ...prev, school }));

3. funnel.Render로 렌더링

funnel.Render를 사용하면 각 step의 렌더링 로직을 한 컴포넌트 안에 선언적으로 모을 수 있습니다.
각 step 이름을 prop으로 받고, 해당 step이 활성화됐을 때 렌더링할 컴포넌트를 함수로 전달합니다.
함수의 인자로 contexthistory가 자동으로 주입되며, context의 타입은 해당 step에 맞게 자동으로 좁혀집니다.

return (
  <funnel.Render
    EmailInput={({ history }) => (
      // EmailInput step에서는 context.email이 string | undefined
      <EmailInput onNext={(email) => history.push('PasswordInput', { email })} />
    )}
    PasswordInput={({ context, history }) => (
      // PasswordInput step에서는 context.email이 string으로 좁혀짐
      <PasswordInput
        email={context.email}
        onNext={(password) => history.push('OtherInput', { password })}
      />
    )}
    OtherInput={() => <OtherInput />}
  />
);

funnel.Render 대신 funnel.step으로 직접 분기하는 방식도 지원합니다.
렌더링 로직을 여러 곳에 나눠야 하거나 조건이 복잡할 때 선택하면 됩니다.

switch (funnel.step) {
  case 'EmailInput':
    return (
      <EmailInput
        onNext={(email) => funnel.history.push('PasswordInput', { email })}
      />
    );
  case 'PasswordInput':
    return (
      <PasswordInput
        email={funnel.context.email}
        onNext={(password) =>
          funnel.history.push('OtherInfoInput', { ...funnel.context, password })
        }
      />
    );
}

4. funnel.Render.with() — 이벤트 기반 분기

한 step 안에서 여러 전환 경로가 필요할 때 사용합니다.
예를 들어 이메일 입력 성공과 실패를 각각 다른 step으로 보내야 하는 경우입니다.
events 객체에 이벤트 이름과 처리 로직을 정의하고, render 안에서는 dispatch()로 이벤트를 발행합니다.

한 가지 주의할 점은, render 함수 안에서는 history를 직접 사용할 수 없습니다.
step 전환은 반드시 events에 정의하고 dispatch()로만 호출해야 합니다.
이렇게 함으로써 step 전환 로직이 events 안에만 모이고, render는 순수하게 UI만 담당하는 구조가 됩니다.

<funnel.Render
  EmailInput={funnel.Render.with({
    events: {
      // 성공 시: 비밀번호 입력 step으로 이동
      EmailInputSuccess: (email: string, { history }) => {
        history.push('PasswordInput', { email });
      },
      // 실패 시: 에러 페이지로 이동
      EmailInputFail: (error: Error, { history }) => {
        history.push('ErrorPage', { error: error.message });
      }
    },
    render({ context, dispatch }) {
      return (
        <EmailInput
          email={context.email}
          onNext={(email) => dispatch('EmailInputSuccess', email)}
          onError={(error) => dispatch('EmailInputFail', error)}
        />
      );
    }
  })}
/>

5. funnel.Render.overlay() — 오버레이 표시

현재 step을 이전 step 위에 겹쳐서 표시할 때 사용합니다.
바텀시트나 모달처럼 이전 화면이 뒤에 비쳐야 하는 UI에 적합합니다.

render의 인자로 historyclose가 주입됩니다. close()를 호출하면 히스토리를 통해 이전 step으로 돌아갑니다.
단, 라우터의 뒤로 가기가 아닌 다른 방법(예: X 버튼 클릭)으로 오버레이를 닫을 경우에는 반드시 close()를 명시적으로 호출해야 합니다. 그렇지 않으면 히스토리와 UI 상태가 어긋날 수 있습니다.

<funnel.Render
  SchoolInput={({ history }) => (
    <SchoolInput
      onNext={(school) => history.push('StartDateInput', { school })}
    />
  )}
  StartDateInput={funnel.Render.overlay({
    render({ history, close }) {
      return (
        <StartDateInputBottomSheet
          onNext={(startDate) => history.push('NextStep', { startDate })}
          onClose={() => close()}  // X 버튼 등으로 닫을 때 반드시 호출
        />
      );
    }
  })}
/>

overlay에서도 funnel.Render.with()처럼 이벤트를 함께 정의할 수 있습니다.
이 경우에도 render 안에서는 history 대신 dispatch()를 사용합니다.

<funnel.Render
  EmailInput={funnel.Render.overlay({
    events: {
      EmailInputComplete: (email: string, { history }) =>
        history.push('PasswordInput', { email }),
      EmailInputFail: (error: Error, { history }) =>
        history.push('ErrorPage', { error: error.message })
    },
    render({ context, dispatch }) {
      return (
        <EmailInput
          email={context.email}
          onNext={(email) => dispatch('EmailInputComplete', email)}
          onError={(error) => dispatch('EmailInputFail', error)}
        />
      );
    }
  })}
/>

마무리

@use-funnel은 멀티 스텝 UI를 구현할 때 흔히 맞닥뜨리는 문제들, 즉 step별 타입 불안정, 히스토리 관리, 분기 처리를 라이브러리 수준에서 해결해줍니다. 특히 TypeScript를 사용하는 프로젝트라면 step 전환 시 context 타입이 자동으로 좁혀지는 것만으로도 실수를 줄이는 데 크게 도움이 됩니다.

회원가입, 온보딩, 결제처럼 여러 단계를 가진 화면을 만들고 있다면 한 번 도입을 검토해볼 만한 라이브러리입니다.

0개의 댓글