Skip to Content
WebTypeScriptTypeScript05. interface와 type 별칭으로 모델 다듬기

이번 편의 결과물: 객체 타입이던 Expenseinterface로 바뀌고, 화면의 입력 폼에서 새 거래를 추가하면 목록에 바로 반영됩니다. · 다루는 개념: interface, type 별칭, extends로 확장, readonly 프로퍼티

04편까지 expenseStore.tsaddExpense로 만든 거래 3건을 getExpenses()로 가져와 한 번만 화면에 그렸습니다. 아직 사용자가 직접 거래를 입력할 방법은 없습니다. 이 편에서는 Expensetype에서 interface로 바꾸고, 화면에 입력 폼을 추가해 실제로 거래를 늘려갈 수 있게 만듭니다.

이 편에서 만드는 파일

expense-tracker/src/ ├── models/ │ └── expense.ts (~, type Expense → interface Expense extends BaseRecord) └── main.ts (~, 입력 폼과 다시 그리기 함수로 재구성)

개념 정리

interface와 type 별칭

객체 모양을 표현하는 방법은 두 가지입니다. 지금까지 Expensetype 별칭으로 정의했습니다.

기준interfacetype 별칭
확장extends로 다른 interface를 확장교차 타입(&)으로 합성
선언 병합같은 이름으로 여러 번 선언하면 자동으로 합쳐짐병합 안 됨(중복 선언 시 오류)
유니온·튜플 표현불가능가능(A 또는 B, 이름표 붙은 배열 등)
객체 모양 표현가능가능

거래처럼 앞으로 계속 넓혀갈 도메인 모델은 interface로, 함수 하나에서만 쓰는 임시 모양은 type으로 정의하는 편이 실무에서 흔한 관례입니다. expenseStore.tsNewExpenseInput은 폼 입력 전용 모양이라 그대로 type으로 남겨두고, 도메인 모델인 Expenseinterface로 바꿉니다.

extends로 확장하기

거래 레코드는 공통으로 iddate를 가집니다. 공통 필드를 BaseRecord interface로 뽑고 Expense가 이를 extends합니다.

interface BaseRecord { readonly id: number date: string } interface Expense extends BaseRecord { category: string amount: number memo: string }

ExpenseBaseRecord의 모든 프로퍼티를 그대로 가지면서 자기 프로퍼티를 추가합니다. 뒤에서 다른 종류의 레코드가 생기면 같은 BaseRecord를 재사용할 수 있습니다.

readonly 프로퍼티

id는 한 번 정해지면 바뀌면 안 되는 값입니다. readonly를 붙이면 생성 시점 이후에는 값을 다시 대입할 수 없다는 것을 컴파일러가 검사합니다. 런타임 검사가 아니라 컴파일 타임에만 막는다는 점에 주의합니다.

실습

1. models/expense.ts를 interface로 교체

// expense-tracker/src/models/expense.ts export const CURRENCY: 'KRW' = 'KRW' export const CATEGORIES: string[] = ['식비', '교통', '문화', '주거', '기타'] export interface BaseRecord { readonly id: number date: string } export interface Expense extends BaseRecord { category: string amount: number memo: string }

expenseStore.tsaddExpense{ id: nextId, date, amount, category, memo } 객체를 그대로 만들어 반환하므로, Expensetype에서 interface로 바뀌어도 expenseStore.ts는 고칠 필요가 없습니다. 구조가 같으면 어느 쪽으로 선언해도 대입할 수 있기 때문입니다.

2. main.ts에 입력 폼과 다시 그리기 함수 작성

// expense-tracker/src/main.ts import './style.css' import { CATEGORIES, CURRENCY, Expense } from './models/expense.ts' import { addExpense, getExpenses } from './services/expenseStore.ts' addExpense({ date: '2026-09-18', amount: 12000, category: '식비', memo: '점심 식사' }) addExpense({ date: '2026-09-19', amount: 4500, category: '교통' }) addExpense({ date: '2026-09-20', amount: 30000 }) function renderExpenseList(list: Expense[]): string { return list .map((expense) => { const amountText = `${expense.amount.toLocaleString()}${CURRENCY}` const memoText = expense.memo || '메모 없음' return `<li>#${expense.id} · ${expense.date} · ${expense.category} · ${amountText} · ${memoText}</li>` }) .join('') } function renderCategoryOptions(): string { return CATEGORIES.map((category) => `<option value="${category}">${category}</option>`).join('') } function render(): void { const app = document.querySelector<HTMLDivElement>('#app') if (!app) return app.innerHTML = ` <main> <h1>가계부</h1> <form id="expense-form"> <label for="date">날짜</label> <input id="date" name="date" type="date" required /> <label for="category">카테고리</label> <select id="category" name="category">${renderCategoryOptions()}</select> <label for="amount">금액</label> <input id="amount" name="amount" type="number" required /> <label for="memo">메모</label> <input id="memo" name="memo" type="text" /> <button type="submit">추가</button> </form> <ul>${renderExpenseList(getExpenses())}</ul> </main> ` } function handleSubmit(event: SubmitEvent): void { event.preventDefault() const form = event.target as HTMLFormElement const formData = new FormData(form) addExpense({ date: String(formData.get('date')), amount: Number(formData.get('amount')), category: String(formData.get('category')), memo: String(formData.get('memo')), }) render() } const app = document.querySelector<HTMLDivElement>('#app') app?.addEventListener('submit', handleSubmit) render()

render()app.innerHTML을 통째로 다시 만들기 때문에, 폼 제출 이벤트 리스너를 폼 자체가 아니라 절대 다시 그려지지 않는 #app에 걸어둡니다. render()가 몇 번을 다시 실행되어도 #app 노드는 그대로라서 리스너를 매번 다시 등록할 필요가 없습니다. event.targetEventTarget 타입이라 as HTMLFormElement로 실제 폼이라고 단언합니다. 더 안전하게 좁히는 방법은 06편에서 다룹니다.

3. 실행

npm run dev

확인

  • 브라우저에서 폼에 날짜·카테고리·금액·메모를 입력하고 “추가”를 누르면 목록 맨 아래에 새 항목이 나타납니다.
  • 제출 후 화면이 다시 그려지면서 폼 입력값이 비워집니다.
  • 편집기에서 main.tsgetExpenses()[0].id = 99를 임시로 추가하면 빨간 밑줄과 함께 오류가 표시됩니다(확인 후 지웁니다).

직접 해보기

  1. BaseRecordreadonly createdAt: string 프로퍼티를 추가하고, expenseStore.tsaddExpense에서 new Date().toISOString()으로 채워 보세요.
  2. Expense에 없는 프로퍼티(tag)를 main.ts의 seed addExpense(...) 호출에 넣어보고 어떤 오류 메시지가 나오는지 확인하세요.

정답 보기

export interface BaseRecord { readonly id: number readonly createdAt: string date: string }
export function addExpense({ date, amount, category = '기타', memo = '' }: NewExpenseInput): Expense { const expense: Expense = { id: nextId, createdAt: new Date().toISOString(), date, amount, category, memo } expenses.push(expense) nextId += 1 return expense }

2번은 Expense 타입에 없는 tag 프로퍼티를 addExpense 호출의 객체 리터럴에 그대로 넣으면 “개체 리터럴은 알려진 속성만 지정할 수 있으며 tag은(는) 형식에 없습니다” 계열의 오류가 납니다. 변수에 담지 않고 직접 만드는 객체 리터럴에만 적용되는 엄격한 검사입니다.

자주 하는 실수

증상원인고치는 법
readonly 프로퍼티에 값을 다시 대입하려다 오류생성 이후 id 등을 변경하려 함새 객체를 만들어 교체하고 기존 객체는 그대로 둔다
interface끼리 이름이 겹쳐 프로퍼티가 이상하게 합쳐짐같은 이름의 interface를 실수로 두 번 선언(선언 병합)이름을 다르게 짓거나 의도한 병합인지 확인한다
다시 그린 뒤 폼에 이벤트가 안 먹힘폼 요소 자체에 리스너를 걸어 innerHTML 재작성 시 사라짐절대 다시 그려지지 않는 #app에 이벤트를 위임한다

확인 문제

문제 14지선다
interface와 type 별칭의 차이로 옳은 것은
문제 24지선다
Expense가 BaseRecord를 extends하는 이유로 가장 알맞은 것은
문제 34지선다
readonly로 선언한 프로퍼티에 대한 설명으로 옳은 것은
문제 44지선다
render 함수가 폼이 아니라 app에 이벤트 리스너를 등록하는 이유는

참고 자료

Last updated on