iBetter Books
수정

완성과 리팩터링

퀴즈 앱이 동작합니다. 이제 코드를 다시 읽으면서 더 나아질 수 있는 부분을 찾겠습니다. 리팩터링은 동작을 바꾸지 않고 코드의 구조를 개선하는 작업입니다.

타입 개선 — 더 정확하게

src/types.tsQuizState에서 selectedAnswers를 다시 봅니다.

selectedAnswers: (number | null)[];

이 배열은 항상 questions.length와 같은 크기여야 합니다. 하지만 타입만으로는 그 연결이 보이지 않습니다. 주석으로 보완합니다.

// 수정: src/types.tsexport interface Question {  id: number;  text: string;  choices: string[];  answerIndex: number;}export interface QuizState {  questions: Question[];  currentIndex: number;  /** questions 배열과 같은 길이. 미답변은 null, 답변했으면 선택한 인덱스 */  selectedAnswers: (number | null)[];  phase: QuizPhase;}export type QuizPhase =  | { kind: "playing" }  | { kind: "answered"; correct: boolean }  | { kind: "finished"; score: number; total: number };export interface QuizResult {  score: number;  total: number;  percentage: number;  grade: "excellent" | "good" | "average" | "poor";}// 편의 타입 — 외부에서 쓸 일이 없는 내부 상태를 읽기 전용으로export type ReadonlyQuizState = Readonly<QuizState>;

ReadonlyQuizState 타입을 추가했습니다. engine.getState()의 반환 타입으로 쓰면 타입 시그니처가 더 명확해집니다.

engine.ts 개선 — 경계 조건 보완

src/engine.tsselectAnswer에서 유효하지 않은 인덱스를 막는 코드를 추가합니다.

// 수정: src/engine.tsimport type { Question, QuizState, QuizResult, ReadonlyQuizState } from "./types";import { createInitialState } from "./data";export class QuizEngine {  private state: QuizState;  constructor() {    this.state = createInitialState();  }  getState(): ReadonlyQuizState {    return this.state;  }  getCurrentQuestion(): Question | null {    const { questions, currentIndex } = this.state;    if (currentIndex >= questions.length) return null;    return questions[currentIndex];  }  selectAnswer(selectedIndex: number): boolean {    const question = this.getCurrentQuestion();    if (!question) return false;    if (this.state.phase.kind !== "playing") return false;    // 유효한 선택지 범위 검사    if (selectedIndex < 0 || selectedIndex >= question.choices.length) {      return false;    }    const isCorrect = question.answerIndex === selectedIndex;    const newSelectedAnswers = [...this.state.selectedAnswers];    newSelectedAnswers[this.state.currentIndex] = selectedIndex;    this.state = {      ...this.state,      selectedAnswers: newSelectedAnswers,      phase: { kind: "answered", correct: isCorrect }    };    return isCorrect;  }  nextQuestion(): void {    if (this.state.phase.kind !== "answered") return;    const nextIndex = this.state.currentIndex + 1;    const isFinished = nextIndex >= this.state.questions.length;    if (isFinished) {      const score = this.calculateScore();      this.state = {        ...this.state,        currentIndex: nextIndex,        phase: { kind: "finished", score, total: this.state.questions.length }      };    } else {      this.state = {        ...this.state,        currentIndex: nextIndex,        phase: { kind: "playing" }      };    }  }  private calculateScore(): number {    return this.state.questions.reduce((score, question, index) => {      const selected = this.state.selectedAnswers[index];      return selected === question.answerIndex ? score + 1 : score;    }, 0);  }  getResult(): QuizResult | null {    const { phase, questions } = this.state;    if (phase.kind !== "finished") return null;    const { score, total } = phase;    const percentage = Math.round((score / total) * 100);    const grade = this.calculateGrade(percentage);    return { score, total, percentage, grade };  }  private calculateGrade(percentage: number): QuizResult["grade"] {    if (percentage >= 90) return "excellent";    if (percentage >= 70) return "good";    if (percentage >= 50) return "average";    return "poor";  }  reset(): void {    this.state = createInitialState();  }}

ReadonlyQuizState를 import해서 getState()의 반환 타입에 씁니다. 타입 선언과 구현이 한 방향으로 정렬됩니다.

이 프로젝트에서 사용한 TypeScript 패턴

퀴즈 앱을 만들면서 TypeScript의 여러 기능을 실전에서 썼습니다. 어떤 패턴을 어디에서 썼는지 정리합니다.

패턴 사용한 곳 효과
인터페이스 Question, QuizState, QuizResult 데이터 구조 명확화
판별 유니온 QuizPhase 단계별 데이터를 타입으로 구분
제네릭 함수 getElement<T>() 반환 타입을 호출 시 결정
유틸리티 타입 Readonly<T>, Record<K, V> 상태 보호, 완전한 매핑 보장
인덱스 타입 QuizResult["grade"] 타입 재사용, 변경에 자동 추적
타입 좁히기 if (phase.kind === "finished") 단계별 안전한 속성 접근
import type 타입 전용 import 런타임 번들에 타입 포함 방지

각 패턴이 "편의"가 아니라 "안전"을 위해 쓰였다는 점을 기억합니다. 판별 유니온 덕분에 잘못된 단계에서 잘못된 속성에 접근하는 버그가 컴파일 시점에 잡힙니다. Readonly<T> 덕분에 상태를 외부에서 직접 수정하는 실수가 방지됩니다.

다음 단계로 나아갈 수 있는 것들

이 앱을 기반으로 확장해볼 수 있는 아이디어입니다.

타입을 활용한 확장

  • 질문에 difficulty: "easy" | "medium" | "hard" 필드 추가
  • 오답 노트: QuizResult에 틀린 문제 목록 추가
  • 타이머: QuizStatetimeLimit: number 추가

기능 확장

  • localStorage에 점수 기록 저장
  • 질문 순서 섞기 (data.tscreateInitialState에서 shuffle 적용)
  • 카테고리별 퀴즈 (Questioncategory: string 추가)

이런 확장을 할 때도 방법은 동일합니다. 먼저 타입을 수정하고, 컴파일러가 가리키는 곳을 따라가며 코드를 채웁니다.


PART 08이 끝났습니다.

타입 설계에서 시작해서 데이터 모델링, 게임 로직, DOM 연결, 리팩터링까지 완전한 프로젝트 사이클을 한 번 돌았습니다. 프레임워크 없이 TypeScript만으로도 브라우저에서 동작하는 앱을 만들 수 있다는 것을 직접 확인했습니다.

다음 PART 09에서는 지금까지 배운 내용을 되돌아보고, TypeScript 학습의 다음 단계로 무엇을 공부하면 좋을지 안내합니다.