단계를 효과적으로 관리하는 useFunnel 만들기

Seonup·2024년 4월 23일
1

정산하자

목록 보기
2/2
post-thumbnail

이번 시리즈는 개인프로젝트 "정산하자"를 구현하는 과정의 일부를 작성한 것으로, 실제 코드와는 다를 수 있습니다. 완성된 소스코드는 GitHub에서, 서비스는 정산하자에서 확인하실 수 있습니다.

정산하자 애플리케이션은 모임이 이뤄진 장소, 장소별 사용 금액, 참여 인원, 총무 정보 등 다양한 정보를 수집하는 과정을 거쳐 정산 결과를 도출한다. 이 정보는 크게 3가지 단계(기본 정보, 참가자 정보, 송금정보)로 나누어 수집한다. UI는 아래 사진에서 확인할 수 있다.

정산하자는 위와 같이 단계적으로 정보를 수집하고 결과를 도출하는 로직을 만들기 위해 퍼널 형식을 선택했다. 지금부터 퍼널을 구현하게 된 과정과 선택 이유를 포스팅하고자 한다.

페이지 라우팅 방식

정산 정보를 수집하는 로직을 구현하기 위해 제일 쉽게 선택할 수 있는 방식은 페이지 라우팅 방식이다. UI처럼 기본정보, 참가자 정보, 송금 정보 페이지를 따로 구현하고, 사용자 입력으로 수집한 정보는 전역 상태 또는 로컬스토리지로 저장해서 결과를 도출한다.

구현

페이지 라우팅 방식을 코드로 간략하게 표현해보자. (예제는 로컬스토리지를 이용해서 값을 저장한다.)

// 기본 정보 페이지 path: '/create'
import { useNavigate } from 'react-router-dom';

export default function DefaultInfo() {
  const [data, setData] = useState();
  const storage = useLocalStorage();
  const navigate = useNavigate();
  
  const onSubmit = () => {
    storage.set(data);
  	navigate('/participantsInfo');
  };
  
  return <form onSubmit={onSubmit}>{/* 생략 */}</form>
}
// 참가자 정보 페이지 path: '/participantsInfo'
import { useNavigate } from 'react-router-dom';

export default function ParticipantsInfo() {
  const [data, setData] = useState();
  const storage = useLocalStorage();
  const navigate = useNavigate();
  
  const onSubmit = () => {
    storage.set(data);
  	navigate('/paymentInfo');
  };
  
  return <form onSubmit={onSubmit}>{/* 생략 */}</form>
}
// 송금 정보 페이지 path: '/paymentInfo'
import { useNavigate } from 'react-router-dom';

export default function ParticipantsInfo() {
  const [data, setData] = useState();
  const storage = useLocalStorage();
  const navigate = useNavigate();
  
  const onSubmit = () => {
    storage.set(data);
  	navigate('/result');
  };
  
  return <form onSubmit={onSubmit}>{/* 생략 */}</form>
}

각 페이지마다 onSubmit 이벤트가 발생하면, 현재 단계에서 작성된 데이터를 로컬스토리지에 저장하고 다음 단계인 페이지로 이동한다.

평가

페이지 방식은 자주 사용해봐서 구현이 매우 간단하다. 그냥 보여지는대로 표현하면 된다. 하지만, 페이지 방식은 단계별로 정보를 수집한다는 흐름을 쉽게 이해하기 어렵다.

<DefaultInfo> 를 열어서 코드를 확인해야만 해당 컴포넌트가 데이터를 전역으로 사용하는 상태에 저장한다, 즉 다른 컴포넌트에서 해당 데이터를 사용한다는 것을 알 수 있고 참가자 정보를 수집하는 페이지(<ParticipantsInfo>)로 이동한다는 것을 알 수 있다.

이마저도 사실 <DefaultInfo> 페이지가 다음 페이지로 이동한다는 것만 인지할 뿐 정보를 수집하기 위한 여러 단계 중 하나라는 것은 다른 컴포넌트를 전부 확인하지 않는 이상 알 수 없다.

이러한 단점을 고려해서 단계별 정보를 수집한다는 흐름을 쉽게 확인할 수 있도록, 응집도와 명시성을 높여보자.

페이지 컴포넌트 내에서 조건부 렌더링

응집도를 높이기 위해, 사용하는 곳에서 필요한 모든 컴포넌트를 나열하는 방식을 사용한다. 더불어 각 단계에 맞는 컴포넌트를 렌더링하기 위해 현재 단계를 상태 변수로 관리하고, 조건부 렌더링을 수행하도록 한다.

예를 들어, /create 경로에 접근하면 정산 만들기 페이지 컴포넌트인 <Create>를 출력하고, <Create> 컴포넌트 내에서는 단계별로 <DefaultInfo>, <ParticipantsInfo>, <PaymentInfo> 컴포넌트를 조건부로 렌더링한다.

위 로직을 코드로 표현하면 아래와 같다.

// 정산 만들기 페이지 path: '/create'
export default function Create() {
  const [step, setStep] = useState('기본정보');
  const [data, setData] = useState();
  const storage = useLocalStorage();
  
  const onSubmit = (_step, _data) => {
    storage.set(_data);
  	setStep(_step);
  };

  if (step === '기본정보') {
    return <DefaultInfo onSubmit={(_data) => onSubmit('참가자정보', _data)} />;
  } else if (step === '참가자정보') {
  	return <ParticipantsInfo onSubmit={(_data) => onSubmit('송금정보', _data)} />;
  } else if (step === '송금정보') {
    return <PaymentInfo onSubmit={(_data) => onSubmit('결과', _data)} />;
  } else if (step === '결과') {
    return <Result data={data} />;
  }
}

위 코드는 라우팅을 통해 방문 스택을 쌓는 페이지 라우팅 방식과 달리 방문 기록이 변경되지 않아 사용자가 브라우저의 뒤로가기 버튼을 사용할 수 없다. 위 코드에서 방문 기록을 관리하여 브라우저의 뒤로가기 버튼을 통해 사용자가 이전 단계로 돌아가거나 정보를 수정할 수 있도록 코드를 수정한다.

// 정산 만들기 페이지 path: '/create'
import { useNavigate } from 'react-router-dom';

export default function Create() {
  const [step, setStep] = useState('기본정보');
  const [data, setData] = useState();
  const storage = useLocalStorage();
  const navigate = useNavigate();
  
  const onSubmit = (_step, _data) => {
    storage.set(_data);
    navigate({
      pathname: '/create', 
      search: `step=${_step}` // searchParams를 이용하여 history를 추가한다.
    });
  	setStep(_step);
  };

  if (step === '기본정보') {
    return <DefaultInfo onSubmit={(_data) => onSubmit('참가자정보', _data)} />;
  } else if (step === '참가자정보') {
  	return <ParticipantsInfo onSubmit={(_data) => onSubmit('송금정보', _data)} />;
  } else if (step === '송금정보') {
    return <PaymentInfo onSubmit={(_data) => onSubmit('결과', _data)} />;
  } else if (step === '결과') {
    return <Result data={data} />;
  }
}

브라우저의 뒤로가기 버튼을 사용할 수 있도록 하려면 페이지의 상태에 의존하는 대신, 브라우저의 History API를 활용해야 한다. 정산하자는 React Router를 사용하고 있으므로, window.location.history 대신 useNavigate 메서드를 사용했다.

또한, path를 변경하지 않고 searchParams을 이용하여 주소를 변경하는 이유는, 각 단계가 하나의 독립적인 페이지로 존재하는 것이 아니라 create 페이지의 일부 데이터를 필터링해서 가져오는 것으로 보았기 때문에 searchParams를 이용했다.

평가

정보 수집에 필요한 모든 컴포넌트와 상태를 하나의 페이지 컴포넌트에 모아두니, 원하던 대로 응집도와 명시성이 높아졌다.

다만, 이제는 <Create> 페이지 컴포넌트에 정산 정보를 수집하는 것 뿐만 아니라 단계별로 컴포넌트를 출력하고, 방문기록을 관리하는 등 기타 기능을 수행하는 코드가 함께 작성되는 것이 불편해보인다.

이번에는 <Create> 페이지 컴포넌트가 정보 수집을 하는 페이지라는 것만 드러나도록 단계별 컴포넌트 출력 기능을 분리해보자.

useFunnel 커스텀 훅 만들기

퍼널이란?

퍼널(Funnel)은 원래 마케팅에서 사용되는 개념으로, 사용자가 특정 목표(예: 구매, 가입, 정보 제공 등)를 달성하기 위해 거치는 단계를 시각화한 모델이다. 이 모델은 고객이 유입되어 최종적으로 전환에 이르기까지 주요 단계들을 수치화하여 분석하는 데 활용된다.

토스는 이러한 퍼널의 "목표 달성을 위한 단계"라는 개념을 개발에 적용하여, useFunnel이라는 개발 모듈을 발표(토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기)했다. 이를 통해 개발에서도 여러 단계를 거쳐 결과를 도출하는 페이지를 퍼널이라고 말한다.

퍼널이란 사용자가 웹사이트를나 애플리케이션을 방문해서 최종 목표까지 달성하는데 거치는 단계 (토스 기술 블로그 일부 발췌)

구현

위에서 작성했던 코드를 리팩토링해서 useFunnel을 구현해보자.
리팩토링 단계는 아래와 같다.

  1. step 관련 로직을 useFunnel로 추상화
  2. 선언적으로 단계를 표현할 수 있도록 합성 컴포넌트 제공
  3. 방문 스택 관리 로직 추가

1. step 관련 로직 추상화

먼저, step과 관련된 로직을 useFunnel 내부로 옮긴다.

export default function useFunnel() {
  const [step, setStep] = useState('기본정보');
  
  return {
  	step,
    setStep,
  }
}

step 정보는 useFunnel을 사용하는 측에서 지정할 수 있도록 분리한다. 각 단계와 단계에 해당하는 컴포넌트를 useFunnel의 steps 매개변수로 전달받는다.

type Steps = {
  step: string;
  component: ReactNode;
}[];

export default function useFunnel(steps: Steps) {
  const [step, setStep] = useState(steps[0].step);
  
  const _setStep = (delta?: Delta) => {
    // 이전 step으로 이동
    if (delta && delta === -1) {
      if (stepIndex > 1) {
      	setStep(steps[stepIndex - 1].step);
		return;
      }
      
	  throw new Error('이전 step이 없습니다.');
    }
    
    // 마지막 step일 경우 에러
    if (stepIndex + 1 > steps.length) throw new Error('다음 step이 없습니다.');
    
  	setStep(steps[stepIndex + 1].step);
  };
  
  return {
  	step,
    setStep,
    hasNextStep: stepIndex < steps.length - 1,
  }
}

현재 step에 해당하는 컴포넌트를 출력하도록 <Component>를 생성하여 useFunnel의 반환값으로 추가해준다.

type Steps = {
  step: string;
  component: ReactNode;
}[];
type Delta = -1;

export default function useFunnel(steps: Steps) {
  const [step, setStep] = useState(steps[0].step);
  const stepIndex = steps.findIndex(({ step: _step }) => _step === step);
  
  const _setStep = (delta?: Delta) => {
    // 이전 step으로 이동
    if (delta && delta === -1) {
      if (stepIndex > 1) {
      	setStep(steps[stepIndex - 1].step);
		return;
      }
      
	  throw new Error('이전 step이 없습니다.');
    }
    
    // 마지막 step일 경우 에러
    if (stepIndex + 1 > steps.length) throw new Error('다음 step이 없습니다.');
    
  	setStep(steps[stepIndex + 1].step);
  };
  
  const Component = useMemo(
    () => steps[stepIndex].component, 
    [step]
  );
  
  return {
  	step,
    setStep,
    hasNextStep: stepIndex < steps.length - 1,
    Component,
  }
}

이때 주의할 점은 step이 변경되지 않는 동안에는 기존에 출력되었던 컴포넌트가 리렌더링되지 않고 유지돼야 한다는 것이다. 때문에 useMemo를 사용하고, 의존성 배열에 step 상태 변수를 추가해준다.

2. 선언적으로 단계를 표현할 수 있도록 합성 컴포넌트 제공

이전 포스팅(합성 컴포넌트로 유연성 높이기)에서 선언적으로 컴포넌트를 조합해서 호출하는 방식을 다뤘으니, 출력해야 할 단계별 컴포넌트는 합성 컴포넌트로 작성할 수 있도록 하고, useFunnel의 Steps 매개변수는 단계에 대한 정보만을 받는 것으로 간소화해보자.

먼저, 서브 컴포넌트 <Step>을 구현한다.

interface FunnelStepProps {
  name: Steps[number];
  children: ReactNode;
}

function Step({ children }: FunnelStepProps) {
  return children;
}

<Step> 컴포넌트는 name props로 해당 단계를 전달 받고, children props로 출력할 컴포넌트를 받는다.

이어서, 메인 컴포넌트인 Funnel을 구현한다.

function Funnel({ children }: FunnelProps) {
  return Children
    .toArray(children)
    .filter((child): child is ReactElement => child.type === target)
    .find((child) => child.props.name === step);
}

<Funnel> 컴포넌트는 자식 컴포넌트를 탐색하며 현재 단계에 해당하는 자식 컴포넌트만을 렌더링한다.

이제 두 컴포넌트를 묶고, useFunnel에 추가한다.

const FunnelContainer = useMemo(
    () =>
      Object.assign(
        function Funnel({ children }: FunnelProps) {
          return Children
            .toArray(children)
            .filter((child): child is ReactElement => child.type === target)
            .find((child) => child.props.name === step);
        },
        {
          Step,
        },
      ),
    [step],
  );

기존 Component 코드와 마찬가지로 단계가 변경되기 전까지는 기존 컴포넌트의 렌더링이 유지되어야 하기 때문에 useMemo를 사용했다.

<Funnel>, <Step>까지 추가된 useFunnel 코드는 아래와 같다.

type Steps = readonly string[];
type Delta = -1;

interface FunnelProps {
  children: ReactNode;
}

interface FunnelStepProps {
  name: Steps[number];
  children: ReactNode;
}

export default function useFunnel(steps: Steps) {
  const [step, setStep] = useState(steps[0].step);
  const stepIndex = steps.findIndex((_step) => _step === step);
  
  const _setStep = (delta?: Delta) => {
    // 이전 step으로 이동
    if (delta && delta === -1) {
      if (stepIndex > 1) {
      	setStep(steps[stepIndex - 1]);
		return;
      }
      
	  throw new Error('이전 step이 없습니다.');
    }
    
    // 마지막 step일 경우 에러
    if (stepIndex + 1 > steps.length) throw new Error('다음 step이 없습니다.');
    
  	setStep(steps[stepIndex + 1]);
  };
  
  const FunnelContainer = useMemo(
    () =>
      Object.assign(
        function Funnel({ children }: FunnelProps) {
          return Children
            .toArray(children)
            .filter((child): child is ReactElement => child.type === target)
            .find((child) => child.props.name === step);
        },
        {
          Step,
        },
      ),
    [step],
  );
  
  return {
  	step,
    setStep,
    hasNextStep: stepIndex < steps.length - 1,
    Funnel: FunnelContainer,
  }
}

function Step({ children }: FunnelStepProps) {
  return children;
}

3. 방문 스택 관리 로직 추가

step이 변경됨에 따라 방문 스택을 쌓는 로직을 추상화한다.
리팩토링 전 코드에서 searchParmas를 이용하여 방문 스택을 쌓았던 로직을 살짝 변경하여 useFunnel의 _setStep 함수에 추가한다.

// 리팩토링 전
navigate({
  pathname: '/create', 
  search: `step=${_step}`
});

// 리팩토링 후. useFunnel에 추가된 코드
navigate({ 
  pathname: location.pathname, 
  search: `funnel-step=${steps[stepIndex + 1]}` 
});

이제 searchParams 로 현재 step을 탐색할 수 있게 되었으므로, step을 상태 변수에서 제거하고 코드를 수정해보자.

import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';

type Steps = readonly string[];
type Delta = -1;

interface FunnelProps {
  children: ReactNode;
}

interface FunnelStepProps {
  name: Steps[number];
  children: ReactNode;
}

export default function useFunnel(steps: Steps) {
  const location = useLocation();
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const currentStep = searchParams.get('funnel-step') || steps[0];
  const stepIndex = steps.findIndex((step) => step === currentStep);
  
  const setStep = (delta?: Delta) => {
    // 이전 step으로 이동
    if (delta && delta === -1) {
      if (stepIndex > 1) {
      	navigate(-1);
		return;
      }
      
	  throw new Error('이전 step이 없습니다.');
    }
    
    // 마지막 step일 경우 에러
    if (stepIndex + 1 > steps.length) throw new Error('다음 step이 없습니다.');
    
    navigate({
      pathname: location.pathname, 
      search: `funnel-step=${steps[stepIndex + 1]}` 
    });
  };
  
  const FunnelContainer = useMemo(
    () =>
      Object.assign(
        function Funnel({ children }: FunnelProps) {
          return Children
            .toArray(children)
            .filter((child): child is ReactElement => child.type === target)
            .find((child) => child.props.name === step);
        },
        {
          Step,
        },
      ),
    [location],
  );
  
  return {
  	step: currentStep,
    setStep,
	hasNextStep: stepIndex < steps.length - 1,
    Funnel: FunnelContainer,
  }
}

function Step({ children }: FunnelStepProps) {
  return children;
}

마지막으로, useFunnel을 이용하여 정산 만들기 페이지를 구현해보자.

// 정산 만들기 페이지 path: '/create'

const steps = ['기본정보', '참가자', '송금정보'] as const;

export default function Create() {
  const { Funnel } = useFunnel(steps);
  const [data, setData] = useState();
  
  return (
    <Funnel>
      <Funnel.Step name="기본정보">
        <DefaultInfo data={data} setData={setData} />;
      </Funnel.Step>
      <Funnel.Step name="참가자정보">
        <ParticipantsInfo data={data} setData={setData} />;
      </Funnel.Step>
      <Funnel.Step name="송금정보">
        <PaymentInfo data={data} setData={setData} />;
      </Funnel.Step>
      <Funnel.Step name="결과">
        <Result data={data} />;
      </Funnel.Step>
    </Funnel>
  );
}

평가

useFunnel로 단계별 컴포넌트 관리 기능을 분리하고 나니 드디어 <Create> 페이지 컴포넌트가 하는 일이 무엇인지 명확해졌다.

마치며

퍼널(Funnel)은 작년에 토스 SLASH 발표를 보면서 "이런 패턴도 있구나" 하고 넘어갔던 개념이었다. 그 당시 리액트 공식 문서를 막 읽기 시작한 단계였기 때문에, 실제로 활용할 기회는 없었지만, 컨퍼런스를 즐겨보는 성향 덕분에 언젠가 쓸모가 있을 것 같아서 관심을 가졌었다. 최근 몇 달 동안은 구현 경험에 집중하느라 이런 자료를 찾아보지 못했는데, 이번 경험을 통해 배운 것은, 어떤 자료라도 무시할 것은 없다는 것이다. 이 기회를 계기로 미뤄두었던 자료들을 다시 찾아볼 계획이다.

현재 내가 구현한 useFunnel에는 상태 관리를 위한 로직이 없어서 아쉽지만, 정산하자에 맞춰 간소화된 버전의 useFunnel을 만드는 과정은 꽤 재미있었다. 토스는 히스토리 관리가 복잡한 퍼널을 위해 useFunnel을 넘어 Flow 모듈도 개발했다고 들었는데, 기회가 된다면 히스토리가 복잡한 기능을 다루면서 유사한 Flow 모듈을 만들어 보고 싶다.

profile
박선우

0개의 댓글