JS 8.3 · 8부. 종합 프로젝트

퀴즈 앱

객체 배열과 상태 관리, 타이머를 다룹니다.

예상 학습 시간 25분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 실전
요구사항

무엇을 만드나

문제를 하나씩 내고, 답을 고르면 다음으로 넘어가고, 끝나면 점수를 보여 주는 화면을 만듭니다.

화면에 있을 것

  • 몇 번째 문제인지와 남은 시간
  • 문제와 보기 단추들
  • 끝났을 때의 점수 화면

지켜야 할 규칙

  • 한 문제에 20초를 주고, 넘기면 틀린 것으로 넘어갑니다.
  • 고른 뒤에는 다시 고를 수 없습니다.
  • 마지막 문제를 마치면 점수 화면으로 바뀝니다.

이 프로젝트의 핵심은 상태를 한곳에 모으는 것입니다. 지금 몇 번째인지, 몇 개 맞혔는지, 이미 골랐는지를 흩어 두면 금방 어긋납니다.

단계별 구현

1단계 — 문제와 상태를 정합니다

script.js
const QUESTIONS = [
	{
		text: "배열의 첫 값을 꺼내는 번호는?",
		choices: ["0", "1", "-1"],
		answer: 0
	},
	{
		text: "값과 타입을 함께 비교하는 것은?",
		choices: ["==", "===", "="],
		answer: 1
	}
];

// 지금 상태를 한 객체에 모읍니다
const state = {
	index: 0,
	score: 0,
	answered: false
};

console.log(QUESTIONS.length);
console.log(QUESTIONS[state.index].text);
console.log(QUESTIONS[state.index].choices.length);
출력
2 배열의 첫 값을 꺼내는 번호는? 3

answer보기의 번호입니다. 글자로 견주면 보기 문구를 고칠 때마다 답도 고쳐야 합니다.

단계별 구현

2단계 — 문제 하나 그리기

script.js
const QUESTIONS = [
	{ text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 },
	{ text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 }
];

const state = { index: 0, score: 0, answered: false };

const progress = document.querySelector("#progress");
const question = document.querySelector("#question");
const choices = document.querySelector("#choices");

function render() {
	const current = QUESTIONS[state.index];

	progress.textContent = `${state.index + 1} / ${QUESTIONS.length}`;
	question.textContent = current.text;

	choices.replaceChildren();

	current.choices.forEach((choice, i) => {
		const btn = document.createElement("button");
		btn.textContent = choice;
		btn.dataset.index = i;
		choices.append(btn);
	});
}

render();

console.log(progress.textContent);
console.log(choices.children.length);
출력
1 / 2 3

8.1 과 같은 방식입니다 — 상태를 보고 화면을 그리는 함수 하나를 두고, 바뀔 때마다 그것만 다시 부릅니다.

단계별 구현

3단계 — 답을 고르고 넘어가기

script.js
const QUESTIONS = [
	{ text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 },
	{ text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 }
];

const state = { index: 0, score: 0, answered: false };

const question = document.querySelector("#question");
const choices = document.querySelector("#choices");
const result = document.querySelector("#result");

function render() {
	const current = QUESTIONS[state.index];

	question.textContent = current.text;
	choices.replaceChildren();

	current.choices.forEach((choice, i) => {
		const btn = document.createElement("button");
		btn.textContent = choice;
		btn.dataset.index = i;
		choices.append(btn);
	});
}

function answer(picked) {
	if (state.answered) return;
	state.answered = true;

	const current = QUESTIONS[state.index];
	const correct = picked === current.answer;

	if (correct) state.score++;

	choices.children[picked].classList.add(correct ? "right" : "wrong");
	result.textContent = correct ? "맞았습니다" : "틀렸습니다";
}

choices.addEventListener("click", event => {
	const btn = event.target.closest("button");
	if (!btn) return;

	answer(Number(btn.dataset.index));
});

render();

// 첫 보기를 두 번 눌러 봅니다
choices.children[0].click();
choices.children[0].click();

console.log(state.score);
console.log(state.answered);
console.log(result.textContent);
출력
1 true 맞았습니다

두 번 눌렀는데 점수는 1 입니다. state.answered 를 맨 앞에서 확인해 두 번째는 그 자리에서 끝냈습니다(7.3).

단계별 구현

4단계 — 시간 재기

setInterval 로 1초마다 줄이고, 0이 되면 넘깁니다. 넘어갈 때 반드시 멈춰야 합니다 — 안 그러면 여러 개가 겹쳐 돕니다(5.1).

script.js
const timer = document.querySelector("#timer");

let left = 3;
let ticking = null;

function stop() {
	if (ticking !== null) {
		clearInterval(ticking);
		ticking = null;
	}
}

function start() {
	stop();   // 먼저 멈춰야 겹치지 않습니다

	left = 3;
	timer.textContent = `${left}초`;

	ticking = setInterval(() => {
		left--;
		timer.textContent = `${left}초`;

		if (left <= 0) {
			stop();
			timer.textContent = "시간이 끝났습니다";
		}
	}, 100);
}

start();

// 끝날 때까지 기다렸다가 확인합니다
await new Promise(resolve => setTimeout(resolve, 500));

console.log(timer.textContent);
console.log(ticking);
출력
시간이 끝났습니다 null

연습이라 100 밀리초로 줄였습니다. 실제로는 1000 을 넣습니다.

tickingnull 이라는 것은 멈췄다는 뜻입니다. 담아 두지 않으면 멈출 방법이 없습니다.

완성 코드

모두 합치기

script.js
const QUESTIONS = [
	{ text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 },
	{ text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 },
	{ text: "코드를 한 번만 실행하려면?", choices: ["setInterval", "setTimeout", "for"], answer: 1 }
];

const SECONDS = 20;

const quiz = document.querySelector("#quiz");
const progress = document.querySelector("#progress");
const timer = document.querySelector("#timer");
const question = document.querySelector("#question");
const choices = document.querySelector("#choices");
const score = document.querySelector("#score");

const state = { index: 0, score: 0, answered: false, left: SECONDS };

let ticking = null;

function stopTimer() {
	if (ticking !== null) {
		clearInterval(ticking);
		ticking = null;
	}
}

function startTimer() {
	stopTimer();

	state.left = SECONDS;
	timer.textContent = `${state.left}초`;

	ticking = setInterval(() => {
		state.left--;
		timer.textContent = `${state.left}초`;

		if (state.left <= 0) next();
	}, 1000);
}

function render() {
	const current = QUESTIONS[state.index];

	state.answered = false;

	progress.textContent = `${state.index + 1} / ${QUESTIONS.length}`;
	question.textContent = current.text;

	choices.replaceChildren();

	current.choices.forEach((choice, i) => {
		const btn = document.createElement("button");
		btn.textContent = choice;
		btn.dataset.index = i;
		choices.append(btn);
	});

	startTimer();
}

function finish() {
	stopTimer();

	quiz.classList.add("hidden");
	score.classList.remove("hidden");
	score.textContent = `${QUESTIONS.length}문제 가운데 ${state.score}개를 맞혔습니다`;
}

function next() {
	stopTimer();

	if (state.index >= QUESTIONS.length - 1) {
		finish();
		return;
	}

	state.index++;
	render();
}

choices.addEventListener("click", event => {
	const btn = event.target.closest("button");
	if (!btn || state.answered) return;

	state.answered = true;
	stopTimer();

	const picked = Number(btn.dataset.index);
	const correct = picked === QUESTIONS[state.index].answer;

	if (correct) state.score++;
	btn.classList.add(correct ? "right" : "wrong");

	setTimeout(next, 600);
});

render();

// 확인용으로 첫 문제의 정답을 고릅니다
choices.children[0].click();

console.log(state.score);
console.log(state.answered);
출력
1 true

stopTimer세 곳에서 부릅니다 — 답을 골랐을 때, 다음으로 넘어갈 때, 끝났을 때. 한 곳이라도 빠뜨리면 타이머가 겹쳐 점수가 어긋납니다.

미리보기 탭에서 끝까지 풀어 보세요. 답을 고르면 0.6초 뒤 다음 문제로 넘어갑니다.

확장 과제

더 해 볼 것

1. 문제 차례 섞기 난이도 하

시작할 때 문제 차례를 무작위로 섞으세요.

slice() 로 복사한 뒤 sort(() => Math.random() - 0.5) 로 섞습니다(3.5). 원본을 바꾸지 않는 것이 중요합니다.
2. 최고 점수 남기기 난이도 중

지금까지의 최고 점수를 남기고 점수 화면에 함께 보여 주세요.

localStorage 에 담습니다(7.4). 꺼낸 값은 글자이므로 Number 로 바꿔 견주고, 처음이면 0 으로 둡니다.
3. 틀린 문제만 다시 풀기 난이도 상

끝난 뒤 틀린 문제만 모아 다시 푸는 단추를 두세요.

고른 답을 문제마다 담아 두어야 합니다 — state.picked 배열을 두고 번호를 넣습니다. 끝나면 filter 로 틀린 것만 모아 새 문제 목록으로 다시 시작합니다(3.3).
요약

이 프로젝트에서 배운 것

  • 지금 상태를 한 객체에 모읍니다. 흩어 두면 금방 어긋납니다.
  • 정답은 글자가 아니라 번호로 담습니다.
  • 같은 일이 두 번 일어나지 않게 맨 앞에서 걸러 냅니다.
  • 타이머는 담아 두어야 멈출 수 있습니다. 넘어갈 때마다 먼저 멈춥니다.
  • 화면 전환은 class 를 켜고 끄는 것으로 합니다(4.4).