[React] React로 퀴즈 앱 만들기 | 개발 회고

2025. 4. 16. 22:43·Programming/React
728x90
반응형

이 프로젝트는 Udemy의 【한글자막】 React 완벽 가이드 2025 with React Router & Redux 강의 일부의 연습 프로젝트 입니다.

 

📋 React Quiz App

이 프로젝트는 사용자가 문제을 읽고 시간 제한 내에 답안을 선택하는 퀴즈 앱 입니다. 주어진 문제를 다 풀면 결과 페이지를 제공합니다. 단순히 문제를 풀기만 하는 UI가 아닌, 상태 관리와 퀴즈 흐름에 초점을 맞춰 구현했습니다. 이를 통해 React에서 컴포넌트를 어떻게 효율적으로 설계하고, Context API와 상태 관리를 어떻게 활용할 수 있는지에 대한 경험을 쌓을 수 있었습니다.

특히, 각 문제의 상태(문제 풀이 중, 정답 확인, 시간 초과 등)를 어떻게 처리할지에 대한 고민과, 시간 제한 및 문제 흐름을 어떻게 자연스럽게 관리할 수 있을지에 대한 구현이 주요 포인트였습니다.

이 글에서는 퀴즈 앱의 주요 기능과 구현 과정, 그리고 퀴즈 앱을 만들면서 고민했던 부분들에 대해 차례대로 설명하려 합니다.


 

💡 프로젝트 개요

🔗 배포 링크 & 깃허브

  • 👉 배포 링크 바로가기
  • 👉 GitHub Repo

📌 프로젝트 소개

  • 컴포넌트: 앱을 작은 독립적인 부분으로 나눠 관리.
  • useState: 퀴즈 진행 상태(현재 문제, 선택한 답 등) 관리.
  • useEffect: 타이머와 상태 변화 관리.
  • useContext: 사용자 답안 상태를 전역적으로 관리.
  • useReducer: 복잡한 상태 관리 (답안 추가 등).
  • 조건부 렌더링: 퀴즈 화면과 결과 화면 전환.

🔑 핵심 기능

  • 퀴즈 문제 & 보기 랜덤 셔플
  • 타이머 기반 자동 진행
  • 사용자 답안 저장 및 요약
  • Context API 기반 상태 관리

 

🏗️ 구현 과정 및 내부 로직

1️⃣ 전체적인 흐름

  1. 퀴즈 시작: 사용자가 앱을 시작하면, Quiz 컴포넌트가 렌더링되어 문제들이 섞인 상태로 표시됩니다.
  2. 문제 풀기: 각 문제는 제한 시간이 주어지며, 사용자는 보기를 선택하거나 Skip 버튼을 눌러 문제를 건너뛸 수 있습니다.
  3. 답안 제출: 사용자가 답을 선택하면, userAnswerContext를 통해 답안 상태가 저장되고, 타이머가 종료되면 자동으로 정답이 표시됩니다.
  4. 다음 문제로 이동: 정답을 확인한 후, Quiz 컴포넌트는 자동으로 다음 문제로 넘어가며, 마지막 문제까지 진행합니다.
  5. 결과 화면: 모든 문제를 끝내면 Summary 컴포넌트가 렌더링되어 정답, 오답, 건너뛴 문제 수와 비율을 표시하고, 각 문제에 대한 사용자 선택 및 결과를 출력합니다.
  6. 상태 관리: UserAnswerContext와 useReducer를 통해 사용자의 답안을 관리하고, 각 컴포넌트 간 상태를 공유합니다.

 

2️⃣ 구조

📂 폴더 구조

📁 src
├── 📁 assets
├── 📁 components
│   ├── 🧩 Answers.jsx
│   ├── 🧩 Header.jsx
│   ├── 🧩 ProgressBar.jsx
│   ├── 🧩 Question.jsx
│   ├── 🧩 Quiz.jsx
│   └── 🧩 Summary.jsx
├── 📁 constants
│   ├── ❓ nonsense_questions_ko.js
│   ├── ❓ questions.js
│   ├── ❓ questions_ko.js
│   └── ❓ stages.js
├── 📁 store
│   └── 🧠 user-answer-context.jsx
├── 🧩 App.jsx
├── 🎨 index.css
└── 🚀 main.jsx

📌 컴포넌트 구조

App
├── Header (상단 로고 + 타이틀)
│ 
├── UserAnswerContextProvider
│   ├── Quiz (퀴즈 진행 중일 때 렌더링)
│   │   ├── Question (질문 + ProgressBar)
│   │   │   └── ProgressBar (타이머 진행 바)
│   │   ├── Answers (선택지 버튼들)
│   │   └── Skip 버튼 영역
│   │
│   └── Summary (퀴즈 종료 후 결과 요약 화면)
│       └── 각 질문에 대한 결과 항목

3️⃣ 주요 컴포넌트 및 기능

🏠 1. App.jsx (메인 컴포넌트)

  • 퀴즈 앱의 전체 흐름을 제어하는 루트 컴포넌트
  • viewMode 상태로 현재 화면을 관리
    • "quiz" → Quiz 컴포넌트 렌더링
    • "summary" → Summary 컴포넌트 렌더링
  // 현재 화면 모드를 상태로 관리 (퀴즈 화면 또는 결과 화면)
  const [viewMode, setViewMode] = useState("quiz");
  
  // 화면 모드를 변경하는 함수 (퀴즈 화면 -> 결과 화면)
  function handleViewModeChange(mode) {
    setViewMode(mode); // 새로운 화면 모드로 상태 업데이트
  }

  return (
    <>
		    ...
        {/* viewMode 상태에 따라 퀴즈 화면 또는 결과 화면을 렌더링 */}
        {viewMode === "quiz" && ( <Quiz quizzes={shuffledQuizzes} onQuizEnd={handleViewModeChange} /> )}
        { viewMode === "summary" && <Summary />  }
		    ...
    </>
  );
  • QUIZS 데이터에서 문제와 보기 순서를 lodash로 셔플하여 출제
// QUIZS에서 문제를 가져와서 순서를 섞고, 각 문제에 대한 답변 순서도 섞음
  const shuffledQuizzes = _.shuffle(QUIZS).map((quiz) => ({
    ...quiz,
    answers: _.shuffle(quiz.answers), // 답안 순서 셔플
    correctAnswer: quiz.answers[0], // 첫 번째 답안을 정답으로 설정
  }));
  • 전역 상태 관리용 UserAnswerContextProvider로 감싸, 하위 컴포넌트에서 사용자 답변 데이터를 공유 및 추적
return (
  <><Header /> {/* 상단 고정 헤더 */}
    <UserAnswerContextProvider>
      {currentView}
    </UserAnswerContextProvider>
  </>
);

 


🧠 2. Quiz.jsx (퀴즈 진행 컴포넌트)

  • Quiz 컴포넌트는 퀴즈의 전체 흐름을 관리하는 중심 컴포넌트로, 하나의 문제에 대해 퀴즈 문제 표시 → 사용자 선택 확인 → 정답 공개라는 3단계 상태 전환 로직을 갖는다.
  • 상태는 useState로 관리하며, 각각 다음과 같이 상태가 전환 되어진다.
  • QUIZ: 문제 풀이 단계
    • 시간 만료 시: handleTimeOut → handleSkipClick 호출 → CORRECT 상태로 이동
    • 사용자 선택 시: handleAnswerSelect → SELECTED 상태로 이동
function handleAnswerSelect(answer) {
  setSelectedAnswer(answer);
  setQuizStage(STAGES.SELECTED);
  addUserAnswer({ ... });
}

function handleSkipClick() {
  setQuizStage(STAGES.CORRECT);
  addUserAnswer({ result: "skipped", ... });
}
  • SELECTED: 사용자가 선택한 답안을 보여주는 단계 
    • 시간 만료 시: handleTimeOut → CORRECT 상태로 이동
**function handleTimeOut() {
  if (quizStage === STAGES.QUIZ) {
    handleSkipClick();
  } else if (quizStage === STAGES.SELECTED) {
    setQuizStage(STAGES.CORRECT);
  } else {
    handleNextQuestion();
  }
}**
  • CORRECT: 정답을 보여주는 단계
    • 시간 만료 시: handleTimeOut → handleNextQuestion 호출 → 다음 문제로 이동, QUIZ 상태 이동
function handleNextQuestion() {
  if (currentQuizIndex >= quizzes.length - 1) {
    onQuizEnd("summary");
    return;
  }
  setSelectedAnswer("");
  setCurrentQuizIndex((prev) => prev + 1);
  setQuizStage(STAGES.QUIZ);
}
  • Question 컴포넌트에 key를 부여함으로써 상태가 바뀔 때마다 React가 컴포넌트를 새로 마운트하게 하여 타이머가 정상적으로 초기화되도록 설계
<Question
  key={`${currentQuizIndex}-${quizStage}`}
  question={quiz.text}
  quizStage={quizStage}
  onTimeOut={handleTimeOut}
/>

  • 사용자의 선택한 답은 컨텍스트를 통해 관리하여 저장 및 추적.
  • Skip버튼을 통해 사용자가 문제를 건너뛰고 바로 정답을 확인할 수 있도록 기능을 추가 QUIZ 상태에서만 표시되도록 조건부 렌더링
{quizStage === STAGES.QUIZ && (
  <button type="button" onClick={handleSkipClick}>Skip</button>
)}
​

 


⏳ 3. ProgressBar.jsx (진행 바 컴포넌트)

  • ProgressBar는 퀴즈 상태에 따라 자동으로 제한 시간을 계산하고, 남은 시간을 10ms 단위로 실시간 표시하는 컴포넌트
  • 퀴즈를 푸는 단계(QUIZ)에서는 10초, 선택 확인이나 정답 공개 단계에서는 1초의 시간이 주어지며, 해당 시간은 상태 변화마다 자동 설정
  • 첫 번째 useEffect에서는 정해진 시간 후 자동 전환을 위한 setTimeout을 등록하고, 상태 변경이나 언마운트 시 이를 정리
useEffect(() => {
  const timer = setTimeout(() => {
    onTimeOut();
  }, time);

  return () => clearTimeout(timer); // 상태 변경 시 타이머 초기화
}, [time]);
  • 두 번째 useEffect에서는 남은 시간을 10ms 단위로 감소시키는 인터벌을 설정하고, 퀴즈 상태가 바뀔 때마다 남은 시간을 다시 초기화하고 새 인터벌을 시작
useEffect(() => {
  setRemainingTimer(time); // 상태 전환 시 남은 시간 초기화

  const interval = setInterval(() => {
    setRemainingTimer((prevTime) => prevTime - 10);
  }, 10);

  return () => {
    clearInterval(interval); // 상태 변경 또는 언마운트 시 인터벌 정리
  };
}, [time]);

  • 진행 바는 <progress> 태그로 구현되며, value와 max 속성으로 남은 시간의 진행률 표시
  • 정답이 공개되는 단계에서는 answered 클래스를 조건부로 추가해 스타일을 바꾸어 시각적으로 상태 전환을 표시
<progress
  className={quizStage !== STAGES.QUIZ ? "answered" : ""}
  value={remainingTimer}
  max={time}
/>

💬 4. Answers.jsx (선택지 목록 컴포넌트)

  • 퀴즈에서 사용자가 선택한 답변과 정답을 시각적으로 구분하여 보여주는 컴포넌트.
  • 현재 문제의 답변 리스트를 렌더링하고, 선택 이벤트 처리
  • quizStage에 따라 답변 버튼의 클래스를 동적으로 설정.
    • STAGES.SELECTED: 사용자가 답을 선택한 상태에서 해당 답에 "selected" 클래스 추가.
    • STAGES.CORRECT: 정답을 표시하는 단계에서 정답에는 "correct"(초록), 틀린 답에는 "wrong" (빨강) 클래스 추가.
function getClassName(answer) {
  if (quizStage === STAGES.SELECTED) {
    return answer === selectedAnswer ? "selected" : "";
  }

  if (quizStage === STAGES.CORRECT) {
    if (answer === correctAnswer) return "correct";
    if (answer === selectedAnswer && selectedAnswer !== correctAnswer)
      return "wrong";
  }

  return "";
}

  • quizStage가 "QUIZ"일 때만 선택이 가능하도록 disabled 설정.
<button
  className={getClassName(answer)} // 동적으로 스타일 클래스 지정
  disabled={quizStage !== STAGES.QUIZ} // 퀴즈 푸는 중에만 클릭 가능
  type="button"
  onClick={() => handleAnswerSelect(answer)} // 답안 선택 핸들러
>
  {answer}
</button>

📊 5. Summary.jsx (요약 결과 화면 컴포넌트)

  • 퀴즈가 끝나면 정답/오답/스킵 비율을 시각적으로 요약
  • userAnswer: 사용자 답안 배열을 UserAnswerContext에서 가져와서 퀴즈 결과를 처리.
  • 문제 통계 계산:
    • 총 문제 수, 정답 수, 오답 수, 건너뛴 문제 수 계산.
    • 각 항목의 비율을 백분율로 계산하여 표시.
const correctAnswers = userAnswer.filter((answer) => answer.result === "correct").length;
const wrongAnswers = userAnswer.filter((answer) => answer.result === "wrong").length;
const skippedAnswers = userAnswer.filter((answer) => answer.result === "skipped").length;

const getPercentage = (count) => Math.round((count / totalQuestions) * 100);

  • 사용자의 응답 결과를 하나씩 나열하며, 정답 여부와 선택한 항목을 강조 표시
    • skipped된 문제는 "SKIPPED"로 표시, 선택된 답은 정답/오답에 따라 스타일링.
    • context.answers와 questions 데이터를 매핑하여 렌더링
<ol>
  {userAnswer.map((answer, index) => {
    const { id, question, selectedAnswer, result } = answer;
    return (
      <li key={id}>
        <h3>{index + 1}</h3>
        <div className="question">{question}</div>
        {result === "skipped" ? (
          <p className="user-answer wrong">SKIPPED</p>
        ) : (
          <div className="answer">
            <p className={`user-answer ${result}`}>{selectedAnswer}</p>
          </div>
        )}
      </li>
    );
  })}
</ol>

🧩 6. user-answer-context.jsx (Context API + 상태관리)

  • 사용자 답안을 전역 상태로 관리하고, 이를 다른 컴포넌트에서 공유할 수 있도록 하는 Context.
    • Context: UserAnswerContext 생성.
    • Reducer: useReducer 훅을 이용하여 상태 관리.
    • Provider: UserAnswerContextProvider 컴포넌트로 자식 컴포넌트에 상태 및 함수를 제공.

왜 사용자 답안을 Context로 관리했을까?

  • 초기에 Prop Drilling 없이 상태를 컴포넌트 내부에서 관리했지만,
  • Answer에서 업데이트한 사용자 답안을 Summary에서 사용해야 했음.
  • App에서 Quiz와 Summary를 조건부로 렌더링하고,
  • Answer → Quiz → App → Summary로 불필요한 props 전달이 이뤄질 수밖에 없는 구조였음.
  • 이런 불필요한 **prop 전달(Prop Drilling)**을 피하고, 상태를 일관되게 접근하고 관리하기 위해 Context API를 도입함.

1. Context 생성:

  • 사용자 답안을 저장하고 업데이트할 수 있는 Context를 생성.
  • userAnswer는 사용자 답안을 배열로 저장하며, 기본값은 빈 배열.
  • addUserAnswer는 답안을 추가하는 함수로 초기에는 빈 함수로 설정.
export const UserAnswerContext = createContext({
  userAnswer: [],
  addUserAnswer: () => {},
});

2. Reducer 정의:

  • userAnswerReducer는 사용자 답안을 관리하는 리듀서.
  • ADD_USER_ANSWER 액션이 발생하면 payload로 전달된 답안을 기존 상태에 추가하여 새로운 상태를 반환.
function userAnswerReducer(state, action) {
  switch (action.type) {
    case "ADD_USER_ANSWER":
      return [...state, action.payload];
    default:
      return state;
  }
}

3. Provider 정의

  • UserAnswerContextProvider는 이 Context를 제공하는 컴포넌트로, useReducer를 사용하여 상태(userAnswerState)와 상태 업데이트 함수(userAnswerDispatch)를 관리합니다. 이 컴포넌트는 자식 컴포넌트들에게 userAnswer와 addUserAnswer를 제공하며, 이를 통해 다른 컴포넌트에서 사용자 답안 상태를 조회하고 업데이트할 수 있습니다.

🌟 주요 단계:

  1. useReducer: userAnswerState와 userAnswerDispatch를 관리.
  2. addAnswer 함수: ADD_USER_ANSWER 액션을 디스패치하여 사용자가 선택한 답안을 상태에 추가.
  3. UserAnswerContext.Provider: 상태와 함수를 자식 컴포넌트들에 전달.
export default function UserAnswerContextProvider({ children }) {
  // 상태 관리: useReducer로 상태(userAnswerState) 및 상태 업데이트(userAnswerDispatch) 처리
  const [userAnswerState, userAnswerDispatch] = useReducer(userAnswerReducer, []);

  // 사용자 답안을 추가하는 함수
  function addAnswer(answer) {
    userAnswerDispatch({ type: "ADD_USER_ANSWER", payload: answer });
  }

  // Context 값 제공
  const ctxValue = {
    userAnswer: userAnswerState,  // 현재 상태 값
    addUserAnswer: addAnswer,     // 답안 추가 함수
  };

  // 자식 컴포넌트들에 Context 값을 제공
  return <UserAnswerContext.Provider value={ctxValue}>{children}</UserAnswerContext.Provider>;
}

🧭 Context 사용 흐름 요약

  1. 사용자가 답안을 선택하면 addAnswer 함수로 답안을 디스패치 (userAnswerDispatch).
  2. userAnswerReducer는 액션을 처리하고 새로운 배열을 반환
  3. React가 상태 변경을 감지하고 컴포넌트를 리렌더링
  4. UserAnswerContext.Provider를 통해 최신 상태를 자식 컴포넌트들에게 전달

 

 


🎯 결과 예시

 


🛠️ 트러블슈팅

❌ 1. 퀴즈가 두 개씩 넘어가는 문제

🚨 문제 원인:

const timer = setTimeout(() => {
  onTimeExpired();
}, TIMER);
  • setTimeout이 컴포넌트 렌더링 때마다 중복 실행되어, onTimeExpired()가 두 번 이상 호출됨.
  • 특히 Question 컴포넌트가 상태 변화로 인해 리렌더링될 경우 타이머가 새로 생성되며 중복 타이머 발생.

💡 해결 방법:

  • useEffect를 사용해 타이머 생성을 제어하고, 클린업 함수로 이전 타이머 제거.
useEffect(() => {
  const timer = setTimeout(() => {
    onTimeExpired();
  }, TIMER);

  return () => clearTimeout(timer);
}, [onTimeExpired]);
  • 이렇게 하면 Question이 리렌더링될 때 기존 타이머 제거 + 새 타이머 생성으로 중복 실행 방지

 

❌ 2. ProgressBar가 리셋되지 않는 문제

🚨 문제 원인:

  • quizStage가 "showSelectedAnswer" → "showCorrectAnswer"로 바뀌어도
  • ProgressBar의 time 값은 변경되지만 컴포넌트 자체는 리렌더링되지 않음.
  • React는 같은 컴포넌트에 prop만 바뀐다고 해서 완전히 리셋하지 않음.

💡 해결 방법:

  • key props를 사용해 Question 컴포넌트를 강제로 재마운트.
  • key가 바뀌면 React는 해당 컴포넌트를 완전히 제거 후 다시 생성함.
<Question
  key={`${currentQuizIndex}-${quizStage}`} // 🔑 이게 핵심!
  question={quiz.text}
  time={time}
  isAnswered={quizStage !== "quiz"}
/>

✅ 이렇게 하면 quizStage가 바뀔 때마다 ProgressBar도 초기화되어 새로 시작됨.

 

❌ 3. 초기에는 질문만 셔플했지만 답안마다 셔플하려다 App 렌더링 시 셔플로 변경

🚨 문제 원인:

  • 답안 목록을 출력할 때 답안을 매번 셔플하려고 했더니 렌더링 시마다 답 순서가 바뀌어 혼란 발생.

💡 해결 방법:

  • 앱이 최초 렌더링될 때 질문 + 답안을 모두 셔플해서 일관된 순서 유지.
  • lodash.shuffle을 사용하여 초기화 단계에서만 섞이도록 수정.

 

 


🔥 배운 점 & 느낀 점

  • useReducer와 Context API를 함께 활용해 복잡한 전역 상태를 구조적으로 관리하는 법을 익혔다.
  • 상태 흐름을 설계하면서 전역 상태와 지역 상태의 경계에 대해 고민해볼 수 있었다.
  • 타이머 기능을 구현하며 useEffect와 setInterval의 클린업 처리의 중요성을 체감했다.
  • 조건부 렌더링과 분기 처리를 통해 앱의 흐름을 명확하게 제어할 수 있었다.
  • 사용자 경험을 고려해 선택지 셔플, 시도 횟수 제한, 정답 확인 등 다양한 로직을 React로 유연하게 구현할 수 있었다.
  • 전역 상태를 기반으로 Answer, Quiz, Summary 컴포넌트 간의 데이터를 주고받으며 prop drilling 없이 컴포넌트 간 소통하는 구조를 설계해볼 수 있었다.
  • 작은 기능이라도 커스텀 훅으로 분리해서 재사용성과 가독성을 높이는 습관을 들이게 되었다.
반응형

'Programming > React' 카테고리의 다른 글

[React] 리액트 클래스 컴포넌트, 왜 아직도 알아야 할까?  (1) 2025.04.18
[React]⚡️React 성능 최적화 정리: memo, useMemo, 그리고 key의 역할까지!  (0) 2025.04.17
[React] Side Effects / useEffect 훅 / useCallback 훅  (0) 2025.04.11
[React] Prop Drilling / Context API / Provider Pattern / userReducer  (0) 2025.04.09
IntelliJ에서 React prettier 설정  (0) 2025.04.08
'Programming/React' 카테고리의 다른 글
  • [React] 리액트 클래스 컴포넌트, 왜 아직도 알아야 할까?
  • [React]⚡️React 성능 최적화 정리: memo, useMemo, 그리고 key의 역할까지!
  • [React] Side Effects / useEffect 훅 / useCallback 훅
  • [React] Prop Drilling / Context API / Provider Pattern / userReducer
제가안난데여♪(´ε`*)
제가안난데여♪(´ε`*)
기억의 유한함을 기록의 무한함으로 ✍️ 예비 개발자가 꿈꾸는 공간 여기는 안나의 개발 블로그 💻
  • 제가안난데여♪(´ε`*)
    안나의 전두엽 어딘가 🧠
    제가안난데여♪(´ε`*)
    기억의 유한함을 기록의 무한함으로 ✍️ 예비 개발자가 꿈꾸는 공간 여기는 안나의 개발 블로그 💻
  • 전체
    오늘
    어제
    • 분류 전체보기 (128)
      • 간단하게 한스푼🥄 (1)
      • Programming (56)
        • Spring (16)
        • Vue.js (6)
        • Deep Learning (3)
        • DataBase (5)
        • React (26)
      • DevOps (21)
        • Docker (12)
        • Linux (4)
      • Algorithm (46)
        • 알고리즘 정리 (5)
        • 자료구조 (0)
        • PS - 백준 (28)
        • 99클럽 코테 스터디 (13)
      • Project (0)
        • CampFire (0)
      • 안나의 취뽀일기 (˵ •̀ ᴗ - ˵ ) ✧ (4)
        • SSAFY_9기 (2)
        • SW 부트캠프 (2)
  • 잔디 달력

    «   2026/07   »
    일 월 화 수 목 금 토
    1 2 3 4
    5 6 7 8 9 10 11
    12 13 14 15 16 17 18
    19 20 21 22 23 24 25
    26 27 28 29 30 31
  • 인기 글

  • 태그

    자바
    백준 구현문제
    greedy
    자바 스택
    java stack
    알고리즘
    front-end
    java 백준
    항해99
    Spring
    linux
    99클럽
    til
    백준 java
    개발자 취업
    코딩테스트 준비
    React
    김영한
    Vue
    Vue.js 입문하기
    Vue.js
    topology sort
    백준
    리액트 상태
    stack
    springboot
    도커
    docker
    도커컨테이너
    java
  • 07-15 19:18
    반응형
  • hELLO· Designed By정상우.v4.10.3
제가안난데여♪(´ε`*)
[React] React로 퀴즈 앱 만들기 | 개발 회고
상단으로

티스토리툴바