[ Plaist 아카이브 ] #6. 팀의 Dev-Log : 트러블슈팅_useFunnel

BOOa·2024년 12월 26일

Archive

목록 보기
7/17
post-thumbnail

📌 코스 생성 프로세스

🚨 문제점

기존 방식으로 코스 생성 프로세스를 구현했을 때, 다음과 같은 문제가 발생했다.

  • 페이지 흐름이 분산 : 생성 단계마다 별도의 컴포넌트와 상태로 분리되어 있어 흐름을 파악하기 어렵고 유지보수가 복잡했다.
  • 상태 관리의 중복 : 각 단계에서 수집된 데이터를 전역 상태로 저장하거나 전달하는 로직이 반복적으로 작성되어 비효율적이었다.
  • 재사용성 부족 : 유사한 단계가 여러 개 있어도 로직이 중복되어 컴포넌트 재사용이 어렵고 불필요한 코드가 증가했다.

💡 문제 분석

  • 단계별 상태와 UI를 중앙에서 제어하여 흐름을 직관적으로 파악할 수 있는 구조가 필요하다.
  • 각 단계의 흐름을 명확히 하고, 상태와 UI 간의 의존성을 줄여야 한다.
  • 단계별 UI와 로직을 분리하고 유사한 단계를 쉽게 재사용할 수 있도록 컴포넌트화가 필요하다.
  • 즉, 각 단계를 독립적으로 관리하면서도 전체 흐름을 쉽게 제어할 수 잇는 설계가 필요하다.
  • 또한, 사용자가 브라우저의 뒤로가기/앞으로가기 버튼을 사용할 때도 상태를 유지할 수 있도록 관리해야 한다.

✅ useFunnel 적용

  • 퍼널 패턴을 도입하여 상태와 UI 흐름을 개선하고, 선언형 방식으로 단계를 정의하는 구조를 설계했다. 여기서 toss 의 useFunnel 라이브러리를 활용하였다.

⭕ 퍼널 패턴 도입

  • 단계별 상태 관리 집중화
    • useFunnel 훅을 사용하여 step 상태와 이동 로직 (nextClickHandler, prevClickHandler)를 한 곳에서 관리
    • 각 단계에서 필요한 데이터와 API 호출 로직을 해당 단계 컴포넌트 내부로 캡슐화
  • UI와 로직의 분리
    • Funnel 과 Step 컴포넌트를 활용하여 각 단계를 선언적으로 정의
    • UI와 상태 관리 로직을 명확히 분리하여 재사용성을 높였다.

⭕ URL 기반 상태 관리

  • 라우팅과 상태의 동기화
    • useFunnel 의 URL 동기화 기능을 활용하여 단계 이동 시 URL 쿼리 파라미터를 업데이트
    • 브라우저의 뒤로가기/앞으로가기 버튼을 사용할 때도 상태가 유지되도록 설계
  • SEO 및 공유 가능성 개선
    • 각 단계가 고유의 URL을 가지므로, 특정 단계로 직접 접근하거나 URL을 공유하는 것이 가능

⭕ 구조적 개선

  • 단계 정의
    • Funnel 컴포넌트를 사용하여 전체 프로세스의 흐름을 선언적으로 정의
    • 각 단계는 Step 컴포넌트를 통해 독립적으로 관리
  • 로직 캡슐화
    • 각 단계의 데이터 처리 및 검증 로직을 해당 Step 컴포넌트 내부로 캡슐화 하여 상위 컴포넌트와의 의존성을 감소


🚀 use-funnel 적용 과정

✔ㅤ1. Funnel 초기화

const funnel = useFunnel<ContextByStep>({
  id: "my-funnel-app",
  initial: {
    step: "태그입력",
    context: { withWhom: [], style: [], locationObjs: [] },
  },
});
  • id : 퍼널의 고유 식별자로, 충돌 방지를 위해 지정해준다.
  • initial : 초기 step 과 context를 정의한다.
  • step : 현재 단계
  • context : 단계 간 공유되는 데이터로 초기값으로 빈 배열이나 객체를 설정한다.

✔ㅤ2. 단계별 상태 관리 및 이동

단계 전환과 관련된 로직은 funnel.history 를 통해 관리된다.

funnel.history.push("코스상세입력", {
  withWhom,
  style,
});
  • push : 다음 단계로 이동하며 데이터를 전달한다.
funnel.history.replace("코스상세입력", {
  ...funnel.context,
  locationObjs: updatedLocationObjs,
});
  • replace : 현재 단계를 유지하며 context 데이터를 업데이트 한다.

✔ㅤ3. 단계별 UI 렌더링

각 단계는 funnel.step 값을 기준으로 렌더링한다.

{(() => {
  switch (funnel.step) {
    case "태그입력":
      return <SelectTag onNext={(withWhom, style) => funnel.history.push("코스상세입력", { withWhom, style })} />;
    case "코스상세입력":
      return (
        <SelectCourseMain
          locationObjs={funnel.context.locationObjs || []}
          locationObjDelete={(id: number) => {
            const updatedLocationObjs = funnel.context.locationObjs?.filter((_, index) => index !== id);
            funnel.history.replace("코스상세입력", {
              ...funnel.context,
              locationObjs: updatedLocationObjs,
            });
          }}
          onNext={(estimatedTime, estimatedCost, locationObjs, channelIdList) => {
            funnel.history.push("게시글작성", {
              estimatedTime,estimatedCost,locationObjs,channelIdList,
            });
          }}
        />
      );
    // 다른 단계의 UI 렌더링 생략...
  }
})()}
  • funnel.step : 현재 단계를 기반으로 컴포넌트를 렌더링한다.
  • 각 단계에서 onNext 콜백을 통해 다음 단계로 데이터를 전달한다.

✔ㅤ4. URL 기반 상태 동기화

useEffect(() => {
  const mainElement = document.querySelector("main");
  if (mainElement) {
    mainElement.scrollTo(0, 0);
  }
}, [funnel.step]);
  • useEffect : 단계가 변경될 때마다 UI를 초기화(스크롤을 맨 위로 이동)

✔ㅤ5. 최종 데이터 처리 및 API 호출

const handlePostCourseResult = async () => {
  const { courseTitle, courseDescription, estimatedTime, estimatedCost, locationObjs, withWhom, style, image, channelIdList } = funnel.context as PostResultContext;

  const validChannelIdList = channelIdList.filter((channelId) => channelId !== null);
  validChannelIdList.push("675e6ed26ada400ee6bec120");

  const titleObj = {
    courseTitle,courseDescription,estimatedTime,estimatedCost,
    locationObjs,withWhom,style,
  };

  try {
    await Promise.all(
      validChannelIdList.map(async (channelId: string) => {
        const response = await postMyCourse({
          title: JSON.stringify(titleObj),
          image,
          channelId,
        });
        if (channelId === "675e6ed26ada400ee6bec120") postId = response._id;
      })
    );
  } catch (error) {
    console.error("Course posting failed:", error);
  }
};
  • funnel.context 에서 최종 데이터를 추출
  • 각 channelId 에 대해 postMyCourse API 호출
  • Promise.all 을 사용하여 API 호출을 병렬로 실행


📂 Dev 조각

📍 push - 새 단계를 추가할 때 사용

  • 현재 단계를 히스토리에 추가한다.
  • 브라우저의 뒤로 가기 를 눌렀을 때, 이전 단계로 돌아갈 수 있다.

💬 사용 상황

  • 사용자가 한 단계씩 순차적으로 진행해야 하는 경우
  • 사용자가 이전 단계로 돌아갈 수 있도록 기록을 남겨야 할 때
  • 브라우저의 뒤로 가기 버튼 동작과 동기화해야 할 때
funnel.history.push("코스상세입력", { withWhom, style });

📍 replace - 현재 단계를 업데이트할 때 사용

  • 현재 단계의 URL과 상태를 덮어쓴다.
  • 브라우저의 뒤로 가기 를 눌렀을 때, 이전 단계로 돌아갈 수 없다.
  • 현재 단계에서 데이터를 수정하거나 보완할 때 적합하다.
  • 히스토리 스택에 새 URL을 추가하지 않으므로, 기록을 남기지 않아도 되는 상황에서 사용한다.

💬 사용 상황

  • 현재 단계의 데이터를 변경해야 하지만, URL이나 히스토리 스택에 변화를 주고 싶지 않을 때
  • 사용자가 뒤로 가기를 눌렀을 때, 해당 데이터 수정 사항이 히스토리에 기록되지 않아야 할 때
funnel.history.replace("코스상세입력", {
  ...funnel.context,
  locationObjs: updatedLocationObjs,
});

📍 Promise.all의 동작 원리

  • 여러 비동기 작업을 병렬로 실행하고, 모든 작업이 성공했을 때 결과를 반환한다.
  • 하나라도 실패하면, 즉시 실패하며 나머지 작업의 결과를 기다리지 않는다.
Promise.all([promise1, promise2, promise3])
  .then((results) => {
    // 모든 promise가 성공했을 때 실행
    console.log(results); // 각 Promise의 결과 배열
  })
  .catch((error) => {
    // 하나라도 실패했을 때 실행
    console.error(error);
  });

💬 문제점

  • 하나의 Promise 가 reject 되면 나머지 작업 결과는 무시된다.
  • 한꺼번에 많은 비동기 작업을 실행하면 리소스 과부하로 성능 문제가 발생할 수 있다.
  • 어떤 Promise 에서 에러가 발생했는지 추적하기 어렵다.

🤔 다른 대안책도 있나?

  • Promise.allSettled : 모든 Promise 결과를 기다리고, 성공/실패 여부를 개별적으로 반환한다.
Promise.allSettled([promise1, promise2, promise3])
  .then((results) => {
    results.forEach((result) => {
      if (result.status === "fulfilled") {
        console.log("Success:", result.value);
      } else {
        console.error("Failed:", result.reason);
      }
    });
  });

📍 for-await-of 와 순차처리

  • 비동기 작업을 순차적으로 처리해야 할 때 적합하다.
async function processSequentially(tasks) {
  for await (const task of tasks) {
    console.log(task);
  }
}
  • 각 작업이 완료된 후 다음 작업을 실행하므로, 리소스 관리가 쉽다.
  • 병렬 처리가 필요 없을 때 적합하다.

0개의 댓글