@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 단계로 이동합니다.
알려진 이슈: 중첩 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)
exitCore의 go(-(historyIndex + 1))이 child exit과 top-level exit을 구분하지 않음:
-
parent.setFunnelIndex(prev - historyIndex): parent의 자체 step 카운트를 child의 historyIndex만큼 잘못 감소시킴.removeChildHistoryIndex가 이미 parent의childHistoryIndexSum에서 child를 제거하므로 parent.funnelIndex를 추가로 줄이면 parent가 이전 step으로 인식됨. -
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 상태가 오염됨.
근본적 제약
exitCore는 popstate 이벤트 + 300ms timeout으로 히스토리 상태를 탐지하는데, @use-funnel/browser도 popstate로 내부 상태(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/v2 | BottomSheet, SplashWrapper UI 컴포넌트 |
@howmuchhome-web/logger | Datadog RUM 뷰 추적 |
@howmuchhome-web/utils-ts | 유틸리티 함수 |
next/navigation | Next.js 라우팅 |