Toss의 퍼널(Funnel) 패턴 적용해보기
Tags: react, toss, funnel
Published: 2024-06-05 15:01:09

> "토스 SLASH 23의 퍼널: 쏟아지는 페이지 관리하기" 를 참고하여 작성되었습니다.   
> [링크](https://www.youtube.com/watch?v=NwLWX2RNVcw)

토스에서는 다른 통신사들 처럼 요금제 가입 신청서를 받고있다.
차별화된 점은 한 페이지로 이루어진 폼 대신, 한 페이지에 한 항목만 제출하는 UI를 가지고 있다.   

하지만 이런식으로 많은 페이지들을 한 번에 관리하기란 쉽지 않다.
위 발표에서는 이러한 페이지들을 효과적으로 관리하는 방법에 대해서 설명하고 있다.

&nbsp;

## 퍼널(Funnel)

퍼널의 사전적인 뜻인 `깔대기` 와 같은 모양을 띠기 때문에 붙여진 이름이라고 한다.
이러한 깔대기 모양인 `퍼널`을 토스의 회원가입 절차에 적용되어 있다고 한다.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yd7iepjhmqbzu4wyednu.png)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xpabrpx0eay4vcpeoeoz.png)

그렇다면 어떻게 플로우를 관리할지 궁금한데, 토스에서는 두 가지 예시를 들어주었다.

### 기존 퍼널 방식
완료 버튼을 이용해 `router`를 이동하면서 페이지마다 수집하는 유저의 정보를 전역상태에 저장하며 마지막 페이지에서 api를 호출하는 정석적인 구현방법

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3sg6z6deefxmor570eb5.png)

### 토스 퍼널 방식
`useState`를 활용하여 유저 정보 및 `page step`을 지역상태로 만들어주고, 가입방식, 주민번호, 집주소 등 현재 어느 UI를 보여줘야하는지 페이지를 조건부 처리하여 step에 따라 원하는 UI로 업데이트 시켜주며 마지막 step에서 api를 호출하는 방법

```tsx
const [registerData, setRegisterData] = useState()
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식")

return (
  <main>
    {step === "가입방식" && <가입방식 onNext={(data) => setStep("주민번호")} />} 
    {step === "주민번호" && <주민번호 onNext={() => setStep("집주소")} />} 
    {step === "집주소" && <집주소 onNext={async () => setStep("가입성공")} />} 
    {step === "가입성공" && <가입성공Step />}
  </main>
)
```

&nbsp;

## 기존 퍼널의 아쉬운 점 및 보완

기존 퍼널 방식도 완벽해 보이지만 몇 가지 아쉬운점이 있다.

1. **페이지 흐름이 흩어져있다.**
가입 플로우를 파악하기 위해서는 3개의 컴포넌트 파일을 넘나들어야 한다.

2. **한 가지 목적을 위한 상태가 흩어져 있다.**
상태를 수집하는 곳과 사용하는 곳이 달라서, api에 기능을 추가하거나 버그를 수정할 때 전체 페이지를 넘나들며 데이터 흐름을 파악해야 한다.

이를 보완하는 방법이 토스 퍼널 방식에서 확인할 수 있다. 

그 방법으로는 퍼널의 `응집도`를 높이고 `추상화`를 통해 라이브러리화를 수행한다. 
이 두 가지 키워드를 가지고 기존 방식의 단점을 보완한다.

&nbsp;

## 응집도 높이기

```tsx
const [registerData, setRegisterData] = useState()
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식")

return (
  <main>
    {step === "가입방식" && <가입방식 onNext={(data) => {
      setRegisterData(prev => ({ ...prev, 가입방식: data })) // 이하 동일
      setStep("주민번호")
    }} />} 
    {step === "주민번호" && <주민번호 onNext={() => setStep("집주소")} />} 
    {step === "집주소" && <집주소 onNext={async () => {
      await fetch("/api/register", { data }) // API 호출 장소 변경
      setStep("가입성공")
    }} />} 
    {step === "가입성공" && <가입성공Step />}
  </main>
)
```

1. `useState`를 이용해 지역상태를 만들어주고 가입방식, 주민번호 등 현재 어느 UI를 보여줘야 하는지 state에 저장한다.

2. 그러면 하나의 컴포넌트 페이지에서 흩어져있는 페이지들을 한 페이지에 응집시켜 관리가 용이하게 만들어 줄 수 있다.

3. 그리고 `step` 상태에 따라 각 UI 컴포넌트를 조건부 렌더링하고, '다음' 버튼을 누를 때 `step` 상태를 원하는 UI로 업데이트 한다.

4. 결과적으로 `step`의 이동을 상위 컴포넌트에서 관리하여 UI 흐름을 한 곳에서 관리할 수 있게 되었다.

마지막으로 API 호출에 필요한 상태도 상위에서 한 번에 관리하면 어떤 상태가 어떤 UI에서 수집되고 있는지 한 눈에 볼 수 있으며, 이제 더 이상 파일을 넘나들면서 전역 상태를 관리하지 않아도 된다.

&nbsp;

## 추상화로 재사용성 높이기

```tsx
const [registerData, setRegisterData] = useState()
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식")

return (
  <main>
    <Step if={step === "가입방식"}>
      <가입방식 onNext={() => setStep("주민번호")} />
    </Step>
    <Step if={step === "주민번호"}>
      <주민번호 onNext={() => setStep("집주소")} />
    </Step>
  // ...
  </main>
)
```

각 `step`을 컴포넌트로 따로 만들어 주어 중복된 `step` 처리를 추상화 해준다.

그리고 props를 조금 더 깔끔하게 작성해 줄 수 있을 것 같다.

```tsx
const [registerData, setRegisterData] = useState()
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식")

return (
  <main>
    <Step name="가입방식">
      <가입방식 onNext={() => setStep("주민번호")} />
    </Step>
    <Step name="주민번호">
      <주민번호 onNext={() => setStep("집주소")} />
    </Step>
   // ...
  </main>
)
```
예제 코드를 보면 조건문을 삭제하고 `name`만 props로 남겼으며, 컴포넌트가 깔끔해진 것을 볼 수 있다.

만들고보니 Step 컴포넌트가 현재 퍼널의 `step`을 알고 있어야 하므로, 이를 위해 퍼널에서 직접 관리하고 있던 `step` 상태도 내부 로직으로 옮겨주기 위해 상태를 담은 함수인 커스텀 훅을 만들어 Step 컴포넌트와 상태를 같이 관리할 수 있게 코드를 짜줍니다. (useFunnel 커스텀 훅 생성)

```tsx
function useFunnel() {
  const [step, setStep] = useState()

  const Step = (props) => {
    return <>{props.children}</>
  }

  const Funnel = ({ children }) => {
    // name이 현재 step 상태와 동일한 Step만 렌더링
    const targetStep = children.find(childStep => childStep.props.name === step);
    return Object.assign(targetStep, { Step })
  }

  return [Funnel, setStep]
}
```

```tsx
const [registerData, setRegisterData] = useState()
const [step, setStep] = useFunnel<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식")

return (
  <Funnel>
    <Funnel.Step name="가입방식">
      <가입방식 onNext={() => setStep("주민번호")} />
    </Funnel.Step>
    <Funnel.Step name="주민번호">
      <주민번호 onNext={() => setStep("집주소")} />
    </Funnel.Step>
    // ...
  </Funnel>
)
```

여기까지 퍼널의 응집도와 추상화를 거쳐서 보다 가독성있고 재사용성이 높은 퍼널을 완성하였다.

> 퍼널은 완성하였지만 한 가지 불편한 점이 존재하는데, 현재 코드는 단일 URL이라 step 사이에 뒤로가기, 앞으로가기 지원이 안 되는 불편함이 존재한다. 
> 이를 router의 shallow push API를 사용해 쿼리파라미터를 업데이트해줘서 가능하도록 구현할 수도 있다.

&nbsp;

## 내 코드에 적용하기

커머스 플랫폼을 사용하면 주문 결제 과정을 반드시 거치게 된다.

필자는 커머스 플랫폼 프로젝트에서 토스의 퍼널 패턴을 적용해 결제 과정 즉, 결제 퍼널을 적용하였다.

장바구니에 담겨진 상품을 주문하면 `배송지 주소` > `주문 목록`, `결제 완료` 의 3단계를 거치게 된다.   

간단하고 심플한 결제 과정인데 굳이 퍼널 패턴을 적용한 이유는 코드 리펙토링을 수행하면서 유연한 유지보수가 가능하도록 하기 위해 퍼널을 적용하였다. 

처음으로 결제 프로세스를 구현해 보았기 때문에 언제든지 결제 프로세스는 변경될 수 있으며, 또한 언제든지 유연하게 코드 수정 및 스타일 수정이 가능하도록 하기 위해 퍼널 패턴을 프로젝트에 적용해 보았다.

### useFunnel
```tsx
import { ReactElement, ReactNode, useState } from 'react';

export interface StepProps {
  name: string;
  children: ReactNode;
}

export interface FunnelProps {
  children: Array<ReactElement<StepProps>>;
}

export const useFunnel = (defaultStep: string) => {
  const [step, setStep] = useState(defaultStep);

  // Step
  const Step = (props: StepProps): ReactElement => {
    return <>{props.children}</>;
  };

  // Funnel
  const Funnel = ({ children }: FunnelProps) => {
    const targetStep = children.find((childStep) => childStep.props.name === step);

    return <>{targetStep}</>;
  };

  const nextClickHandler = (nextStep: string) => {
    setStep(nextStep);
  }

  const prevClickHandler = (prevStep: string) => {
    setStep(prevStep);
  }

  return {
    Funnel,
    Step,
    currentStep: step,
    nextClickHandler,
    prevClickHandler
  } as const;
};
```

```tsx
const { Funnel, Step, nextClickHandler, prevClickHandler, currentStep } = useFunnel(steps[0].name);
```

부모 컴퍼넌트에서 `useFunnel`을 선언해 주어 자식 컴포넌트 props로 값을 전달하여 모든 상태 관리 및 함수는 최상위 부모 컴포넌트에서 관리해 주도록 하였다.

바로 아래 자식 컴포넌트 코드가 퍼널을 적용한 컴포넌트 코드이다.

### Funnel 적용
```tsx
export const OrderSetup = (
  { 
    steps, 
    Funnel, 
    Step, 
    nextClickHandler, 
    prevClickHandler, 
    order, 
    onSetOrder 
  }: OrderSetupProps) => {

  return (
    <>
      <Funnel>
        <Step name="배송지입력">
          <OrderAddress
            onNext={() => nextClickHandler(steps[1].name)}
            onSetOrder={onSetOrder}
            order={order}
          />
        </Step>

        <Step name="주문목록확인">
          <OrderList
            onPrev={() => prevClickHandler(steps[0].name)}
            onNext={() => nextClickHandler(steps[2].name)}
            order={order}
          />
        </Step>

        <Step name="결제완료">
          <OrderResult />
        </Step>
      </Funnel>
    </>
  )
}
```

&nbsp;

## 마치며

토스의 코드를 흉내내어 퍼널의 개념을 적용해 보았지만, 커스텀 훅과 합성 컴포넌트를 구현하면서 추상화에 대한 고민도 한층 늘었다.

또한 결제 프로세스의 추상화 및 응집도에 대한 POC 과정도 이번 토스 퍼널 패턴을 통해 큰 도움이 되었다.

끝으로 진유림님과의 프라이빗 네크워킹을 신청해 보았는데, 기회가 된다면 꼭 한번 만나 뵙고 싶다. [링크](https://toss.im/slash-23/session-detail/A1-3)

감사합니다.