https://youtu.be/NwLWX2RNVcw?si=MXMWsc5Fb821aJjn
저는 최근 두잇투게더라는 프로젝트에 사용자의 청소 성향을 수집하는 온보딩 파트 부분을 담당했습니다.
총 4단계로 이루어진 설문조사였는데요, 처음에는 단계마다 페이지를 파고 사용자가 고르는 답변을 전역으로 관리했습니다.
그리고 결과페이지에서 모든 답변을 불러와 청소 성향을 return하는 API를 호출했습니다.
근데 문득, 이렇게 하는 방법이 과연 효율적인가? 실무에서도 이렇게 개발을 하는가? 라는 의문이 들었습니다.
그러다가 토스의 퍼널 패턴에 대해 알게 되었는데요.
퍼널 패턴이란, 단계를 지역 상태로 관리하고 한 페이지에서 단계 별로 컴포넌트를 띄워주는 방법이었습니다.
흩뿌려져있던 상태와 페이지를 이렇게 하나로 모으다니! 역시 토스!
근데 저는 토스의 방법을 그대로 따라하지않았습니다.
대신 그냥 이러한 아이디어를 가지고 제 맘대로 개발을 한 것이죠.
그러다보니 어제 한 스타트업의 기술 면접에서 왜 퍼널을 이렇게 짰냐? 라는 지적아닌 지적을..당했답니다..ㅎㅎ
페이지 흐름이 상위에서 제어되지않고 여러 곳에서 순환되면서 동작하고있다..
이게 제가 생각한 문제점입니다.
면접 끝나자마자 아 빨리 제대로 된 퍼널을 공부해야겠다. 라는 다짐이 들어서 오늘 위의 토스 영상을 정리해보고자 합니다.
그리고 직접 useFunnel이라는 훅을 사용해보고 이해하는 시간을 가지겠습니다.(아자아자)
대표적인 프론트엔드 패턴 중 하나인 설문조사.
여러 페이지들을 통해 상태를 수집하고, 결과 페이지를 보여줍니다.
퍼널
퍼널의 의미는 깔때기로,
토스는 사용자의 최종목표지점까지 조금씩 이탈해가는 퍼널 패턴을 사용하고 있습니다.
router를 통해 페이지를 이동하면서 데이터는 전역상태로 저장하고(페이지가 떨어져있으니)
마지막 페이지에서 전역으로 관리하는 데이터를 이용해 API를 호출하는 방법, 이게 바로 아주 정석적인 패턴입니다.
(저도 원래 이렇게 구현했습니다.)
하지만 아쉬운 점이 있습니다.
첫번째는, 페이지의 흐름이 흩어져있습니다.
이 흐름을 파악하기 위해서는 총 세개의 페이지를 따라다녀야한다는 어려움이 있습니다.
두번째는, 한 가지 목적을 위한 상태가 흩어져있습니다.
전역으로 관리되기 때문에 상태를 수집되는 곳과 사용하는 곳이 다릅니다.
이렇게 되면 또 유지보수를 할 때 앱 전체를 대상으로 데이터 흐름을 추척해야합니다.
응집도 개선하기
"연관된 코드는 가까운 곳에 배치하자"라는 코드 개선입니다.
먼저 가입퍼널이라는 하나의 페이지를 만듭니다.
페이지 안에 사용자의 답변을 모을 registerData를 전역 상태가 아닌 지역 상태로 만들어줍니다.
단계도 역시 지역 상태로 만들어줍니다.
어떤 UI를 보여줄지를 저장합니다.
그리고 한 흐름으로 관리해야 하는 UI들을 각각 컴포넌트로 만들어서
가입퍼널 페이지에 넣어줍니다.
step 상태에 따라 각 UI 컴포넌트를 조건부 렌더링합니다.
가입방식일 땐, 가입방식 컴포넌트를. 주민번호일 땐, 주민번호 컴포넌트를 등등..
그리고 다음 버튼을 누를 때 step 상태를 원하는 UI로 업데이트 해줍니다.(setStep을 통해 단계 변경)
UI 세부 사항은 하위 컴포넌트에서 관리하고,
step의 이동은 상위에서 관리해 UI의 흐름을 한군데서 관리할 수 있게 되었습니다.
API 호출에 필요한 상태도 상위에서 관리하면
어떤 상태가 어떤 UI에서 수집되는지 한눈에 알 수 있습니다.
그리고 더이상 전역상태로 관리하지 않아도 됩니다.
라이브러리로 추상화하기
이 패널 패턴을 다른 페이지에서도 재사용할 수 있게 라이브러리화 해줍니다.
조건부 렌더링 추상화
조건에 맞으면 이 컴포넌트를 보여줘라는 기능이니깐
<Step if={step === "가입방식"}>
<가입방식 onNext={() => setState("주민번호")} />
</Step>
function Step({if, children}) {
if(if === true){
return children;
}else{
return null;
}
}
다음과 같이 조건과 컴포넌트를 넘겨주면 됩니다.
하지만 조건문이 반복되고있으니, 이것도 내부 로직으로 옮깁니다.
조건문을 삭제하고 name만 남겨줍니다.
근데 이렇게 되면 Step 컴포넌트가 현재 퍼널의 step을 알고 있어야합니다.
그래서 step 상태도 내부 로직으로 옮겨줍니다.
useFunnel 훅 생성
상태를 담은 함수를 만들기 위해 커스텀 훅을 만들어줍니다.
이름은 Funnel을 관리한다는 뜻에서 useFunnel이라고 지어줍니다.
function useFunnel<T extends string>(initialStep: T) {
const [step, setStep] = useState<T>(initialStep);
const Step = (props) => {
//UI 컴포넌트가 children으로 들어온다
return <>{props.children}</>
}
const Funnel = ({children}) => {
//children으로 모든 <Funnel.Step>컴포넌트들의 배열을 받는다
//거기서 name이랑 step이 같은 Step 컴포넌트(객체)를 return
const targetStep = children.find(childStep => childStep.props.name === step);
//객체끼리 병합
return Object.assign(targetStep, { Step });
}
return [Funnel, setStep];
}
// targetStep은 이런 형태
const targetStep = {
props: {
name: "가입방식",
children: <가입방식 onNext={() => setStep("주민번호")} />
}
}
// { Step }은 이런 형태
const stepObject = {
Step: (props) => <>{props.children}</>
}
// Object.assign으로 합치면
const result = Object.assign(targetStep, { Step })
// 결과:
{
props: {
name: "가입방식",
children: <가입방식 onNext={() => setStep("주민번호")} />
},
Step: (props) => <>{props.children}</>
}
//제네릭 T가 "가입방식"|"주민번호"|"집주소"|"가입성공"이라는 구체적인 문자열 리터럴 유니온 타입으로 지정
const [Funnel, setStep] = useFunnel<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식");
return(
<Funnel>
<Funnel.Step name={"가입방식"}>
<가입방식 onNext={() => setStep("주민번호")} />
</Funnel.Step>
</Funnel>
)
React는 컴포넌트가 반환하는 객체의 props.children을 자동으로 렌더링합니다.
그럼 Funnel.Step이 여러개 있으면 처음에는 모든 Step이 렌더링 되겠죠?
근데 우리는 name과 step이 같은 Step만 반환하니깐
그 반환된 Step의 props.children만 우리 눈에 보여지는 것입니다.
Object.assign(targetStep, { Step });
이 코드의 의미는 객체끼리 병합을 하는 것인데,
이렇게 하는 이유는 우선 Funnel 컴포넌트에 Step이라는 속성이 추가되기 때문입니다.
그럼 <Funnel.Step>이라는 코드를 쓸 수 있는거죠.
Step이라는 속성은 왜 추가하냐..
그건 코드의 의미와 관계를 명확하게 표현할 수 있다는 것입니다.
그냥 Step 컴포넌트를 쓴다면 종속되지않고 따로 정의된 컴포넌트를 쓰니깐 Funnel의 Step이라는 걸 알기가 어려워지는거죠.
(제가 이해하는게 맞겠죠 하하..)
두잇투게더의 적용해보기
import { StepType } from '@/types/surveySteps';
import React, { useState } from 'react';
interface StepProps<T> {
children: React.ReactNode;
name: T;
}
type FunnelProps<T> = {
children: React.ReactElement<StepProps<T>>[] | React.ReactElement<StepProps<T>>;
};
type FunnelComponent<T> = React.FC<FunnelProps<T>> & {
Step: React.FC<StepProps<T>>;
};
const useFunnel = <T extends StepType>(initialStep: T) => {
const [step, setStep] = useState<T>(initialStep);
const Step = (props: StepProps<T>) => {
return <>{props.children}</>;
};
const Funnel: FunnelComponent<T> = Object.assign(
({ children }: FunnelProps<T>) => {
const targetStep = React.Children.toArray(children).find(
childStep => (childStep as React.ReactElement).props.name === step
);
return targetStep as React.ReactElement;
},
{ Step }
);
return [Funnel, setStep, step] as const;
};
export default useFunnel;
우선 useFunnel을 구현하고,
import { motion } from 'framer-motion';
import BackBtn from '@/components/common/button/BackBtn/BackBtn';
import { Progress } from '@/components/common/ui/progress';
import { Step1, Step2, Step3, Step4, Step5, LoadingScreen } from '@/components/survey';
import MetaTags from '@/components/common/metaTags/MetaTags';
import {
DUMMY_QUESTION_STEP1,
DUMMY_QUESTION_STEP2,
DUMMY_QUESTION_STEP3,
DUMMY_QUESTION_STEP4,
} from '@/constants/onBoarding';
import useFunnel from '@/hooks/useFunnel';
import useLoadingState from '@/hooks/survey/useLoadingState';
import useUserName from '@/hooks/survey/useUserName';
import useSurveyState from '@/hooks/survey/useSurveyState';
import { StepType } from '@/types/surveySteps';
const SurveyPage = () => {
const item = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
duration: 0.5,
ease: 'easeIn',
},
},
};
const [Funnel, setStep, currentStep] = useFunnel<StepType>('첫번째');
//로딩 상태 관리 커스텀 훅
const { isLoading, isCompleted, setIsLoading, setIsCompleted } = useLoadingState();
//사용자 이름을 가져오는 커스텀 훅
const { username } = useUserName(currentStep);
//설문을 관리하는 커스텀 훅
const { progressStep, result, handleNextStep, handleDone, handlePrevStep } = useSurveyState({
currentStep,
setStep,
setIsLoading,
setIsCompleted,
});
return (
<div className={`flex h-screen flex-col`}>
<MetaTags
title={'두잇투게더 - 설문조사'}
description={'나의 집안일 스타일을 알아보세요.'}
url={`https://doit-together.vercel.app/survey/`}
/>
{currentStep !== '설문결과' && (
<>
{/* 헤더 */}
<motion.div variants={item} initial='hidden' animate='show'>
<div className='p-5'>
<BackBtn handleClick={handlePrevStep} />
</div>
<Progress value={(progressStep / 5) * 100} className='mb-8' />
</motion.div>
</>
)}
{isLoading ? (
<>
<LoadingScreen username={username} isCompleted={isCompleted} />
</>
) : (
<>
{/* 본문 */}
<Funnel>
<Funnel.Step name='첫번째'>
<Step1
title={`평소 정리정돈에 대해\n어떻게 생각하시나요?`}
questions={DUMMY_QUESTION_STEP1}
handleNextStep={handleNextStep}
/>
</Funnel.Step>
<Funnel.Step name='두번째'>
<Step2
title={`어떤 방식으로 일하는 것을\n선호하시나요?`}
questions={DUMMY_QUESTION_STEP2}
handleNextStep={handleNextStep}
/>
</Funnel.Step>
<Funnel.Step name='세번째'>
<Step3
title={`주변 환경이\n작업에 얼마나 영향을 주나요?`}
questions={DUMMY_QUESTION_STEP3}
handleNextStep={handleNextStep}
/>
</Funnel.Step>
<Funnel.Step name='네번째'>
{progressStep !== 5 && (
<Step4
title={`집안일을 할 때\n어떤 감정을 느끼시나요?`}
questions={DUMMY_QUESTION_STEP4}
handleNextStep={handleNextStep}
/>
)}
</Funnel.Step>
<Funnel.Step name='설문결과'>
<Step5
title={`${username}님의 청소성향은`}
results={result}
handleDone={handleDone}
/>
</Funnel.Step>
</Funnel>
</>
)}
</div>
);
};
export default SurveyPage;
useFunnel 적용 후, 하나의 훅에 다 모여져있던 로직들을 각 목적에 맞게 분리해서 작성했습니다.
음 이렇게 작성하니깐 퍼널의 구조가 확 이해되고 상위에서 깔끔하게 step이 관리되는 느낌이랄까.... !!!!

라이브러리도 있지만 이렇게 내부 로직을 간단하게 따라해보고 적용해보니 아주 뿌듯합니다..
그리고 리팩토링하면서 좀 이상하게 짜여져있던 코드도 고쳐서 더욱더 만족!
'⚡️etc.' 카테고리의 다른 글
👋두잇투게더만의 온라인 협업을 위한 컨벤션을 정리해보자(Git, Code, 폴더 구조 및 네이밍) (2) | 2024.12.19 |
---|---|
우리 프로젝트에 제대로 된 Git 브랜치 전략을 세우는 게 어때요? (3) | 2024.11.24 |
[chart.js/react-chartjs-2] React 프로젝트에 chart.js 적용해보자👋(+Typescript, 반응형) (0) | 2024.09.27 |
[GitHub Pages] gh-pages 라이브러리를 활용하여 배포하기 (0) | 2024.09.02 |
Axios와 Promise에 대해 알아보자 (0) | 2024.06.19 |