마지막 퀘스트: 작고 재미있는 TypeScript 프로젝트 완성하기
TypeScript는 문법 설명을 읽을 때보다, 직접 손으로 무언가를 만들 때 훨씬 빨리 자기 것이 된다. 그래서 많은 학습 자료가 작은 프로젝트를 꾸준히 만들어 보라고 권한다.1 초보자용 TypeScript 프로젝트 아이디어를 모아 놓은 자료를 봐도 할 일 앱, 날씨 앱, 퀴즈 앱처럼 작지만 완성감 있는 앱이 반복해서 등장한다.23
이번 챕터에서는 지금까지 배운 내용을 한데 모아 작은 실전 프로젝트를 완성해 본다. 이름하여 모험가 입단 시험. 사용자는 문제를 풀고, 점수를 확인하고, 다시 도전할 수 있다. 크기는 작지만 안에는 TypeScript의 핵심이 촘촘히 들어 있다.
이번 챕터에서 만들 프로젝트
우리가 만들 앱은 이야기 톤이 살짝 들어간 퀴즈 앱이다. 기능은 단순하지만, 배운 내용을 연결하기에는 아주 좋다.
문제 데이터를 비동기로 불러온다.
각 문제와 선택지의 타입을 직접 설계한다.
함수로 상태를 바꾸고 점수를 계산한다.
잘못된 데이터가 들어오지 않도록 타입 가드를 사용한다.
strict 설정과 간단한 테스트까지 챙긴다.
즉, 이 프로젝트는 작은 놀이터이면서 동시에 종합 시험장이다. 여기서 중요한 것은 화려한 화면이 아니라, 안전하게 읽히고 고치기 쉬운 코드를 만드는 경험이다.
먼저 그림부터 그려 보자
앱의 흐름은 이렇다.
시작 버튼을 누른다.
questions.json파일을fetch로 읽어 온다.문제를 하나씩 보여 준다.
사용자가 답을 고르면 점수를 계산한다.
마지막 문제까지 끝나면 결과 메시지를 보여 준다.
이 흐름이 좋은 이유는 아주 분명하다. 데이터가 있고, 화면이 있고, 사용자 입력이 있고, 비동기 작업도 있다. 다시 말해, 지금까지 배운 거의 모든 조각을 한 번씩 다 써 볼 수 있다.
프로젝트 준비
이번 프로젝트는 가장 가볍게 시작하기 위해 Vite + 바닐라 TypeScript 조합으로 진행하겠다.
npm create vite@latest ts-adventure-quiz -- --template vanilla-ts
cd ts-adventure-quiz
npm install
npm run dev파일 구조는 이렇게 잡아 두면 깔끔하다.
public/
questions.json
src/
main.ts
types.ts
quizService.ts
quizEngine.ts
style.css그리고 tsconfig.json에서는 strict 모드를 켜 두자.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}이 설정은 처음엔 조금 까다롭게 느껴질 수 있다. 하지만 작은 프로젝트일수록 이런 설정을 켜 두면 습관이 좋아진다. 실수는 빨리 들키고, 코드는 더 또렷해진다.
타입부터 먼저 설계하자
초보자가 자주 하는 실수는 화면부터 만들고 타입은 나중에 붙이는 것이다. 그런데 TypeScript 프로젝트에서는 오히려 반대로 가는 편이 편하다. 데이터의 모양을 먼저 정하면, 나머지 코드가 훨씬 덜 흔들린다.
src/types.ts를 만들어 보자.
export type Difficulty = 'easy' | 'normal' | 'hard';
export type ChoiceId = 'A' | 'B' | 'C' | 'D';
export type QuizStatus = 'idle' | 'playing' | 'finished';
export interface Choice {
id: ChoiceId;
text: string;
}
export interface Question {
id: number;
prompt: string;
difficulty: Difficulty;
choices: Choice[];
answer: ChoiceId;
explanation: string;
}
export interface QuizState {
currentIndex: number;
score: number;
questions: Question[];
answers: Partial<Record<number, ChoiceId>>;
status: QuizStatus;
selectedChoiceId: ChoiceId | null;
}여기에는 지금까지 배운 개념이 꽤 많이 숨어 있다.
Difficulty, ChoiceId, QuizStatus는 리터럴 유니언 타입이다. 값의 후보를 좁혀 두면 오타가 들어갈 틈이 확 줄어든다. 예를 들어 'eazy' 같은 실수는 바로 잡힌다.
Question과 QuizState는 객체 설계의 핵심이다. 프로그램이 다루는 세계를 문장 대신 타입으로 설명한 셈이다. 이 시점부터 우리 앱은 대충 돌아가는 코드가 아니라, 규칙을 가진 작은 시스템이 된다.
JSON 데이터도 그냥 믿지 말자
이제 문제 데이터를 준비하자. public/questions.json에 이런 식으로 넣으면 된다.
[
{
"id": 1,
"prompt": "TypeScript에서 async 함수의 반환 타입은 무엇일까요?",
"difficulty": "easy",
"choices": [
{ "id": "A", "text": "boolean" },
{ "id": "B", "text": "string" },
{ "id": "C", "text": "Promise<T>" },
{ "id": "D", "text": "never" }
],
"answer": "C",
"explanation": "async 함수는 항상 Promise를 반환합니다."
},
{
"id": 2,
"prompt": "interface가 특히 빛나는 순간은 언제일까요?",
"difficulty": "easy",
"choices": [
{ "id": "A", "text": "객체 구조를 설명할 때" },
{ "id": "B", "text": "반복문을 대신할 때" },
{ "id": "C", "text": "CSS를 작성할 때" },
{ "id": "D", "text": "이미지를 압축할 때" }
],
"answer": "A",
"explanation": "interface는 객체의 모양을 설명하는 데 특히 잘 어울립니다."
}
]중요한 포인트가 하나 있다. TypeScript 타입은 컴파일 시점에만 도움을 준다. 즉, fetch로 읽어 온 JSON이 정말 Question[]인지 런타임에서는 자동으로 검사되지 않는다. 그래서 한 번쯤은 직접 확인해 줘야 한다.
src/quizService.ts를 보자.
import type { Choice, ChoiceId, Difficulty, Question } from './types';
const validChoiceIds: ChoiceId[] = ['A', 'B', 'C', 'D'];
const validDifficulties: Difficulty[] = ['easy', 'normal', 'hard'];
function isChoice(value: unknown): value is Choice {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === 'string' &&
validChoiceIds.includes(candidate.id as ChoiceId) &&
typeof candidate.text === 'string'
);
}
function isQuestion(value: unknown): value is Question {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === 'number' &&
typeof candidate.prompt === 'string' &&
typeof candidate.difficulty === 'string' &&
validDifficulties.includes(candidate.difficulty as Difficulty) &&
Array.isArray(candidate.choices) &&
candidate.choices.every(isChoice) &&
typeof candidate.answer === 'string' &&
validChoiceIds.includes(candidate.answer as ChoiceId) &&
typeof candidate.explanation === 'string'
);
}
export async function loadQuestions(): Promise<Question[]> {
const response = await fetch('/questions.json');
if (!response.ok) {
throw new Error('문제 파일을 불러오지 못했습니다.');
}
const data: unknown = await response.json();
if (!Array.isArray(data) || !data.every(isQuestion)) {
throw new Error('문제 데이터 형식이 올바르지 않습니다.');
}
return data;
}여기서 아주 좋은 습관 두 가지가 보인다.
첫째, response.json() 결과를 곧바로 Question[]라고 우기지 않고 unknown으로 받았다. 이것은 데이터가 아직 검증되지 않았다는 뜻이다.
둘째, 타입 가드 함수 isQuestion으로 실제 구조를 검사했다. 이 한 단계를 넣는 순간, 비동기 코드와 타입 시스템이 손을 잡는다.
그리고 async 함수는 항상 Promise를 반환하므로, 반환 타입을 Promise<Question[]>처럼 명시해 두면 읽는 사람도 덜 헷갈린다.4
퀴즈 엔진 만들기
이제 앱의 핵심 로직을 함수로 묶어 보자. 화면 코드와 점수 계산 코드를 뒤섞으면 금방 복잡해진다. 반대로 로직을 분리해 두면 테스트도 쉬워지고, 버그도 덜 숨어든다.
src/quizEngine.ts는 이렇게 만들 수 있다.
import type { ChoiceId, Question, QuizState } from './types';
export function shuffle<T>(items: T[]): T[] {
const result = [...items];
for (let i = result.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function createInitialState(questions: Question[]): QuizState {
return {
currentIndex: 0,
score: 0,
questions: shuffle(questions),
answers: {},
status: 'playing',
selectedChoiceId: null
};
}
export function getCurrentQuestion(state: QuizState): Question | undefined {
return state.questions[state.currentIndex];
}
export function submitAnswer(state: QuizState, choiceId: ChoiceId): QuizState {
const question = getCurrentQuestion(state);
if (!question || state.selectedChoiceId !== null) {
return state;
}
return {
...state,
score: question.answer === choiceId ? state.score + 1 : state.score,
answers: {
...state.answers,
[question.id]: choiceId
},
selectedChoiceId: choiceId
};
}
export function goToNextQuestion(state: QuizState): QuizState {
const nextIndex = state.currentIndex + 1;
const isFinished = nextIndex >= state.questions.length;
if (isFinished) {
return {
...state,
status: 'finished',
selectedChoiceId: null
};
}
return {
...state,
currentIndex: nextIndex,
selectedChoiceId: null
};
}
export function getResultMessage(score: number, total: number): string {
const rate = total === 0 ? 0 : score / total;
if (rate === 1) {
return '전설의 타입 마법사입니다.';
}
if (rate >= 0.7) {
return '훌륭합니다. 이제 실전에서도 든든합니다.';
}
if (rate >= 0.4) {
return '좋아요. 몇 문제만 더 풀면 레벨 업입니다.';
}
return '괜찮습니다. 타입은 도망가지 않으니 다시 도전하면 됩니다.';
}이 파일에서 특히 눈여겨볼 부분은 shuffle<T>다. 제네릭 하나만 들어갔을 뿐인데, 숫자 배열이든 문제 배열이든 똑같은 함수를 재사용할 수 있다. 제네릭이란 결국 이런 것이다. 형태는 유지하고, 실제 타입은 나중에 채워 넣는 기술.
그리고 셔플 구현도 눈여겨볼 만하다. 짧게 쓰겠다고 sort(() => Math.random() - 0.5)를 쓰는 예제를 종종 보지만, 그 방식은 순서가 고르게 섞이지 않을 수 있다. 여기서는 한 칸씩 자리를 바꿔 가는 방식으로 더 믿을 만한 셔플을 썼다.
또한 submitAnswer와 goToNextQuestion은 상태를 직접 마구 바꾸지 않고, 새 객체를 만들어 돌려준다. 이런 스타일은 디버깅할 때도 유리하고, 나중에 React 같은 프레임워크로 옮겨 가도 감각이 그대로 이어진다.
화면과 연결하기
이제 드디어 사용자가 만지는 부분을 만든다. src/main.ts는 화면을 그리는 파일이다.
import './style.css';
import { createInitialState, getCurrentQuestion, getResultMessage, goToNextQuestion, submitAnswer } from './quizEngine';
import { loadQuestions } from './quizService';
import type { ChoiceId, QuizState } from './types';
const validChoiceIds: ChoiceId[] = ['A', 'B', 'C', 'D'];
function isChoiceId(value: string): value is ChoiceId {
return validChoiceIds.includes(value as ChoiceId);
}
const app = document.querySelector<HTMLDivElement>('#app');
if (!app) {
throw new Error('#app 요소를 찾지 못했습니다.');
}
let state: QuizState = {
currentIndex: 0,
score: 0,
questions: [],
answers: {},
status: 'idle',
selectedChoiceId: null
};
function bindStartButton(): void {
const startButton = document.querySelector<HTMLButtonElement>('#start-button');
startButton?.addEventListener('click', startGame);
}
function bindNextButton(): void {
const nextButton = document.querySelector<HTMLButtonElement>('#next-button');
nextButton?.addEventListener('click', () => {
state = goToNextQuestion(state);
render();
});
}
function bindChoiceButtons(): void {
document.querySelectorAll<HTMLButtonElement>('[data-choice-id]').forEach((button) => {
button.addEventListener('click', () => {
const rawChoiceId = button.dataset.choiceId;
if (!rawChoiceId || !isChoiceId(rawChoiceId)) {
return;
}
state = submitAnswer(state, rawChoiceId);
render();
});
});
}
async function startGame(): Promise<void> {
app.innerHTML = '<p>문제를 불러오는 중...</p>';
try {
const questions = await loadQuestions();
state = createInitialState(questions);
render();
} catch (error) {
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
app.innerHTML = `<p>${message}</p><button id='start-button'>다시 시도</button>`;
bindStartButton();
}
}
function render(): void {
if (state.status === 'idle') {
app.innerHTML = `
<h1>🛡️ 모험가 입단 시험</h1>
<p>타입 왕국의 문은 안전한 코드로만 열립니다.</p>
<button id='start-button'>시험 시작</button>
`;
bindStartButton();
return;
}
if (state.status === 'finished') {
app.innerHTML = `
<h1>🎉 결과 발표</h1>
<p>${state.score} / ${state.questions.length}</p>
<p>${getResultMessage(state.score, state.questions.length)}</p>
<button id='start-button'>다시 도전</button>
`;
bindStartButton();
return;
}
const question = getCurrentQuestion(state);
if (!question) {
app.innerHTML = '<p>문제를 찾을 수 없습니다.</p>';
return;
}
const explanation =
state.selectedChoiceId !== null
? `<p><strong>해설:</strong> ${question.explanation}</p>`
: '';
app.innerHTML = `
<h1>🧩 모험가 입단 시험</h1>
<p>${state.currentIndex + 1} / ${state.questions.length} | 점수 ${state.score}</p>
<h2>${question.prompt}</h2>
<div class='choices'>
${question.choices
.map((choice) => {
const isCorrect = state.selectedChoiceId !== null && choice.id === question.answer;
const isWrong = state.selectedChoiceId === choice.id && choice.id !== question.answer;
const className = isCorrect ? 'correct' : isWrong ? 'wrong' : '';
const disabled = state.selectedChoiceId !== null ? 'disabled' : '';
return `<button class='${className}' data-choice-id='${choice.id}' ${disabled}>
${choice.id}. ${choice.text}
</button>`;
})
.join('')}
</div>
${explanation}
<button id='next-button' ${state.selectedChoiceId === null ? 'hidden' : ''}>다음 문제</button>
`;
bindChoiceButtons();
bindNextButton();
}
render();이 코드는 길어 보여도 실제로는 세 가지 일만 한다.
첫 번째, 상태에 따라 어떤 화면을 보여 줄지 결정한다.
두 번째, 버튼 클릭 이벤트를 연결한다.
세 번째, 사용자의 행동이 일어나면 순수 함수인 submitAnswer, goToNextQuestion으로 상태를 바꾸고 다시 그린다.
이 구조가 중요한 이유는 역할이 분명하기 때문이다. 화면은 화면답게, 로직은 로직답게 분리되어 있다.
그리고 button.dataset.choiceId as ChoiceId처럼 바로 단언하지 않고 isChoiceId()로 한 번 확인한 점도 눈여겨볼 만하다. 작은 프로젝트라도 이런 습관이 쌓이면, "일단 우기기"보다 "확인하고 쓰기"가 몸에 붙는다.
스타일은 가볍게, 피드백은 분명하게
프로젝트를 완성도 있게 보이게 만드는 것은 거대한 디자인보다 명확한 피드백이다. 정답은 초록색, 오답은 빨간색처럼 딱 보이게 만들면 사용자는 훨씬 편하다.
예를 들어 style.css는 이렇게 시작할 수 있다.
body {
font-family: Pretendard, sans-serif;
background: #10131a;
color: #f5f7fb;
display: grid;
place-items: center;
min-height: 100vh;
margin: 0;
}
#app {
width: min(640px, 92vw);
background: #1a2030;
border-radius: 16px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
button {
margin-top: 12px;
padding: 12px 16px;
border: 0;
border-radius: 10px;
cursor: pointer;
}
.choices {
display: grid;
gap: 10px;
}
.correct {
background: #2e7d32;
color: white;
}
.wrong {
background: #c62828;
color: white;
}멋진 애니메이션이 없어도 괜찮다. 사용자가 지금 무엇을 했고, 프로그램이 어떻게 반응했는지가 잘 보이면 이미 좋은 앱이다.
실전에서 중요한 건 오류 처리다
작은 프로젝트를 만들다 보면 성공 흐름만 생각하기 쉽다. 하지만 현실의 프로그램은 늘 순순하지 않다. 파일이 없을 수도 있고, JSON 구조가 틀릴 수도 있고, 버튼이 예상보다 빨리 두 번 눌릴 수도 있다.
이번 프로젝트에서는 그런 상황을 몇 가지 방식으로 막았다.
fetch실패 시 에러를 던진다.JSON 데이터는
unknown으로 받고 타입 가드로 확인한다.이미 답을 고른 뒤에는 다시 점수가 올라가지 않게 막는다.
DOM 요소가 없으면 즉시 에러를 던져 문제를 빨리 드러낸다.
dataset값도 바로 믿지 않고 한 번 더 확인한다.
이런 방어 코드가 바로 TypeScript의 진짜 친구다. 타입은 문을 잠그는 자물쇠이고, 오류 처리는 창문까지 점검하는 일에 가깝다.
테스트를 딱 하나만 써도 달라진다
챕터 11에서 배운 좋은 습관을 여기서 다시 꺼내 보자. 로직이 분리되어 있다면 테스트는 생각보다 어렵지 않다.
예를 들어 결과 메시지를 검사하는 테스트 하나만 있어도 마음이 꽤 편해진다.
npm i -D vitestsrc/quizEngine.test.ts:
import { describe, expect, it } from 'vitest';
import { getResultMessage } from './quizEngine';
describe('getResultMessage', () => {
it('만점일 때 전설 메시지를 반환한다', () => {
expect(getResultMessage(5, 5)).toContain('전설');
});
});이 테스트는 대단한 일을 하지는 않는다. 하지만 프로젝트가 조금씩 커질수록 이런 작은 확인들이 큰 안심으로 바뀐다.
여기서 한 단계 더 가고 싶다면
지금 만든 앱은 이미 완성형에 가깝다. 하지만 재미는 보통 완성 뒤에 더 커진다. 이런 확장 퀘스트도 도전해 볼 수 있다.
localStorage를 붙여 최고 점수를 저장하기문제를 난이도별로 필터링하기
제한 시간을 넣어 더 긴장감 있게 만들기
퀴즈 문제와 랭킹 데이터를
Promise.all로 함께 불러오기
특히 마지막 확장은 비동기 감각을 키우는 데 좋다. 여러 작업을 순서대로 기다리기보다 함께 시작하고 한 번에 받는 방식은 실전에서 자주 쓰인다.4
이 프로젝트에서 배운 것을 다시 묶어 보자
이번 챕터는 사실 새로운 문법을 많이 소개한 챕터가 아니다. 대신, 흩어져 있던 것을 한 줄로 꿰는 챕터였다.
타입은 데이터의 모양을 설명했고, 함수는 상태를 바꾸는 규칙이 되었고, 객체는 앱의 세계를 표현했다. 비동기는 외부 데이터를 가져오는 통로가 되었고, strict 설정은 실수를 초기에 잡아 주었다. 테스트는 마지막 안전망이 되었다.
이쯤 오면 TypeScript는 더 이상 머릿속 이론이 아니다. 버튼을 누르면 화면이 바뀌고, JSON을 고치면 타입 오류가 나고, 잘못된 데이터를 넣으면 코드가 막아 주는 경험이 이미 쌓였다. 그것이 바로 내 기술이 되는 순간이다.
마치며
책의 마지막 퀘스트답게, 이번 프로젝트는 작지만 많은 것을 담고 있다. 그리고 더 중요한 사실이 하나 있다. 이제부터는 무엇을 만들든 시작할 수 있다는 점이다.
할 일 앱을 만들어도 되고, 날씨 앱을 만들어도 되고, 개인 가계부를 만들어도 된다. 중요한 것은 거창한 아이디어가 아니라, 타입을 먼저 생각하고, 작은 기능을 끝까지 완성해 보는 경험이다.
TypeScript를 배웠다는 말은 문법을 읽었다는 뜻이 아니다. 작은 프로그램을 스스로 설계하고, 안전하게 만들고, 끝까지 완성해 봤다는 뜻에 더 가깝다.
그리고 이제, 정말로 그 출발선에 서 있다.
참고
2Best TypeScript Project Ideas for Beginners
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.