All That Arsenal 프로젝트에 새로운 기능을 추가하는 과정에서 퀴즈 기능이 있으면 재미있겠다는 생각이 들었다. 각 페이지마다 서로 다른 퀴즈 문제를 제시하고, 사용자는 각 페이지에서 문제를 풀며 그 결과를 전역 상태로 저장한다. 이후 최종적으로 정답과 사용자의 답안을 비교하는 방식으로 기능을 구현하려 했다.
이 아이디어를 스터디원과 공유했는데, 스터디원이 제안한 방식은 조금 달랐다. 스터디원은 Funnel 패턴이라는 것이 있고, 해당 라이브러리가 있어 그것을 적용해보는 게 어떻겠냐는 의견을 주었고, Funnel패턴에 대해 알아본 후,이 방식을 고려해보기로 했다. 이 포스트는 Funnel 패턴을 본격적으로 도입하기 전에 내가 구상한 방식과 Funnel 패턴의 차이점을 이해하기 위한 학습 기록이다.
퀴즈 기능을 진행하지 않았지만 대략적으로 기능을 만들기전에 구상했던 것은 위와 같이
1. quiz를 dynamic routes 페이지에서 문제를 내고 페이지간에 router.push("/이동할 페이지")나 link를 이용하여 이동하고,
2. 사용자가 기입한 답들은 전역상태관리툴로 관리하여 최종적으로 정답을 데이터 페칭하여 비교
하는 방식으로 진행하려고 했었다. 큰 문제점이 없다고 생각했지만 다음과 같은 패턴에서는 유지보수적 관점에서 몇가지 아쉬운 점이 있다는 것을 알게 되었다.
토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기라는 영상에서도 도입부에 위와 비슷한 설계를 소개하였다.
간단하게 설명하자면
1. 페이지를 위한 파일 4개를 만들고 버튼을 누르면 router.push로 이동.
2. api는 집주소 페이지에서 호출 전역상태를 사용하여 상태유지한다.
내가 초기에 설계한 것과 유사했다.
하지만 영상에서는 몇가지 아쉬운점이 있었다고 설명하고있다.
가입방식을 선택한 뒤 주민번호 -> 집주소 -> 가입완료 순으로 흐름을 파학하기 위해 3개 파일을 넘나들며 코드를 따라가야 한다는 어려움이 있다.
집주소 페이지에서 호출되는 고객 등록 API에서 쓰이는 데이터는 서로 다른 세 페이지에서 전역상태를 통해 수집하고 있다. 여기서 문제가 상태를 사용하는 곳과 수집하는 곳이 달라 추후에 API기능을 추가하거나 버그를 수정할때 상태를 이용한 집주소 페이지뿐만아니라 나머지 세 페이지 전체를 대상으로 흐름을 추적해야한다.
기존에 생각했던 설계에서는 위와같이 유지보수적으로 아쉬운점을 발견하였다. 이렇게 흩어져있는 페이지 흐름과 상태를 개선하여 응집도를 높이기 위해 Funnel에 대해서 알아보기로 했다.
Funnel은 마케팅 업계에서 사용하는 용어이고,유저가 서비스에 들어와서 최종 목표지점에 이르기까지 조금씩 이탈해서 '깔대기' 와 같은 모양을 띠기때문에 붙여진 이름이다.
프론트엔드에서 퍼널 패턴은 사용자가 애플리케이션에서 최종단계에 도달하기 까지 거치는 단계를 의미하기도 한다.
- 퍼널 이미지 예시 : 토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기에서
Slash에서 소개된 useFunnel은 서비스 종료로 인해 @use-funnel을 사용하였다.
@use-funnel
을 사용하게 된 가장 큰 이유는 코드의 응집도를 개선하기 위함이었다. 초기 설계에서는 Funnel 패턴을 고려하지 않아 페이지 흐름과 상태 관리가 분산되어 있었고, 이로 인해 코드의 유지보수성이 떨어지는 문제가 있었다. @use-funnel
을 적용함으로써 관련된 코드를 한 곳에 모아 관리할 수 있었고, 이는 자연스럽게 코드의 응집도 개선으로 이어졌다. 결과적으로 관련된 코드들이 가까운 위치에 배치되어 가독성과 유지보수성이 향상되었기 때문에 @use-funnel
을 도입하게 되었다.
@use-funnel
에서 사용하는 용어
설계는 다음과 같이 하였다.
1. 사용자가 각 단계에서 문제를 푼다면 각 step
에서의 답들은 context
로 관리된다.
2. 마지막 Result
단계에서 사용자의 대답들(Context)을 API통신으로 API Routes
에 보낸다.
3. API Routes
에서는 DB에서 정답들을 가져와서 대답들(Context)과 비교하여 채점을 하여 다시 Result
페이지에 보여주는 방식으로 진행하였다.
채점을 프론트엔드에서 처리할 경우, 정답 데이터와 사용자의 응답이 클라이언트 측 코드에 노출될 가능성이 크다.이것은 다음과 같은 보안 문제로 이어질 수도 있다.
데이터 탈취 가능성
클라이언트 측 코드에 저장된 정답 데이터는 브라우저 개발자 도구를 통해 쉽게 접근할 수 있다. 채점 과정에서 정답을 확인하여 점수 결과를 다르게 만들 수도 있다.
코드 변조
사용자는 자신의 로컬 환경에서 자바스크립트 코드를 쉽게 변조할 수 있다. 예를 들어, 채점 로직을 직접 수정해 실제로는 틀린 답을 맞다고 처리하도록 조작할 수 있다. 이러한 조작을 방지하기 위해서는 채점 로직을 서버 측에서 실행하는 것이 맞다고 판단하였다.
뒤에 나오는 API통신 및 백엔드에서의 채점에서 API Routes에 채점 로직을 넣은 이유에 대해서 설명한다.
app router에서 패키지 명령어:
npm install @use-funnel/browser --save
step1은 아무것도 입력되지 않은 상태이기에 ? (optional)로 정의 하였고 단계가 진행될수록 앞의 필드가 필수적으로 입력되어 있어야 한다 그래서 step이 진행될 수록 optional도 줄어들게 된다.
이렇게 각 step의 상태를 타입으로 정의하면 코드에서 타입 안전성을 유지할 수 있고, step별로 필요한 정보를 쉽게 추적할 수 있다.
//_type/index.ts
export interface step1 {
step1?: string;
step2?: string;
step3?: string;
step4?: string;
step5?: string;
}
export interface step2 {
step1: string;
step2?: string;
step3?: string;
step4?: string;
step5?: string;
}
export interface step3 {
step1: string;
step2: string;
step3?: string;
step4?: string;
step5?: string;
}
export interface step4 {
step1: string;
step2: string;
step3: string;
step4?: string;
step5?: string;
}
export interface step5 {
step1: string;
step2: string;
step3: string;
step4: string;
step5?: string;
}
export interface result {
step1: string;
step2: string;
step3: string;
step4: string;
step5: string;
}
다음은 useFunnel()
을 사용해 초기 단계를 설정하는 과정이다. 먼저 step
을 key로 한 context
객체를 useFunnel()
의 제네릭으로 지정한다. 앞 단계에서 각 단계의 상태를 정의한 타입을 useFunnel()
에 전달해서 사용한다.그리고 해당 컴포넌트에 진입했을 때 사용할 step
과 context
객체를 initial 프로퍼티에 지정한다.
본 프로젝트는 초기단계인 "step1"과 해당단계에서 사용할 빈 context객체를 설정했다.id는 한 컴포넌트에 여러 퍼널이 있을 때 구분하기 위한 고유 식별자이다.
//Funnel.tsx
import { useFunnel } from "@use-funnel/browser";
import type { result, step1, step2, step3, step4, step5 } from "@/app/_types";
function FunnelContent() {
const funnel = useFunnel<{
step1: step1;
step2: step2;
step3: step3;
step4: step4;
step5: step5;
result: result;
}>({
id: "my-funnel-app",
initial: {
step: "step1",
context: {},
},
});
// ...
}
useFunnel()에서 반환된 step에 따라 context와 history를 사용한다. 각 단계별로 UI를 구성하고, 필요한 상태와 이벤트를 처리할 수 있다.여기서 useFunnel()이 반환하는 <funnel.Render />
컴포넌트를 사용하였다.이 컴포넌트를 사용하여 단계별 UI를 더 간편하게 관리하였다.
주의) funnel안의 모든 context객체들을 작성하지 않으면 funnel.Render쪽에서 해당하는 속성이 없다는 오류가 발생한다.
//Funnel.tsx
return (
<div className="">
<div className="my-5">
<FunnelProgress step={funnel.step} />
</div>
<div>
<funnel.Render
step1={({ history }) => (
<Step1 onNext={(step1) => history.push("step2", { step1 })} />
)}
step2={({ history }) => (
<Step2
onNext={(step2) => history.push("step3", { step2 })}
onPrev={() => history.push("step1")}
/>
)}
step3={({ history }) => (
<Step3
onNext={(step3) => history.push("step4", { step3 })}
onPrev={() => history.push("step2")}
/>
)}
step4={({ history }) => (
<Step4
onNext={(step4) => history.push("step5", { step4 })}
onPrev={() => history.push("step3")}
/>
)}
step5={({ history }) => (
<Step5
onNext={(step5) => history.push("result", { step5 })}
onPrev={() => history.push("step4")}
/>
)}
result={({ context }) => <QuizResult context={context} />}
/>
</div>
</div>
);
앞서 설계를 설명할때에 클라이언트 사이드에서 채점을 진행할 때의 문제점에 대해서 설명했다. 다음은 채점 기능을 백엔드(API Routes)에서 처리한 이유에 대해서 설명한다.
서버에서의 안전한 처리
사용자의 대답은 클라이언트를 통해 API 요청으로 서버(API Routes)에 전달되며, 서버는 이를 안전하게 처리한다. 정답 데이터는 DB와 서버 내부에서만 존재하며, 외부에 노출되지 않으므로 데이터 유출 위험을 줄일 수 있다.
로직의 무결성 유지
채점 로직을 서버에서 실행하므로, 사용자가 이를 변조할 가능성이 없다. 그래서 채점 시스템의 신뢰성을 크게 향상 시킬 수 있다.
//quizResult.ts
import { connectDB } from "@/utils/database";
import { NextApiRequest, NextApiResponse } from "next";
import { result } from "@/app/_types";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Failed (Code:405)" });
}
try {
const { context } = req.body as { context: result };
const client = await connectDB;
const db = client.db("arsenal");
// DB에서 정답 가져오기
let [quizAnswer] = await db.collection("quizResult").find().toArray();
const results = {
step1: context.step1 === quizAnswer.step1,
step2: context.step2 === quizAnswer.step2,
step3: context.step3 === quizAnswer.step3,
step4: context.step4 === quizAnswer.step4,
step5: context.step5 === quizAnswer.step5,
};
// 정답 개수 계산
const correctCount = Object.values(results).filter(Boolean).length;
// 결과 반환
return res.status(200).json({
success: true,
correctCount,
results,
totalQuestions: 5,
});
} catch (error) {
console.error("Error on quiz results:", error);
return res.status(500).json({
success: false,
message: "Error on quiz results (Code:500)",
});
}
}
ReferenceError:window is not defined at Funnel 에러가 떴다. 해당 에러는 Next.js위에서 일부 custom훅을 사용할때 발생하는 에러로, Funnel패턴 학습과 무관하여 다른
포스트에서 이 에러에 대해서 다루었다.
@use-funnel
을 사용해보니, Slah영상에서 소개된 useFunnel
보다 훨씬 사용하기 편하다고 느꼈다. useFunnel 소개처럼 흩어져있는 페이지 흐름과 상태를 관리하기에 적합했고, 조금 더 로직에 집중할 수 있는 라이브러리 같았다. 다만, 내가 만든 프로젝트 경우 데이터 페칭을 서버컴포넌트에서 진행하여 Funnel.tsx
에서 데이터 페칭을 하지 않고 API통신을 QuizResult컴포넌트
쪽에서 진행되고 있어 해당 라이브러리를 반쪽만 활용하고 있다는 생각이 들어 아쉬움이 남는다.(번외: 에러 & 에러 해결에서 그 이유가 밝혀질 예정이다.)추후에 이것을 개선을 한다면, 후속포스트로 작성할 예정이다.
추후 개선사항으로 예정된 것은 의도하지 않은 종료나 새로고침이 발생 시 이를 해결하기 위해서 Local Storage나 클라이언트쪽의 스토리지를 통해 작성중인 답변을 임시 저장하는 기능을 추가하려고 계획중에 있다. 또한 admin 계정에서 문제와 답변을 작성하는 페이지도 계획에 있다.
https://www.youtube.com/watch?v=NwLWX2RNVcw
https://use-funnel.slash.page/ko/docs/overview
https://toss.tech/article/engineering-note-1