개발을 하다보면 추상화 등 보다 좋은 컴포넌트 설계에 대한 고민을 반드시 하게 됩니다.
저 역시 마찬가지였는데, 그 중 한 케이스가 회원가입, 로그인 및 계정 연동 기능 구현을 맡았을 때였습니다.
메타마스크, 카카오톡, 구글 등 많은 간편로그인 기능을 제공하면서도 이메일/패스워드로 가입한 기존 계정과 간편로그인 계정, 그리고 메타마스크와 sns 로그인 계정을 연동하는 플로우를 구현해야 했습니다. 이에 따른 예외 케이스 - 이미 계정을 연동했거나, 다른 계정을 연동해야 하는 경우 등에 대한 플로우도 고려해야 했죠.
그러면서 들었던 고민이 있었습니다.
오늘은 이런 경우 어떻게 컴포넌트 구조를 개선할 수 있는지에 대해 정리하려 합니다.
(사내 코드는 공개할 수 없으니 마침 진행중인 프로젝트에 비슷한 형태로 구현해보겠습니다.)
토스에서 이러한 고민을 해소할 수 있는 세션이 공개된 적이 있습니다.
토스의 slash 23 특별 세션에서 공개되었던 useFunnel을 참고해 적용해보고, 이 과정을 정리했습니다.
오늘 적용해볼 부분은 이 부분입니다.
칸반보드 프로젝트에서 사이드바의 메뉴를 다루는 부분입니다.
많은 메뉴들이 있는데 그 중 change background를 예로 들면, 눌렀을 때 사진, 색상, 사용자 지정 이미지 카테고리를 선택할 수 있고 각각을 누르면 선택할 수 있는 화면이 나옵니다.
즉 사이드 바에는 각각의 메뉴들이 있고, 각 메뉴를 누르면 그에 맞는 뎁스별 컴포넌트가 표시됩니다. 그만큼 사이드바에서 표시할 컴포넌트의 갯수는 많아지겠죠!
위에서 든 예시를 가지고 구현 로직을 먼저 작성해보겠습니다.
먼저 쉽게 생각할 수 있는 일반적인 방식으로 코드를 작성해보았습니다.
function Drawer() {
const [searchParams] = useSearchParams();
const content = searchParams.get(SEARCH_PARAMS_KEY.MENU)
const contents = {
[DRAWER_CONTENT.CHANGE_BACKGROUND]: <ChangeBackground />,
[DRAWER_CONTENT.PHOTOS]: <BackgroundPhotos />,
//...
<div>
{contents[content]}
</div>
};
Drawer 컴포넌트 안에 표시할 컴포넌트를 생성한 다음 쿼리파라미터별로 표시할 컴포넌트들을 객체로 정의해주었습니다.
그리고 현재 쿼리 파라미터로 이 객체에 접근해 컴포넌트를 렌더링해줍니다.
이러한 구조로 코드를 작성하면 다음과 같은 단점이 있습니다.
위에서 예시로 든 이미지를 보면, 사이드바에는 많은 메뉴들이 있습니다. 이 중 여러 메뉴들이 각각 클릭하면 위와 같이 여러 뎁스를 거쳐 컴포넌트를 보여주게 됩니다. 즉, 여러 유저 플로우가 존재합니다.
그럴 경우 한 객체 안에 모든 사이드 바안에 렌더링 될 컴포넌트들을 정의해주게 됩니다.
function Drawer() {
const [searchParams] = useSearchParams();
const content = searchParams.get(SEARCH_PARAMS_KEY.MENU)
const contents = {
[DRAWER_CONTENT.CHANGE_BACKGROUND]: <ChangeBackground />,
[DRAWER_CONTENT.PHOTOS]: <BackgroundPhotos />,
[DRAWER_CONTENT.COLOR]: <BackgroundColors />,
[DRAWER_CONTENT.CUSTOM_FIELDS]: <CUSTOM_FIELDS />,
//...그 외 다른 컴포넌트들...
//...
<div>
{contents[content]}
</div>
};
이 코드를 봤을 때는 특정 메뉴를 클릭했을 때 어떤 영역으로 이동해 어떤 컴포넌트를 보여주는지 쉽게 파악할 수 없습니다. 이를 알기 위해서는 각 컴포넌트로직을 따라가며 쿼리파라미터를 추가하는 코드를 확인해야 합니다.
예를 들어,위에서 언급된 플로우대로 change background 버튼을 클릭했을 때 표시되는 컴포넌트를 알기 위해서는 이렇게 접근해야 합니다.
function DrawerMenuLists() {
const [searchParams, setSearchParams] = useSearchParams();
const handleContent = (content: string) => {
searchParams.set(SEARCH_PARAMS_KEY.MENU, content);
setSearchParams(searchParams);
};
return (
<>
//...
<Menu.Button onClick={...}>About this board</Menu.Button>
<Menu.Button onClick={() => handleContent(DRAWER_CONTENT.CHANGE_BACKGROUND)}>
//...
const contents = {
[DRAWER_CONTENT.CHANGE_BACKGROUND]: <ChangeBackground />,
function ChangeBackground() {
const [searchParams, setSearchParams] = useSearchParams();
const handleContent = (content: string) => {
searchParams.set(SEARCH_PARAMS_KEY.MENU, content);
setSearchParams(searchParams);
};
return (
<>
//...
<Menu.Button onClick={...}>About this board</Menu.Button>
<Menu.Button onClick={() => handleContent(DRAWER_CONTENT.BACKGROUND_PHOTOS)}>
//...
특히 유사한 컴포넌트명이 있거나 영역간의 이동이 겹치는 경우는 더욱더 혼란이 발생하게 됩니다.
이렇게 각각 컴포넌트가 흩어져있고, 그 컴포넌트 마다 영역간의 이동을 담당하는 로직을 각자 관리하고 있으면 구조 파악도 어렵고 유지보수 측면에서도 불리합니다. 즉 현재 방식은 로직의 응집도가 떨어집니다.
또한 각 뎁스별로 유저가 입력한 데이터 값을 관리해야 한다면 이 상탯값을 한 곳에 관리하는 것도 고려해야 합니다.
하나씩 뎁스를 거치며 유저가 선택한 데이터들을 모두 모아 관리해야 한다면, 이 값을 context API 또는 전역상태 관리 라이브러리를 사용해 별도로 모아 관리해야 합니다.
우선, 응집도 개선을 위해 상위 컴포넌트에서 모든 영역 이동 흐름을 관리할 수 있도록 한 곳에 로직을 작성합니다.
다른 컴포넌트에서도 공통적으로 사용할 것이기 때문에 커스텀 훅을 작성합니다.
해당 로직이 어떤 일을 하는지 추상화를 하면 다른 유사한 기능 구현에도 사용할 수 있을 뿐 아니라 기능을 이해하기도 쉬워집니다.
나아가 공통 모듈로 분리하거나 라이브러리로 만들면 사내 다른 프로젝트에도 공통적으로 적용할 수 있습니다.
이 커스텀 훅에서는 동일하게 쿼리파라미터를 사용합니다. 먼저 이를 핸들링하는 함수를 작성하겠습니다.
function useFunnel<T>(initialStep?: T) {
const [searchParams, setSearchParams] = useSearchParams(`${SEARCH_PARAMS_KEY.FUNNEL_STEPS}=${initialStep}`);
const step = searchParams.get(SEARCH_PARAMS_KEY.FUNNEL_STEPS);
const navigate = useNavigate();
const handleStep = (step: T) => {
searchParams.set(SEARCH_PARAMS_KEY.FUNNEL_STEPS, step as string);
setSearchParams(searchParams);
};
그리고 한 상위 컴포넌트의 각 이동흐름을 알 수 있도록 하려면 먼저 하나의 상위 컴포넌트를 정의하고, 그 내부의 하위 컴포넌트들을 정의해 하나의 유저플로우에 해당하는 컴포넌트들을 한 눈에 파악할 수 있도록 합니다.
상위 컴포넌트에서는 현재 쿼리파라미터에 해당하는 하위 컴포넌트를 렌더링하도록 합니다.
const Funnel = ({ children }: { children: ReactElement[] }) => {
const targetStep = children.find((childStep: ReactElement) => childStep?.props?.name === step) ?? children[0];
return <>{targetStep}</>;
};
하위 컴포넌트에서는 인자로 전달받은 값으로 클릭했을 때 어떤 곳으로 이동할지를 결정합니다.
const Step = ({ children, nextStep }: { children: ReactNode; name: T; nextStep?: T }) => {
return <div onClick={() => nextStep && handleStep(nextStep)}>{children}</div>;
};
Funnel.Step = Step;
그 외 뒤로가기 등 필요한 로직들은 별도로 작성해주면 됩니다.
이 글에서는 생략하겠습니다!
그러면 만든 로직을 사용해보겠습니다.
<Funnel>
<Funnel.Step name="change-background">
<Menu.Title hasGoBackButton>Change Background</Menu.Title>
<ButtonList>
<Button onClick={() => handleStep("photos")}>Photos</Button>
<Button onClick={() => handleStep("colors")}>Colors</Button>
</ButtonList>
</Funnel.Step>
<Funnel.Step name="photos">
<BackgroundPhotos/>
</Funnel.Step>
<Funnel.Step name="colors">
<BackgroundColors/>
</Funnel.Step>
</Funnel>
</div>
change background 메뉴 클릭 후 이와 관련된 영역 이동 흐름은 모두 이 곳에서 제어할 수 있습니다.
토스에서는 이러한 패턴을 설문조사 패턴이라고 합니다.
이러한 방식을 더 유용하게 사용할 수 있는 경우는, 뎁스별 유저가 입력한 데이터를 관리해야할 때입니다. 대표적으로 설문조사나 회원가입, 로그인과 같은 데이터를 단계별로 한번에 하나씩 입력해 나가는 패턴을 예로 들 수 있습니다. 토스는 사용자가 간편하게 사용할 수 있도록 한 화면에 하나씩 데이터를 입력받는 것이 대부분이라 특히 많이 사용할 수 밖에 없는 것 같아요.
기능 구현에 앞서 좋은 코드 설계가 얼마나 중요한지 새삼 느낀 시간이기도 합니다.
디자인 패턴이나 이러한 설계방식에 대해서 틈틈히 공부하고 적용해 나가는 것 또한 좋은 프론트엔드 개발자로 성장할 수 있는 방법 중 하나인 것 같아요. 왜 컨퍼런스나 테크 블로그를 통한 다른 기업의 문제해결 사례를 꾸준히 보고 공부해나가야 하는지에 대해 느꼈던 계기이기도 했습니다.