1. 기존의 불편함
회원가입 페이지 같은 경우, 여러 단계들로 이루어져 있다. 이러한 구조를 퍼널 패턴이라 한다. 주로 마케팅 분야에서 많이 사용되며, 구매 과정에서 고객이 거치는 여러 단계를 설명할 대 사용되는 용어이다.
만약, 퍼널의 각 단계에 대한 상태를 query string으로 관리한다고 했을 경우 코드는 아래와 같다.
export function JoinPage(){
const searchParams = useSearchParams();
const step = searchParams.get('step');
return(
<main>
{step === '1' && <JoinStep1 />}
{step === '2' && <JoinStep2 />}
</main>
)
}
// ..
export function JoinStep1(){
const router = useRouter();
const handlePrev = (data) => {
router.push('/login')
}
const handleSubmit = (data) => {
await submit(data);
router.push('/join?step=2')
}
// ..
}
// ..
export function JoinStep2(){
const router = useRouter();
const handlePrev = (data) => {
router.push('/join?step=1')
}
const handleSubmit = (data) => {
await submit(data);
router.push('/join?step=3')
}
// ..
}
단계가 늘어나면, 코드가 더 복잡해질 것이다. 위와 같이 각 단계에서 라우팅 훅을 호출하여 페이지의 넘어감을 관리하면 여러가지 불편함이 있다.
첫 번째로 하나의 ‘회원가입’이라는 목적을 가진 step들인데, 페이지를 바꾸는 것은 각 컴포넌트 내에서 관리를 하고 있기 때문에 관심사가 멀다고 할 수 있다. 나중에 step1과 step2 사이에 step이 하나 추가된다면, 각 step컴포넌트 내부에 들어가 라우팅을 수정해야 한다는 불편함이 있다.
또한, 다른 곳에서 이러한 퍼널 step 구조가 생길 때마다 step 분기 처리, 그리고 각 페이지에서 라우팅하는 과정을 해야 한다.
이러한 과정을 컴포넌트 혹은 훅으로 추상화하여 불편함을 해소하면 어떨까?해서 시작해였다.
2. 개발 설계
Funnel을 사용하는 측에서의 구조를 먼저 작성해보았다. useFunnel훅을 이용하여 Funnel 컴포넌트, prevStep, nextStep함수를 가져올 것이다.
- Funnel은 퍼널 스탭에 대한 상태를 뿌려주는 퍼널 구조를 감싸는 래퍼역할을 한다.
- prevStep, nextStep, setStep을 이용하여 이전 스탭 혹은 다음 스탭으로, 혹은 원하는 스탭으로 이동할 수 있다.
- currentStep프로퍼티를 이용하여 현재 스탭에 대한 정보를 얻을 수 있다.
3. 코드 작성하기
🤔 step에 대한 상태는 뭐로 관리하는 것이 좋을까?
후보로는 state + Context를 이용하여 뿌려주기, 전역 상태 관리, query string이 있었다. 우선 이 step이 정말 전역일까? 생각해보았을 때 아니다. step은 이 퍼널 구조에서만 쓰이는 내부 데이터이다. 전역으로 관리하는 것은 불필요하다고 생각하였다.
state + Context를 이용하면 Funnel 컴포넌트 내부에서만 step에 대한 상태를 관리하고, 사용할 수 있다.
query string을 사용했을 때 장점은, 외부에서 해당 퍼널에 들어올 때 특정 step부터 사용자에게 보여줄 수 있다는 점이다. state로 관리할 경우, 이는 어렵고 결국에는 query string을 사용해야 한다.
결론적으로, query string을 활용하기로 결정하였다.
1. 인터페이스 설계하기
useFunnel훅이 반환할 것들에 대한 인터페이스를 작성하였다.
interface IFunnel {
currentStep: string;
Funnel: React.FC<FunnelProps>;
nextStep: () => void;
prevStep: () => void;
setStep: (step: string) => void;
}
🤔 인터페이스를 미리 작성한 이유가 뭐야?
내가 무얼 만들 지에 대한 설계 가이드를 작성하기 위해 인터페이스를 미리 선언했다. 이제, 나는 위 함수와 값, 컴포넌트를 차례대로 구현하면 된다.
2. searchParams와 라우팅 훅 호출하기
export function useFunnel<Steps extends NonEmptyArray<string>>(){
const searchParams = useSearchParams();
const { push, back, replace } = useRouter();
const pathname = usePathname();
// ..
}
- useSearchParams : URL의 쿼리 스트링을 다룰 수 있는 훅이다.
- useRouter : 라우팅을 할 때 사용하는 훅이다.
- usePathname : URL의 현재 pathname를 얻을 수 있는 훅이다.
🤔 window.location 쓰면 안돼?
물론, window.location.href를 이용하여 현재 페이지 주소도 얻어올 수 있고 페이지를 이동시킬 수 있다. 하지만, Next.js에서는 SSR과 호환을 위해서는 Next.js에서 제공하는 라우팅 관련 훅을 사용할 것을 권장한다. window.location은 브라우저에서만 동작하기에 오류를 발생시킬 수 있다.
또한, Next.js는 라우팅 관련 훅에 자동 코드 분할이나, 타입 안정성 그리고 성능 최적화를 지원한다.
3. 초기 값 설정
const initialStep = initialStep ?? steps[0];
const queryKey = stepQueryKey ?? 'step';
let currentStep: Steps[number] = searchParams.get(queryKey) ?? initialStep;
if (!steps.includes(currentStep)) {
currentStep = initialStep;
}
initialStep: 초기 step의 값이다. 만약 initialStep인자를 넘겨주었다면, 해당 값으로 혹은 첫 번째로 설정한다.queryKey: 현재 step qeury string의 key값이다. 만약 인자를 넘겨주지 않으면 step으로 설정한다.currentStep: 현재 step에 대한 상태이다. 만약, 현재 searchParams에 값이 있다면 해당 값을 취하고 아니라면 1번에서 전달받은 initialStep으로 설정한다.
4. step 이동 함수
const nextStep = () => {
const currentIndex = steps.indexOf(currentStep);
if (currentIndex < steps.length - 1) {
push(`${pathname}?${queryKey}=${steps[currentIndex + 1]}`);
}
};
const prevStep = () => {
const currentIndex = steps.indexOf(currentStep);
if (currentIndex > 0) {
back();
}
};
const setStep = (step: Steps[number]) => {
if (steps.includes(step)) {
push(`${pathname}?${queryKey}=${step}`);
}
};
nextStep,prevStep: 다음 step, 이전 step으로 이동하는 함수이다. 현재 step을 구하고 현재 step에서 다하거나 빼 이동한다.setStep: 원하는 step으로 이동하는 함수이다.
5. Funnel 컴포넌트
Funnel 컴포넌트에서는 자식 컴포넌트인 Step컴포넌트들이 실제 Step컴포넌트가 맞는 지, 그리고 각 Step의 name props가 Funnel의 steps props배열에 들어있는 지 체크할 것이다.
추후, isValidElement와 컴포넌트 타입 체크: 개발/배포 환경 간 차이 해결하기 위와 같은 에러로 인해서 컴포넌트명이 맞는 지 체크하는 로직은 제거하였다.
const Funnel = ({ children }: FunnelProps) => {
const childrenArray = Children.toArray(children)
.filter(isValidElement)
.filter((child) => (child.props).name !== undefined);
childrenArray.forEach((child) => {
if (!steps.includes((child.props).name)) {
throw new Error('스텝 이름이 잘못되었습니다.');
}
});
return <>{children}</>;
};
-
react의 Children.toArray()메서드를 이용하여 children prop을 배열화해주었다.
const childrenArray = Children.toArray(children) -
각 요소에 대해 react의 isValidElement를 이용하여 React 엘리먼트인지 확인한다.
.filter(isValidElement) -
각 요소에 대해 name이 undefined인 지 확인한다.
.filter((child) => (child.props).name !== undefined);💡 name?
-
만약, steps배열에 현재 자식의 이름이 없다면 에러를 던진다.
즉, steps배열의 각 원소들이 각 step컴포넌트로 들어와야 한다는 것을 의미한다.
childrenArray.forEach((child) => { if (!steps.includes((child.props as StepProps<Steps>).name)) { throw new Error('스텝 이름이 잘못되었습니다.'); } });
toArray는 Children객체의 메서드로, childrn prop을 배열로 변환한다. 이를 통해 중첩된 배열 구조를 평탄화하거나, nullish한 값을 제거할 수 있다.
isValidElement는 React에서 제공하는 유틸리티 함수로, React Element인지 확인한다.
💡 React element vs React Node
React 노드는 React 엘리먼트보다 포괄적인 개념이다.
<div />,createElement(’div’): React 엘리먼트- JSX 태그로 작성하여 생성된 값(컴포넌트) : React 엘리먼트
- createPortal로 생성된 portal
- 문자열
- 숫자
- true, false, null, 또는 undefined
- 다른 React 노드의 배열
6. Step 컴포넌트
const Step = ({ name, children }: StepProps<Steps>) => {
return currentStep === name ? <>{children}</> : null;
};
Funnel.Step = Step;
4. 훅 사용하기
export default function DeleteSection() {
const { Funnel, nextStep, prevStep } = useFunnel(['1', '2']);
const { push } = useAppRouter();
return (
<DeleteProvider>
<Funnel>
<Funnel.Step name="1">
<DeleteHeader onPrevClick={() => push('/profile/setting')} icon="close" />
<Step1 onNextClick={nextStep} />
</Funnel.Step>
<Funnel.Step name="2">
<DeleteHeader onPrevClick={prevStep} icon="arrow_back" />
<Step2 />
</Funnel.Step>
</Funnel>
</DeleteProvider>
);
}
위와 같이 사용하였다. useAppRouter는 RN에 메시지를 전송하여 페이지를 이동시킬 대 사용하는 훅이다. 퍼널 내에서는 useFunnel에서 제공하는, 즉 웹 내 라우팅을 사용하고 퍼널 바깥으로 이동할 때는 앱 내 라우팅을 진행한다.
5. 맺으며
useFunnel 훅을 직접 구현해보면서, 여러 단계로 이루어진 퍼널 구조를 어떻게 관리할 지 고민을 하였다. '한 눈에 보인다'는 점이 가장 많이 와닿았고, 이 useFunnel의 매력이라 생각한다.
이 훅을 통해 얻은 가장 큰 이점은 코드의 가독성과 유지보수성이 크게 향상되었다는 점이다. 기존에는 각 단계마다 라우팅 로직을 반복적으로 작성해야 했지만, 이제는 useFunnel을 사용함으로써 그런 반복을 피할 수 있게 되었다.
또한, 퍼널의 흐름을 변경하거나 새로운 단계를 추가하는 작업이 훨씬 간편해졌다. 예를 들어, 단계를 추가하고 싶다면 단순히 steps 배열에 새로운 단계를 추가하고 해당하는 Funnel.Step 컴포넌트를 작성하기만 하면 된다. 이는 기존의 방식에 비해 훨씬 유연하고 확장성 있는 구조를 제공한다.
물론, 이 접근 방식에도 개선의 여지는 있다. 예를 들어, Context API를 활용하여 상태 관리를 더욱 효율적으로 할 수 있을 것이다. 또한, 현재는 query string을 사용하고 있는데, 이를 내부 상태로 관리하는 방식도 고려해볼 만하다.
실제, useFunnel이라는 toss/slash 훅도 존재하지만, 직접 원리를 파악하며 설계해본 좋은 경험이었던 거 같다. ☺️
해당 파일 링크 :
https://github.com/gloddy-dev/gloddy-client/blob/master/packages/web/src/hooks/useFunnel.tsx