Skip to content

Commit

Permalink
docs: 새 글 추가
Browse files Browse the repository at this point in the history
  • Loading branch information
khj0426 committed Aug 15, 2024
1 parent 3a7d100 commit 588f323
Show file tree
Hide file tree
Showing 3 changed files with 311 additions and 0 deletions.
311 changes: 311 additions & 0 deletions posts/페이지의 흐름을 제어해보자.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
---
title: '페이지의 흐름을 제어해보자'
excerpt: '페이지의 흐름을 선언적으로 관리해보자'
date: '2024-08-15'
author: '김효중'
category: 'react'
image: '/images/postImg/0815.png'
---

최근 진행 중인 프로젝트에서 다음과 같이 사용자의 입력을 여러 페이지에서 받아야 했습니다. 어떻게 하면 효과적으로 , 선언적으로 여러 페이지의 흐름을 제어 할 수 있을까 살펴보다가 퍼널 패턴을 발견하게 되었고, 제 프로젝트에 적용한 경험을 회고해보려고 합니다.

크게는 아래의 디자인처럼, 사용자가 회원 탈퇴의 유형과, 탈퇴 이유를 선택하고, 최종적으로 탈퇴 완료 화면을 띄우는 요구사항이었습니다.

![](/images/postImg/0815_1.png)

### 퍼널이란

퍼널은 마케팅과 디자인에서 쓰이는 용어로, 사용자들이 특정 목표를 달성하기 위해 거쳐야 하는 단계를 시각화한 모델입니다.

유저가 서비스에 들어와서 최종 목표까지 어느지점에서 이탈을 하는지 파악하기 쉽고, 결국 여러 페이지에서 상태를 수집하고, 결과를 보여주는 것이 퍼널의 목적입니다.

### useFunnel

그럼 퍼널개발 시 어떤 문제점이 있을까요?

여러 페이지에서 관리하는 상태의 추적이 어렵습니다. 상태를 수집하는 곳, 사용하는 곳이 다르기 때문에 코드의 흐름을 추적하려면 여러 페이지에 걸쳐서 읽어야 합니다.

useFunnel이라는 퍼널 관리의 로직을 추상화한 훅이 존재합니다.

해당 라이브러리를 Next app router에서 동작시키려고 하니, 실행이 안되었습니다. 정확히는 NextRouter was not mounded이라는 오류가 있었습니다. 찾아보니 해당 오류는 useRouter는 13버전부터 next/navigation에서 불러와야 한다는 오류였습니다.

useFunnel은 next/router에서 router를 import하고 있었고, app router에서 useFunnel이라는 훅을 쓸 수 없을까... 하고 찾아보니, app router와 호환되는 라이브러리가 있었습니다.

[next-use-funnel](https://github.com/CodePsy-2001/next-use-funnel)

그러나 이 라이브러리는 14버전을 의존성으로 두고 있어서, 13버전을 쓰고 있었고, 이 라이브러리 때문에 Next버전을 올리는 게 맞지 않다고 생각했습니다.

그래서 useFunnel라이브러리를 직접 구현해보기로 했습니다. 물론 대부분의 코드는 라이브러리에서 제공하지만, 직접 따라 만들어보면서 배워가는 게 있을 것 같았습니다. 먼저 Funnel컴포넌트 부터 살펴보겠습니다.

```tsx
import { Children, ReactElement, ReactNode, isValidElement } from 'react';
export interface FunnelProps<Steps extends StepArray> {
steps?: Steps;
step?: Steps[number];
children: ReactElement | Array<ReactElement>;
}

type StepArray = Readonly<string[]>;
function Funnel<Steps extends StepArray>({
step,
steps,
children,
}: FunnelProps<Steps>): ReactElement {
const targetStep = Children.toArray(children)
.filter(isValidElement<StepProps<Steps>>)
.filter((i) => steps?.includes(i.props.name ?? ''));

const target = targetStep.find((child) => child.props.name === step);

return <>{target}</>;
}
```
먼저 Funnel컴포넌트는 step과 steps이라는 props을 받고 있습니다. 하위 Step 컴포넌트 중 현재 활성화된 Step을 렌더링 하는 컴포넌트로 자식 컴포넌트 중 현재 step 상태와 이름이 같은 자식 컴포넌트를 찾아 리턴합니다.

다음으로 useFunnel훅입니다. next/navigation에서 useRouter를 가져와서 사용합니다.

```tsx
import { ReactElement, ReactNode, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Funnel from './Funnel';

type StepArray = Readonly<string[]>;

export interface FunnelProps<Steps extends StepArray> {
steps: Steps; // 단계 배열
step: Steps[number]; // 현재 활성화된 단계
children: ReactElement | Array<ReactElement>;
}

interface StepProps<Steps extends StepArray> {
name: Steps[number]; // 단계 이름
children: ReactNode;
}

interface RouteFunnel<Steps extends StepArray> {
(props: FunnelProps<Steps>): ReactElement;
}

interface RouterFunnelStep<Steps extends StepArray> {
(props: StepProps<Steps>): ReactElement;
}

interface FunnelOptions<Steps extends StepArray> {
initialStep?: Steps[number]; // 초기 단계
stepQueryKey?: string; // URL 쿼리 키
stepChangeType?: 'push' | 'replace'; // 라우터 push인지 replace인지 여부
}

function useFunnel<Steps extends StepArray>(
steps: Steps,
options: FunnelOptions<Steps> = {
initialStep: steps[0],
stepChangeType: 'push',
},
): [
RouteFunnel<Steps> & { Step: RouterFunnelStep<Steps> },
(step: Steps[number]) => void,
() => void,
number,
] {
const router = useRouter();
const searchParams = useSearchParams();

// 현재 활성화된 Step 반환하는 함수
const getCurrentStep = () => {
return (
searchParams.get(options.stepQueryKey as string) ?? options.initialStep
);
};

const [currentStep, setCurrentStep] = useState(() => getCurrentStep());
const activeStepIndex = steps.findIndex((s) => s === currentStep); // 현재 단계 인덱스

const updateStep = (step: Steps[number]) => {
setCurrentStep(step);
const searchParam = new URLSearchParams(searchParams);
searchParam.set(options.stepQueryKey ?? 'step', step);
if (options.stepChangeType === 'push') {
router.push(`?${searchParam}`);
} else {
router.replace(`?${searchParam}`);
}
};

// 이전 단계로 이동하는 함수
const prevStep = () => {
if (currentStep && activeStepIndex > 0) {
updateStep(steps[activeStepIndex - 1]); // 이전 단계로 업데이트
} else {
//제일 초기 step이라면
router.back(); // 브라우저의 뒤로 가기
}
};

const FunnelComponent: RouteFunnel<Steps> = (props) => {
return (
<Funnel steps={steps} step={steps[activeStepIndex]}>
{props.children}
</Funnel>
);
};

const Step: RouterFunnelStep<Steps> = ({ name, children }) => {
if (name === currentStep) {
return <>{children}</>;
}
return <></>;
};

return [
Object.assign(FunnelComponent, { Step }), // FunnelComponent와 Step을 결합
updateStep, // 단계 업데이트 함수
prevStep, // 이전 단계로 이동하는 함수
activeStepIndex, // 현재 활성 단계 인덱스
];
}

export default useFunnel;
```

그리고 회원탈퇴에서 필요한 모든 상태와 상태 갱신은 useSignOut 훅에서 한 곳에서 관리하게 설정해주었습니다. 그리고 회원 탈퇴 페이지를 Context API의 Provider로 관리해 , 필요한 곳에서 쓸 수 있게 해주었습니다

```tsx
import {
Dispatch,
ReactNode,
SetStateAction,
createContext,
useMemo,
useState,
useContext,
} from 'react';
import { SIGNOUT_REASON_JUNIOR, SIGNOUT_REASON_SENIOR } from './constant';

interface SignOutInfo {
isJunior: boolean;
signOutReason:
| keyof typeof SIGNOUT_REASON_JUNIOR
| keyof typeof SIGNOUT_REASON_SENIOR;
etc?: string;
}

interface SignOutInfoContextType {
signOutInfo: SignOutInfo | null;
setSignOutInfo: Dispatch<SetStateAction<SignOutInfo | null>> | null;
getSignOutReasonMessage: () => string;
}

const SignOutInfoContext = createContext<SignOutInfoContextType>({
signOutInfo: {
isJunior: false,
signOutReason: 'DIS_SATISFACTION',
},
getSignOutReasonMessage: () => '',
setSignOutInfo: null,
});

function SignOutInfoProvider({ children }: { children: ReactNode }) {
const [signOutInfo, setSignOutInfo] = useState<SignOutInfo | null>(null);

const getSignOutReasonMessage = () => {
if (signOutInfo?.isJunior) {
return SIGNOUT_REASON_JUNIOR[
signOutInfo.signOutReason as keyof typeof SIGNOUT_REASON_JUNIOR
];
} else if (signOutInfo) {
return SIGNOUT_REASON_SENIOR[
signOutInfo.signOutReason as keyof typeof SIGNOUT_REASON_SENIOR
];
}
return '';
};

const value = useMemo(() => {
return {
signOutInfo,
setSignOutInfo,
getSignOutReasonMessage,
};
}, [signOutInfo]);

return (
<SignOutInfoContext.Provider value={value}>
{children}
</SignOutInfoContext.Provider>
);
}

function useSignOutInfo() {
const context = useContext(SignOutInfoContext);
if (!context) {
throw new Error('useSignOutInfo must be used within a SignOutInfoProvider');
}
return context;
}

export { SignOutInfoProvider, useSignOutInfo };
```

최종적으로 다음과 같이 한 페이지에서 깔끔하게 페이지의 흐름을 제어할 수 있게 되었습니다. 쿼리파라미터로 현재 step을 명시해두고, 사용하는 쪽에서도 페이지의 흐름을 쉽게 볼 수 있게 되었습니다!

![](https://private-user-images.githubusercontent.com/59411107/357119436-d9dfed5d-621e-43c5-8e18-e6b7e08855ff.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjM3MzE4MDQsIm5iZiI6MTcyMzczMTUwNCwicGF0aCI6Ii81OTQxMTEwNy8zNTcxMTk0MzYtZDlkZmVkNWQtNjIxZS00M2M1LThlMTgtZTZiN2UwODg1NWZmLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA4MTUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwODE1VDE0MTgyNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTYwOTYzMzkzN2I2NjgwY2I3NzJlMWJlNTFkNDUwMzdmZDBhNTNiMDk0ODIwMDk3ZTM3ZTFhZTc5NmMyY2Y1NDUmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.Pa46y_FvjbN5UctHA4Q4NFvkIQWgO0ND6XRPTZbGlaM)

```tsx
'use client';
import useFunnel from '@/hooks/useFunnel';
import { SignOutInfoProvider } from '@/app/signout/signoutContext';

import { SignOutFinish } from '@/app/signout/(components)/signout-finish';
import { SignOutReason } from '@/app/signout/(components)/signout-reason';
import { SignOutInfo } from '@/app/signout/(components)/signout-info';
import { SignOutTypeSelect } from '@/app/signout/(components)/signout-type-select';

import { SignOutHeader } from '@/app/signout/(components)/Header';

const signOutSteps = [
'signout_info',
'signout_type_select',
'signout_reason',
'signout_finish',
] as const;
export default function SignOut() {
const [SignoutFunnel, setSignoutStep, prevStep, _activeStep] = useFunnel(
signOutSteps,
{
initialStep: 'signout_info',
stepChangeType: 'replace',
} as const,
);
const _handleSignOutFinish = () => {
//회원탈퇴 FLow
};

return (
<main>
<SignOutInfoProvider>
<SignOutHeader onClick={() => prevStep()} />

<SignoutFunnel steps={signOutSteps} step="signout_info">
<SignoutFunnel.Step name={'signout_info'}>
<SignOutInfo
onClick={() => setSignoutStep('signout_type_select')}
/>
</SignoutFunnel.Step>
<SignoutFunnel.Step name={'signout_type_select'}>
<SignOutTypeSelect
onClick={() => setSignoutStep('signout_reason')}
/>
</SignoutFunnel.Step>
<SignoutFunnel.Step name={'signout_reason'}>
<SignOutReason onClick={() => setSignoutStep('signout_finish')} />
</SignoutFunnel.Step>
<SignoutFunnel.Step name={'signout_finish'}>
<SignOutFinish onClick={_handleSignOutFinish} />
</SignoutFunnel.Step>
</SignoutFunnel>
</SignOutInfoProvider>
</main>
);
}
```
Binary file added public/images/postImg/0815.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/postImg/0815_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 588f323

Please sign in to comment.