완성과 리팩터링
퀴즈 앱이 동작합니다. 이제 코드를 다시 읽으면서 더 나아질 수 있는 부분을 찾겠습니다. 리팩터링은 동작을 바꾸지 않고 코드의 구조를 개선하는 작업입니다.
타입 개선 — 더 정확하게
src/types.ts의 QuizState에서 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.ts의 selectAnswer에서 유효하지 않은 인덱스를 막는 코드를 추가합니다.
// 수정: 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에 틀린 문제 목록 추가 - 타이머:
QuizState에timeLimit: number추가
기능 확장
localStorage에 점수 기록 저장- 질문 순서 섞기 (
data.ts의createInitialState에서 shuffle 적용) - 카테고리별 퀴즈 (
Question에category: string추가)
이런 확장을 할 때도 방법은 동일합니다. 먼저 타입을 수정하고, 컴파일러가 가리키는 곳을 따라가며 코드를 채웁니다.
PART 08이 끝났습니다.
타입 설계에서 시작해서 데이터 모델링, 게임 로직, DOM 연결, 리팩터링까지 완전한 프로젝트 사이클을 한 번 돌았습니다. 프레임워크 없이 TypeScript만으로도 브라우저에서 동작하는 앱을 만들 수 있다는 것을 직접 확인했습니다.
다음 PART 09에서는 지금까지 배운 내용을 되돌아보고, TypeScript 학습의 다음 단계로 무엇을 공부하면 좋을지 안내합니다.