이번 편의 결과물: 카테고리별 합계 계산 과정이 콘솔에 기록되고, 이번 달 수입·지출·순액 요약이 화면에 표시됩니다. · 다루는 개념: ReturnType, Parameters, Readonly, 매핑된 타입과 템플릿 리터럴 타입 소개
이 편에서 만드는 파일
expense-tracker/src/
├── services/
│ └── stats.ts (~)
└── main.ts (~)개념 정리
왜 필요한가
calculateCategoryTotals의 반환 타입을 다른 함수의 매개변수 타입에도 써야 할 때, 그 타입을 다시 손으로 적으면 함수 구현이 바뀔 때마다 두 곳을 같이 고쳐야 합니다. ReturnType으로 함수가 실제로 반환하는 타입을 그대로 가져와 쓰면 한 곳만 고치면 됩니다. 월별 요약 객체는 계산이 끝난 뒤 값이 바뀌면 안 되므로, Readonly로 실수로 다시 쓰는 것을 막습니다.
ReturnType과 Parameters
| 유틸리티 타입 | 의미 |
|---|---|
ReturnType<typeof 함수> | 함수가 반환하는 값의 타입을 그대로 가져옴 |
Parameters<typeof 함수> | 함수의 매개변수 타입들을 튜플로 가져옴 |
두 타입을 함께 쓰면 어떤 함수든 그대로 감싸는 함수를 만들 수 있습니다. 뒤에서 만들 logAndCompute는 Parameters로 원래 함수의 인자를 그대로 받고, ReturnType으로 원래 함수의 반환 타입을 그대로 돌려줍니다.
Readonly와 런타임 동결
Readonly<T>는 컴파일 타임에만 프로퍼티 재할당을 막습니다. 실제로 객체를 바꿀 수 없게 만들려면 Object.freeze를 함께 호출해야 합니다. 월별 요약 계산 함수는 두 가지를 같이 적용해, 타입 검사와 런타임 모두에서 값을 되쓰는 코드를 막습니다.
매핑된 타입 기초와 템플릿 리터럴 타입
Partial, Readonly, Record 같은 유틸리티 타입은 모두 매핑된 타입(mapped type)으로 만들어져 있습니다. 기본 문법은 다음과 같습니다.
type Flags<T> = { [K in keyof T]: boolean }in keyof T는 “T의 모든 키를 하나씩 순회한다”는 뜻입니다. 템플릿 리터럴 타입은 문자열 리터럴을 조합해 새 문자열 타입을 만듭니다.
type TransactionEvent = `${'created' | 'updated' | 'deleted'}Transaction`이 타입은 createdTransaction, updatedTransaction, deletedTransaction 세 문자열만 값으로 허용합니다. 두 문법 모두 다음 편(모듈 분리) 이후 필요할 때 직접 응용할 수 있도록 이 편에서는 소개만 하고, expense-tracker 코드에는 아직 적용하지 않습니다.
실습
1. 통계 파일 열기
expense-tracker/src/services/stats.ts를 엽니다.
2. 코드 작성
// expense-tracker/src/services/stats.ts
import type { Transaction, ExpenseCategory } from '../models/expense.ts'
const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
export function calculateCategoryTotals(transactions: Transaction[]) {
const totals = EXPENSE_CATEGORIES.reduce((acc, category) => {
acc[category] = 0
return acc
}, {} as Record<ExpenseCategory, number>)
for (const transaction of transactions) {
if (transaction.type === 'expense') {
totals[transaction.category] += transaction.amount
}
}
return totals
}
export type CategoryTotals = ReturnType<typeof calculateCategoryTotals>
export interface MonthlySummary {
month: string
incomeTotal: number
expenseTotal: number
netTotal: number
}
export function calculateMonthlySummary(transactions: Transaction[], month: string): Readonly<MonthlySummary> {
const monthlyTransactions = transactions.filter((transaction) => transaction.date.startsWith(month))
const incomeTotal = monthlyTransactions
.filter((transaction) => transaction.type === 'income')
.reduce((sum, transaction) => sum + transaction.amount, 0)
const expenseTotal = monthlyTransactions
.filter((transaction) => transaction.type === 'expense')
.reduce((sum, transaction) => sum + transaction.amount, 0)
const summary: MonthlySummary = {
month,
incomeTotal,
expenseTotal,
netTotal: incomeTotal - expenseTotal,
}
return Object.freeze(summary)
}
export function logAndCompute<F extends (...args: any[]) => any>(fn: F, ...args: Parameters<F>): ReturnType<F> {
console.log(`${fn.name} 계산을 시작합니다.`)
const result = fn(...args) as ReturnType<F>
console.log(`${fn.name} 계산을 완료했습니다.`)
return result
}calculateCategoryTotals는 이제 반환 타입을 직접 적지 않고 추론에 맡겼습니다. CategoryTotals는 그 추론 결과를 ReturnType으로 그대로 옮겨 담은 타입 별칭입니다. logAndCompute의 F extends (...args: any[]) => any는 “어떤 함수든 받는다”는 뜻의 제네릭 제약이며, 함수를 감싸는 유틸리티에서 흔히 쓰는 형태입니다.
이제 main.ts에서 합계를 구할 때 이 함수들을 가져다 쓰고, 이번 달 요약 패널을 추가합니다.
// expense-tracker/src/main.ts
import type { Transaction, ExpenseCategory, IncomeCategory } from './models/expense.ts'
import { sortBy, filterBy } from './lib/arrayUtils.ts'
import { Storage } from './services/storage.ts'
import { calculateCategoryTotals, calculateMonthlySummary, logAndCompute } from './services/stats.ts'
import type { CategoryTotals } from './services/stats.ts'
type EditableFields = Pick<Transaction, 'date' | 'amount' | 'memo'>
const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
const INCOME_CATEGORIES: IncomeCategory[] = ['salary', 'bonus', 'interest', 'etc-income']
const CATEGORY_LABELS: Record<ExpenseCategory, string> = {
food: '식비',
transport: '교통비',
housing: '주거비',
shopping: '쇼핑',
'etc-expense': '기타 지출',
}
const storage = new Storage<Transaction>('expense-tracker:transactions')
let transactions: Transaction[] = storage.load()
let editingId: string | null = null
const app = document.querySelector<HTMLDivElement>('#app')!
app.innerHTML = `
<h1>expense-tracker</h1>
<form id="transaction-form">
<select name="type" id="type-select">
<option value="expense">지출</option>
<option value="income">수입</option>
</select>
<select name="category" id="category-select"></select>
<input name="date" type="date" required />
<input name="amount" type="number" required />
<input name="memo" type="text" placeholder="메모" />
<button type="submit">추가</button>
</form>
<form id="edit-form" hidden>
<p>거래 수정</p>
<input name="date" type="date" required />
<input name="amount" type="number" required />
<input name="memo" type="text" placeholder="메모" />
<button type="submit">저장</button>
<button type="button" id="edit-cancel">취소</button>
</form>
<div>
<label>종류 필터
<select id="filter-type">
<option value="all">전체</option>
<option value="expense">지출</option>
<option value="income">수입</option>
</select>
</label>
<label>정렬
<select id="sort-key">
<option value="date">날짜</option>
<option value="amount">금액</option>
</select>
</label>
</div>
<ul id="transaction-list"></ul>
<section>
<h2>카테고리별 지출 합계</h2>
<ul id="category-totals"></ul>
</section>
<section>
<h2>이번 달 요약</h2>
<p id="monthly-summary"></p>
</section>
`
const form = document.querySelector<HTMLFormElement>('#transaction-form')!
const typeSelect = document.querySelector<HTMLSelectElement>('#type-select')!
const categorySelect = document.querySelector<HTMLSelectElement>('#category-select')!
const filterSelect = document.querySelector<HTMLSelectElement>('#filter-type')!
const sortSelect = document.querySelector<HTMLSelectElement>('#sort-key')!
const listEl = document.querySelector<HTMLUListElement>('#transaction-list')!
const totalsEl = document.querySelector<HTMLUListElement>('#category-totals')!
const summaryEl = document.querySelector<HTMLParagraphElement>('#monthly-summary')!
const editForm = document.querySelector<HTMLFormElement>('#edit-form')!
const editCancelButton = document.querySelector<HTMLButtonElement>('#edit-cancel')!
function categoryOptionsHtml(type: Transaction['type']): string {
const categories = type === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES
return categories.map((category) => `<option value="${category}">${category}</option>`).join('')
}
function refreshCategoryOptions(): void {
categorySelect.innerHTML = categoryOptionsHtml(typeSelect.value as Transaction['type'])
}
function totalsToListItems(totals: CategoryTotals): string {
return EXPENSE_CATEGORIES.map(
(category) => `<li>${CATEGORY_LABELS[category]}: ${totals[category].toLocaleString()}원</li>`,
).join('')
}
function renderTotals(): void {
const totals = logAndCompute(calculateCategoryTotals, transactions)
totalsEl.innerHTML = totalsToListItems(totals)
}
function currentMonth(): string {
return new Date().toISOString().slice(0, 7)
}
function renderSummary(): void {
const summary = calculateMonthlySummary(transactions, currentMonth())
summaryEl.textContent = `수입 ${summary.incomeTotal.toLocaleString()}원 · 지출 ${summary.expenseTotal.toLocaleString()}원 · 순액 ${summary.netTotal.toLocaleString()}원`
}
function render(): void {
const filterValue = filterSelect.value as Transaction['type'] | 'all'
const sortValue = sortSelect.value as 'date' | 'amount'
const filtered = filterValue === 'all' ? transactions : filterBy(transactions, (t) => t.type === filterValue)
const sorted = sortBy(filtered, sortValue)
listEl.innerHTML = sorted
.map(
(t) => `
<li data-id="${t.id}">
<span>${t.date}</span>
<span>${t.type === 'expense' ? '지출' : '수입'}</span>
<span>${t.category}</span>
<span>${t.amount.toLocaleString()}원</span>
<span>${t.memo}</span>
<button type="button" data-action="edit" data-id="${t.id}">수정</button>
<button type="button" data-action="remove" data-id="${t.id}">삭제</button>
</li>`,
)
.join('')
renderTotals()
renderSummary()
}
function createId(): string {
return crypto.randomUUID()
}
function handleSubmit(event: SubmitEvent): void {
event.preventDefault()
const formData = new FormData(form)
const type = formData.get('type') as Transaction['type']
const date = String(formData.get('date'))
const amount = Number(formData.get('amount'))
const memo = String(formData.get('memo'))
const transaction: Transaction =
type === 'expense'
? { id: createId(), type: 'expense', date, amount, memo, category: formData.get('category') as ExpenseCategory }
: { id: createId(), type: 'income', date, amount, memo, category: formData.get('category') as IncomeCategory }
transactions.push(transaction)
storage.save(transactions)
form.reset()
refreshCategoryOptions()
render()
}
function openEditForm(transaction: Transaction): void {
editingId = transaction.id
const dateInput = editForm.elements.namedItem('date') as HTMLInputElement
const amountInput = editForm.elements.namedItem('amount') as HTMLInputElement
const memoInput = editForm.elements.namedItem('memo') as HTMLInputElement
dateInput.value = transaction.date
amountInput.value = String(transaction.amount)
memoInput.value = transaction.memo
editForm.hidden = false
}
function closeEditForm(): void {
editingId = null
editForm.hidden = true
editForm.reset()
}
function handleEditSubmit(event: SubmitEvent): void {
event.preventDefault()
if (editingId === null) {
return
}
const formData = new FormData(editForm)
const changes: Partial<EditableFields> = {
date: String(formData.get('date')),
amount: Number(formData.get('amount')),
memo: String(formData.get('memo')),
}
transactions = transactions.map((t) => (t.id === editingId ? { ...t, ...changes } : t))
storage.save(transactions)
closeEditForm()
render()
}
function handleListClick(event: MouseEvent): void {
const target = event.target as HTMLElement
const removeButton = target.closest<HTMLButtonElement>('[data-action="remove"]')
if (removeButton !== null) {
const id = removeButton.dataset.id!
transactions = transactions.filter((t) => t.id !== id)
storage.save(transactions)
render()
return
}
const editButton = target.closest<HTMLButtonElement>('[data-action="edit"]')
if (editButton !== null) {
const id = editButton.dataset.id!
const found = transactions.find((t) => t.id === id)
if (found !== undefined) {
openEditForm(found)
}
}
}
form.addEventListener('submit', handleSubmit)
typeSelect.addEventListener('change', refreshCategoryOptions)
filterSelect.addEventListener('change', render)
sortSelect.addEventListener('change', render)
listEl.addEventListener('click', handleListClick)
editForm.addEventListener('submit', handleEditSubmit)
editCancelButton.addEventListener('click', closeEditForm)
refreshCategoryOptions()
render()totalsToListItems의 매개변수 타입 CategoryTotals는 stats.ts에서 ReturnType으로 만든 타입을 그대로 가져온 것입니다. calculateCategoryTotals의 구현이 바뀌어 반환 모양이 달라져도 이 타입은 자동으로 따라 바뀝니다.
3. 실행
npm run dev4. 확인
- 화면에 “이번 달 요약” 문구가 수입, 지출, 순액 순서로 표시됩니다.
- 개발자 도구 콘솔에 거래를 추가할 때마다 “calculateCategoryTotals 계산을 시작합니다.”와 “완료했습니다.” 로그가 함께 찍힙니다.
calculateMonthlySummary가 반환한 객체는Object.freeze로 동결되어 있어, 임의로 값을 바꾸는 코드를 추가하면 타입 오류가 납니다.
직접 해보기
renderSummary안에서summary.incomeTotal = 0처럼 값을 직접 바꾸는 코드를 한 줄 추가해 보고, 편집기가 어떤 오류를 보여주는지 확인한 뒤 지웁니다.Flags<MonthlySummary>타입을stats.ts어딘가에 실제로 선언해 보고, 그 타입이 어떤 프로퍼티들로 이루어지는지 편집기에서 확인합니다.
정답 보기
1번은 “Cannot assign to incomeTotal because it is a read-only property”류의 컴파일 오류가 발생합니다. Readonly로 선언한 프로퍼티에 값을 대입하려 했기 때문입니다.
2번의 Flags<MonthlySummary>는 month, incomeTotal, expenseTotal, netTotal 네 프로퍼티가 모두 boolean인 타입이 됩니다. 원래 값의 타입은 무시되고 키 이름만 그대로 옮겨졌기 때문입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| Readonly를 붙였는데도 런타임에 값이 바뀜 | Readonly는 컴파일 타임 검사일 뿐 런타임 보호가 아님 | Object.freeze를 함께 호출한다 |
| ReturnType 사용 시 타입 오류 | typeof 없이 함수 이름만 타입 자리에 씀 | 값(함수)을 타입으로 쓰려면 반드시 typeof를 붙인다 |
| logAndCompute 호출 시 인자 타입 오류 | Parameters로 받은 인자 순서와 실제로 넘긴 인자 순서가 다름 | 원래 함수의 매개변수 순서 그대로 전달한다 |