best

toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(2)

냠냠맨 2023. 11. 3. 02:42

💫 이전글에서 내부 구현을 간단히 다루었습니다.

https://xionwcfm.tistory.com/422

 

따라서 이번에는 위 구현방식을 참고해서 제가 필요한 기능만 뽑은

 

커스텀 useFunnel을 제작해보겠습니다.

 

사실 그냥 토스의 useFunnel 훅을 갖다쓰고 싶은 마음이 더 컸지만

 

사용에 꽤 많은 애로사항이 있어서 그냥 필요한 기능만 들어간 커스텀훅을 만들기로 했습니다.

 

 

토스의 구현 방식에서 얻을 수 있었던 인사이트를 나열해보면 다음과 같습니다.

 

1. nextjs의 shallow routing을 통해 뒤로가기를 구현하면서도 상태를 유지할 수 있다.

 

(pages router에서는 쉘로우라우팅이 잘 지원되는데

 

앱라우터는 이슈가 많더라구요 앱라우터 쓰시는 분들은 참고하셔야겠습니다.)

2. 뒤로가기의 히스토리 관리는 1을 통해 브라우저 히스토리 스택으로 관리하면 편하다.

 

3. 합성컴포넌트 패턴을 통해 퍼널의 세부 구현을 아주 잘 감춰줄 수 있다.

 

4. 제네릭을 통해 합성컴포넌트들의 타입을 적절히 추론하게 하면 개발자 경험이 좋다

 

 

더 많은 깨달음을 얻은 분들도 계시겠지만.. 제게 인상깊었던 것은 저 네개였습니다.

 

이제 저 네개를 참고로하여 필요한 부분을 커스터마이징한 useFunnel을 만들어봅시다.

 

토스의 원본 구현과 비교하여 제게 필요 없었던 기능은 다음과 같습니다.

 

 

1. 퍼널에 대한 정보를 특정 스토리지에 구애받지않고 스토리지에 저장할 수 있는 기능

 

2. 중첩 퍼널의 고려

 

3. 히스토리스택 비우기 기능

 

 

반대로 필요한 기능은 다음과 같았는데요

 

1. 언제든 재사용할 수 있는 퍼널 기능과 컴포넌트

 

2. 다음 스텝으로 넘어갈 수 있는 함수

 

3. 초기 시작값 설정 기능

 

4. 쿼리스트링을 기반으로한 뒤로가기 기능

 

5. 쉘로우라우팅을 통한 이전 상태 유지 기능

 

 

제게 필요한 간단한 기능만 구현하고자해보니 생각보다 간단히 구현할 수 있었습니다.


🐯 나만의 useFunnel 만들기

export type NonEmptyArray<T> = readonly [T, ...T[]];
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
  steps: Steps;
  step: Steps[number];
  children:
    | Array<React.ReactElement<StepProps<Steps>>>
    | React.ReactElement<StepProps<Steps>>;
}
export interface StepProps<Steps extends NonEmptyArray<string>> {
  name: Steps[number];
  onEnter?: () => void;
  children: React.ReactNode;
}
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<
  FunnelProps<Steps>,
  'steps' | 'step'
>;

먼저 필요한 타입들을 선언해주겠습니다.

 

위 타입들에 대한 설명이 궁금하신 분들은 이전 글을 참고해주세요

 

https://xionwcfm.tistory.com/422

 

 

이제 Funnel 컴포넌트를 구현해봅시다.

export const Funnel = <Steps extends NonEmptyArray<string>>({
  step,
  steps,
  children,
}: FunnelProps<Steps>) => {
  const validChildren = Children.toArray(children)
    .filter(isValidElement)
    .filter((item) =>
      steps.includes((item.props as Partial<StepProps<Steps>>).name ?? ''),
    ) as Array<React.ReactElement<StepProps<Steps>>>;
  const targetStep = validChildren.find((child) => child.props.name === step);
  return <>{targetStep}</>;
};

Funnel 컴포넌트 역시 토스의 원본 구현과 크게 다르지 않습니다.

 

Funnel 컴포넌트가 하는 일은 그저

 

타겟이 될 step 프랍과 문자열 배열 steps를 받고

 

children중에 props.name이 step인 children을 렌더링할뿐인 순수한 컴포넌트입니다.

 

 

다음은 Step 컴포넌트를 보겠습니다.

const Step = <Steps extends NonEmptyArray<string>>({
  children,
}: StepProps<Steps>) => {
  return <>{children}</>;
};

Step 컴포넌트는 더욱 심플한데요

 

name 과 children을 받으면서 children을 리턴하는 일만 수행합니다.

 

현재 스텝에 따라 스텝 컴포넌트를 렌더링할지 말지는

 

Funnel 컴포넌트가 담당하기때문에 Step 컴포넌트는

 

자기자신을 식별할 수 있는 name과 렌더링할 칠드런만 알고있으면 됩니다.

 

const useFunnel = <Steps extends NonEmptyArray<string>>(
  array: Steps,
  option?: {
    initialStep: Steps[number];
  },
) => {
  const [steps, setSteps] = React.useState<Steps>(array);
  const router = useRouter();
  const step = router.query.step as unknown as string;
  const nextStep = (nextQuery: Steps[number]) => {
    return () =>
      router.push(`${router.pathname}?step=${nextQuery}`, undefined, {
        shallow: true,
      });
  };
  useEffect(() => {
    if (option?.hasOwnProperty('initialStep')) {
      router.replace(`${router.pathname}?step=${option.initialStep}`);
    } else {
      router.replace(`${router.pathname}?step=${steps[0]}`);
    }
  }, []);

  const FunnelComponent = useMemo(() => {
    // eslint-disable-next-line react/display-name
    return Object.assign(
      (props: RouteFunnelProps<Steps>) => {
        return <Funnel<Steps> step={step} steps={steps} {...props} />;
      },
      {
        Step: (props: StepProps<Steps>) => {
          return <Step {...props} />;
        },
      },
    );
  }, [step, steps]);
  return [FunnelComponent, nextStep] as const;
};

다음은 Funnel훅의 구현입니다.

 

사실 이 훅을 구현하면서 가장 아쉬웠던 부분은

 

next.js의 next/router 경로의 useRouter 훅에 로직의 중요한 부분들이 강하게 의존하고있기때문에

 

next.js의 page router를 사용할 때에만 성립이 되는 훅이라는 점입니다.

 

이 훅의 구현을 위해 필요한것은 쿼리스트링에 포함된 ?step= 의 내부밸류인데요

 

쿼리스트링의 밸류와 일치하는 컴포넌트를 렌더링하는 것을 통해 퍼널을 구현하고 있기 때문입니다.

 

step = legend인 경우 name이 legend인 컴포넌트를 렌더링한다.

 

 

아무튼 내부 구현은 토스의 useFunnel 훅에 비하면 매우 간소화된 형태로 구현했습니다.

 

  const [steps, setSteps] = React.useState<Steps>(array);
  const router = useRouter();
  const step = router.query.step as unknown as string;
  const nextStep = (nextQuery: Steps[number]) => {
    return () =>
      router.push(`${router.pathname}?step=${nextQuery}`, undefined, {
        shallow: true,
      });
  };

steps 상태를 만들어주고

 

useRouter를 통해 쿼리의 step을 뽑아옵니다.

 

그리고 매번 router.push를 직접작성하는것은 실수의 위험이 있기때문에

 

쉘로우라우팅과 현재 경로에서 step 로직만 추가하는 함수를 만들어 반환해주었습니다.

 

const useFunnel = <Steps extends NonEmptyArray<string>>(
  array: Steps,
  option?: {
    initialStep: Steps[number];
  },
useEffect(() => {
    if (option?.hasOwnProperty('initialStep')) {
      router.replace(`${router.pathname}?step=${option.initialStep}`);
    } else {
      router.replace(`${router.pathname}?step=${steps[0]}`);
    }
  }, []);

다음은 초기값을 정해주는 일을 수행했는데요

 

만약 option 객체의 initialStep을 지정해주었다면 지정된 초기값으로 시작하고

 

따로 지정해주지 않았다면 배열의 첫번째 요소로 시작하도록 지정했습니다.

  const FunnelComponent = useMemo(() => {
    // eslint-disable-next-line react/display-name
    return Object.assign(
      (props: RouteFunnelProps<Steps>) => {
        return <Funnel<Steps> step={step} steps={steps} {...props} />;
      },
      {
        Step: (props: StepProps<Steps>) => {
          return <Step {...props} />;
        },
      },
    );
  }, [step, steps]);
  return [FunnelComponent, nextStep] as const;

 

다음으로는 Funnel과 Step을 담고있으면서

 

제네릭으로 추론이 쉽게 될 수 있는 코드를 작성해야했습니다.

 

토스의 FunnelComponent 구현에서 인사이트를 얻어

 

위와 같이 제네릭을 받아 넘겨주는 형태로 Funnel과 Step을 한번 래핑해주었고

 

step이 바뀔때마다 다시 동작하도록 useMemo의 의존성배열을 추가해줬습니다.

 

이제 커스텀으로 만든 useFunnel을 사용해보죠!

 


✨ 이개외데냐

const FunnelExample = ({}: funnelProps) => {
  const route = ['iam', 'zzang', 'legend'] as const;
  const [Funnel, nextStep] = useFunnel(route);

  return (
    <div>
      <Funnel>
        <Funnel.Step name="iam">
          <div className="" onClick={nextStep('legend')}>
            wow
          </div>
        </Funnel.Step>
        <Funnel.Step name="zzang">
          <div className="" onClick={nextStep('iam')}>
            hi
          </div>
        </Funnel.Step>
        <Funnel.Step name="legend">
          <div className="" onClick={nextStep('zzang')}>
            zzang im nida
          </div>
        </Funnel.Step>
      </Funnel>
    </div>
  );
};

제법 그럴듯하게 사용할 수 있어졌습니다.

 

prop에 다음 스텝에 대한 정보를 넘겨줄 때에 항상 메인컨테이너에서 확인할 수 있는 구조가 되었고

 

내부 컴포넌트는 어디로 이동하는지에 대해 알 필요없이 주입받은 경로로 이동만 하는

 

느슨한 구조를 만들 수 있었습니다.

 

 

이정도만 되어도 제가 필요한만큼의 요구사항은 충족하기 때문에

 

우선 이렇게 쓰면서 필요하다면 기능을 추가해나갈 것 같습니다.

 

 

nextRouter에 대한 의존성을 좀 더 느슨하게 만들고 싶지만..

 

일단은 만족스럽네요!

 


🐝 마치며

토스에서 만들어둔 useFunnel 훅의 모든 기능을 구현하지는 못했지만

 

어느정도 열화판으로나마 만들어보면서 타입스크립트를 많이 배운 것 같습니다.

 

사실 이펙티브 타입스크립트도 다 읽어보고 타입스크립트에 대한 아티클들도 꽤 찾아봐서

 

어느정도 타입스크립트를 할 줄 안다라고 생각했는데

 

정말 처음보는 기법들을 많이 배웠네요..

 

시간날때마다 다른 훅들도 구현을 좀 뜯어봐야겠습니다.

 

이런 코드를 쓰는 사람들과 같이 일하면 정말 행복할 것 같아요...

반응형