Skip to Content
FunnelFunnel 컨벤션

Funnel 컨벤션

Funnel을 작성할 때 따르는 파일 구조, 네이밍, 코드 패턴입니다. 개념 및 설계 가이드는 Funnel 관리를, 전체 예시는 예시를, API는 패키지 문서를 참고하세요.

네이밍 규칙

항목규칙예시
Funnel idkebab-case'listing-create', 'identity-verification', 'select-address'
Step 이름camelCase'selectAddress', 'confirmComplex', 'onboarding'
funnelName한국어'집주인 인증', '설문조사', '대리인 본인인증'
viewName한국어 설명'IV 생성', '단지 선택', '수동 인증 주소 검색'
컴포넌트 파일XxxFunnel.tsxListingCreateFunnel.tsx, ListingSelectAddressFunnel.tsx
타입 파일funnel.ts컴포넌트와 같은 디렉토리
타입 이름XxxFunnelSurveyDetailFunnel, IdentityCaptureFunnel

파일 구조

다음 기준 중 하나라도 해당하면 funnel.ts를 분리합니다.

  • 스텝이 3개 이상인 경우
  • 하나라도 컨텍스트 필드({} 이외)가 있는 경우
  • 타입을 스텝 컴포넌트 등 다른 파일에서 import하는 경우

모든 스텝이 2개 이하이고 컨텍스트가 전부 {}인 경우에만 인라인을 허용합니다.

복잡하거나 여러 곳에서 재사용하는 Funnel은 StepContextMap 타입을 funnel.ts에 분리합니다.

MyFeature/ MyFeatureFunnel.tsx # useFunnel, Funnel 컴포넌트 funnel.ts # StepContextMap 타입 정의 steps/ Step1.tsx Step2.tsx
// funnel.ts export type MyFeatureFunnel = { onboarding: {}; inputInfo: { name: string }; confirm: { name: string; phone: string }; verified: {}; };
// MyFeatureFunnel.tsx import { MyFeatureFunnel } from './funnel'; export function MyFeatureFunnel() { const funnel = useFunnel<MyFeatureFunnel>({ ... }); return <Funnel funnel={funnel} route={{ ... }} />; }

단계가 2~3개이고 컨텍스트가 단순한 경우 인라인 정의도 허용합니다.

// 인라인 예시 (간단한 경우) const funnel = useFunnel<{ intro: {}; answer: {}; }>({ id: 'survey-detail', initial: { step: 'intro', context: {} }, });

StepContextMap 작성 규칙

  • type alias 사용 (interface 사용 금지)
  • 진입/종료 단계의 컨텍스트는 {} (빈 객체)로 정의
  • 이전 단계 데이터가 자동 carry되지 않으므로, 단계별로 필요한 모든 필드를 명시
export type IdentityCaptureFunnel = { onboarding: {}; // 진입 단계: 빈 객체 guide: { type: IDCardType }; capture: { type: IDCardType }; confirm: { type: IDCardType; image: Blob; ocrResult: IDCardOCRResult | DriverLicenseOCRResult; }; verified: {}; // 종료 단계: 빈 객체 };

컴포넌트 구조

'use client'; // Funnel은 항상 클라이언트 컴포넌트 import { Funnel, useFunnel } from '@howmuchhome-web/funnel'; import { MyFeatureFunnel } from './funnel'; export default function MyFeatureFunnel(props: Props) { // 1. useFunnel은 컴포넌트 최상단에 const funnel = useFunnel<MyFeatureFunnel>({ id: 'my-feature', initial: { step: 'onboarding', context: {} }, }); // 2. 핸들러는 return 전에 정의 const handleComplete = async (value: FormValue) => { const result = await someApiCall(value); // async는 history 호출 전에 funnel.history.exitAndRedirect('/home'); }; // 3. Funnel 컴포넌트를 return return ( <Funnel funnel={funnel} funnelName="기능명" route={{ onboarding: { type: 'page', viewName: '온보딩', render: ({ history }) => ({ element: <OnboardingStep onNext={() => history.push('inputInfo', {})} />, }), }, // ... }} /> ); }

초기 단계 설정

대부분의 경우 초기 단계를 하드코딩합니다.

initial: { step: 'onboarding', context: {} }

진입 시점의 상태에 따라 시작 단계가 달라지는 경우 조건부로 설정합니다.

initial: alreadyAttended ? { step: 'assembly', context: {} } : { step: 'entrance', context: {} }

push vs replace

상황사용
사용자가 이 단계에서 뒤로가기 가능해야 할 때push
이 단계에서 뒤로가기를 막아야 할 때replace
부모 Funnel이 데이터를 넘겨줄 때replace
폼 입력값을 저장하면서 다음 단계로 이동할 때replacepush
// push: 일반 단계 이동 (뒤로가기 가능) history.push('verifyCode', {name, phone, authId}); // replace: 부모에서 데이터를 넘겨받아 단계 초기화 (뒤로가기 불가) funnel.history.replace('search', { identityVerificationId: props.identityVerificationId, ownerName: props.ownerName, }); // replace 후 push: 입력값 보존하면서 다음 단계 이동 await history.replace('inputInfo', {defaultValues: formValue}); // 현재 단계 저장 await history.push('verifyCode', {name, phone, authId}); // 다음 단계 이동

replacepush 패턴의 상세 설명은 고급 패턴을 참고하세요.

컨텍스트 전달 패턴

다음 단계에 이전 데이터를 유지해야 하는 경우 spread합니다.

history.push('confirm', { ...context, // 이전 단계 데이터 유지 phone: inputValue, // 새 데이터 추가 });

비동기 처리

모든 비동기 작업은 history 메서드 호출 전에 완료합니다. render 함수 내부에서 비동기 작업을 하지 않습니다.

// 올바른 패턴 const handleSubmit = async (value: FormValue) => { try { const result = await submitApi(value); // API 호출 먼저 funnel.history.exit(() => onNext(result)); // 완료 후 이동 } catch (e) { showErrorAlert(); // 에러 시 현재 단계 유지 } };

에러 처리

에러가 발생해도 별도의 error step을 만들지 않습니다. 현재 단계에 머물면서 alert 또는 모달로 처리합니다.

// StepContextMap에 error step 추가 금지 type MyFunnel = { step1: {}; step2: {}; // error: {} ← 이렇게 하지 않음 }; // 에러는 현재 단계에서 alert으로 처리 const handleSubmit = async () => { try { await api(); history.push('step2', {}); } catch (e) { errorAlert.open(); // 현재 단계 유지 } };

조건부 단계 건너뛰기

조건에 따라 다음 단계를 동적으로 결정합니다. 건너뛴 단계는 히스토리에 남지 않습니다.

const nextStep = isVerified ? 'complete' : 'verification'; history.push(nextStep, context);

재사용 가능한 서브 Funnel

여러 곳에서 공유하는 플로우는 콜백 props를 받는 독립 Funnel로 만듭니다. 완료/취소 시 history.exit(callback)으로 부모에게 제어권을 돌려줍니다.

// 서브 Funnel 정의 function AddressFunnel({ onComplete, onCancel, }: { onComplete: (address: Address) => void; onCancel: () => void; }) { const funnel = useFunnel<AddressFunnel>({ id: 'select-address', initial: { step: 'search', context: {} }, }); return ( <Funnel funnel={funnel} route={{ search: { type: 'page', viewName: '주소 검색', render: ({ history }) => ({ element: ( <AddressSearch onNext={(query) => history.push('result', { query })} onCancel={() => history.exit(onCancel)} /> ), }), }, result: { type: 'page', viewName: '주소 선택', render: ({ context, history }) => ({ element: ( <AddressResult query={context.query} onSelect={(address) => history.exit(() => onComplete(address))} /> ), }), }, }} /> ); }

부모 Funnel에서 사용:

selectAddress: { type: 'page', render: ({ history }) => ({ element: ( <AddressFunnel onComplete={(address) => history.push('confirm', { address })} onCancel={() => history.back()} /> ), }), },

exit 전략 선택

상황사용
최상위 Funnel 완료 후 다른 페이지로 이동history.exitAndRedirect('/path')
서브 Funnel 완료 후 부모에게 결과 전달history.exit(() => onComplete(result))
서브 Funnel 취소 후 부모에게 제어권 반환history.exit(onCancel)
// 최상위 Funnel: 다른 URL로 이동 history.exitAndRedirect('/home'); // 서브 Funnel: 결과를 부모에게 전달하고 종료 history.exit(() => onComplete(identityVerificationId)); // 서브 Funnel: 취소하고 부모에게 제어권 반환 history.exit(onCancel);

viewName 설정 기준

viewName을 설정하지 않으면 ${funnelName} - ${stepId} 형태로 자동 생성됩니다. 자동 생성값으로 Datadog 추적이 가능하므로 생략해도 동작은 합니다.

다음 경우에는 명시적으로 설정합니다.

  • Datadog에서 특정 이름으로 확인해야 하는 단계
  • step id가 의미를 충분히 전달하지 못하는 경우 (예: step1, index)
  • funnelName이 없어서 자동 생성값이 불명확한 경우
// funnelName + stepId로 충분히 명확한 경우 → 생략 가능 // funnelName: '본인인증', step: 'inputInfo' → "본인인증 - inputInfo" // stepId가 불명확한 경우 → 명시 설정 index: { type: 'page', viewName: '본인인증 안내', // 'index'만으로는 의미 불명확 render: ... }

FunnelOverlayProvider 배치

앱 최상위 layout.tsx한 번만 배치합니다. HowmuchhomeUIProvider 같은 UI provider 안쪽, {children} 바로 위에 위치합니다.

// app/layout.tsx <HowmuchhomeUIProvider> <FunnelOverlayProvider> {/* UI provider 안, children 바로 위 */} <main>{children}</main> </FunnelOverlayProvider> </HowmuchhomeUIProvider>
Last updated on