@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 };
};반환값
| 속성 | 타입 | 설명 |
|---|---|---|
step | string | 현재 단계 |
context | object | 현재 컨텍스트 |
history | History | 네비게이션 메서드 |
history 메서드
| 메서드 | 히스토리 스택 | 설명 |
|---|---|---|
push(step, context?) | 추가 (+1) | 새 단계로 이동 |
replace(step, context?) | 변경 없음 | 현재 단계를 교체 (뒤로가기 불가) |
back() | 제거 (-1) | 이전 단계로 이동 |
exit(callback?) | 전체 제거 | Funnel의 모든 단계를 히스토리에서 제거 |
exitAndRedirect(href) | 전체 제거 후 이동 | Funnel 종료 후 지정 URL로 리다이렉트 |
reset() | 전체 제거 후 초기화 | 초기 단계로 리셋 |
replace는 push와 달리 히스토리 스택을 늘리지 않습니다. 이전 단계로 돌아오지 못하게 할 때 적합합니다.
push()와 replace()는 모두 Promise를 반환합니다. 이동 완료 후 추가 로직이 필요한 경우 await할 수 있습니다.
await history.replace('identityVerify', { defaultValues: formValue });
await history.push('code', { formValue, authId });Funnel 컴포넌트
<Funnel
funnel={funnel}
route={routeMap}
/>props
| prop | 타입 | 설명 |
|---|---|---|
funnel | FunnelState | useFunnel 반환값 |
route | RouteMap | 단계별 렌더링 설정 |
funnelName | string (선택) | 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() 실행
결과: AFunnel이 완전히 제거되고 A로 되돌아갑니다.
초기: A → (B → C → D)
D에서 exitAndRedirect(E) 실행
결과: A → EFunnel이 제거되고 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 단계로 이동합니다.
6. 부모 초기 step의 자식 Funnel에서 부모 step replace
초기: external → (A → (C → D)) (부모가 자체 push 없이 초기 step A에서 자식 시작)
D에서 자식 exit(() => parentHistory.replace('Result')) 실행
결과: external → Result자식 funnel이 부모의 초기 step에 위치하고, callback에서 부모의 replace를 수행하는 패턴입니다.
자식 exit이 funnel 진입점까지만 히스토리를 정리하므로, callback의 replace가 정상 pathname에서 실행됩니다.
실제 사용 예시: /online-assembly/[assemblyId] 페이지의 entrance step에서 입장 서명을 마친 뒤 assembly step으로 replace.
E2E 테스트: apps/app/e2e/tests/funnel.spec.ts 케이스 8.
child exit 동작 분기
exitCore는 자식 funnel exit 시 mount 시점의 외부 히스토리 존재 여부(hasExternalAtMount = window.history.length > 1)에 따라 두 가지 동작으로 분기합니다.
외부 히스토리 있음 (hasExternalAtMount === true)
히스토리: landing → parent-A → parent-B/child-C → parent-B/child-D
D에서 child exit() 실행
결과: landing → parent-B (child만 제거)go(-historyIndex)로 정확히 child 히스토리만큼만 이동해 부모의 containing step에 머무릅니다. 케이스 5, 7, 8이 이에 해당합니다.
외부 히스토리 없음 (hasExternalAtMount === false)
히스토리: (parent-A 직접 진입) → parent-B/child-C → parent-B/child-D
D에서 child exit() 실행
결과: parent-A (parent push까지 모두 정리)go(-(historyIndex+1))로 funnel 초기 상태까지 모두 정리합니다. 케이스 4가 이에 해당합니다.
판정 시점과 신뢰성
hasExternalAtMount는 funnel mount 시점에 window.history.length를 한 번 확인해서 ref에 저장합니다. 이후 navigation으로 length가 변해도 영향받지 않습니다.
length === 1: funnel 페이지가 세션의 첫 entry → 외부 없음 (100% 보장)length > 1: 다른 entry가 더 있음 → 외부 있음으로 판정 (대부분 정확)
length는 forward 히스토리도 포함하지만, 자식 funnel mount 시점에는 보통 forward 히스토리가 없는 첫 진입 상태이므로 문제되지 않습니다. 또한 우리 분기 로직은 go(-N)만 호출하고 forward 영역은 건드리지 않아 forward 히스토리 유무와 무관하게 동작합니다.
의존성
| 패키지 | 용도 |
|---|---|
@use-funnel/browser | 핵심 Funnel 로직 |
@howmuchhome-web/ui/v2 | BottomSheet, SplashWrapper UI 컴포넌트 |
@howmuchhome-web/logger | Datadog RUM 뷰 추적 |
@howmuchhome-web/utils-ts | 유틸리티 함수 |
next/navigation | Next.js 라우팅 |
Native (Expo)
위 문서는 web(@howmuchhome-web/funnel)을 전제로 합니다. web 구현은 @use-funnel/browser + next/navigation + window.history/popstate 에 의존해 RN 에서 동작하지 않습니다. native 는 별도 진입점 @howmuchhome-web/funnel/native 를 씁니다.
import {Funnel, useFunnel, FunnelOverlayProvider} from '@howmuchhome-web/funnel/native';native useFunnel 은 기본적으로 memory 라우터(React state 기반, useFunnelMemory)로 URL/브라우저 히스토리를 건드리지 않고 한 스크린 안에서 step 을 전환합니다(옵션으로 'expo-router' 스택 모드도 있음). 뒤로가기·이탈 가드도 다릅니다 — web 의 라우터 back 가로채기 대신 native 는 하드웨어 백버튼 + 화면 back 버튼을 BackGuardProvider/PreventLeaveConfirm(native) 로 한 채널에서 처리합니다. exit/exitAndRedirect 등 history 메서드의 동작 차이도 있으니 자세한 내용은 native 데이터 페칭·상태 관리 § Funnel를 참고하세요.