slash useFunnel 라이브러리 뜯어보기 (2) - useFunnel 동작 원리

김유진·2023년 11월 15일
6

slash

목록 보기
2/3
post-thumbnail

이전 글에 이어서, 이번에는 useFunnel 커스텀 훅이 어떻게 동작하는지 분석해보자. 구현 원리가 복잡하여 다소 긴 글이 될 것 같다. 먼저 전체 코드를 살펴보자.

/** @tossdocs-ignore */

import { assert } from '@toss/assert';
import { safeSessionStorage } from '@toss/storage';
import { useQueryParam } from '@toss/use-query-param';
import { QS } from '@toss/utils';
import deepEqual from 'fast-deep-equal';
import { useRouter } from 'next/router.js';
import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { Funnel, FunnelProps, Step, StepProps } from './Funnel';
import { NonEmptyArray } from './models';

interface SetStepOptions {
  stepChangeType?: 'push' | 'replace';
  preserveQuery?: boolean;
  query?: Record<string, any>;
}

type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;

type FunnelComponent<Steps extends NonEmptyArray<string>> = ((props: RouteFunnelProps<Steps>) => JSX.Element) & {
  Step: (props: StepProps<Steps>) => JSX.Element;
};

const DEFAULT_STEP_QUERY_KEY = 'funnel-step';

export const useFunnel = <Steps extends NonEmptyArray<string>>(
  steps: Steps,
  options?: {
    /**
     * 이 query key는 현재 스텝을 query string에 저장하기 위해 사용됩니다.
     * @default 'funnel-step'
     */
    stepQueryKey?: string;
    initialStep?: Steps[number];
    onStepChange?: (name: Steps[number]) => void;
  }
): readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
  withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
    initialState: StateExcludeStep
  ) => [
      FunnelComponent<Steps>,
      StateExcludeStep,
      (
        next:
          | Partial<StateExcludeStep & { step: Steps[number] }>
          | ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
      ) => void
    ];
} => {
  const router = useRouter();
  const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;

  assert(steps.length > 0, 'steps가 비어있습니다.');

  const FunnelComponent = useMemo(
    () =>
      Object.assign(
        function RouteFunnel(props: RouteFunnelProps<Steps>) {
          // eslint-disable-next-line react-hooks/rules-of-hooks
          const step = useQueryParam<Steps[number]>(stepQueryKey) ?? options?.initialStep;

          assert(
            step != null,
            `표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
          );

          return <Funnel<Steps> steps={steps} step={step} {...props} />;
        },
        {
          Step,
        }
      ),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  const setStep = useCallback(
    (step: Steps[number], setStepOptions?: SetStepOptions) => {
      const { preserveQuery = true, query = {} } = setStepOptions ?? {};

      const url = `${QS.create({
        ...(preserveQuery ? router.query : undefined),
        ...query,
        [stepQueryKey]: step,
      })}`;

      options?.onStepChange?.(step);

      switch (setStepOptions?.stepChangeType) {
        case 'replace':
          router.replace(url, undefined, {
            shallow: true,
          });
          return;
        case 'push':
        default:
          router.push(url, undefined, {
            shallow: true,
          });
          return;
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [options, router]
  );

  /**
   * 아래부터 withState() 구현입니다.
   * 외부 함수로 분리하기 어려워 당장은 inline 해둡니다.
   * FIXME: @junyong-lee withState() 구현을 외부 함수로 분리하기
   */

  type S = Record<string, unknown>;
  const [state, _setState] = useFunnelState<S>({});
  type Step = Steps[number];
  type NextState = S & { step?: Step };

  const nextPendingStepRef = useRef<Step | null>(null);
  const nextStateRef = useRef<Partial<S> | null>(null);
  const setState = useCallback(
    (next: Partial<NextState> | ((next: Partial<NextState>) => NextState)) => {
      let nextStepValue: Partial<NextState>;
      if (typeof next === 'function') {
        nextStepValue = next(state);
      } else {
        nextStepValue = next;
      }

      if (nextStepValue.step != null) {
        nextPendingStepRef.current = nextStepValue.step;
      }
      nextStateRef.current = nextStepValue;

      _setState(next);
    },
    [_setState, state]
  );

  useEffect(() => {
    if (nextPendingStepRef.current == null) {
      return;
    }
    if (deepEqual(nextStateRef.current, state)) {
      setStep(nextPendingStepRef.current);
      nextPendingStepRef.current = null;
    }
  }, [setStep, state]);

  const initializedRef = useRef(false);
  function withState<State extends Record<string, unknown>>(initialState: State) {
    if (!initializedRef.current) {
      setState(initialState);
      initializedRef.current = true;
    }
    return [FunnelComponent, state, setState] as const;
  }

  return Object.assign([FunnelComponent, setStep] as const, { withState }) as unknown as readonly [
    FunnelComponent<Steps>,
    (step: Steps[number], options?: SetStepOptions) => Promise<void>
  ] & {
    withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
      initialState: StateExcludeStep
    ) => [
        FunnelComponent<Steps>,
        StateExcludeStep,
        (
          next:
            | Partial<StateExcludeStep & { step: Steps[number] }>
            | ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
        ) => void
      ];
  };
};

type FunnelStateId = `use-funnel-state__${string}`;

function createFunnelStateId(id: string): FunnelStateId {
  return `use-funnel-state__${id}`;
}

/**
 * NOTE: 이후 Secure Storage 등 다른 스토리지를 사용하도록 스펙이 변경될 수 있으므로, Asynchronous 함수로 만듭니다.
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function createFunnelStorage<T>(funnelStateId: FunnelStateId, storageType = 'sessionStorage'): FunnelStorage<T> {
  switch (storageType) {
    case 'sessionStorage':
      return {
        get: async () => {
          const d = safeSessionStorage.get(funnelStateId);
          if (d == null) {
            return null;
          }
          return JSON.parse(d) as Partial<T>;
        },
        set: async (value: Partial<T>) => {
          safeSessionStorage.set(funnelStateId, JSON.stringify(value));
        },
        clear: async () => {
          safeSessionStorage.remove(funnelStateId);
        },
      };
    default:
      throw new Error('정확한 스토리지 타입을 명시해주세요.');
  }
}

interface FunnelStorage<T> {
  get: () => Promise<Partial<T> | null>;
  set: (value: Partial<T>) => Promise<void>;
  clear: () => Promise<void>;
}

function useFunnelState<T extends Record<string, any>>(
  defaultValue: Partial<T>,
  options?: { storage?: FunnelStorage<T> }
) {
  const { pathname, basePath } = useRouter();

  const storage = options?.storage ?? createFunnelStorage<T>(createFunnelStateId(`${basePath}${pathname}`));
  const persistentStorage = useRef(storage).current;

  const initialState = useQuery({
    queryFn: () => {
      return persistentStorage.get();
    },
    suspense: true,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
  }).data;

  const [_state, _setState] = useState<Partial<T>>(initialState ?? defaultValue);

  const setState = useCallback(
    (state: SetStateAction<Partial<T>>) => {
      _setState(prev => {
        /**
         * React Batch Update 그리고 Local State와 Persistent Storage의 State의 일관성을 위해서 이렇게 작성했습니다.
         */
        if (typeof state === 'function') {
          const newState = state(prev);
          persistentStorage.set(newState);
          return newState;
        } else {
          persistentStorage.set(state);
          return state;
        }
      });
    },
    [persistentStorage]
  );

  const clearState = useCallback(() => {
    _setState({});
    persistentStorage.clear();
  }, [persistentStorage]);

  return [_state, setState, clearState] as const;
}

커스텀 훅에서 사용하는 타입 정의

악. 길다... 맨 위 코드부터 차근차근 보자.

interface SetStepOptions {
  stepChangeType?: 'push' | 'replace';
  preserveQuery?: boolean;
  query?: Record<string, any>;
}

stepChangeType은 스텝 페이지가 넘어갈 때 동작하는 라우터 방식을 push로 할지, replace로 할지 결정하는 부분이다. 이 것만 읽어보면 SetStepOptions가 어떤 일을 하는지 알기 어렵다.

type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;

type FunnelComponent<Steps extends NonEmptyArray<string>> = ((props: RouteFunnelProps<Steps>) => JSX.Element) & {
  Step: (props: StepProps<Steps>) => JSX.Element;
};

아직까지는 RouteFunnelProps가 어떤 일을 하는지 잘 모르겠지만, FunnelProps에서 Omit을 활용하여 steps,step 이라는 요소를 제거한 타입을 정의한다. 그럼 React Element인 children만이 남는다.

자 여기서 이제 FunnelComponent의 타입을 정의하기 위해서 교집합 연산을 사용하는데, 이게 무엇인지 궁금하다면 이전에 Effective TypeScript 스터디하면서 정리했던 내용을 참고해볼 수 있다 ㅎㅎ (깨알 광고) & 연산자를 통해서 두 타입을 말 그대로 합쳐준다!! 아래 예시 코드를 보자면,,

interface Person {
  name: string;
  age: number;
}
interface Developer {
  name: string;
  skill: number;
}
type Capt = Person & Developer;

의 결과물은

{
  name: string;
  age: number;
  skill: string;
}

다시 본론으로 돌아오자면 FunnelComponent는 아래 두 가지 조건에 모두 해당하는 타입이 되어야 한다는 것이다.

  • ReactElement인 RouteFunnelProps 타입을 매개변수로 받아 JSX.Element를 반환하는 함수
  • Step을 props로 가진 컴포넌트

크아악.. 도대체 함수와 객체가 공존하는 타입은 어떻게 구현되어 있을까? 너무 궁금했다. 궁금증을 꾹 참고 더 읽어보도록 하자....

const DEFAULT_STEP_QUERY_KEY = 'funnel-step';

기본적인 쿼리키가 funnel-step 으로 정의되어 있는 것 보니까 url에 정의할 이름인가보다.

구현부 뜯어보기

드디어 커스텀 훅의 내부로 들어간다. 집중 빡 하고 같이 보자. 첫 줄부터 만만치 않다. 타입 정의 + 매개변수 불러오는 부분 엄청 길다.

export const useFunnel = <Steps extends NonEmptyArray<string>>(
  steps: Steps,
  options?: {
    /**
     * 이 query key는 현재 스텝을 query string에 저장하기 위해 사용됩니다.
     * @default 'funnel-step'
     */
    stepQueryKey?: string;
    initialStep?: Steps[number];
    onStepChange?: (name: Steps[number]) => void;
  }
): readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
  withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
    initialState: StateExcludeStep
  ) => [
      FunnelComponent<Steps>,
      StateExcludeStep,
      (
        next:
          | Partial<StateExcludeStep & { step: Steps[number] }>
          | ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
      ) => void
    ];
} => {
  ...
}

useFunnel의 Props

  • steps: string 타입의 수정 불가능한 배열
  • options 객체 (optional)
    • stepQueryKey: 문자열 (아마 스텝을 나타내는 query string이지 않을까)
    • initialStep: 첫번째 스텝 지정하기
    • onStepChange: 스텝이 변화할 때 실행하는 함수

useFunnel 의 반환 타입 정의

 readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
  withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
    initialState: StateExcludeStep
  ) => [
      FunnelComponent<Steps>,
      StateExcludeStep,
      (
        next:
          | Partial<StateExcludeStep & { step: Steps[number] }>
          | ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
      ) => void
	];
}

먼저 반환하는 간단한 튜플 타입부터 똑 떼서 보자.

readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void]

수정 불가능한 튜플 타입을 반환하고 있다.
첫 번째 튜플 요소는 ,FunnelComponent이다. 위에서 아까 정의하였듯이 두 가지 특성을 가지고 있다.

  • ReactElement인 RouteFunnelProps 타입을 매개변수로 받아 JSX.Element를 반환하는 함수
  • Step을 props로 가진 컴포넌트

이제 두번째 튜플 요소는 useFunnel의 props 요소들을 받아 void를 반환하는 함수이다.
교집합으로 정의한 객체는 어떤 타입을 가지고 있는지 또 따로 똑 떼어서 보자.

{
  withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
    initialState: StateExcludeStep
  ) => [
      FunnelComponent<Steps>,
      StateExcludeStep,
      (
        next:
          | Partial<StateExcludeStep & { step: Steps[number] }>
          | ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
      ) => void
	];
}

withState라는 키가 존재하는 객체인데, withState는 튜플을 반환하는 함수이다. 타입 정의가 조금 복잡하게 되어 있는데, 하나씩 분석해보면 크게 어려울 것 은 없다.

 <StateExcludeStep extends Record<string, unknown> & { step?: never }>

Record<string, unknown이면 모든 객체 타입을 허용하는 것으로 보이는데, 여기에 교집합으로 {step?: never}을 넣음으로써 step이라는 키는 사용하지 못하게 하는 것으로 보인다.
아래에 보면

(
  next:
  | Partial<StateExcludeStep & { step: Steps[number] }>
  | ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void

이렇게 이미 step 이라는 키 값을 수정 불가능한 string 타입의 배열인 Steps의 값을 정의하는 데 쓰이고 있기 때문에 이렇게 정의를 한 것 같다.

useFunnel 내부로 들어가자

 const router = useRouter();
 const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;

 assert(steps.length > 0, 'steps가 비어있습니다.');

router 객체를 사용할 수 있는 useRouter API 함수를 불러와 사용한다. 그리고, stepQueryKey를 정의하는데, optionsstepQueryKey를 정의하지 않았다면 기본적으로 설정되어 있는 값인 funnel-step을 이용한다.

만약 steps의 길이가 0이라면, 비어 있는 배열이라고 오류를 내뱉는다.

FunnelComponent

const FunnelComponent = useMemo(
    () =>
      Object.assign(
        function RouteFunnel(props: RouteFunnelProps<Steps>) {
          // eslint-disable-next-line react-hooks/rules-of-hooks
          const step = useQueryParam<Steps[number]>(stepQueryKey) ?? options?.initialStep;

          assert(
            step != null,
            `표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
          );

          return <Funnel<Steps> steps={steps} step={step} {...props} />;
        },
        {
          Step,
        }
      ),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

Object.assign은 객체들의 모든 열거 가능한 자체 속성을 복사해 첫번째 객체에 붙여넣고, 복사한 결과의 객체를 반환한다.
먼저, RouteFunnel 함수가 어떤 일을 하는지 보자. 먼저 props로는 react element가 되는 children 객체를 받고, useQueryParam이라는 훅을 이용하여 step을 정의한다. useQueryParam은 토스 라이브러리에서 정의하여 사용하고 있는 커스텀 훅인데, 내부 구현을 조금 뜯어보자.

export function useQueryParam<T = string>(name: string): T | undefined;
export function useQueryParam<T = string>(name: string, options: Options<T> & { required: true }): T;
export function useQueryParam<T = string>(name: string, options: Options<T>): T | undefined;
export function useQueryParam<T = string>(name: string, options?: Options<T>) {
  const router = useNextRouter({ suspense: options?.suspense });
  const value = router.query[name] as string | undefined;

  if (options?.required === true && value == null) {
    throw new Error(`${name} is required`);
  }

  if (options?.parser != null && value != null) {
    return options.parser(value);
  }

  return value;
}

오버로딩을 통하여 다양한 타입이 정의되어 있는데, 우리에게 필요한 것만 떼 와서 해석해보자면, name이라는 이름으로 string 값을 가져오고 Next의 useRouter 값을 반환하는 useNextRouter를 활용하여 router 객체를 생성한다.
단, optionsd에서 suspense 상태가 true라면 router.isReady가 true가 될 때까지 Suspense를 발생시킨다고 한다. Next의 API 함수를 정말 잘 이용한다. 다시 본론으로 돌아와서 useNextRouter 함수를 통하여 현재 현재 stepQueryKey값으로 가지고 있는 queryString을 가져와 반환한다.

예를 들어 설명하자면,

https://tify.com/profile/newTaste/BMLIP?funnel-step=OneAnswer

stepQueryKeyfunnel-steop의 값인 OneAnswer를 반환해서 value값에 저장하고 반환하는 것이다.
그런 다음, 해당 step값을 가진 Funnel을 반환하는 것이다.

return <Funnel<Steps> steps={steps} step={step} {...props} />;

그리고 두 번째 객체값인

{ 
	Step,
}

export const Step = <Steps extends NonEmptyArray<string>>({ onEnter, children }: StepProps<Steps>) => {
 useEffect(() => {
   onEnter?.();
 }, [onEnter]);

 return <>{children}</>;
};

이렇게 구현되어 있어 onEnter 함수가 있다면 이를 실행시키는 역할을 하며 Funnel 스텝의 자식 요소들을 랜더링한다. 결론을 정리하자면

현재 queryString의 값과 일치하는 step을 가진 Funnel 컴포넌트를 찾아 반환한다. 이 때, onEnter 함수가 있다면 이를 실행시킨다. ex. <퍼널.Step name="">

FunnelComponent함수는 어짜피 처음으로 랜더링 될 때 기능을 수행하고 다시 값이 바뀌지 않아도 되므로 빈 의존성 배열을 가진 useMemo 로 감싸 처리해 불필요한 메모리 낭비를 방지한다.

setStep

const setStep = useCallback(
    (step: Steps[number], setStepOptions?: SetStepOptions) => {
      const { preserveQuery = true, query = {} } = setStepOptions ?? {};

      const url = `${QS.create({
        ...(preserveQuery ? router.query : undefined),
        ...query,
        [stepQueryKey]: step,
      })}`;

      options?.onStepChange?.(step);

      switch (setStepOptions?.stepChangeType) {
        case 'replace':
          router.replace(url, undefined, {
            shallow: true,
          });
          return;
        case 'push':
        default:
          router.push(url, undefined, {
            shallow: true,
          });
          return;
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [options, router]
  );

스텝을 설정하는 부분의 구현부를 뜯어보자. setStepOptions를 통하여 받아온 값들을 객체 구조 분해 할당으로 하나씩 꺼내온다. 그리고 퍼널 스텝을 생성할 url을 만드는데, 그 과정은 토스의 QS 유틸을 사용한다. 유틸이 어떻게 생겼는지 보자.

createQueryString (QS.create)
GET 파라미터로 전달되는 쿼리 스트링을 제작합니다. 주의: params가 비었을 경우, 빈 문자열을 반환하지만, params에 키-값 쌍이 존재하면 ?가 앞에 추가됩니다.
{ a: 1, b: 2, c: '가나다' } // => '?a=1&b=2&c=%EA%B0%80%EB%82%98%EB%8B%A4',
{} // => ''

첫 번째 파라미터 Record의 key는 string이고, 값은 string | number | string[] | number[]이어야 합니다.

객체를 넘겨주면, 그 객체를 이용하여 쿼리 파라미터로 만들어주는 유틸함수임을 알 수 있다.

const url = `${QS.create({
  ...(preserveQuery ? router.query : undefined),
  ...query,
  [stepQueryKey]: step,
})}`;

이므로 preserveQuery 값이 true라면 현재 router 에 있는 query 객체를 가져오고, 추가 옵션으로 사용하고 있는 쿼리가 있다면 그 쿼리에 대한 쿼리들도 생성해준다. 마지막으로 가장 중요한 것은 stepQueryKey 를 통해서 라우터할 퍼널 컴포넌트가 달라지므로 쿼리스트링의 맨 끝에 추가해준다.

options?.onStepChange?.(step);

      switch (setStepOptions?.stepChangeType) {
        case 'replace':
          router.replace(url, undefined, {
            shallow: true,
          });
          return;
        case 'push':
        default:
          router.push(url, undefined, {
            shallow: true,
          });
          return;
      }
    },

step을 변화시키는 방식을 선택할 수 있다. replace, push 중에서 선택할 수 있는데 두가지 동작에는 차이점이 존재한다.

  • replace: 현재 페이지를 바꾸고자 하는 url로 대체. history Stack이 하나더 쌓이지 않는다.
  • push : history Stack에 요소를 하나 더 추가한다.

그리고 shallow Routing을 지원하여 새로고침 깜빡! 없이 화면을 자연스럽게 넘겨줄 수 있다.

예시코드 살펴보기

여기까지 withState를 제외한 부분에 대한 구현부를 함께 살펴보았다. 예시코드를 살펴보자.

function TestComponent() {
      const [테스트퍼널, setStep] = useFunnel(퍼널스텝리스트);

      return (
        <테스트퍼널>
          <테스트퍼널.Step name="test1">
            <h1>Test1</h1>
            <button onClick={() => setStep('test2')}>next</button>
          </테스트퍼널.Step>
          <테스트퍼널.Step name="test2">
            <h1>Test2</h1>
          </테스트퍼널.Step>
        </테스트퍼널>
      );
    }

이제 이 퍼널의 기본적인 원리를 이용하여 TIFY 프로젝트에 적용해보자.

0개의 댓글