이전 포스트와 연결됩니다.
toss의 usefunnel 도입기
목차
useFunnel 목표
두 버전을 고려하여 다음과 같은 방식으로 useFunnel을 사용할 수 있게 만들 예정입니다.
만들기 위해 두 버전의 라이브러리 소스코드를 참고하여 제작했습니다.
- 타입 정의
먼저 타입 지정은 각 step과 step별 context의 타입을 지정합니다.
export type ShareCourseData = z.infer<typeof ShareCourseListSchema>; export type TTripInfo = ICourseInfo & { roomId: string; tripId: string; } export type TShareCourseContext = { 여행상세: TTripInfo, 코스목록?: ShareCourseData, // 빈 객체 공유?: Record<string, never>, }
- useFunnel
그리고 useFunnel에서는 generic 타입으로 각 step의 context에 대한 타입을 정의하고, options를useFunnel에 전달합니다.
이는 최신 버전과 같이 options를 지정합니다.
이때 useFunnel은 Funnel과 setStep함수, 현재 저장된 상태를 확인할 수 있는 context를 반환합니다.
이때 init의 context는 정의된 타입과 일치해야 합니다. 아닐 경우 에러로 사용자에게 알려줘야 합니다.
const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:courseInfo, //TTripInfo type }, stepQueryKey:"step", }); {/* 에러일 경우 */} const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:{"a":"test"}, //error! }, stepQueryKey:"step", });
- 퍼널 추상화
그리고 각 퍼널 추상화를 다음과 같이 만들고자 합니다.
Funnel.Step에서 각 스텝 별 이름을 지정합니다.
<Funnel> <Funnel.Step name="여행상세"> <Detail courseInfo={courseInfo} onNext={()=>setStep<"여행상세">("코스목록",courseInfo)} /> </Funnel.Step> <Funnel.Step name="코스목록"> <List onNext={(places)=>setStep<"코스목록">("공유",places)} context={context} /> </Funnel.Step> <Funnel.Step name="공유"> <Share context={context}/> </Funnel.Step> </Funnel>
- setStep 사용법
setStep에서는 현재 step을 generic type에 정의해주고, 넘어가고자 하는 step과 상태를 전달합니다.
이때 setStep내에서 전달하고자 하는 상태는 정의된 타입과 일치해야 합니다.
만약 다음과 같이 다른 상태가 들어온다면 에러가 발생해야 합니다.
<Funnel.Step name="코스목록"> <List onNext={(places)=>setStep<"코스목록">("공유",places)} context={context} /> </Funnel.Step> {/* 에러일 경우 */} <Funnel.Step name="코스목록"> <List onNext={(places)=>setStep<"코스목록">("공유",{"hello":"funnel"})} //error! context={context} /> </Funnel.Step>
useFunnel 타입 정의
이제 타입을 지정하고자 합니다. 타입은 두 버전의 useFunnel 라이브러리를 참고하였습니다.
- Options Type
options에서 사용할 타입을 지정하겠습니다. 먼저 특정 funnel에서의 state 타입을 지정합니다.
type NonEmptyArray<T> = [T, ...T[]]; //steps type은 비어있지 않은 string 배열 export type StepsType<T extends string> = Readonly<NonEmptyArray<T>>;
export type AnyContext = Record<string,any>; //step이름이랑 Context 매핑 //전체 context 타입 //{ // "step1":{"name":"hello"} //} export type AnyStepContextMap = Record<string,AnyContext>; //step 에서의 context 상태 //특정 funnel state 타입 // { // step:"step1", // context:{ // message:"hello" // } // } export interface FunnelState<TName extends string, TContext = never>{ step:TName; context: TContext; } export type AnyFunnelState = FunnelState<string,AnyContext>
그리고 이를 활용하여 FunnelStateByContextMap을 만들어 generic type으로 funnel의 step, context 타입을 받아 FunnelState<…> | FunnelState<…> 와 같은 유니온 타입으로 만듭니다.
//AnyStepContext의 key로 FunnelState 매핑 //FunnelState 유니온 타입으로 변경 export type FunnelStateByContextMap<TStepContextMap extends AnyStepContextMap> = { [key in keyof TStepContextMap & string]: FunnelState<key, TStepContextMap[key]>; }[keyof TStepContextMap & string];
이를 활용하여 options의 타입을 다음과 같이 지정합니다.
export interface useFunnelOption<TStepContextMap extends AnyStepContextMap>{ steps: StepsType<keyof TStepContextMap & string>, init:FunnelStateByContextMap<TStepContextMap>, stepQueryKey: string; }
이렇게 하면 다음과 같은 option 지정이 가능합니다.
const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:courseInfo, }, stepQueryKey:"step", }); {/* 에러일 경우 */} const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:{"a":"test"}, //error! }, stepQueryKey:"step", });
useFunnel 구현
이제 useFunnel을 정의된 타입을 가지고 구현해보겠습니다.
- useFunnel
useFunnel 내부에 queryString을 확인하며 현재 step 상태를 확인합니다.
export const useFunnel = <TStepContextMap extends AnyStepContextMap>( options: useFunnelOption<TStepContextMap> ):readonly [ FunnelComponent<typeof options.steps>, setStepFn<TStepContextMap>, TStepContextMap ]=>{ const stepQueryKey = options?.stepQueryKey ?? "step"; const [searchParams, setSearchParams] = useSearchParams(); const currentStep = (searchParams.get(stepQueryKey) ?? options?.init.step) as keyof TStepContextMap & string;
다음으로 useFunnel 내부에서 상태를 관리하기 위해 contextMap을 선언하여 내부에서 step에 대한 상태를 선언합니다.
이때 초기 상태 값이 존재할 경우 initial 값으로 넣어줍니다.
//init 파라미터로 초기 상태를 설정 const [contextMap, setContextMap] = useState<TStepContextMap>(() => { const initialState = {} as TStepContextMap; initialState[options.init.step] = options.init.context ?? ({} as TStepContextMap[typeof options.init.step]); return initialState; });
그리고 FunnelComponent로 queryString에서 받고 있는 step 값으로 해당되는 퍼널을 렌더링 합니다.
이때 typescript는 런타임 에러를 잡을 수 없으므로 step == null 일 경우 에러를 내보냅니다.
//<Funnel> 컴포넌트 반환 //StepProps를 할당한 Step assign const FunnelComponent = useMemo( () => Object.assign( function RouteFunnel(props: RouteFunnelProps<typeof options.steps>) { const step = searchParams.get(stepQueryKey) ?? options?.init.step; //런타임 에러 방지 if (step == null) { throw new Error( `표시할 스텝을 step 쿼리 파라미터에 지정해주세요.` ); } return <Funnel<StepsType<keyof TStepContextMap & string>> steps={options.steps} step={step} {...props} />; },{ Step } ), [searchParams] );
- setStep
다음으로 setStep 함수의 타입을 지정하겠습니다. 이는 위처럼 generic type으로 nextStep과 지정될 context의 타입을 제한합니다.
export type setStepFn<TStepContextMap extends AnyStepContextMap> = <TCurrentStep extends keyof TStepContextMap>( nextStep: keyof TStepContextMap, context: TStepContextMap[TCurrentStep] ) => void;
다음과 같이 react project이므로 queryString를 설정하며 현재 step을 관리하였습니다.
또한 setContextMap 함수로 useFunnel 내부에서 context를 관리하였습니다.
이때도 지정된 type에 맞춰 입력되어야 합니다.
//setStep 함수로 url 파라미터를 변경한다. const setStep = useCallback(<TCurrentStep extends keyof TStepContextMap>( nextStep: keyof TStepContextMap, context: TStepContextMap[TCurrentStep] ) => { setSearchParams((prev) => { prev.set(stepQueryKey, nextStep as string); return prev; }); setContextMap(prev => ({ ...prev, [currentStep]: context })); }, [setSearchParams, stepQueryKey, currentStep])
- FunnelComponent
Funnel component는 step에 맞는 퍼널을 찾아 렌더링하는 역할을 합니다.
기존에는 밑과 같이 해당되는 이름을 가진 타겟 컴포넌트만 렌더링 하였지만,
const Funnel = ({ children }: FunnelProps) => { const targetStep = children.find((childStep) => childStep.props.name === step); return <>{targetStep}</>; };
이번 useFunnel에서는 유효한 children만 추출 한 후, 해당되는 이름을 가진 퍼널을 렌더링 해 줍니다.
또한 애니메이션을 추가하여 퍼널 렌더링 시 애니메이션을 보여줍니다.
//Funnel 컴포넌트는 여러 단계의 Step 컴포넌트 중 현재 활성화된 스텝을 렌더링한다. //find를 통해 Step 중 현재 Step을 찾아 렌더링 export const Funnel = <Steps extends StepsType<string>>({ steps,step,children }: FunnelProps<Steps>) => { //children 중 유효한 children만 추출 //후 children의 props(StepsProps) 중 name이 steps에 포함되어 있는 경우만 추출 const validChildren = Children.toArray(children) .filter(isValidElement) .filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array< ReactElement<StepProps<Steps>> >; //validChildren 중 step과 일치하는 컴포넌트 찾기 const targetStep = validChildren.find(child => child.props.name === step); if(!targetStep) throw new Error(`${step} 스텝이 존재하지 않습니다.`); return ( <AnimatePresence mode="wait"> <motion.div key={step} initial={{ y: 100, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -100, opacity: 0 }} transition={{ type: "spring", stiffness: 260, damping: 30, duration: 0.1 }} style={{ width: "100%", height: "100%", }} > {targetStep} </motion.div> </AnimatePresence> ); };
사용
실제 사용시 다음과 같이 사용됩니다.
<Funnel> <Funnel.Step name="생성"> <CreateVote onCalendar={() => setStep("캘린더", contextMap["생성"])} onSearch={() => setStep("주소검색", contextMap["생성"])} /> </Funnel.Step> <Funnel.Step name="캘린더"> <CreateCalendar onNext={() => setStep("생성", contextMap["캘린더"])} /> </Funnel.Step> <Funnel.Step name="주소검색"> <SearchPage handleSelectItem={(item) => { setSelectedPlace({ place_name: item.place_name, x: item.x.toString(), y: item.y.toString(), }); navigate(-1); }} /> </Funnel.Step> </Funnel>
이런식으로 우리 팀 서비스에 맞는 useFunnel을 개발하여 다음과 같은 이점이 있었습니다.
- 강력한 타입 정의로 퍼널 간 타입 관리 용이
- 응집도 개선
- useFunnel 내에서 상태관리
- 스텝 별 페이지 추상화