이번 편의 결과물: 객체 타입이던 Expense가 interface로 바뀌고, 화면의 입력 폼에서 새 거래를 추가하면 목록에 바로 반영됩니다. · 다루는 개념: interface, type 별칭, extends로 확장, readonly 프로퍼티
04편까지 expenseStore.ts는 addExpense로 만든 거래 3건을 getExpenses()로 가져와 한 번만 화면에 그렸습니다. 아직 사용자가 직접 거래를 입력할 방법은 없습니다. 이 편에서는 Expense를 type에서 interface로 바꾸고, 화면에 입력 폼을 추가해 실제로 거래를 늘려갈 수 있게 만듭니다.
이 편에서 만드는 파일
expense-tracker/src/
├── models/
│ └── expense.ts (~, type Expense → interface Expense extends BaseRecord)
└── main.ts (~, 입력 폼과 다시 그리기 함수로 재구성)개념 정리
interface와 type 별칭
객체 모양을 표현하는 방법은 두 가지입니다. 지금까지 Expense는 type 별칭으로 정의했습니다.
| 기준 | interface | type 별칭 |
|---|---|---|
| 확장 | extends로 다른 interface를 확장 | 교차 타입(&)으로 합성 |
| 선언 병합 | 같은 이름으로 여러 번 선언하면 자동으로 합쳐짐 | 병합 안 됨(중복 선언 시 오류) |
| 유니온·튜플 표현 | 불가능 | 가능(A 또는 B, 이름표 붙은 배열 등) |
| 객체 모양 표현 | 가능 | 가능 |
거래처럼 앞으로 계속 넓혀갈 도메인 모델은 interface로, 함수 하나에서만 쓰는 임시 모양은 type으로 정의하는 편이 실무에서 흔한 관례입니다. expenseStore.ts의 NewExpenseInput은 폼 입력 전용 모양이라 그대로 type으로 남겨두고, 도메인 모델인 Expense만 interface로 바꿉니다.
extends로 확장하기
거래 레코드는 공통으로 id와 date를 가집니다. 공통 필드를 BaseRecord interface로 뽑고 Expense가 이를 extends합니다.
interface BaseRecord {
readonly id: number
date: string
}
interface Expense extends BaseRecord {
category: string
amount: number
memo: string
}Expense는 BaseRecord의 모든 프로퍼티를 그대로 가지면서 자기 프로퍼티를 추가합니다. 뒤에서 다른 종류의 레코드가 생기면 같은 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.ts의 addExpense는 { id: nextId, date, amount, category, memo } 객체를 그대로 만들어 반환하므로, Expense가 type에서 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.target은 EventTarget 타입이라 as HTMLFormElement로 실제 폼이라고 단언합니다. 더 안전하게 좁히는 방법은 06편에서 다룹니다.
3. 실행
npm run dev확인
- 브라우저에서 폼에 날짜·카테고리·금액·메모를 입력하고 “추가”를 누르면 목록 맨 아래에 새 항목이 나타납니다.
- 제출 후 화면이 다시 그려지면서 폼 입력값이 비워집니다.
- 편집기에서
main.ts에getExpenses()[0].id = 99를 임시로 추가하면 빨간 밑줄과 함께 오류가 표시됩니다(확인 후 지웁니다).
직접 해보기
BaseRecord에readonly createdAt: string프로퍼티를 추가하고,expenseStore.ts의addExpense에서new Date().toISOString()으로 채워 보세요.Expense에 없는 프로퍼티(tag)를main.ts의 seedaddExpense(...)호출에 넣어보고 어떤 오류 메시지가 나오는지 확인하세요.
정답 보기
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에 이벤트를 위임한다 |