[퀴즈앱] 컴퓨터 상식 퀴즈앱 만들기

비얌·2023년 6월 23일
2
post-thumbnail
post-custom-banner

🧹 개요

최근에 컴퓨터 상식 문제를 풀 수 있는 퀴즈앱을 만들었다!

어떤 과정을 거쳐서 만들었는지 회고해보자😆

GitHub 레포와 배포 링크는 아래에서 확인하실 수 있습니다.



✨ 퀴즈앱 소개

과정을 알아보기에 앞서, 만든 퀴즈앱에 대해 소개하려고 한다.

이 퀴즈앱의 사용은 퀴즈 시작하기/퀴즈 풀기/결과 확인하기 이렇게 세 단계로 나눌 수 있다.

데스크탑 버전의 레이아웃과 모바일 버전의 레이아웃은 아래와 같다.

데스크탑모바일
데스크탑모바일

1. 퀴즈 시작하기

퀴즈앱에 들어가면 아래와 같은 메인 페이지가 보인다.

이곳에서 닉네임을 입력할 수 있는데, 빈칸 혹은 공백을 입력하면 '퀴즈 풀기' 버튼이 활성화되지 않는다.

닉네임을 입력하고 '퀴즈 풀기' 버튼을 누르면 문제 풀이 페이지로 넘어갈 수 있다.

문제 풀이 페이지로 넘어갈 때 API에서 문제 데이터를 받아오기까지 로딩 시간이 있는데, 이때 아래처럼 로딩 화면이 표시된다.


2. 퀴즈 풀기

'퀴즈 풀기' 버튼을 클릭하면 퀴즈 페이지로 넘어간다. 제시되는 문제는 총 4개로, 컴퓨터 상식 관련 문제이다.

한개의 답만을 선택할 수 있고, 답안을 선택하면 바로 정답인지 오답인지 알 수 있다. 오답이라면 어떤 문항이 정답인지도 알 수 있다.

만약 문항을 선택하지 않고 '다음 문제로' 버튼을 누른다면 '문항을 선택해주세요'라는 알림이 뜨며 넘어갈 수 없다.


3. 결과 확인하기

마지막 문제에서는 '다음 문제로'라는 버튼 대신 '결과 보러 가기'버튼이 생긴다. 이 버튼을 누르면 결과 페이지로 이동할 수 있다.

결과 페이지에서는 아래와 같이 닉네임, 점수, 정답/오답 개수, 문제를 푸는데 소요된 시간 정보를 알 수 있다.

마지막에 있는 '다시 풀기' 버튼을 누르면 닉네임을 입력했던 페이지로 돌아갈 수 있다.



📜 구현 목록

최종적인 구현 목록은 아래와 같다.

목록 순서대로 기능을 구현했는데, 닉네임을 입력하는 페이지를 만들고, 문제 페이지를 만들고, 결과 페이지를 만들었다.

그리고 Chakra UI를 적용하여 데스크탑 버전의 레이아웃을 구성한 후 모바일 버전의 레이아웃도 추가했다.

  • ✅ 사용자는 닉네임을 입력하고 '퀴즈 풀기' 버튼을 클릭하여 문제 페이지로 넘어간다.
    • ✅ 닉네임을 상태로 저장하여 이후 결과 페이지에서 사용한다.
    • ✅ '퀴즈 풀기' 버튼을 초기에는 비활성화한다.
    • ✅ 입력받은 닉네임이 빈칸 혹은 공백이 아닐 때 '퀴즈 풀기' 버튼을 활성화한다.
  • ✅ 사용자는 문제를 풀 때 문항을 선택할 수 있다.
    • ✅ 4개 중 하나만 선택 가능하게 한다.
  • ✅ 사용자는 답안 선택 후 다음 문항으로 넘어갈 수 있다.
    • ✅ 다음 문항으로 넘어가는 버튼은 답안을 선택했을 때에만 기능한다.
    • ✅ 답안을 선택하지 않고 버튼을 누르면 답안을 선택하라는 메시지를 띄운다.
    • ✅ 마지막 문항에서는 다음 문항 버튼을 렌더링하지 않는다.
  • ✅ 답안이 맞았는지 틀렸는지는 문항 선택 후 바로 알 수 있다.
    • ✅ 답안 선택 후 별도의 제출 과정 없이 바로 결과를 렌더링한다.
    • ✅ 한번 클릭하면 다시 선택할 수 없다.
  • ✅ 모든 문항을 다 풀면 사용자는 결과 페이지에서 아래의 정보를 알 수 있다.
    • ✅ 입력한 닉네임
    • ✅ 점수
    • ✅ 정답 개수
    • ✅ 오답 수
    • ✅ 소요 시간
  • ✅ 반응형 레이아웃을 구현한다
    • ✅ 데스크탑
    • ✅ 모바일


💥 문제 해결

퀴즈앱을 만들며 겪은 문제와 이를 해결한 과정을 정리해보았다. 분명히 더 좋은 방법이 있겠지만, 여기서 사용한 방법 위주로 회고해보려고 한다.

💥 API에서 받아온 문제 문항 섞기

API에서 받아오는 문제 정보는 아래와 같다. 4개의 문제를 가져오고, 각각 질문/정답1개/오답3개 등의 정보를 받아온다. 하지만 이 정보를 그대로 화면에 렌더링하면 모든 문제의 정답은 1번이 될 것이다.

{"response_code":0,
  "results":[
    {"category":"Science: Computers","type":"multiple","difficulty":"easy",
       "question":"Which company was established on April 1st, 1976 by Steve Jobs, Steve Wozniak and Ronald Wayne?",
       "correct_answer":"Apple","incorrect_answers":["Microsoft","Atari","Commodore"]
    },
    {"category":"Science: Computers","type":"multiple","difficulty":"hard",
       "question":"Who is the original author of the realtime physics engine called PhysX?",
       "correct_answer":"NovodeX","incorrect_answers":["Ageia","Nvidia","AMD"]
    },
    {"category":"Science: Computers","type":"multiple","difficulty":"medium",
       "question":".at is the top-level domain for what country?",
       "correct_answer":"Austria","incorrect_answers":["Argentina","Australia","Angola"]
    },
    {"category":"Science: Computers","type":"multiple","difficulty":"medium",
       "question":"How many bits make up the significand portion of a single precision floating point number?",
       "correct_answer":"23","incorrect_answers":["8","53","15"]
    }
  ]
}

그래서 가져온 문항들을 random 함수를 사용하여 섞었다. 그리고 [질문, 섞은 문항들, 문항들 중 정답]을 backendData라는 상태로 저장했다.

const fetchData = async () => {
  setFetchStatus("loading");
  try {
    const response = await fetch(
      "https://opentdb.com/api.php?amount=4&category=18&type=multiple"
    );
    const data = await response.json();
    const newData = data.results.map((question) => {
      const answer = question.correct_answer;
      const random = [
        question.correct_answer,
        ...question.incorrect_answers,
      ].sort(() => Math.random() - 0.5);
      return {
        question: question.question,
        answer: answer,
        options: random,
      };
    });
    setBackendData(newData);
    setFetchStatus("loaded");
  } catch (error) {
    setFetchStatus("error");
  }
};

💥 답안 선택을 한번만 가능하도록 하기

아무 조건도 넣지 않으면 type이 radio인 input 태그는 여러번 선택할 수 있다.

하지만 점수를 내야 하기 때문에, 답안을 단 한번만 선택할 수 있게 하고 싶었다. 그래서 disabled라는 input 태그의 내장 속성을 추가했다.

const handleChange = (event) => {
  if (event.target.checked) {
    setUserAnswer([
      ...userAnswer,
      {
        question: backendData[currentQuestionIndex].question,
        answer: event.target.value,
        isCorrect: 
        	event.target.value === backendData[currentQuestionIndex].answer
      },
    ]);
  }
};

<input 
  onChange={handleChange}
  disabled={userAnswer.length !== currentQuestionIndex}
>
  • 문항을 선택하면 handleChange 함수가 실행된다.

  • handleChange 함수에는 선택한 문항에 대한 정보를 userAnswer에 누적한다. 만약 답이 4번 문항인 1번 문제에서 3번 문항을 선택했다면, userAnswer[{quesion: 1번 문제, answer: 3번 문항, isCorrect: false}]가 될 것이다.

  • 그러면 userAnswer 배열의 길이는 1이 되고, 현재 문항의 index(currentQuestionIndex)인 0과 달라져 disabled 속성이 false가 되고, 최종적으로 버튼이 비활성화된다.

  • 다음 문제로 넘어가면 현재 문항의 index(currentQuestionIndex)는 1 증가하여 userAnswer 배열의 길이와 같아진다. 따라서 disabled 속성이 true가 되어 문항을 선택할 수 있다. 그리고 어떤 문항을 선택하면 userAnswer 배열의 길이가 1 증가하여 disabled 속성이 true가 되고, 버튼이 비활성화된다.


💥 dangerouslySetInnerHTML

API에서 가져온 데이터를 그대로 렌더링하니, 아래와 같이 HTML Entity가 그대로 문자열 형태로 렌더링되는 문제가 발생했다.

그 이유는 예전 공식 문서에 따르면(최신 공식 문서에서 관련 내용을 찾는데 실패했다) 리액트가 Cross-site scripting(XSS) 공격을 방지하기 위해 렌더링 전에 모두 문자열으로 변경하기 때문이다.

그래서 dangerouslySetInnerHTML를 사용했는데, 이것은 innerHTML와 같은 기능을 수행한다. 그리고 &#039를 문자열이 아닌 HTML Entity로 인식하여 '로 렌더링할 수 있게 해준다.

<div dangerouslySetInnerHTML={{ __html: currentQuestion.question}}/>

하지만 innerHTML은 XSS 공격에 취약하기 때문에 리액트 공식 문서에서도 이를 안전한 상황에서만 사용할 것을 권고하고 있다.


💥 페이지 새로고침시 생기는 404 오류

분명히 로컬 환경에서는 페이지를 새로고침해도 아무런 문제가 없었는데, 배포한 환경에서 새로고침하면 404 오류가 발생하는 것을 발견했다.

검색해보니, 리액트가 SPA(Single Page Application)라서 생기는 오류라고 한다.

public 폴더 아래에 _redirects라는 파일 생성 후 /* /index.html 200를 적고 저장하여 해결했다.

영상을 보고 왜 이러한 오류가 발생했는지를 이해할 수 있었다🤩



🔮 개선하고 싶은 부분

  • 레이아웃을 개선하고 싶다. 예를 들면 문제가 길어지면 정답/오답 버튼과 다음 문제로 넘어가는 버튼의 위치가 아래로 내려오는데, 위치가 통일되도록 수정하고 싶다.

  • 서버에 결과를 저장하고 싶다. 그리고 소요 시간이나 점수를 기준으로 랭킹을 보여주고 싶다.

  • 문제 난이도를 선택할 수 있게 하고 싶다. (https://opentdb.com 에서 난이도에 따라 다른 API를 제공한다)

  • 지금까지 받은 데이터를 기반으로 문제마다 정답률을 보여주고 싶다.

  • 결과를 공유하는 기능을 만들고 싶다.



🐹 회고

이번에 Chakra UI를 처음 사용해봤다. 단기간에 퀴즈앱을 완성하고 싶었고, 그래서 CSS를 작성하는데 들이는 시간을 최소화하고 싶었다. 처음에는 Chakra UI를 이용하면 이 작업이 두시간이면 끝날 거라고 예상했는데, 필요한 기능을 찾아 문서를 뒤지다보니 5시간이 걸렸다🤣 그래도 이걸 사용하지 않았으면 훨씬 오래걸렸을 거라고 생각하니 아찔하다.

카카오톡으로 친구에게 링크를 공유 했는데, 이미지도 없고 제목도 없어서 예쁘지 않았다. 그래서 미리보기를 어떻게 변경할 수 있는지 찾아봤고 간단하게 meta 태그를 수정하여 아래와 같은 미리보기를 얻을 수 있었다😆 반영되기까지 몇시간을 기다려야 했는데, https://developers.kakao.com 에서 바로 반영되도록 할 수 있다고 해서 다음에는 사용해봐야겠다! 그리고 이 미리보기에 결과 보기 / 퀴즈 풀기와 같은 버튼을 만들 수 있다고 하여 그런 기능을 추가해보고 싶다.

나흘 동안 만들어 배포했고, 그 후 이틀은 피드백받은 기능을 추가하고 버그를 해결했다. 밀도있게 만들어 완성하고 배포까지 해서 뿌듯하다!!

profile
🐹강화하고 싶은 기억을 기록하고 공유하자🐹
post-custom-banner

2개의 댓글

comment-user-thumbnail
2023년 6월 23일

그.. 카카오톡이나 이런 데 미리보기 형식으로 보여주는 거는 오픈 그래프 라는 키워드를 통해 잘 찾아 보시면 도움이 되실 거에요.

참고) https://velog.io/@sweetpumpkin/Open-Graph-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

1개의 답글