XionWCFM의 로고 이미지

프론트엔드 퍼널의 베스트 프래티스를 찾아서 - Toss Frontend Accelerator

retrospect
XionWCFM의 로고 이미지
유길종(XionWCFM)
2024.08.04. 22:00
멘토링 합격했다고 좋아했던 때가 엊그제같은데 어느새 멘토링 기간도 중반을 넘어섰습니다. 이럴때면 시간이 야속하게 느껴지기도 합니다만 그래도 뭐 어쩔 수 없죠
여튼저튼 이번 주차의 주제는 "퍼널" 이었습니다.
저 또한 회사에서 웹뷰를 주로 다루다보니 퍼널에 대한 고민이 많았는데요 이번 주차에서의 시간을 통해 퍼널을 어떻게 관리해야할까?에 대해 고민하면서 나온 해결책들을 정리해보았습니다.

퍼널이란 무엇인가?

Funnel이란 깔때기라는 뜻으로 주로 마케팅 용어로 많이 사용된다고 하는데요. 서비스의 각 흐름마다 이탈되는 사용자들과 남는 사용자들이 존재합니다. 그래서 각 흐름이 깔때기와 비슷해보인다. 라는 면에서 퍼널이라고 부르는 것 같습니다.
사실 이러한 의미로 이해해보자면 퍼널은 개별적인 각각의 단계를 의미하는 것이기 때문에 기존에 많이 사용하는 useFunnel과 같은 네이밍은 조금 어색하기도 합니다.
다만 이미 "퍼널"이라는 용어 자체가 연속된 단계들을 통틀어 일컫는 말과 같은 형태로 널리 사용되고 있기 때문에 편의상 다들 퍼널이라고 명명하는 분위기인 듯 합니다.

퍼널은 왜 필요한가?

저는 단계를 구분지을 수 있다면 그것은 퍼널이라고 정의할 수 있다라고 생각합니다.
해당 관점에서 GUI로 이루어진 모든 인터페이스는 크고 작은 퍼널이 있다고도 볼 수 있습니다. (온세상이 퍼널이다)
그렇기에 퍼널을 잘 관리하게되면 그만큼 우리는 더 많은 가치를 창출할 수 있을 것입니다.

퍼널을 잘 관리하기 어려운 한가지 이유

프론트엔드 개발자로서 퍼널을 관리하는 것은 상당히 어려운 일이며 그 이유의 상당수는 "웹"의 특성에서 기인합니다
사용자의 실수를 방지하거나, 모바일 상에서의 부드러운 제스처 지원을 위해서, 그리고 사용자의 직관과 어긋나지 않은 경험을 제공하기 위해서 저는 퍼널을 구현할 때에는 실제로 히스토리 스택도 퍼널의 상태와 함께 변하는 것이 중요한 가치라고 생각합니다.
그런데 웹의 히스토리스택은 개발자가 마음대로 제어할 수 없는 경우가 많습니다. 뒤로가기 , 앞으로가기에 대한 대응도 쉽지 않고 히스토리스택을 비우는 것도 쉽지 않습니다.
게다가 URL은 사용자에게 그대로 노출되기 때문에 악의적 사용자의 비정상적인 접근에 대해서도 고민할 지점이 생겨납니다.
그럼에도 불구하고 사용자경험을 위해서는 히스토리스택을 기반으로 퍼널을 구현하는 것이 좋아보이는 것도 사실이고요

Toss Slash의 useFunnel이 해결해주지 못한 것들

토스 | SLASH 23 - 퍼널 : 쏟아지는 페이지 한 방에 관리하기는 확실히 지금까지 없었던 개념을 제시했습니다. 사실상 퍼널이라는 용어가 FE 개발자들에게 대중화 된 것도 이 영상이 거의 모든 역할을 했다고 보아도 과언이 아닐 것입니다.
특히 한 파일에서 퍼널의 모든 흐름을 제어하고 파악할 수 있는 API를 제시한 것은 놀라운 일입니다.
그러나 이 Funnel은 그와 동시에 해결하지 못한 여러가지 문제를 가졌습니다. 대표적으로는 이런 문제가 있을 거에요
1. 악의적 사용자를 막기 위한 대책을 제공하지 못한다.
2. 특정 환경, 라이브러리에 매우 의존적이다.
3. 중첩 퍼널에 대한 고려가 되어있지 않다.
1, 2, 3에 대한 해결책과 더불어 기존 퍼널보다 더욱 우아한 방법이 있을까를 여러 방면으로 생각해보았습니다. 이 과정에서 저는 여러가지 해결을 시도했고 처참하게 망하기도 했습니다.
대부분은 히스토리 스택의 제약조건을 고려하지 못하여 실패한 케이스였는데요 멘토님의 조언을 통해 이번에는 제약조건을 우선 고려하고 해결방법을 고민하는 형태로 접근 방식을 바꿔보았습니다.
결국은 히스토리 스택에 쌓인 스택들에 접근할 방법이 없으니 "주어진 현재 스텝" 만을 단일 원천으로 삼아 구현하는 형태로 다시 돌아왔어요

악의적 사용자를 잘 막는 방법

악의적 사용자를 막는 가장 대표적인 케이스는 가드 컴포넌트를 두는 것입니다. HOC 혹은 그냥 단순히 부모컴포넌트를 두기 등 이미 "로그인"과 같은 기능을 구현할 때에 같이 따라오는 패턴이기 때문에 매우 익숙합니다.
다만 여기에서 고민이 되었던 점은 선언적으로 악의적 사용자를 막는 것과 더불어 접근해선 안되는 페이지에 대한 대응을 페이지별로 개인화하는 방법이었습니다.
저는 이 부분에 대해서 접근 가능한지에 대한 여부를 판단하는 것과 접근 불가능할 때의 처리를 분리하는 것이 좋다고 생각하여 그렇게 구현을 하기로 했습니다.

특정 환경, 라이브러리에 의존적이다.

이 부분은 코어 로직과 세부사항에 대한 구현을 분리하는 것을 통해 해결 가능합니다. Tanstack Query가 가장 대표적인 예시라고 할 수 있는데요
Tanstack Query의 패키지 구성을 살펴보면 코어 패키지인 @tanstack/query-core에 코어 로직이 들어가며 이를 기반으로 @tanstack/react-query @tanstack/solid-query 등 프레임워크의 세부사항에 맞게 구현된 패키지들을 제공합니다.
이와 비슷하게 퍼널또한 어떤 라우터를 사용하는지, 어떤 프레임워크를 사용하는지가 중요하지 않도록 인터페이스 형태로 분리해낸 뒤 해당 인터페이스를 구현하는 어댑터를 제공하는 방식으로 구현하면 됩니다.
다만 저는 리액트만 고려하기로 했습니다.

중첩 퍼널에 대한 고려가 되어있지 않다.

해당 부분은 꽤 까다로운 지점 중 하나입니다. 중첩된 퍼널을 표현하기 위해서는 쿼리스트링 관리를 어떻게 할 것인가에 대한 고민이 조금 더 필요하기 때문입니다.
저같은 경우에는 쿼리스트링키를 외부에서 주입하는 형태로 구현하는 것을 통해 중첩 퍼널을 구현할 수 있지 않을까? 라고 생각을 했습니다.

구현하기

제가 생각한 방법을 토대로 문제를 해결하는 코드를 작성해보았습니다. 최종적으로는 다음과 같은 API가 나왔어요
const EXAMPLE_FUNNEL_ID = "hello-this-is-funnel-id";
const exampleFunnelOptions = funnelOptions({
  steps: ["a", "b", "c"],
  funnelId: EXAMPLE_FUNNEL_ID,
});
 
export default function ExampleFunnel() {
  const [Funnel, controller] = useFunnel(exampleFunnelOptions);
 
  return (
    <div className=" px-4 py-4">
      <Funnel>
        <Funnel.Step name={"a"}>
          <FunnelA nextStep={() => controller.onStepChange("b")} />
        </Funnel.Step>
 
        <Funnel.Step name={"b"}>
          <Funnel.Guard
            condition={async () => {
              if (Math.random() > 0.5) {
                return true;
              }
              await new Promise((res) => setTimeout(res, 1000));
              return false;
            }}
            onFunnelRestrictEvent={async () => {  
              controller.onStepChange("a", { type: "replace" });
            }}
            fallback={<div>fallback..</div>}
          >
            <FunnelB nextStep={() => controller.onStepChange("c")} />
          </Funnel.Guard>
        </Funnel.Step>
 
        <Funnel.Step name={"c"}>
          <FunnelC nextStep={() => controller.onStepChange("a")} />
        </Funnel.Step>
      </Funnel>
    </div>
  );
}
 
  );
}
세부 구현은 재미없으니까.. 다루지 않고 넘어가도록 하겠습니다. 그리고 funnel의 선언과 동작부분을 분리하는 것을 통해 좀 더 유연하게 퍼널을 처리할 수 있지 않을까했는데요
tanstackquery의 구조를 참고하여 항상 동일한 구조를 가진 객체 하나에 의존하는 형태로 구성하는 것을 통해 해당 목표를 달성할 수 있었습니다.

가드의 인터페이스를 설계할 때에는 퍼널에 진입할 수 있는가?에 대한 여부를 판단하는 것과 진입할 수 없을 때의 처리, 그리고 fallback을 각각 필요에 따라 주입하면 적절할 것 같았습니다.
condition을 판단할 때에 async하게 판단하는 것도 가능하게 하면 fallback을 몇초동안 보여줄것인가에 대한 처리도 자연스럽게 녹일 수 있었던 것이 구현을 해보면서 좋았던 지점이었습니다.
또 한편으로는 confirm 을 통해 사용자에게 진입할 수 없다는 것을 안내하는 것도 좋은 방법이 될 것 같다는 생각이 들었는데요 overlay-kit을 활용하면 쉽게 구현할 수 있었습니다.
 
        <Funnel.Step name={"b"}>
          <Funnel.Guard
            condition={async () => {
              if (Math.random() > 0.5) {
                return true;
              }
              await new Promise((res) => setTimeout(res, 1000));
              return false;
            }}
            onFunnelRestrictEvent={async () => {
              await overlay.openAsync(({ close, unmount }) => (
                <div>
                  <div>접근할 수 없는 상태에요</div>
                  <button
                    type="button"
                    onClick={() => {
                      close(true);
                    }}
                  >
                    처음 화면으로 돌아가기
                  </button>
                </div>
              ));
              controller.onStepChange("a", { type: "replace" });
            }}
            fallback={<div>fallback..</div>}
          >
            <FunnelB nextStep={() => controller.onStepChange("c")} />
          </Funnel.Guard>
        </Funnel.Step>
 
이렇게요!

퍼널의 렌더링 최적화 어떻게 이룰 수 있을까요?

페이지를 넘나드는 퍼널 구현 대신 한페이지에서 퍼널을 처리하도록 구현하면 상태 공유에 대한 고민을 비교적 적게할 수 있습니다.
상태의 취합을 위해 전역 상태, 외부 스토리지 사용등이 사실상 강제되는 솔루션에 비하여 한페이지안에서 이루어지기 때문에 상태를 쉽게 내려줄 수 있다는 것입니다.
그러나 상태를 단순히 페이지의 맨위에서 가지고 그 상태를 바꾸게 되면 리액트의 특성상 모든 하위 컴포넌트가 리렌더링 되는 문제가 발생합니다.
이것은 비교적 단순한 화면만 표현하는 퍼널에서는 문제가 되지 않지만 일부 상황에서는 문제가 될 수 있습니다.
이를 해결하기 위한 일반적인 해결책으로 전역 상태 사용을 고려할 수 있습니다.

전역 상태로 관리하면 정말 해결될까요?

jotai, zustand, redux 등 대표적인 전역 상태 관리 라이브러리들은 문자그대로 상태를 관리해줍니다. Selector를 통하여 특정 상태를 구독하고 있는 컴포넌트만 상태가 변했을때 리렌더링 시키는 것이 가능하죠
안타깝게도 리액트에서 자체적으로 이런 셀렉터 기능을 지원하지는 않기에 이 문제를 해결하려면 전역 상태를 선택할 수 밖에 없어보입니다.
그러나 전역 상태를 사용하게 되면 상태를 완벽하게 은닉할 수 없기에 특정 컨텍스트에서만 사용되는 퍼널의 상태가 암시적으로 모든 코드 범위에 영향을 받게되는 문제가 발생합니다. 이것은 응집도 측면에서도 좋지 않은 일입니다.

외부 스토리지 사용은 어떨까요?

서버데이터에 의존하며 서버데이터를 업데이트하거나 또는 로컬 스토리지와 같은 외부스토리지를 통해 전역 상태 없이도 상태를 공유할 수 있습니다.
그러나 위 두가지 방법 모두 모든 상황에서 사용 가능한 범용적인 솔루션은 아닙니다. 매번 서버데이터를 업데이트하는 것에 제한이 있을 수 있고 또 보안 측면에서 민감하다면 외부 웹 스토리지 사용이 어려울 수 있습니다.

그렇다면 어떻게 해결해야 할까요?

위 두가지 방법을 사용하지 않으면서도 렌더링을 최소화하면서 은닉화를 달성하고 보안적인 측면에서도 비교적 안전할 수 있는 방법은 무엇이 있을까요?
멘토님은 이 문제에 대해 상태를 "커밋"하는 것과 상태를 변경하는 동작을 분리하는 것을 통해 이룰 수 있다는 의견을 주셨습니다.
화이트보드에 그려주시는 구조도를 보면서 제가 평소에 구현하던 방법과 유사한 지점을 발견했는데요 간단한 코드로 아이디어를 표현하자면 다음과 같습니다.
const Example = (props:{initialState:string , onCommit:(newValue:string) => void}) => {
  const [state,setState] = useState(props.initialState)
  return (
    <div>
      <input value={state} onChange={(e) => setState(e.target.value)} />
      <button onClick={() => props.onCommit(state)}>commit</button>
    </div>
  )
}
이 예시에서는 외부에서 초기값이 될 커밋된 밸류와 커밋을 수행할 세터를 전달받고 초기값을 통해 지역 상태를 만든 뒤 컴포넌트 내부에서는 지역 상태만을 변경합니다.
이렇게 초기값을 입력받고 그것을 활용하여 지역상태를 두는 기법을 통해 onCommit을 수행하기 전 렌더링 범위는 Example 컴포넌트만으로 자연스레 좁혀지게 됩니다.
그런 뒤 커밋을 수행하면 한번 전체 퍼널에 대하여 리렌더링이 일어나게되지만 커밋을 했다는 것은 해당 퍼널스텝이 끝났다는 것을 의미하기 때문에 모든 퍼널에 대해 리렌더링이 일어나는 것 자체도 자연스럽습니다.
위 아이디어를 조금 더 잘 추상화한다면 다음과 같은 커스텀 훅을 작성할 수 있습니다.
export const useDraft = <T>(initialState: T) => {
  const [draft, setDraft] = useState<T>();
  const value = draft ?? initialState;
  const onChangeValue = useCallback((newValue: T) => setDraft(newValue), []);
 
  return [value, onChangeValue] as const;
};
 
이러한 draft state에 대한 아이디어는 draft state 아이디어로 버그 없이 폼 처리하기에서 얻었습니다. 해당 훅이 유용한 이유는 원문에서 잘 설명해주시고 있기 때문에 굳이 옮겨적지는 않겠습니다.
다만 해당 방법을 채택하게되면 좀 더 많은 코드를 작성해야하는 수고로움이 있다보니 렌더링이 성능에 영향을 미칠 여지가 없거나 중요하지 않다면 그냥 리렌더링을 좀 비효율적으로 치는 것도 방법이라고 생각합니다.

마치며

오늘은 기존 toss slash의 useFunnel이 가졌던 한계를 넘어서기 위한 고민과 퍼널에서의 렌더링 최적화에 대한 주제를 다뤄보았습니다.
추가로 저는 이번 주차에서 얻은 인사이트를 바탕으로 구현체를 작성하면서 리액트 생태계의 다양한 라우터를 지원하는 Funnel 라이브러리를 만들고 싶다는 생각을 하게 되었습니다.
어느정도 완성한 뒤 새로운 글로 다시 찾아뵙겠습니다. 읽어주셔서 감사합니다!
XionWCFM을 나타내는 캐릭터 이미지
유길종(XionWCFM)
무언가를 쉽게 설명 해낼 때 쾌감을 느끼는 사람입니다. 현재는 프론트엔드 개발자로 일하고 있습니다.