iBetter Books
수정

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();

앱의 흐름이 명확합니다.

  1. 페이지가 로드되면 renderQuestion()으로 첫 번째 문제를 표시합니다.
  2. 선택지를 클릭하면 handleAnswer()engine.selectAnswer()를 호출하고 화면에 정답/오답을 표시합니다.
  3. 다음 버튼을 클릭하면 engine.nextQuestion()을 호출하고, 결과 단계면 renderResult(), 아니면 renderQuestion()을 호출합니다.

빌드하고 실행하기

터미널에서 빌드합니다.

npm run build

dist/ 폴더에 컴파일된 JavaScript 파일이 생깁니다. index.html을 브라우저에서 직접 열거나, 간단한 로컬 서버를 씁니다.

# Python으로 로컬 서버 실행python3 -m http.server 3000# 또는 Node.js 패키지 사용npx serve .

브라우저에서 http://localhost:3000을 열면 퀴즈 앱이 동작합니다.

다음 챕터에서는 코드 전체를 돌아보고 개선할 점을 찾겠습니다.