Funnel 컨벤션
Funnel을 작성할 때 따르는 파일 구조, 네이밍, 코드 패턴입니다. 개념 및 설계 가이드는 Funnel 관리를, 전체 예시는 예시를, API는 패키지 문서를 참고하세요.
네이밍 규칙
| 항목 | 규칙 | 예시 |
|---|---|---|
Funnel id | kebab-case | 'listing-create', 'identity-verification', 'select-address' |
| Step 이름 | camelCase | 'selectAddress', 'confirmComplex', 'onboarding' |
funnelName | 한국어 | '집주인 인증', '설문조사', '대리인 본인인증' |
viewName | 한국어 설명 | 'IV 생성', '단지 선택', '수동 인증 주소 검색' |
| 컴포넌트 파일 | XxxFunnel.tsx | ListingCreateFunnel.tsx, ListingSelectAddressFunnel.tsx |
| 타입 파일 | funnel.ts | 컴포넌트와 같은 디렉토리 |
| 타입 이름 | XxxFunnel | SurveyDetailFunnel, 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 작성 규칙
typealias 사용 (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 |
| 폼 입력값을 저장하면서 다음 단계로 이동할 때 | replace 후 push |
// 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}); // 다음 단계 이동replace 후 push 패턴의 상세 설명은 고급 패턴을 참고하세요.
컨텍스트 전달 패턴
다음 단계에 이전 데이터를 유지해야 하는 경우 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>