Toss Slash 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기


퍼널: 쏟아지는 페이지 한 방에 관리하기
오늘 토스 컨퍼런스 세션 중 “쏟아지는 페이지 한 방에 관리하기”를 듣고, 정리를 해두면서 내 것으로 만들고 싶다는 생각이들어서 집에 와서 다시 영상을 보면서 정리를 해봤다.
예전 토스의 UX 관련한 영상을 보면서, 필요한 Input 만 페이지에 띄우고 페이지를 넘기는 식으로 구성한 것을 보며 좋다고 생각하면서도 저 케이스에서는 상태관리를 어떻게 하면 좋을까 생각을 했었는데 이 부분에 대해 어떤식으로 구현을 했는지에 대해 알려주시는 세션이었다.
🤔 서비스를 개발할 때 대표적인 FE Pattern.
회사나 토이프로젝트를 할 때 사용되는 대표적인 프론트엔드 패턴은 다음과 같이 상점, 단일 페이지 앱 그리고 설문조사형 패턴이 있다. 각각의 패턴의 예시를 알아보자.
패턴 1 .상점
상점, 블로그, 뉴스, 투두리스트와 같은 형태의 패턴이다. 쉽게 말해서 리스트의 포스트를 클릭해서 들어갔을 때 상세페이지가 노출되는 방식이다.
패턴 2. 단일 페이지 앱
채팅이나 지도처럼 페이지 이동 없이 한 화면 내에서 상호작용하는 패턴이다.
패턴 3. 설문조사
회원가입 절차나 MBTI 검사와 같이 여러 페이지를 통해 상태와 결과를 수집하는 패턴이다. MBTI 검사도 대표적 방식일 수 있지만, 무엇보다도 토스 앱이 가장 생각나는 패턴 같은데, 토스에서는 이 패턴을 “퍼널” 이라고 부른다.
👀 Funnel ?
퍼널은 마케팅 업계에서 주로 사용하는 용어로, 유저가 첫 페이지에 진입해서 마지막 페이지까지 진행하는 과정에서 조금씩 이탈해서 최종적으로는 진입부터 마지막까지가 퍼널의 사전적 뜻인 ‘깔대기’와 같은 모양을 띠기 때문에 붙여진 이름이다.
🤡 세 가지 키워드로, 퍼널 코드 개선하기.
응집도
추상화
시각화
발표에서는 위 세가지 키워드로 퍼널 코드를 개선한 사례에 대해 공유를 해주셨는데, 나는 응집도와 추상화 키워드 부분까지만 정리를 해보려한다. 시각화 부분은 유튜브를 참고하면 좋겠다. 개발자 경험 개선에 큰 도움을 준 사례라 이것도 추후에 한번 해보고 싶다. ( 📽️ 10:48 ~ )
💭 생각해보자.
아래와 같은 퍼널을 개발한다고 생각해보자. 가입방식, 주민번호, 집주소를 입력하고 집주소 페이지에서 form
을 submit
한다. 이후에 완료가 된다면 가입완료 페이지로 이동해야한다.
🤔 페이지 이동으로 구현?
각각의 단계에 대한 페이지를 개발하고, 페이지를 이동시켜가며 flow 를 구성한다고 생각해보자. 그렇다면 각 페이지마다 파일이 생성될 것이고, 화면 전환에 대한 로직이 각각 들어가야할 것이다. 여러개의 파일을 따라가며 작업해야하므로 뭔가 복잡스럽다.
상태관리 관점에서도 아쉽다. 집주소 페이지에서 form
을 submit
한다고 계획한다면, 집주소 페이지는 이전 페이지에 대한 상태를 가질 수 없으므로 form
에 대한 전체 데이터는 전역에서 관리해야할 것이다. 상태를 사용하는 곳과 수집하는 곳이 다르기 때문에, 뭔가 수정해야한다면 흐름을 타고 올라가며 수정을 해야하는 어려움이 따른다.
🖐🏻 조건부 렌더링으로 구현?
그럼, 각각의 페이지가 아닌 조건부 렌더링으로 구현한다면 페이지 이동을 하지 않아도 되지 않을까? 그리고 상단에서 지역 상태를 만들어서 관리하는 것이다. 아래 코드와 같이 말이다.
const [registerData, setRegisterData] = useState();
const [step, setStep] = useState<'가입방식'|'주민번호'|'집주소'|'가입성공'>('가입방식');
return (
<main>
{step === '가입방식' && <가입방식 onNext={(data) => setStep('주민번호')} />}
{step === '주민번호' && <주민번호 onNext={() => setStep('집주소')} />}
{step === '집주소' && <집주소 onNext={(data) => setStep('가입성공')} />}
{step === '가입방식' && <가입성공 />}
</main>
)
각각의 스텝에 대해 지역상태를 만들고, 해당 스텝일 때 조건부 렌더링으로 컴포넌트를 보여주는 방식이다. 이렇게 되면, 최종적으로 제출해야하는 데이터는 최상단에서 관리되므로 데이터를 전역으로 뺄 필요도 없어진다. 제출에 필요한 데이터를 수집하고 api 콜을 하는 것 까지 작성을 해보면, 아래와 같이 작성할 수 있겠다.
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 === '가입방식' && <가입성공 />}
</main>
)
위와 같이 작성하게 되면, 수집 데이터와 제출 데이터가 한 곳에서 관리되며 어떤 상태가 어떤 UI에서 수집되는지도 한 눈에 볼 수 있다. 또한 전반적인 UI 흐름도 한 눈에 파악이 되고, api 콜을 하는 위치가 바뀐다면 쉽게 위치를 변경해줄 수도 있다.
👀 Funnel code 재사용해보기.
const [registerData, setRegisterData] = useState();
👉🏻 const [step, setStep] = useState<'가입방식'|'주민번호'|'집주소'|'가입성공'>('가입방식');
return (
<main>
👉🏻 { step === '가입방식' &&
<가입방식 onNext={(data) => {
setRegisterData(prev => ({...prev, 가입방식: data})); // 이하 동일
setStep('주민번호');
}}/>
}
👉🏻 { step === '주민번호' && <주민번호 onNext={() => setStep('집주소')} />}
// ...
</main>
)
작성했던 코드에서 퍼널 흐름과 관련된 부분은 step 이라는 지역 상태와 step 에 따라 조건부 렌더링이 되는 부분이다. 이 부분들은 반복되면서 계속 재사용되기 때문에, 이를 활용해서 뭔가를 만들어볼 수 있겠다.
👉🏻 조건부 렌더링되는 부분 컴포넌트로 추상화하기.
<Step if={step === '가입방식'}>
<가입방식 onNext={() => setStep('주민번호')} />
</Step>
// 구현
function Show({ if, children }) {
if ( if === true ) {
return children;
}
return null;
}
특정 조건에 따라서 컴포넌트가 보여지는 방식이니, 위와 같이 조건과 컴포넌트를 받는 컴포넌트를 만들어준다. (진짜.. so sexy.. 🥰) 그럼 이제, 작성한 컴포넌트를 만들어서 사용한다면 아래와 같이 사용할 수 있겠다.
const [registerData, setRegisterData] = useState();
const [step, setStep] = useState<'가입방식'|'주민번호'|'집주소'|'가입성공'>('가입방식');
return (
<main>
<Step if={step === '가입방식'}>
<가입방식 onNext={() => setStep('주민번호')} />
</Step>
<Step if={step === '주민번호'}>
<주민번호 onNext={() => setStep('집주소')} />
</Step>
// ...
</main>
)
🎄 useFunnel 훅 생성
그럼 이제, Step 컴포넌트가 현재 어떤 step 인지 알 수 있도록 상태를 담은 커스텀 훅을 만들어서 컴포넌트와 step 을 설정하는 함수를 반환시켜보자.
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];
}
이제 이렇게 작성한 훅을 페이지에서 사용을 한다면, 아래와 같이 사용이 가능하며 이 훅은 다른 퍼널을 만들 때에도 범용성있게 사용이 가능해진다.
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>
)
💾 useFunnel 의 히스토리 관리 기능
지금까지 작성했던 코드는 멀티 페이지가 아닌 단일 페이지이므로 URL 또한 하나이다. 그러므로 각 스텝 사이에 뒤로가기, 앞으로가기 지원이 힘들다. 이를 router
의 shallow push
API 를 사용해서 쿼리 파라미터를 업데이트해줄 수도 있다. 세션 발표자님 말씀처럼 이런 로직들이 비즈니스 로직마다 들어가있었으면 가독성 문제가 많이 떨어졌을 것 같다.
function useFunnel() {
const step = useQueryParam('funnel-step');
const setStep = (step: string) => {
const nextUrl = `${QS.create({ ...prevQuery, 'funnel-step: step'})}`;
router.push(url, undefined, { shallow: true });
};
// ...
return [Funnel, setStep];
}
📂 틈새 공부 - shallow routing
Next.js 의 page 디렉토리에서 shallow routing 을 지원해주는데, 이는 data fetching method 를 다시 호출하지 않고 URL 을 변경시킨다.
import useRouter = from 'next/router';
const router = useRouter();
// router.push(url, as, option);
router.push('/?pokemon=25', undefined, { shllow: true });
next/router
에서 useRouter
훅을 import 해서 사용할 수 있는데, 아래와 같이 사용한다면 페이지가 새로고침되지 않고 url 이 변경되는 것을 확인할 수 있다.
다만 공부하는 과정에.. 그리고 지금 Next.js 13 의 앱 디렉토리 구조를 사용하면서 shallow routing 을 사용하는 방법은 아직 찾아내지 못해서 이 부분은 찾거나 정리가 되면 한번 잘 메모해둬야겠다.
🖐🏻 마무리.
오늘은 평소에 많이 궁금했던 설문지 패턴으로 구현된 퍼널에 대해서 공부해봤다. 새로운 관점에서 생각하고 효율적인 코드를 작성해나간 사례를 보면서, 나도 조금만 더 신경쓰면 구조적인 문제나 힘들게 상태관리를 하고 있지 않을 수 있겠다는 생각이 들었다.
당장 이 방식을 적용시킬 곳은 없을 것 같긴 하지만 굳이 퍼널이 아니더라도 응용해서 사용할 곳이 떠올라서 조만간 한번 잘 사용해봐야겠다. 좋은 경험 공유해주신 토스의 진유림님! 감사합니다!
참고
Subscribe to my newsletter
Read articles from leetrue directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

leetrue
leetrue
직면하는 모든 문제에 유치한 것은 없으며, 의미 없는 삽질 또한 없다고 믿습니다.