이번 편의 결과물: calculateCategoryTotals, calculateMonthlySummary 함수가 StatsCalculator 클래스의 메서드로 리팩터링되고, 내부 캐시 필드가 은닉됩니다. · 다루는 개념: class 필드/메서드, private/protected/readonly, implements, 생성자 매개변수 프로퍼티
11편까지 calculateCategoryTotals, calculateMonthlySummary는 services/stats.ts의 독립된 함수였습니다. 호출할 때마다 거래 배열을 다시 넘겼고, 같은 달 요약을 여러 번 구하면 매번 배열을 다시 훑었습니다. 이 편에서는 두 함수를 StatsCalculator 클래스의 메서드로 묶고, 월별 요약 결과를 캐시하는 필드를 추가합니다.
이 편에서 만드는 파일
expense-tracker/src/
├── services/
│ ├── stats.ts ~ (calculateCategoryTotals, calculateMonthlySummary → StatsCalculator 클래스)
│ └── index.ts ~ (StatsCalculator를 배럴에 반영)
└── ui/
└── statsPanel.ts ~ (StatsCalculator 인스턴스 사용)개념 정리
| 키워드 | 의미 |
|---|---|
private | TypeScript 컴파일 타임에만 검사되는 접근 제한. 컴파일된 JS에는 흔적이 남지 않는다 |
#필드 | JavaScript 자체의 진짜 비공개 필드. 09편의 Storage에서도 #key로 이미 썼다 |
protected | 선언한 클래스와 이를 상속한 하위 클래스에서 접근 가능 |
readonly | 값이 한 번 정해지면 이후 재할당 불가 |
implements | 클래스가 인터페이스에 정의된 메서드·프로퍼티 시그니처를 모두 갖췄는지 컴파일 타임에 검사 |
TypeScript의 private은 컴파일 후 사라지는 표시일 뿐이라 컴파일된 JavaScript 코드에서는 여전히 값에 접근할 수 있습니다. 반면 #으로 시작하는 필드는 JavaScript 언어 자체의 비공개 필드라 컴파일 후에도 클래스 밖에서는 절대 접근할 수 없습니다. Storage가 #key를 쓴 이유도 같고, 이 편의 캐시 필드에도 #을 씁니다.
implements는 클래스 선언이 특정 인터페이스의 형태를 따르도록 강제합니다. Storage<T> implements DataStore<T>(09편)에서 이미 한 번 썼습니다. extends와 달리 구현을 물려주지 않고 형태만 검사합니다.
생성자 매개변수 앞에 private, protected, public, readonly 중 하나를 붙이면 그 매개변수는 자동으로 같은 이름의 클래스 필드로 선언되고, 생성자 안에서 따로 대입하지 않아도 값이 채워집니다. StatsCalculator는 생성자 매개변수가 없어 이 문법이 등장하지 않지만, 형태만 먼저 봐 둡니다. 15편에서 예산 경고 규칙 클래스를 만들 때 그대로 씁니다.
class Notifier {
constructor(protected readonly channel: string) {}
notify(text: string): string {
return `[${this.channel}] ${text}`
}
}constructor(protected readonly channel: string)는 매개변수 하나로 필드 선언과 대입을 동시에 처리합니다. channel은 protected라 Notifier를 상속한 클래스에서는 접근할 수 있지만 클래스 밖에서는 접근할 수 없습니다.
실습
1. 함수 두 개를 StatsCalculator 클래스로 통합
// expense-tracker/src/services/stats.ts
import type { Transaction, ExpenseCategory } from '../models'
const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
export interface MonthlySummary {
month: string
incomeTotal: number
expenseTotal: number
netTotal: number
}
interface StatsSource {
categoryTotals(transactions: Transaction[]): Record<ExpenseCategory, number>
monthlySummary(transactions: Transaction[], month: string): Readonly<MonthlySummary>
}
export class StatsCalculator implements StatsSource {
#cache = new Map<string, Readonly<MonthlySummary>>()
categoryTotals(transactions: Transaction[]): Record<ExpenseCategory, number> {
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
}
monthlySummary(transactions: Transaction[], month: string): Readonly<MonthlySummary> {
const cached = this.#cache.get(month)
if (cached) {
return cached
}
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 = Object.freeze({ month, incomeTotal, expenseTotal, netTotal: incomeTotal - expenseTotal })
this.#cache.set(month, summary)
return summary
}
clearCache(): void {
this.#cache.clear()
}
}
export type CategoryTotals = ReturnType<StatsCalculator['categoryTotals']>
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
}categoryTotals는 지출 카테고리만 골라 합산합니다(11편과 동일한 계산). monthlySummary는 같은 월을 다시 요청하면 #cache에 저장해 둔 값을 그대로 돌려주고, 처음 요청한 월만 실제로 배열을 훑어 계산합니다. StatsSource 인터페이스는 두 메서드의 시그니처를 미리 정의해 두고, implements로 StatsCalculator가 이를 그대로 따르는지 컴파일 타임에 검사합니다. CategoryTotals 타입 별칭은 이제 함수 대신 클래스의 메서드 타입을 인덱스로 넣어 ReturnType으로 그대로 가져옵니다. logAndCompute는 이 편에서 손대지 않고 그대로 남겨둡니다.
2. 배럴과 호출부 갱신
// expense-tracker/src/services/index.ts
export { Storage } from './storage.ts'
export type { DataStore } from './storage.ts'
export { StatsCalculator, logAndCompute } from './stats.ts'
export type { CategoryTotals, MonthlySummary } from './stats.ts'// expense-tracker/src/ui/statsPanel.ts (발췌 — 달라진 부분만)
import type { Transaction, ExpenseCategory } from '../models'
import { StatsCalculator } from '../services'
const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
const CATEGORY_LABELS: Record<ExpenseCategory, string> = {
food: '식비',
transport: '교통비',
housing: '주거비',
shopping: '쇼핑',
'etc-expense': '기타 지출',
}
const statsCalculator = new StatsCalculator()
function currentMonth(): string {
return new Date().toISOString().slice(0, 7)
}
export function mountStatsPanel(root: HTMLElement) {
// root.innerHTML 설정은 12편과 동일
const totalsEl = root.querySelector<HTMLUListElement>('#category-totals')!
const summaryEl = root.querySelector<HTMLParagraphElement>('#monthly-summary')!
return {
update(transactions: Transaction[]): void {
const totals = statsCalculator.categoryTotals(transactions)
totalsEl.innerHTML = EXPENSE_CATEGORIES.map(
(category) => `<li>${CATEGORY_LABELS[category]}: ${totals[category].toLocaleString()}원</li>`,
).join('')
const summary = statsCalculator.monthlySummary(transactions, currentMonth())
summaryEl.textContent = `수입 ${summary.incomeTotal.toLocaleString()}원 · 지출 ${summary.expenseTotal.toLocaleString()}원 · 순액 ${summary.netTotal.toLocaleString()}원`
},
}
}로그를 남기던 logAndCompute 래퍼는 이 자리에서 걷어냅니다. 클래스 메서드를 직접 부르는 것으로 충분합니다. 12편에서 main.ts 모듈 최상단에 있던 EXPENSE_CATEGORIES, CATEGORY_LABELS는 이미 statsPanel.ts 안에 있던 것을 그대로 유지합니다.
3. 실행과 확인
npm run dev
npx tsc --noEmit- 화면에 카테고리별 합계·이번 달 요약이 이전과 동일하게 보인다.
npx tsc --noEmit이 오류 없이 통과한다.- 편집기에서
statsCalculator뒤에 마침표를 찍으면categoryTotals,monthlySummary,clearCache세 메서드만 자동완성 목록에 나타나고#cache는 나타나지 않는다.
직접 해보기
statsCalculator는 statsPanel.ts 모듈 전체에서 하나만 만들어져 계속 재사용됩니다. 거래를 하나 추가한 뒤 같은 달의 “이번 달 요약”이 새 거래를 반영해 갱신되는지 확인해보세요. 갱신되지 않는다면 원인을 생각해보고, update 안에서 statsCalculator.clearCache()를 호출하도록 고쳐 해결해보세요.
정답 보기
monthlySummary는 같은 month 키로 다시 호출되면 #cache에 남은 이전 값을 그대로 반환합니다. 거래를 추가해도 month 문자열은 그대로라 캐시가 무효화되지 않습니다.
update(transactions: Transaction[]): void {
statsCalculator.clearCache()
// 이어지는 categoryTotals, monthlySummary 호출은 그대로
}캐시는 한 번의 update 호출 안에서만 의미가 있고, 인스턴스를 오래 유지하며 데이터가 바뀌는 상황에서는 무효화 전략이 함께 필요합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
#cache를 클래스 밖에서 읽으려다 컴파일 에러 | # 필드는 선언한 클래스 내부에서만 접근 가능 | 필요하면 값을 반환하는 퍼블릭 메서드를 추가한다 |
| 거래를 추가해도 이번 달 요약이 그대로 | 같은 인스턴스의 #cache에 남은 값을 그대로 반환받음 | 데이터가 바뀌는 시점마다 clearCache()를 호출한다 |
StatsCalculator가 StatsSource를 구현하지 못했다는 컴파일 에러 | 메서드 이름이나 매개변수 타입이 인터페이스와 다름 | 인터페이스 시그니처와 정확히 맞춘다 |
private과 #을 같은 의미로 착각 | private은 컴파일 타임 표시일 뿐 런타임에는 사라짐 | 런타임에도 확실히 숨겨야 하면 #을 쓴다 |