수제 useFunnel로 퍼널 데이터 은닉화하기

오형근·2023년 8월 9일
16

Frontend

목록 보기
4/10
post-thumbnail

2023 토스 컨퍼런스를 라이브로 시청하면서 가장 재밌게 들었던 파트는 진유림님의 퍼널: 쏟아지는 페이지 한 방에 관리하기 였다. useFunnel이라는 새로운 커스텀 훅을 소개하며 규모 있는 퍼널을 어떻게 관리하면 좋을지에 대한 얘기를 해주셨다.

나는 이를 보고 이를 잘만 커스텀해서 사용하면 예약 및 결제 퍼널에서 사용되는 데이터를 능숙하게 다룰 수 있지 않을까? 라는 생각이 들어 회사에 이를 추천했고, 마침 동료분께서 이를 커스텀해보자고 말씀해주셔서 직접 제작에 들어가게 되었다.

회사에서 현재 사용되는 형태는 내가 소개하고자 하는 형태와는 조금 다르지만, 그만큼 다양한 형태로 커스텀이 가능하고 확장에 유연하게 대처할 수 있는 모듈이라고 생각되어 글로 정리하고자 한다.


나는 본래 코드를 작성하면서 컴포넌트가 통합되는 곳에서는 props를 줄이고 해당 컴포넌트의 이름 만으로 모든 것을 파악할 수 있는 구조를 지향해왔었다.

간략하게 말하면 아래와 같은 구조이다!

MainPage.tsx

import GameSection from '../components/GameSection';
import Header from '../components/Header';
import ResetButton from '../components/organism/ResetButton';
import SuccessModal from '../components/SuccessModal';

const MainPage = () => {
  return (
    <>
      <Header />
      <GameSection />
      <ResetButton />
      <SuccessModal />
    </>
  );
};

export default MainPage;

하지만 이러한 형태는 매우 높은 은닉화가 진행된 형태이고, 여기서 보여주고자하는 정보와 그렇지 않은 정보에 따라 은닉화의 정도는 얼마든지 달라질 수 있다.

그래서 나의 경우에는 우선 은닉화를 최대한 진행하고, 이후에 어떤 데이터를 보여주는 것이 더욱 가독성을 높일 수 있을지 고려하여 필요에 따라 데이터를 props로 받도록 변경해주고는 한다.

이번에 useFunnel 제작을 진행하면서도 동일하게 생각했다. 내가 소개할 useFunnel 모듈을 제작하기 전에 고려한 점은 다음과 같았는데,

1. 퍼널의 순서가 정해져 있으므로 그 페이지 별 순서를 쉽게 파악할 수 있게 하자!

2. 퍼널에서 숨기고자 하는 데이터와 보여주고자 하는 데이터를 확실하게 하자!

3. 우선 처음 모델은 은닉화가 가장 높은 형태로 진행하자!

이렇게 처음 생각한 것들을 바탕으로 하나씩 모듈을 제작해나갔다.


Context 생성하기

전체 funnel의 상태를 책임지는 관리 모듈을 ContextAPI를 활용해 먼저 제작해주었다.

import { createContext, useContext, useReducer } from 'react';

interface PageMatchingObjectBluePrint {
  [key: string]: React.ReactNode;
}

interface FunnelContextProps {
  children: React.ReactNode[];
  current: React.ReactNode;
  data: object | null;
  setChildren: (value: React.ReactNode[]) => void;
  setCurrent: (value: React.ReactNode) => void;
  setData: (value: object) => void;
  setDefault: () => void;
}

const initialFunnelContext: FunnelContextProps = {
  children: [],
  current: null,
  data: null,
  setChildren: () => {},
  setCurrent: () => {},
  setData: () => {},
  setDefault: () => {},
};

const FunnelContext = createContext(initialFunnelContext);

FunnelContext.displayName = 'FunnelContext';

export const useFunnelContext = () => {
  const context = useContext(FunnelContext);

  if (!context) {
    throw new Error('FunnelContext must be used in <FunnelContetProvider />');
  }

  return context;
};

type PickFromFunnelContextProps<T extends keyof FunnelContextProps> = Pick<
  FunnelContextProps,
  T
>[T];

type FunnelActionType =
  | {
      type: 'SETCHILDREN';
      value: PickFromFunnelContextProps<'children'>;
    }
  | {
      type: 'SETCURRENT';
      value: PickFromFunnelContextProps<'current'>;
    }
  | {
      type: 'SETDATA';
      value: PickFromFunnelContextProps<'data'>;
    }
  | {
      type: 'RESETALL';
    };

const reducer = (state: FunnelContextProps, action: FunnelActionType): FunnelContextProps => {
  switch (action.type) {
    case 'SETCHILDREN':
      return {
        ...state,
        children: action.value,
      };
    case 'SETCURRENT':
      return {
        ...state,
        current: action.value,
      };
    case 'SETDATA':
      return {
        ...state,
        data: {
          ...state.data,
          ...action.value,
        },
      };

    case 'RESETALL':
      return initialFunnelContext;
    default:
      return state;
  }
};

export const FunnelContetProvider = ({
  children,
  value,
}: {
  children: PickFromFunnelContextProps<'children'>;
  value?: unknown;
}) => {
  const [state, dispatch] = useReducer(reducer, initialFunnelContext);

  return (
    <FunnelContext.Provider
      value={{
        ...state,
        setChildren: (value: PickFromFunnelContextProps<'children'>) =>
          dispatch({ type: 'SETCHILDREN', value }),
        setCurrent: (value: PickFromFunnelContextProps<'current'>) =>
          dispatch({ type: 'SETCURRENT', value }),
        setData: (value: PickFromFunnelContextProps<'data'>) =>
          dispatch({ type: 'SETDATA', value }),
        setDefault: () => dispatch({ type: 'RESETALL' }),
      }}>
      {children}
    </FunnelContext.Provider>
  );
};

여기서 다루고 있는 데이터는 크게 두 개로 나눌 수 있었는데,

  1. 결제에 직접적으로 필요한 데이터

  2. 현재 보여지고 있는 페이지에 대한 데이터

였다.

이 모듈을 만들 때 모든 데이터에 대한 은닉화를 진행하고자 했으므로, context 내부에 data 와 current 값을 통해 위의 두 데이터를 context 내부에서 관리하도록 해주었다.

여기서 funnelContext에 들어가는 값들에 대해 간략하게 소개하자면,

  • children: Funnel 컴포넌트 내부에 들어오는 children. 각 children이 페이지에 해당되므로 이 퍼널에 어떤 페이지가 들어있는지에 대한 정보를 담는다.

  • current: 현재 보여지고 있는 페이지에 대한 정보를 담는다. children 내부 요소 중 하나이므로, 인덱스로 접근해 값을 가져오는 방식이다.

  • data: 결제와 관련된 데이터를 담는다. 비동기 함수에 담아주어야 하므로, object 형태를 띈다.

이렇게 세 가지의 값을 다루는 context를 생성하고, 이후에는 해당 Context를 다루는 곳과 값을 사용하는 곳을 따로 정의해주었다.

ContextSetter, Renderer?

이 형태는 제작하면서 고민이 많이 되었던 부분이다.

코드를 작성하다보니 초반에 children과 current 에 대한 초깃값을 설정해주고, 이후에 current 변화를 감지해주는 역할을 하는 부분과 이 변경되는 데이터를 끌어와서 사용하는 부분이 한 파일 내부에 존재하면 단일 책임 원칙에서 멀어짐과 동시에 가독성 측면에서 이점을 가져오기 어려울 것이라고 생각했다.기존에 개발에 참여했던 개발자라면 모르지만, 새로운 사람이 이를 읽었을 때 이해하기까지의 리소스를 적게 들였으면 하는 바램이 있었다.

그래서 Context를 소비하는 곳을 Renderer(페이지를 렌더링한다는 뜻!)라고 생각하고, Context를 설정해주는 곳을 ContextSetter로 생각해 Funnel 내부 컴포넌트를 역할 별로 두 갈래로 나누었다.

import { FunnelContextSetter, Funnel } from './components';
import { FunnelContetProvider } from './context';

export const Funnel = ({ children }: { children: React.ReactNode[] }) => {
  return (
    <FunnelContetProvider>
      <FunnelContextSetter>{children}</FunnelContextSetter>
      <FunnelRenderer />
    </FunnelContetProvider>
  );
};

여기에서 특이한 점은 Funnel에서 내려오는 children 정보를 Renderer는 모른다는 점이다. Renderer가 렌더링해야하는 current page에 대한 정보는 오직 context에서 가져오고(Renderer는 Context만 바라본다), children 정보는 이를 context에 넣어주어야하는 ContextSetter에서만 알 수 있도록 만들었다.

이제 children의 정보를 받은 ContextSetter를 살펴보자.

FunnelContextSetter.tsx

import { useEffect } from 'react';

import { useFunnelContext } from '../context';

export const FunnelContextSetter = ({ children }: { children: React.ReactNode[] }) => {
  const { setChildren, setCurrent } = useFunnelContext();

  useEffect(() => {
    setChildren(children);
    setCurrent(children[0]);
  }, []);

  return null;
};

구조 자체는 매우 단순한데, 처음 funnel이 마운트 되었을 때 children의 정보를 가져와서 children의 정보를 업데이트 해주고, 첫 페이지가 되는 children[0] 의 값을 current 에 담아주고 있다. 이를 통해 Renderer가 children[0]을 띄워주도록 한다. 또한, 반환하는 값이 없어 렌더링되는 것을 전부 Renderer에게 전적으로 맡기고 있음을 알 수 있다.

이제 Renderer를 살펴보자.

import { useFunnelContext } from '../context';

export const FunnelRenderer = () => {
  const { current } = useFunnelContext();

  return current as JSX.Element;
};

단순하다. context에서 current 정보를 가져와서 렌더링해주고 있다.

여기에서 기존에 퍼널 별 GA 연동을 위해 next router의 shallow query를 활용해 url에 page 정보를 기입해주는 로직이 있었는데, 그 로직을 추가해 완성한 코드가 다음과 같다.

동료 개발자분의 허락을 구했습니다!

import { useEffect } from 'react';

import { useRouter } from 'next/router';

import { useFunnelContext } from '../context';

export const FunnelRenderer = () => {
  const { current } = useFunnelContext();

  const router = useRouter();

  useEffect(() => {
    if (!current) return;
    router.push(
      {
        pathname: router.pathname,
        query: {
          ...router.query,
          screen: Object(current).type.name,
        },
      },
      undefined,
      { shallow: true },
    );
  }, [current]);

  return current as JSX.Element;
};

이렇게 완성된 코드를 아래 예시와 같이 만들어주면, 각 페이지에 대한 추가 정보가 없이 렌더링되는 컴포넌트를 기준으로 퍼널이 생성되고 url에 각 퍼널 별 정보까지 담아줄 수 있는 형태가 완성되었다.

간단한 실제 사용 형태

const Example = () => {
  return (
    <Funnel>
      <Page1 />
      <Page2 />
      <Page3 />
      <Page4 />
    </Funnel>
  );
};

export default Example;

각 페이지 컴포넌트 내부는 다음과 같이 되어 있다.

const Page1 = () => {
  const { data, setCurrent, setData } = useFunnelContext();

  useEffect(() => {
    console.log('data: ', data);
  }, []);

  return (
    <div
      onClick={() => {
        setCurrent(<Page2 />);
        setData({ one: 1 });
      }}>
      1번페이지~
    </div>
  );
};

그렇다면, ContextSetter라는 컴포넌트는 굳이 컴포넌트가 아니라 커스텀 훅으로 제작하면 안되는건가요?

라고 말할 수 있고, 나도 그 부분을 고려해서 리팩토링을 할까 고민도 많이 했다.

하지만 그렇게 되었을 때 Funnel이라는 파일 내부에서는 Funnel 데이터를 Set 하는 과정과 렌더링 컴포넌트가 혼재되어 있을 것이고, 이는 위의 예시처럼 Setter와 Renderer를 리액트 컴포넌트라는 동일 위상으로 간주해 둘이 동일한 맥락에서의 역할을 해주고 있음을 명시하지 못한다고 생각했다.

그래서 기존의 컴포넌트 형태를 유지하되, 반환값을 없애는 방식을 채택했다.

이렇게 children의 순서에 따라 퍼널의 순서가 자동적으로 지정되고, 해당 페이지로 접근했을 때 url에 페이지에 대한 정보를 함께 실어주어 퍼널별 분석이 원활하게 가능하도록 한다.

위의 코드를 보면 setCurrent에 실제 리액트 컴포넌트가 렌더링되는 것을 확인할 수 있는데, context 코드 내부적으로 리액트 컴포넌트가 아니라 children의 index를 통해서

// 이전 퍼널로 이동
const goPrev = () => setCurrent(prev => prev - 1)

// 다음 퍼널로 이동
const goNext = () => setCurrent(prev => prev + 1)

이런 식으로 적용한다면 setCurrent에 실제 컴포넌트를 명시해주는 방식이 아니라 단순하게 앞 퍼널로 이동, 뒷 퍼널로 이동 이라는 단순한 방법을 가져갈 수 있다. 물론 이는 퍼널이 정해진 프로세스를 거친다는 특수성을 가지고 있기 때문에 가능한 것으로 보인다.


처음 시작은 useFunnel을 모방해서 커스텀해보고자 하는 목표였지만, 방향을 잡다 보니 조금 더 확장해서 사용할 수 있는 모듈을 제작하게 되었다. 아직 프로토타입에 불과하고, 앞으로 이 모듈이 확장성 있는 아키텍쳐를 제작하는 데 도움이 되었으면 좋겠다!

4개의 댓글

comment-user-thumbnail
2023년 8월 9일

이렇게 유용한 정보를 공유해주셔서 감사합니다.

1개의 답글
comment-user-thumbnail
2023년 11월 9일

저도 토스 슬래시 유튜브 채널에서 useFunnel을 가장 관심있게 보았는데 해당 부분을 이해하기 쉽게 글로 잘풀어주신 것 같네요 잘 배우고 갑니다!

1개의 답글