Skip to Content
Packages@howmuchhome-web/funnel

@howmuchhome-web/funnel

React 및 use-funnel  기반의 다단계 플로우(Funnel) 관리를 위한 패키지입니다. 개념 및 설계 가이드는 Funnel 관리를 참고하세요.

설치

monorepo 내부 패키지로, 별도 설치 없이 사용합니다.

import { useFunnel, Funnel, FunnelOverlayProvider } from '@howmuchhome-web/funnel';

useFunnel

Funnel 상태와 네비게이션을 관리하는 훅입니다.

const funnel = useFunnel<StepContextMap>({ id: 'my-funnel', // Funnel 고유 식별자 (필수) initial: { // 초기 단계 설정 (필수) step: 'step1', context: { name: '' } }, funnelName: '회원가입', // 분석 도구에 표시될 이름 (기본값: id) });

StepContextMap 타입 정의

각 단계와 해당 단계에서 사용하는 컨텍스트 타입을 매핑합니다. 각 단계의 컨텍스트는 독립적으로 정의되며, 이전 단계의 데이터가 자동으로 carry되지 않습니다. push()/replace() 호출 시 직접 필요한 데이터를 전달해야 합니다.

type StepContextMap = { step1: { name: string }; step2: { name: string; age: number }; // step1 데이터를 쓰려면 명시적으로 포함 step3: { name: string; age: number; email: string }; };

반환값

속성타입설명
stepstring현재 단계
contextobject현재 컨텍스트
historyHistory네비게이션 메서드

history 메서드

메서드히스토리 스택설명
push(step, context?)추가 (+1)새 단계로 이동
replace(step, context?)변경 없음현재 단계를 교체 (뒤로가기 불가)
back()제거 (-1)이전 단계로 이동
exit(callback?)전체 제거Funnel의 모든 단계를 히스토리에서 제거
exitAndRedirect(href)전체 제거 후 이동Funnel 종료 후 지정 URL로 리다이렉트
reset()전체 제거 후 초기화초기 단계로 리셋

replacepush와 달리 히스토리 스택을 늘리지 않습니다. 이전 단계로 돌아오지 못하게 할 때 적합합니다.

push()replace()는 모두 Promise를 반환합니다. 이동 완료 후 추가 로직이 필요한 경우 await할 수 있습니다.

await history.replace('identityVerify', { defaultValues: formValue }); await history.push('code', { formValue, authId });

Funnel 컴포넌트

<Funnel funnel={funnel} route={routeMap} />

props

prop타입설명
funnelFunnelStateuseFunnel 반환값
routeRouteMap단계별 렌더링 설정
funnelNamestring (선택)Datadog 뷰 이름 prefix. 미설정 시 funnel.id 사용

Route 설정

페이지 타입

{ type: 'page'; viewName?: string | ((props: { funnelId: string; stepId: string; funnelName: string }) => string); render: (funnelStep: { context: Context; history: History }) => { element: ReactElement; splashScreen?: { splash: ReactElement; // 로딩 중 표시할 컴포넌트 duration: number; // 표시 시간 (ms) onFinish?: () => void; // 완료 후 콜백 }; }; }

splashScreen한 번만 표시됩니다. 같은 Funnel 인스턴스 내에서 해당 단계로 다시 돌아오면 스플래시 없이 바로 element가 렌더링됩니다.

viewName을 설정하지 않으면 ${funnelName} - ${stepId} 형태로 자동 생성됩니다. 자세한 규칙은 Datadog 연동을 참고하세요.

바텀시트 타입

{ type: 'bottom-sheet'; viewName?: string | ((props: { funnelId: string; stepId: string; funnelName: string }) => string); render: (funnelStep: { context: Context; history: History }) => { element: ReactElement; // ⚠️ bottom-sheet는 splashScreen을 지원하지 않습니다 }; }

바텀시트 외부 영역 클릭 시 history.back()이 자동으로 호출됩니다.

사이드 드로어 타입

오른쪽에서 슬라이드 인 되는 드로어입니다. 바텀시트와 동일하게 동작하며, 외부 클릭 시 history.back()이 호출됩니다.

{ type: 'side-drawer'; viewName?: string | ((props: { funnelId: string; stepId: string; funnelName: string }) => string); render: (funnelStep: { context: Context; history: History }) => { element: ReactElement; // ⚠️ side-drawer는 splashScreen을 지원하지 않습니다 }; }

FunnelOverlayProvider

exitAndRedirect 호출 시 화면이 순간적으로 비는 현상을 방지하는 Provider입니다.

동작 원리: exitAndRedirect가 실행되면 히스토리 스택을 정리하는 동안 현재 화면의 DOM을 스냅샷으로 복사해 fixed overlay로 고정합니다. 새 페이지 렌더링이 시작되면 overlay가 제거됩니다.

부모 Funnel이 없는 최상위 Funnel에서만 활성화됩니다. 중첩된 자식 Funnel에서는 동작하지 않습니다.

앱 최상위 layout에 한 번만 배치합니다. 개별 Funnel마다 감쌀 필요 없습니다.

// app/layout.tsx export default function RootLayout({ children }) { return ( <html> <body> <FunnelOverlayProvider> {children} </FunnelOverlayProvider> </body> </html> ); }

exitAndRedirect를 사용하지 않거나 전환 중 화면이 비어도 무방한 경우 생략할 수 있습니다.

Exit 동작 레퍼런스

1. 단일 Funnel 완전 제거

초기: (A → B → C) C에서 exitAndRedirect(D) 실행 결과: D

히스토리 스택에서 Funnel이 완전히 사라지고 D만 남습니다.

2. 이전 페이지로 복귀

초기: A → (B → C → D) D에서 exit() 실행 결과: A

Funnel이 완전히 제거되고 A로 되돌아갑니다.

초기: A → (B → C → D) D에서 exitAndRedirect(E) 실행 결과: A → E

Funnel이 제거되고 E로 리다이렉트됩니다.

3. 중첩 Funnel - 자식 종료

초기: (A → (B → C → D)) D에서 자식 Funnel exit() 실행 결과: A

자식 Funnel이 종료되면 최상위 부모로 돌아갑니다.

4. 중첩 Funnel - 선택적 종료

초기: A → (B → (C → D)) D에서 자식 Funnel exit() 실행 결과: A → B

자식 Funnel만 종료되고 부모 Funnel의 B 단계로 돌아갑니다.

초기: A → (B → (C → D)) D에서 부모 Funnel exit() 실행 결과: A

부모 Funnel까지 종료되어 A로 돌아갑니다.

5. Exit 후 부모 Funnel 계속

초기: A → (B → (C → D)) D에서 자식 exit(() => history.push(E)) 실행 결과: A → (B → E)

자식 Funnel이 종료된 후 부모 Funnel의 E 단계로 이동합니다.

알려진 이슈: 중첩 Funnel child exit 버그

E2E 테스트: apps/app/e2e/tests/funnel.spec.ts 케이스 5 (fixme)

증상

이전 페이지가 있는 상태에서 중첩 Funnel의 child가 exit()를 호출하면, child만 종료되고 parent step에 머물러야 하는데, parent의 이전 step이나 이전 페이지까지 되돌아가버림.

히스토리: landing → parent-A → parent-B/child-C → parent-B/child-D D에서 child exit() 실행 기대: landing → parent-B (child만 제거) 실제: landing으로 이동 (parent까지 제거됨)

이전 페이지가 없는 케이스 4에서는 동일한 코드가 정상 동작함.

원인 분석 (useFunnel.ts exitCore)

exitCorego(-(historyIndex + 1))이 child exit과 top-level exit을 구분하지 않음:

  1. parent.setFunnelIndex(prev - historyIndex): parent의 자체 step 카운트를 child의 historyIndex만큼 잘못 감소시킴. removeChildHistoryIndex가 이미 parent의 childHistoryIndexSum에서 child를 제거하므로 parent.funnelIndex를 추가로 줄이면 parent가 이전 step으로 인식됨.

  2. go(-(historyIndex + 1))의 +1: top-level exit에서는 “funnel 이전 페이지 존재 여부”를 판단하기 위해 필요하지만, child exit에서는 이 +1이 parent의 히스토리 영역을 침범함.

수정 방향

child exit (parentFunnel 존재)과 top-level exit을 분기해야 함:

  • child exit: go(-historyIndex)로 child 히스토리만 정리 → parent의 containing step으로 복귀 → callback 실행
  • top-level exit: 기존 로직 유지 (go(-(historyIndex + 1)) + probe)

단, child exit 시 이전 페이지가 없을 때(케이스 4) parent의 containing step까지 되돌리려면 추가 probe가 필요한데, 이 probe가 @use-funnel/browser의 내부 popstate handler와 충돌하여 parent step 상태가 오염됨.

근본적 제약

exitCorepopstate 이벤트 + 300ms timeout으로 히스토리 상태를 탐지하는데, @use-funnel/browserpopstate로 내부 상태(step, context)를 관리함. probe용 history.go()@use-funnel/browser의 popstate handler를 트리거하여 funnel의 내부 상태를 의도치 않게 변경함.

해결하려면 popstate probe에 의존하지 않는 히스토리 상태 판단 방식이 필요함 (예: history.state에 funnel 메타데이터 기록, history.length 기반 판단 등).

테스트 재현

# 케이스 5 (fixme) 활성화 후 실행 npx playwright test e2e/tests/funnel.spec.ts --grep="케이스 5" --workers=1

테스트 페이지: apps/app/src/app/e2e/funnel/nested/page.tsx

의존성

패키지용도
@use-funnel/browser핵심 Funnel 로직
@howmuchhome-web/ui/v2BottomSheet, SplashWrapper UI 컴포넌트
@howmuchhome-web/loggerDatadog RUM 뷰 추적
@howmuchhome-web/utils-ts유틸리티 함수
next/navigationNext.js 라우팅

더 보기

  • Funnel 관리 — 개념, 설계 가이드, Datadog 연동
  • 컨벤션 — 파일 구조, 코드 패턴, 모범 사례
Last updated on