
🚨 문제점
기존 방식으로 코스 생성 프로세스를 구현했을 때, 다음과 같은 문제가 발생했다.
페이지 흐름이 분산: 생성 단계마다 별도의 컴포넌트와 상태로 분리되어 있어 흐름을 파악하기 어렵고 유지보수가 복잡했다.상태 관리의 중복: 각 단계에서 수집된 데이터를 전역 상태로 저장하거나 전달하는 로직이 반복적으로 작성되어 비효율적이었다.재사용성 부족: 유사한 단계가 여러 개 있어도 로직이 중복되어 컴포넌트 재사용이 어렵고 불필요한 코드가 증가했다.
💡 문제 분석
- 단계별 상태와 UI를 중앙에서 제어하여 흐름을 직관적으로 파악할 수 있는 구조가 필요하다.
- 각 단계의 흐름을 명확히 하고, 상태와 UI 간의 의존성을 줄여야 한다.
- 단계별 UI와 로직을 분리하고 유사한 단계를 쉽게 재사용할 수 있도록 컴포넌트화가 필요하다.
- 즉, 각 단계를 독립적으로 관리하면서도 전체 흐름을 쉽게 제어할 수 잇는 설계가 필요하다.
- 또한, 사용자가 브라우저의 뒤로가기/앞으로가기 버튼을 사용할 때도 상태를 유지할 수 있도록 관리해야 한다.
✅ useFunnel 적용
- 퍼널 패턴을 도입하여 상태와 UI 흐름을 개선하고, 선언형 방식으로 단계를 정의하는 구조를 설계했다. 여기서
toss의useFunnel라이브러리를 활용하였다.
useFunnel 훅을 사용하여 step 상태와 이동 로직 (nextClickHandler, prevClickHandler)를 한 곳에서 관리Funnel 과 Step 컴포넌트를 활용하여 각 단계를 선언적으로 정의useFunnel 의 URL 동기화 기능을 활용하여 단계 이동 시 URL 쿼리 파라미터를 업데이트Funnel 컴포넌트를 사용하여 전체 프로세스의 흐름을 선언적으로 정의Step 컴포넌트를 통해 독립적으로 관리Step 컴포넌트 내부로 캡슐화 하여 상위 컴포넌트와의 의존성을 감소const funnel = useFunnel<ContextByStep>({
id: "my-funnel-app",
initial: {
step: "태그입력",
context: { withWhom: [], style: [], locationObjs: [] },
},
});
id : 퍼널의 고유 식별자로, 충돌 방지를 위해 지정해준다.initial : 초기 step 과 context를 정의한다.step : 현재 단계context : 단계 간 공유되는 데이터로 초기값으로 빈 배열이나 객체를 설정한다.단계 전환과 관련된 로직은
funnel.history를 통해 관리된다.
funnel.history.push("코스상세입력", {
withWhom,
style,
});
push : 다음 단계로 이동하며 데이터를 전달한다.funnel.history.replace("코스상세입력", {
...funnel.context,
locationObjs: updatedLocationObjs,
});
replace : 현재 단계를 유지하며 context 데이터를 업데이트 한다.각 단계는
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 콜백을 통해 다음 단계로 데이터를 전달한다.useEffect(() => {
const mainElement = document.querySelector("main");
if (mainElement) {
mainElement.scrollTo(0, 0);
}
}, [funnel.step]);
useEffect : 단계가 변경될 때마다 UI를 초기화(스크롤을 맨 위로 이동)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 호출을 병렬로 실행📍 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); } }
- 각 작업이 완료된 후 다음 작업을 실행하므로, 리소스 관리가 쉽다.
- 병렬 처리가 필요 없을 때 적합하다.