새로운 프로젝트를 진행하면서 만난 수 많은 페이징을 퍼널을 통해 하나의 url에서 관리하는 방식을 사용했다. 이를 구현하면서 겪었던 과정을 공유하고자 한다.
이번 프로젝트는 프로젝트 팀원 및 팀을 구할 수 있는 플랫폼 올라월드를 통해 팀을 구하여 진행하였다.
이 때 분명 FE 기술 스택으로 React가 있어 당연히 웹 개발인줄 알고 참여하게 됐는데, 첫 회의 날 기획자분이 가져온 기획서와 디자인은 모바일 APP의 형태여서 적잖아 당황했던 기억이 있다.
당시 팀 내에 FE 개발을 담당하는 개발자는 나와 팀원 1인, 총 2인으로 구성되었는데 우리 모두 Native 개발 경험이 없었기에 팀원들과 협의를 통해 "웹 뷰" 형태로 개발을 진행하기로 했다.
기획자분이 작성해온 기술 명세서를 토대로 디자이너 분들이 피그마로 전체적인 디자인을 만들어 공유해 주셨을 때, 나는 또 한 번 당황했다.
바로 회원가입 절차가 대략 7개의 절차로 나누어져 있었기 때문이다. (살려주세요 디자이너님)
(약관 동의 - 학교 - 학과 - 이메일 - 비밀번호 - 프로필 설정 - 완료 총 7단계로 이루어진 회원가입 절차 피그마)
그동안 진행했던 프로젝트는 웹 사이트를 기반으로 개발했기에 회원가입은 대부분의 커뮤니티 사이트와 같이 한 페이지에서 진행됐다.
이렇게 많은 절차는 처음 담당하기에 어떻게 구현해야할까 고민하던 찰나, 때마침 당시 시청했던 Toss SLASH 23 진유림님의 영상 "퍼널: 쏟아지는 페이지 한 방에 관리하기" 가 떠올랐다. (아직 못보신 분들은 꼭 보시는걸 추천합니다 !)
중첩된 페이지 라우팅을 구현하기 위해서 생각해볼 수 있는 방법은 두 가지 정도가 있었는데,
첫 번째는 위에서 언급한 'Funnel' 관리 즉, 페이지를 state로 관리하여 컴포넌트만 바꿔주는 방식이고,
두 번째는 '/signup/step1' 과 같이 중첩 라우팅을 사용해 구현하는 방식이었다.
두 가지 방식을 고민하면서 생각해볼 기준은 다음과 같다.
1. 뒤로 가기를 필요로 하는 페이지인가?
2. URL을 통한 접근을 허용 해야하는 페이지인가?
3. SEO를 고려해야 하는가?
=> 위 세 가지 기준을 고려했을 때, 회원가입 페이지의 경우 순서대로 진행되는 플로우를 가진 페이지이고, 약관동의와 회원 정보 입력 등 연속된 form을 가지고 있기에 각 페이지에서 뒤로가기는 최소화 하는것이 좋다. 또한 각 단계는 이전 단계를 완료했을 때 접근 가능한 페이지들로 이루어져 있어 URL을 통한 직접 접근은 허용되지 않는 페이지이다. 마지막으로, 회원가입 페이지의 경우 SEO를 고려할 필요가 없기에 state를 통한 관리가 더 적합하다고 판단하여 최종적으로 'Funnel' 방식을 채택했다.
이제 구현할 방식을 정했으니 이를 코드로 한 번 작성해보자.
토스 슬래쉬의 영상을 참고해보면 효율적으로 Funnel과 각 절차 (Step)을 관리할 수 있는 useFunnel 이라는 커스텀 훅을 작성하는 방식을 제공한다.
아래는 이를 참고한 코드이다.
// useFunnel.tsx
import React, { ReactElement, ReactNode, useState } from 'react';
export interface StepProps {
name: string;
children: ReactNode;
}
export interface FunnelProps {
children: Array<ReactElement<StepProps>>;
}
export const useFunnel = (defaultStep: string) => {
// state를 통해 현재 스텝을 관리한다.
// setStep 함수를 통해 현재 스텝을 변경할 수 있다.
const [step, setStep] = useState(defaultStep);
// 각 단계를 나타내는 Step 컴포넌트
// children을 통해 각 스텝의 컨텐츠를 렌더링 한다.
const Step = (props: StepProps): ReactElement => {
return <>{props.children}</>;
};
// 여러 단계의 Step 컴포넌트 중 현재 활성화된 스텝을 렌더링하는 Funnel
// find를 통해 Step 중 현재 Step을 찾아 렌더링
const Funnel = ({ children }: FunnelProps) => {
const targetStep = children.find((childStep) => childStep.props.name === step);
return <>{targetStep}</>;
};
return { Funnel, Step, setStep, currentStep: step } as const;
};
이제 위에서 작성한 useFunnel 훅을 이용해서 각 스텝을 렌더링해보자. 각 스텝에 관련된 컴포넌트를 만들어주고, 이를 부모 컴포넌트에서 Funnel로 감싸준다. 그 후 Step 컴포넌트로 각각의 절차를 묶어준다.
아래 코드를 참고해보자.
// ProfileSetup.tsx
import React from 'react';
// type 지정을 위한 import
import { FunnelProps, StepProps } from '../../../hooks/useFunnel';
// 이 외 import ...
export interface ProfileSetupInterface {
steps: string[];
nextClickHandler: (nextStep: string) => void;
Funnel: React.ComponentType<FunnelProps>;
Step: React.ComponentType<StepProps>;
}
const ProfileSetup = ({ steps, nextClickHandler, Funnel, Step }: ProfileSetupInterface) => {
return (
<SetupPageLayout>
<Funnel>
<Step name='약관 동의'>
<CheckAgreement onNext={() => nextClickHandler(steps[1])} />
</Step>
<Step name='학교 선택'>
<SetupSchool onNext={() => nextClickHandler(steps[2])} />
</Step>
<Step name='학과 선택'>
<SetupMajor onNext={() => nextClickHandler(steps[3])} />
</Step>
<Step name='이메일 인증'>
<SetupEmail onNext={() => nextClickHandler(steps[4])} />
</Step>
<Step name='비밀번호 설정'>
<SetupPassword onNext={() => nextClickHandler(steps[5])} />
</Step>
<Step name='프로필 설정'>
<SetupProfileInfo />
</Step>
</Funnel>
</SetupPageLayout>
);
};
export default ProfileSetup;
위 코드 처럼 각 Step에는 name 속성을 통해 해당 스텝이 어떤 절차를 나타내는지 표시해주었고, 전체 단계를 나타내는 배열 steps와 Funnel / Step 컴포넌트, 다음 스텝으로 넘어가는 함수 nextClickHandler는 부모 컴포넌트인 Page 컴포넌트에서 props로 전달 받도록 했다.
마지막 단계인 '프로필 설정' 단계에서는 onNext props를 전달하지 않는데, 이는 마지막 단계에서는 다음 절차로 넘어갈 필요가 없고 form을 submit 해주기 때문이다.
// SignUpPage.tsx
import React, { useState } from 'react';
import { useFunnel } from '@/hooks/useFunnel';
// react hook form을 사용한 form 컴포넌트
import GenericForm from '@/components/public/form/GenericForm';
// 이 외 import ...
// 전체 스텝을 담은 배열
const steps = [
'약관 동의',
'학교 선택',
'학과 선택',
'이메일 인증',
'비밀번호 설정',
'프로필 설정',
];
const SignUpPage = () => {
const { submitSignup } = useSignup();
const { Funnel, Step, setStep } = useFunnel(steps[0]);
// 이 외 state ...
const nextClickHandler = // ... 로직
const prevClickHandler = // ... 로직
return (
<>
<PageHeader title='회원가입' onClick={prevClickHandler} />
<PageLayout>
<GenericForm<ProfileSetupDataInterface>
formOptions={{ mode: 'onChange' }}
onSubmit={submitSignup}
>
<ProfileSetup
steps={steps}
nextClickHandler={nextClickHandler}
Funnel={Funnel}
Step={Step}
/>
</GenericForm>
</PageLayout>
</>
);
};
export default SignUpPage;
이런식으로 회원가입 절차의 최상위 컴포넌트인 Page 컴포넌트에서 Funnel과 Step을 import 하고 Setup 컴포넌트의 props로 전달하여, Setup 컴포넌트에서 이를 이용해 각 단계를 렌더링 한다.
이 때, 회원가입 단계에는 학교, 학과, 이메일, 비밀번호, 닉네임, 사진 등 다양한 정보를 입력할 수 있는 여러개의 Input이 사용되는데, 이들의 상태를 통합적으로 관리하기 위해 React의 form 관리 라이브러리인 'React-Hook-Form'을 사용했다.
간단하게 설명하자면, 리액트 훅 폼은 React App에서 폼을 쉽고 효율적으로 다룰수 있도록 하는 라이브러리다. 해당 라이브리러를 통해 폼의 입력값을 관리하고, 유효성 검사, 에러 처리 등을 간편하게 할 수 있도록 다양한 기능을 제공한다.
사용법에 대한 내용이 궁금하다면 리액트 훅 폼 공식문서를 참고해보자.
필자가 구현한 회원가입 절차에서는 다양한 Input에 여러가지 값을 받아 이를 하나의 data로 저장해 submit시 페이로드에 담아서 회원가입 api를 호출해야했다. 이를 효율적으로 관리할 수 있는 방법으로 바로 리액트 훅 폼이 떠올랐고, 바로 적용해보았다.
리액트 훅 폼을 사용하기 위해 폼 컴포넌트를 작성했다. 이후 프로젝트 내에서 게시글 작성, 회원정보 수정 등과 같은 부분에서도 비슷한 기능을 사용할 것을 고려해 공용으로 사용할 수 있도록 했다.
// GenericForm.tsx
import React from 'react';
import { useForm, FormProvider, SubmitHandler, UseFormProps, FieldValues } from 'react-hook-form';
// 제네릭 타입을 사용한 폼 interface 정의
interface GenericFormInterface<TFormData extends FieldValues> {
children: React.ReactNode;
onSubmit: SubmitHandler<TFormData>;
formOptions?: UseFormProps<TFormData>;
}
const GenericForm = <TFormData extends FieldValues>({
children,
onSubmit,
formOptions,
}: GenericFormInterface<TFormData>) => {
const methods = useForm<TFormData>(formOptions);
return (
// form provider를 통해 useForm에서 가져온 methods를 children (하위 컴포넌트)에 전달
<FormProvider {...methods}>
// onSubmit을 통해 폼 제출
<form onSubmit={methods.handleSubmit(onSubmit)}>{children}</form>
</FormProvider>
);
};
export default GenericForm;
이 처럼 제네릭 타입을 사용해 다양한 폼 형식에 범용적으로 사용할 수 있도록 설계하여, 재사용성을 높일 수 있도록 신경썼다.
폼 컴포넌트에서 'FormProvider'를 제공하기 때문에 이 폼으로 감싸진 하위 컴포넌트에서는 'useFormContext'를 이용해 해당 폼에서 제공하는 methods를 사용할 수 있게된다.
// SetupSchool.tsx
// context import
import { useFormContext } from 'react-hook-form';
// 이 외 import ...
const SetupSchool = () => {
const { register, errors, status } = useFormContext();
<Input
type='text'
status={schoolStatus}
{...register('school')}
errorMessage={
errors.school && typeof errors.school.message === 'string'
? errors.school.message
: undefined
}
placeholder='학교 이름을 검색해주세요.'
onFocus={onFocus}
onChange={inputchangeHandler}
/>
// 이 외 코드 ...
};
이런식으로 각 컴포넌트의 Input을 context를 통해 가져온 method를 이용해 form과 연결시켜 각 Input의 값과 유효성 검사, 에러 등을 하나의 폼에서 간단하게 처리할 수 있다.
마지막 스텝에서 회원가입 제출 버튼의 type을 'submit' 으로 지정하면 폼이 연결된 모든 Input의 값을 data로 받아 폼을 제출한다.
리액트 훅 폼을 사용하지 않으면 각 Input에 대한 state를 만들어 값을 관리하고, 유효성 검사를 하거나 에러 메세지를 띄우는 등의 작업을 처리해줬어야 할 것이다.
위 과정을 거쳐 만들어진 최종 회원가입 절차 결과물 ..