HTML과 연결하기
게임 로직이 준비됐습니다. 이제 사용자가 보고 클릭할 수 있는 화면을 만들 차례입니다. index.html을 작성하고, src/app.ts에서 DOM을 TypeScript로 조작합니다.
index.html 작성
HTML은 최소한으로 유지합니다. 구조만 잡고, 내용은 TypeScript가 채웁니다.
<!-- 새 파일: index.html --><!DOCTYPE html><html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>TypeScript 퀴즈</title> <style> body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; } #quiz-container { display: block; } #result-container { display: none; } .choice-btn { display: block; width: 100%; padding: 10px 16px; margin: 8px 0; font-size: 1rem; cursor: pointer; border: 2px solid #ccc; border-radius: 6px; background: white; text-align: left; } .choice-btn.correct { border-color: #22c55e; background: #dcfce7; } .choice-btn.wrong { border-color: #ef4444; background: #fee2e2; } .choice-btn:disabled { cursor: default; } #next-btn { margin-top: 16px; padding: 10px 24px; font-size: 1rem; cursor: pointer; display: none; } </style> </head> <body> <div id="quiz-container"> <p id="progress">1 / 5</p> <h2 id="question-text">질문 텍스트</h2> <div id="choices"></div> <button id="next-btn">다음 문제</button> </div> <div id="result-container"> <h2>결과</h2> <p id="score-text"></p> <p id="grade-text"></p> <button id="restart-btn">다시 시작</button> </div> <script type="module" src="./dist/app.js"></script> </body></html>
<script type="module">을 쓰면 ES 모듈을 브라우저에서 직접 쓸 수 있습니다. tsc로 컴파일한 dist/app.js를 불러옵니다.
DOM 요소 가져오기 — 타입 단언
src/app.ts에서 DOM 요소를 가져올 때 타입 문제가 생깁니다. document.getElementById의 반환 타입은 HTMLElement | null입니다. 실제로 어떤 종류의 요소인지, 존재하는지 TypeScript는 알 수 없기 때문입니다.
// 새 파일: src/app.tsimport { QuizEngine } from "./engine";import type { QuizResult } from "./types";// DOM 요소 가져오기 — 타입 단언으로 구체적인 타입 지정function getElement<T extends HTMLElement>(id: string): T { const el = document.getElementById(id); if (!el) throw new Error(`요소를 찾을 수 없습니다: #${id}`); return el as T;}const quizContainer = getElement<HTMLDivElement>("quiz-container");const resultContainer = getElement<HTMLDivElement>("result-container");const progressEl = getElement<HTMLParagraphElement>("progress");const questionTextEl = getElement<HTMLHeadingElement>("question-text");const choicesEl = getElement<HTMLDivElement>("choices");const nextBtn = getElement<HTMLButtonElement>("next-btn");const scoreTextEl = getElement<HTMLParagraphElement>("score-text");const gradeTextEl = getElement<HTMLParagraphElement>("grade-text");const restartBtn = getElement<HTMLButtonElement>("restart-btn");
getElement<T> 제네릭 함수를 만든 이유가 두 가지입니다.
첫째, null 처리를 한 곳에서 합니다. 요소가 없으면 에러를 던져서 이후 코드에서 null 체크를 반복하지 않아도 됩니다.
둘째, 제네릭 타입 매개변수로 구체적인 타입을 지정합니다. getElement<HTMLButtonElement>("next-btn")으로 쓰면 nextBtn의 타입이 HTMLButtonElement가 됩니다. 그래서 nextBtn.disabled = true 같은 버튼 전용 속성을 에러 없이 쓸 수 있습니다.
화면 렌더링 함수
현재 상태를 받아서 화면을 업데이트하는 함수들을 만듭니다.
// 수정: src/app.tsimport { QuizEngine } from "./engine";import type { QuizResult } from "./types";function getElement<T extends HTMLElement>(id: string): T { const el = document.getElementById(id); if (!el) throw new Error(`요소를 찾을 수 없습니다: #${id}`); return el as T;}const quizContainer = getElement<HTMLDivElement>("quiz-container");const resultContainer = getElement<HTMLDivElement>("result-container");const progressEl = getElement<HTMLParagraphElement>("progress");const questionTextEl = getElement<HTMLHeadingElement>("question-text");const choicesEl = getElement<HTMLDivElement>("choices");const nextBtn = getElement<HTMLButtonElement>("next-btn");const scoreTextEl = getElement<HTMLParagraphElement>("score-text");const gradeTextEl = getElement<HTMLParagraphElement>("grade-text");const restartBtn = getElement<HTMLButtonElement>("restart-btn");const engine = new QuizEngine();// 퀴즈 화면 렌더링function renderQuestion(): void { const state = engine.getState(); const question = engine.getCurrentQuestion(); if (!question) return; const total = state.questions.length; const current = state.currentIndex + 1; // 진행 상황 표시 progressEl.textContent = `${current} / ${total}`; // 질문 텍스트 questionTextEl.textContent = question.text; // 선택지 버튼 생성 choicesEl.innerHTML = ""; question.choices.forEach((choice, index) => { const btn = document.createElement("button"); btn.className = "choice-btn"; btn.textContent = `${index + 1}. ${choice}`; btn.addEventListener("click", () => handleAnswer(index)); choicesEl.appendChild(btn); }); // 다음 버튼 숨기기 nextBtn.style.display = "none";}// 정답 선택 후 화면 업데이트function handleAnswer(selectedIndex: number): void { const question = engine.getCurrentQuestion(); if (!question) return; const isCorrect = engine.selectAnswer(selectedIndex); // 모든 버튼 비활성화 + 정답/오답 표시 const buttons = choicesEl.querySelectorAll<HTMLButtonElement>(".choice-btn"); buttons.forEach((btn, index) => { btn.disabled = true; if (index === question.answerIndex) { btn.classList.add("correct"); } else if (index === selectedIndex && !isCorrect) { btn.classList.add("wrong"); } }); // 다음 버튼 표시 const state = engine.getState(); const isLastQuestion = state.currentIndex + 1 >= state.questions.length; nextBtn.textContent = isLastQuestion ? "결과 보기" : "다음 문제"; nextBtn.style.display = "inline-block";}// 결과 화면 렌더링function renderResult(result: QuizResult): void { quizContainer.style.display = "none"; resultContainer.style.display = "block"; scoreTextEl.textContent = `${result.total}문제 중 ${result.score}문제 정답 (${result.percentage}%)`; const gradeMessages: Record<QuizResult["grade"], string> = { excellent: "훌륭합니다! TypeScript 마스터에 가까워지고 있습니다.", good: "잘 했습니다! 조금 더 연습하면 완벽해질 거예요.", average: "절반은 맞췄습니다. 틀린 문제를 복습해보세요.", poor: "다시 교재를 읽고 도전해보세요. 화이팅!" }; gradeTextEl.textContent = gradeMessages[result.grade];}
querySelectorAll<HTMLButtonElement>(".choice-btn")은 결과 타입이 NodeListOf<HTMLButtonElement>가 됩니다. 제네릭 타입을 넘기지 않으면 NodeListOf<Element>가 되어서 btn.disabled에 접근할 때 오류가 납니다.
Record<QuizResult["grade"], string>은 등급 메시지를 담는 객체의 타입입니다. 키가 반드시 "excellent" | "good" | "average" | "poor" 중 하나여야 하고, 값은 string입니다. 새 등급이 추가되면 이 객체에도 항목을 추가하지 않으면 컴파일 오류가 납니다.
이벤트 연결과 앱 시작
// 수정: src/app.tsimport { QuizEngine } from "./engine";import type { QuizResult } from "./types";function getElement<T extends HTMLElement>(id: string): T { const el = document.getElementById(id); if (!el) throw new Error(`요소를 찾을 수 없습니다: #${id}`); return el as T;}const quizContainer = getElement<HTMLDivElement>("quiz-container");const resultContainer = getElement<HTMLDivElement>("result-container");const progressEl = getElement<HTMLParagraphElement>("progress");const questionTextEl = getElement<HTMLHeadingElement>("question-text");const choicesEl = getElement<HTMLDivElement>("choices");const nextBtn = getElement<HTMLButtonElement>("next-btn");const scoreTextEl = getElement<HTMLParagraphElement>("score-text");const gradeTextEl = getElement<HTMLParagraphElement>("grade-text");const restartBtn = getElement<HTMLButtonElement>("restart-btn");const engine = new QuizEngine();function renderQuestion(): void { const state = engine.getState(); const question = engine.getCurrentQuestion(); if (!question) return; const total = state.questions.length; const current = state.currentIndex + 1; progressEl.textContent = `${current} / ${total}`; questionTextEl.textContent = question.text; choicesEl.innerHTML = ""; question.choices.forEach((choice, index) => { const btn = document.createElement("button"); btn.className = "choice-btn"; btn.textContent = `${index + 1}. ${choice}`; btn.addEventListener("click", () => handleAnswer(index)); choicesEl.appendChild(btn); }); nextBtn.style.display = "none";}function handleAnswer(selectedIndex: number): void { const question = engine.getCurrentQuestion(); if (!question) return; const isCorrect = engine.selectAnswer(selectedIndex); const buttons = choicesEl.querySelectorAll<HTMLButtonElement>(".choice-btn"); buttons.forEach((btn, index) => { btn.disabled = true; if (index === question.answerIndex) { btn.classList.add("correct"); } else if (index === selectedIndex && !isCorrect) { btn.classList.add("wrong"); } }); const state = engine.getState(); const isLastQuestion = state.currentIndex + 1 >= state.questions.length; nextBtn.textContent = isLastQuestion ? "결과 보기" : "다음 문제"; nextBtn.style.display = "inline-block";}function renderResult(result: QuizResult): void { quizContainer.style.display = "none"; resultContainer.style.display = "block"; scoreTextEl.textContent = `${result.total}문제 중 ${result.score}문제 정답 (${result.percentage}%)`; const gradeMessages: Record<QuizResult["grade"], string> = { excellent: "훌륭합니다! TypeScript 마스터에 가까워지고 있습니다.", good: "잘 했습니다! 조금 더 연습하면 완벽해질 거예요.", average: "절반은 맞췄습니다. 틀린 문제를 복습해보세요.", poor: "다시 교재를 읽고 도전해보세요. 화이팅!" }; gradeTextEl.textContent = gradeMessages[result.grade];}// 다음 문제 버튼nextBtn.addEventListener("click", () => { engine.nextQuestion(); const state = engine.getState(); if (state.phase.kind === "finished") { const result = engine.getResult(); if (result) renderResult(result); } else { renderQuestion(); }});// 다시 시작 버튼restartBtn.addEventListener("click", () => { engine.reset(); quizContainer.style.display = "block"; resultContainer.style.display = "none"; renderQuestion();});// 앱 시작renderQuestion();
앱의 흐름이 명확합니다.
- 페이지가 로드되면
renderQuestion()으로 첫 번째 문제를 표시합니다. - 선택지를 클릭하면
handleAnswer()가engine.selectAnswer()를 호출하고 화면에 정답/오답을 표시합니다. - 다음 버튼을 클릭하면
engine.nextQuestion()을 호출하고, 결과 단계면renderResult(), 아니면renderQuestion()을 호출합니다.
빌드하고 실행하기
터미널에서 빌드합니다.
npm run build
dist/ 폴더에 컴파일된 JavaScript 파일이 생깁니다. index.html을 브라우저에서 직접 열거나, 간단한 로컬 서버를 씁니다.
# Python으로 로컬 서버 실행python3 -m http.server 3000# 또는 Node.js 패키지 사용npx serve .
브라우저에서 http://localhost:3000을 열면 퀴즈 앱이 동작합니다.
다음 챕터에서는 코드 전체를 돌아보고 개선할 점을 찾겠습니다.