토스 SLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기

Sheryl Yun·2023년 8월 19일
0

서론

  • 일반 통신사 가입은 왼쪽처럼 긴 폼을 작성해야 함
  • 토스는 꼭 필요한 정보만 한 번에 하나씩 입력하도록 하여 사용자 UX를 높임 (17초 만에 가입 완료)

유려한 UX를 위한 관건

  • 여러 페이지를 빠르게 보여주기
  • 페이지들을 넘나드는 상태를 적절히 관리하기

디자이너가 피그마에 작업해 둔 수많은 화면을
프론트엔드에서 관리하기 위해 많은 분기와 상태가 생기게 됨

=> 쏟아지는 페이지들을 한번에 관리하기 위한 설계 방법 = 퍼널

대표적인 프론트엔드 화면 패턴 3가지

1. 상점형

  • 형태: 목록 페이지 + 상세 페이지
  • 예: 쇼핑몰, 블로그, 뉴스, TodoList

2. SPA형 (단일 페이지 앱)

  • 형태: 페이지 이동 없이 한 화면 내에서 상호작용하는 것
  • 예: 채팅, 지도

3. 설문조사형

  • 여러 페이지들을 통해 상태를 수집한 뒤 결과 페이지를 보여주는 형태
  • 예: 회원가입, MBTI 검사, 병원비 청구서 제출

=> 3번의 설문조사형 형태를 바로 '퍼널'이라고 한다.

예: 회원가입 퍼널

  • 유저가 서비스에 들어와서 최종 목표지점에 이르기까지 조금씩 이탈하게 되는 모양을 '깔대기'(퍼널)와 같다고 해서 붙여진 이름

퍼널 페이지 개선하기 (리팩토링 & 설계 관점)

3가지 키워드

  1. 응집도
  2. 추상화
  3. 시각화

상황

총 4개의 페이지 가정

  • 가입 방식, 주민번호, 집 주소를 입력받고 3개의 데이터를 모아 세 번째 페이지에서 가입 API 호출
  • 성공하면 '가입 완료' 페이지로 이동

1번째 방법

  • 페이지 파일 4개 생성
  • 버튼 클릭 시 router.push로 다음 페이지로 이동
  • 단계를 지나다가 3번째 페이지에서 API 호출
    => 필요한 상태가 3개의 페이지에 떨어져 있으므로 전역 상태를 사용해서 상태를 관리

가장 정석적인 설계이고 동작에도 문제가 없지만 유지보수에 아쉬운 점이 있음

아쉬운 점

  • 페이지 흐름이 흩어져 있음
    • 3개의 파일을 넘나들면서 router.push 코드를 따라가야 함
  • 한 가지 목적을 위한 상태가 흩어져 있음
    • 각 페이지의 입력 값을 서로 다른 페이지에서 전역 상태로 수집
      = 상태를 사용하는 곳(각 페이지)과 수집하는 곳(전역)이 다름

=> 이렇게 되면 나중에 API에 기능을 추가하거나 버그를 수정할 때 앱 전체를 대상으로 데이터 흐름을 추적해야 함 (여러 파일을 넘나들면서 디버깅 필요)

응집도

연관된 페이지를 가까운 곳에 배치하는 것

=> 흩어져 있는 페이지 흐름 및 상태를 한 곳으로 모으기

2번째 방법

  • 여러 개 페이지가 아닌 '가입 퍼널'이라는 하나의 페이지 생성
  • API에 전달할 데이터를 전역이 아닌 지역 상태로 선언
  • step이라는 상태를 추가로 선언
    • step에 어느 화면을 보여줄지 저장
  • 각 페이지에서 '다음' 버튼을 누를 때 step 상태를 변경, 다음 페이지를 렌더링
const [registerData, setRegisterData] = useState();
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식");

return (
	<main>
    	{step === "가입방식" && <가입방식 onNext={(data) => {
        	setRegisterData((prev) => ({ ...prev, 가입방식: data }));
        	setStep("주민번호");
        } />}
        {step === "주민번호" && <주민번호 onNext={(data) => {
        	setRegisterData((prev) => ({ ...prev, 주민번호: data }));
        	setStep("집주소");
        } />}
        {step === "집주소" && <집주소 onNext={(data) => {
        	await fetch("/api/register", { data }); // 3번째 페이지에서 API 호출
        	setStep("가입성공");
        } />}
        {step === "가입성공" && <가입성공 />}
    </main>
);
  • UI 세부 사항은 하위 컴포넌트에서 관리, step 이동은 상위에서 관리
    • UI의 흐름을 한 군데서 관리할 수 있게 됨
    • 어떤 상태가 어떤 UI에서 수집되는지 한눈에 볼 수 있음

=> 더 이상 파일을 넘나들면서 전역 상태를 관리할 필요 없게 됨

=> API 디자인 스펙 변경 시 유연하게 대응 가능

  • 위 파일에서 한 군데만 수정하면 OK
    • 예: '회사 주소'까지 받고 그 다음에 가입 API를 호출하는 것으로 요구사항 변경
    • 아래는 변경 예시 코드
const [registerData, setRegisterData] = useState();
const [step, setStep] = useState<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식");

return (
	<main>
    	// ...
        {step === "집주소" && <집주소 onNext={(data) => {
        	setRegisterData((prev) => ({ ...prev, 집주소: data }));
        	setStep("회사주소");
        } />}
        {step === "회사주소" && <회사주소 onNext={(data) => {
        	await fetch("/api/register", { data }); // 3번째 페이지에서 API 호출
        	setStep("가입성공");
        } />}
        {step === "가입성공" && <가입성공 />}
    </main>
);

2. 추상화

  • 공통된 로직을 뽑아 다른 동료들도 재사용할 수 있도록 라이브러리(훅)로 묶어내는 것
  • 현재 코드에서 퍼널의 흐름과 관련된 코드는?
    • step 지역 상태
    • return문에서 step에 따라 조건부 렌더링하는 부분

조건부 렌더링 추상화하기

// 추상화 
function Step({ if, children }) {
	if (if === true) return children;
    return null;
}

// 사용
	return (
    	<Step if={step === '가입방식'}>
			<가입방식 onNext={() => setStep("주민번호")} />
		</Step>
		<Step if={step === '주민번호'}>
			<주민번호 onNext={() => setStep("집주소")} />
		</Step>
    );

조건문(if prop)도 내부 로직으로 옮기기

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

=> 근데 이렇게 하려면 Step 컴포넌트가 현재 step을 알고 있어야 함

  • 가입 퍼널 페이지에서 직접 관리하고 있던 step 상태도 내부 로직으로 옮겨준다. (추상화)
  • useFunnel 함수 생성 (커스텀 훅이니까 앞에 'use~'를 붙임)
function useFunnel() {
	const [step, setStep] = useState();
    
    const Step = (props) => {
    	return <>{props.children}</>
    };
    
    const Funnel = ({ children }) => {
        // Funnel 컴포넌트로 받은 name 프로퍼티와 step이 일치하는 컴포넌트를 찾아 반환
    	const targetStep = children.find((childStep) => childStep.props.name === step);
        
        return Object.assign(targetStep, { Step });
    }
    
    return [Funnel, setStep];
}
  • useFunnel 코드를 원본 코드에 적용하기
const [registerData, setRegisterData] = useState();
const [step, setStep] = useFunnel<"가입방식"|"주민번호"|"집주소"|"가입성공">("가입방식");

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

=> useFunnel 훅 호출만으로 여러 페이지를 셋트로 렌더링해야 하는 다양한 상황을 해결

추가: useFunnel로 브라우저 히스토리 관리하기

  • 아래는 단일 URL용 훅
    • step의 뒤로 가기, 앞으로 가기 지원이 안 됨
      => 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];
}

이러한 디테일한 기능 로직이 비즈니스 로직과 섞여 있으면 코드 가독성이 떨어질 수 있으니 꼭 분리해주자.

3. 시각화

useFunnel을 Mermaid 라이브러리로 다이어그램 시각화한 것

Mermaid 라이브러리 링크🔗

profile
영어강사, 프론트엔드 개발자를 거쳐 데이터 분석가를 준비하고 있습니다 ─ 데이터분석 블로그: https://cherylog.tistory.com/

1개의 댓글

comment-user-thumbnail
2023년 8월 19일

개발자로서 배울 점이 많은 글이었습니다. 감사합니다.

답글 달기