본문 바로가기

React/실험실

useFunnel 만들기

지난번엔 Funnel 컴포넌트에 대해서 알아봤다. 

토스에서 이미 잘 만들어둔 useFunnel 컴포넌트가 있지만 해당 기능을 참고해서 유사한 기능을 하는 

Funnel 컴포넌트를 만들어 보려고 한다. 

 

핵심 기능은 다음과 같다 : 

  • Funnel에 steps를 넘겨줘서 현재 Step에 해당하는 컴포넌트만 렌더링
  • next() 함수를 통해서 다음 Step으로 이동 

Funnel이란게 어려운 기능이 아니다. 현재 Step에 맞는 컴포넌트를 렌더링 해주는 것이 끝이다. 

 

추가로 다음과 같은 기능을 구현할 생각이다 :

  • next 외 prev, update 함수를 통해 컨트롤할 수 있는 기능을 구현할 계획이다.
  • useFunnel이 토스의 Funnel 컴포넌트의 시작 지점인데, 나는 useFunnel을 전역 상태를 관리하는 용도로 
    사용할 것이다. 
  • 다음 Step 또는 이전 Step으로 이동할 때 history 스택에 추가해서 앞으로 가기, 뒤로가기 기능과 
    호환이 되도록 할 예정이다. 
  • Funnel이 전체 Step이 하나의 기능이라고 생각하므로 중간에 새로고침 또는 뒤로 가기를 통해 
    페이지를 이탈하는 경우 경고 문구를 나타낼 계획이다. 

그럼 바로 개발을 진행해보자! 

 

Funnel에 Steps를 넘겨줘서 현재 Step에 해당하는 컴포넌트만 렌더링 

Funnel에게 Steps라는 배열을 넘겨줘서 현재 Step을 가지고 있어야 할 것이다. 

여기서 Steps는 props로 받아올 것이고, 상태를 통해서 Step을 관리할 계획이다. 

const Funnel = (props) => {
  const { steps } = props;
  const [currentStep, setCurrentStep] = useState(0);
}

 

현재 Step을 문자로 실제 Step의 명칭으로 관리할 수 있겠지만 next, prev를 사용할 때 간단하게 처리하려면 

숫자로 처리하는게  편할 것 같다고 생각했다. 

 

Funnel은 컴포넌트이기 때문에 자식 요소를 받아와서 만들어질 것이다. 

<Funnel steps={["child1", "child2", "child3"]}>
  <Children1>
  <Children2>
  <Children3>
</Funnel>

 

그렇다면 자식을 렌더링하기 위해서 children을 사용해야 할 것이며, 현재 Step만 렌더링 하기 위한 조건도 필요할 것이다. 

조건 컨트롤만 담당하는 컴포넌트를 따로 만들어서 Funnel에 귀속시킬 계획이다.  

 

따로 export 해도 되지만 해당 요소는 따로 쓰일 일이 없어서 Compound Pattern으로 만들면 좋겠다고 생각된다. 

const Step = (props) => {
   const { currentStep, name, children } = props;
     
   if(name !== currentStep) return null;
   return children
}

const Funnel = (props) => {
  const { steps, children } = props;
  const [ currentStep, setCurrentStep ] = useState(0);
 
  return cloneElement(children, { currentStep: steps[currentStep] });
}

Funnel.Step = Step;

 

조건 컨트롤을 담당하는 컴포넌트는 Step으로 만들었고 할당받은 name이 현재 Step과 다르면 렌더링을 하지 

않는다. 

 

이때 currentStep은 Funnel에서 cloneElement를 통해서 추가로 넘겨줬다. 

이것으로 컨트롤은 불가능하지만 현재 Step에 맞는 컴포넌트만 렌더링하는 작업은 완료했다. 

 

next() 함수를 통해서 다음 Step으로 이동 

이제 next를 사용해서 Step을 컨트롤할 수 있는 기능을 구현할 것이다. 

단순하게 Step을 변경하기 위해서 사용할 수 있겠지만 추가로 사용하는 측에서 추가적인 사이드이펙트를 발생시킬 수 

있도록 작업할 계획이다. 

 

Funnel UI는 전체 상태를 하나로 관리해야 하기 때문에 따로 상태 관리하는 기능을 매번 넣으면 불편하니 

사이드이펙트로 관리하면 편할 것 같다고 생각된다. 

const Step = (props) => {
   const { currentStep, nextStep, onNext = () => {}, name, children } = props;
     
   const handleNextStep = (...data) => {
      onNext(...data);
      nextStep();
   }  
     
   if(name !== currentStep) return null;
   return cloneElement(children, { onNext: handleNextStep })
}

const Funnel = (props) => {
  const { steps, children } = props;
  const [ currentStep, setCurrentStep ] = useState(0);
  
  const nextStep = () => {
    const next = currentStep + 1;
    
    if(next < steps.length) {
      setCurrentStep(next);
    }
  }  
 
  return cloneElement(children, { currentStep: steps[currentStep], nextStep });
}

Funnel.Step = Step;

 

점점 거대해진다!? 

 

Funnel 컴포넌트에서 Step을 변경해주는 nextStep 함수를 만들어줬다. 

Steps 보다 큰 Step은 있을 수 없으므로 조건식으로 처리해주고 currentStep과 마찬가지로 넘겨줬다. 

 

Step 컴폰넌트에서는 Funnel에서 받을 nextStep과 사이드 이펙트를 위한 onNext를 받아온다. 

데이터를 어떻게 넘길지 모르기 때문에 [ ... 연산자 ]를 통해서 넘겨주고 있으며 다음 Step으로 변경해준다. 

 

실제로 사용한다면 다음과 같이 사용될 것이다. 

<Funnel steps={[ "child1", "child2", "child3" ]}>
  <Funnel.Step name={"child1"} onNext={(data) => console.log(data)}>
    <Chilren1 />
  </Funnel.Step>
  // ...
</Funnel>

 

next 외 prev, update 함수를 통해 컨트롤할 수 있는 기능을 구현

다음 Step으로 변경하는 작업은 완료되었고 비슷한 기능은 prev, update 함수를 같이 구현해보자.

const Step = (props) => {
   const { currentStep, nextStep, onNext = () => {}, name, children } = props;
     
   const handleNextStep = (...data) => {
      onNext(...data);
      nextStep();
   }  
   
   const handlePrevStep = // ... 
     
   const handleUpdateStep = (name, data) => {
     onUpdate(data);
     updateStep(name);
   }
     
   if(name !== currentStep) return null;
   return cloneElement(children, { onNext: handleNextStep })
}

const Funnel = (props) => {
  const { steps, children } = props;
  const [ currentStep, setCurrentStep ] = useState(0);
  
  const nextStep = () => {
    const next = currentStep + 1;
    
    if(next < steps.length) {
      setCurrentStep(next);
    }
  }  
  
  const prevStep = () => {
    const prev = currentStep - 1;
    
    if(prev >= 0) {
      setCurrentStep(prev);
    }
  }
  
  const updateStep = (step) => {
    const index = steps.indexOf(step);
    
    if(index !== -1) {
      setCurrentStep(index);
    }
  }
 
  return cloneElement(children, { currentStep: steps[currentStep], nextStep, prevStep, updateStep });
}

Funnel.Step = Step;

 

절때 귀찮아서 그런거 맞다. 

전체적인 작동 방식은 nextStep과 동일하기 때문에 Step에서의 작업은 대략적으로 하고 Funnel에서가 중요하니깐~

 

prevStep은 nextStep과 완전 동일한 - 버전이고, updateStep은 업데이트할 때는 또 0, 1 과 같은 숫자로 받으면 

불편할 것 같아서 문자열로 받게끔 작업했다. 

그걸 indexOf로 조회 후 갱신해주는 방식을 채택!

 

전역 상태 관리 

지금까지 만들면서 불편다고 느끼는게 Funnel에서 Step로 넘기는 props와 Step의 사용할 때 받아오는 props가 

섞여있어서 관리가 어렵게 느껴진다. 

 

또 Step을 통해서가 아닌 다른 컴포넌트에서도 next, prev, update를 할 수 있는데 지금 플로우 상에서는 

깔끔하게 처리가 안된다. 이때 제일 편한게 바로 전역 상태!  context api를 사용해서 처리할 생각이다. 

export const FunnelContext = createContext(null);

const useFunnel = () => {
  const { currentStep, setCurrentStep, nextStep, prevStep, updateStep } =
    useContext(FunnelContext);

  return { currentStep, setCurrentStep, nextStep, prevStep, updateStep };
};

export default useFunnel;

 

전역에서 관리할 수 있는 커스텀 훅을 먼저 만들었다. 

useFunnel은 step과 컨트롤러를 관리하는 용도로 사용하고 있다. 

const Step = (props) => {
   const { onNext = () => {}, onPrev = () => {}, onUpdate = () => {}, name, children } = props;
   const { currentStep, nextStep, prevStep, updateStep } = useFunnel();
     
   const handleNextStep = (...data) => {
      onNext(...data);
      nextStep();
   }  
   
  const handlePrevStep = (...data) => {
    onPrev(...data);
    prevStep();
  };

  const handleUpdateStep = (name, data) => {
    onUpdate(data);
    updateStep(name);
  };
     
   if(name !== currentStep) return null;
   return cloneElement(children, { onNext: handleNextStep })
}

const Funnel = (props) => {
  const { steps, children } = props;
  const [ currentStep, setCurrentStep ] = useState(0);
  
  const nextStep = () => {
    const next = currentStep + 1;
    
    if(next < steps.length) {
      setCurrentStep(next);
    }
  }  
  
  const prevStep = () => {
    const prev = currentStep - 1;
    
    if(prev >= 0) {
      setCurrentStep(prev);
    }
  }
  
  const updateStep = (step) => {
    const index = steps.indexOf(step);
    
    if(index !== -1) {
      setCurrentStep(index);
    }
  }
 
  return (
    <FunnelContext.Provider
      value={{
        currentStep: steps[currentStep],
        setCurrentStep,
        nextStep,
        prevStep,
        updateStep,
      }}
    >
      {children}
    </FunnelContext.Provider>
  )
}

Funnel.Step = Step;

 

Step은 props로 전달받던 nextStep, prevStep, ... 을 전역 변수로 전달 받을 수 있게 Funnel에서 전역으로 설정했다. 

 

history 스택 관리 

이제 앞으로 이동하고 뒤로 이동할 때마다 history에 스택을 사용해서 화면이 변경되게 해줄 생각이다. 

기존에는 state를 통해서만 관리하기 때문에 상태가 변경되더라도 브라우저의 앞으로 가기, 뒤로 가기 기능이 불가능했다. 

 

history에는 pushState를 통해 임의로 스택을 추가해줄 수 있다. 이걸 nextStep 등 이동 함수에 넣어주면 원하는 기능이

동작할 것 같다. 

 

거기에 추가로 브라우저의 이전, 다음 기능을 사용했을 때도 currentStep을 업데이트를 해야해서 

popState 이벤트를 같이 사용할 생각이다. 현재 활성화된 기록 항목이 바뀌면 발생되는 함수이다. 

const pustState = (state) => {
  window.history.pushState(state, "");
};

 

페이지가 이동 될 때 같이 실행될 함수를 만들었다. 

state에 현재 페이지의 currentStep 값을 넣어두고 추후 뒤로 가기나 앞으로 가기 시 값을 업데이트 시켜줄 생각이다. 

useEffect(() => {
  const handlePopState = (e) => {
    const state = e.state?.funnelStep || 0;

    setCurrentStep(state);
  };

  window.addEventListener("popstate", handlePopState);
  return () => {
    window.removeEventListener("popstate", handlePopState);
  };
}, []);


handlePopState에서 pushState를 통해 전달해둔 state를 받아서 currentStep을 업데이트 시켜준다. 

이때 제일 첫 페이지의 경우 funnelStep이 없을 것이기 때문에 없다면 0으로 초기값 세팅을 해주었다. 

 

이제 pushState를 사용하는 곳을 보자면 :

const nextStep = () => {
  const next = currentStep + 1;

  if (next < steps.length) {
    pushState({ funnelStep: next });
    setCurrentStep(next);
  }
};

 

nexStep에서 pushState를 사용해서 funnelStep을 업데이트 시켜주고 있다. 

매번 객체로 넣어줘야 한다는게 좀 불편하긴 하겠지만 추후 history 관련 코드는 커스텀 훅으로 추상화를 시켜서

관리할 계획이기 때문에 미리 만들면서 준비를 했다. 

 

페이지 이탈 경고 문구

Step 이동에 따른 history 스택에 업데이트를 시켜줬으므로 

새로고침이나 페이지를 이탈하는 경우 정말 페이지를 이탈하겠냐는 안내 문구가 나오게 작업을 할 생각이다. 

 

전체 플로우가 하나의 기능을 하는 경우 사용하는 Funnel 패턴의 특성상 페이지 이탈 시 전체 플로우를 

다시 진행해야 하므로 사용자에게 안내는 필요하다고 생각했다. 

 

이때 beforeunload 이벤트를 사용할 것인데, 사용자가 페이지를 이탈할 때 이벤트가 호출된다. 

useEffect(() => {
  const handleBeforeUnload = (e) => {
    e.preventDefault();
    e.returnValue = "";
  };

  window.addEventListener("beforeunload", handleBeforeUnload);
  return () => {
    window.removeEventListener("beforeunload", handleBeforeUnload);
  };
}, []);

 

구현은 심플하다. 

추후 popState와 beforeunload는 따로 다룰 것이니 자세한 설명은 생략하고 최초 이벤트를 등록 시키고

이탈 시 호출된다. 

 

커스텀훅으로 추상화 

Funnel 디자인 패턴에서 history의 기능들이 다 포함되야 할 지 의문이다. 

커스텀훅이 하나의 기능을 할 수 있도록 하기 위해서 history 부분은 따로 useHistory라는 커스텀훅으로 추상화를 

시켜줄 생각이다. 

import { useEffect } from "react";

const useHistory = (popState) => {
  const pustState = (state) => {
    window.history.pushState(state, "");
  };

  useEffect(() => {
    window.addEventListener("popstate", popState);
    return () => {
      window.removeEventListener("popstate", popState);
    };
  }, [popState]);

  useEffect(() => {
    const handleBeforeUnload = (e) => {
      e.preventDefault();
      e.returnValue = "";
    };

    window.addEventListener("beforeunload", handleBeforeUnload);
    return () => {
      window.removeEventListener("beforeunload", handleBeforeUnload);
    };
  }, []);

  return pustState;
};

export default useHistory;

 

popState에 해당하는 작업은 history api를 사용하는 경우에 따라 달라지기 때문에 props로 받아서 

사용할 때마다 처리할 수 있도록 작업하였다. 

 

마치며.

이렇게 Funnel 디자인 패턴을 직접 구현해봤는데, 구현하는 방식은 그렇게 어렵지 않았던 것 같다. 

방법에 따라 달라지고 오늘의 나와 미래의 나의 개발 스타일이 달라지기 때문에 더 좋은 방법이 생길 수 있다. 

 

실제로 그래서 같은 주제의 글이 내 블로그에 여러개가 존재하기도 하고....

 

하지만 현재 내가 사용할 수 있는 개발 기술로 생각보다 만족할만한 코드가 나와서 뿌듯하고 이런 아이디어를 

생각할 수 있는 개발자가 되어야겠다는 생각을 하게 되는 구현이었다. 

반응형