Skip to Content
WebTypeScriptTypeScript14. 클래스와 접근 제어자

이번 편의 결과물: calculateCategoryTotals, calculateMonthlySummary 함수가 StatsCalculator 클래스의 메서드로 리팩터링되고, 내부 캐시 필드가 은닉됩니다. · 다루는 개념: class 필드/메서드, private/protected/readonly, implements, 생성자 매개변수 프로퍼티

11편까지 calculateCategoryTotals, calculateMonthlySummaryservices/stats.ts의 독립된 함수였습니다. 호출할 때마다 거래 배열을 다시 넘겼고, 같은 달 요약을 여러 번 구하면 매번 배열을 다시 훑었습니다. 이 편에서는 두 함수를 StatsCalculator 클래스의 메서드로 묶고, 월별 요약 결과를 캐시하는 필드를 추가합니다.

이 편에서 만드는 파일

expense-tracker/src/ ├── services/ │ ├── stats.ts ~ (calculateCategoryTotals, calculateMonthlySummary → StatsCalculator 클래스) │ └── index.ts ~ (StatsCalculator를 배럴에 반영) └── ui/ └── statsPanel.ts ~ (StatsCalculator 인스턴스 사용)

개념 정리

키워드의미
privateTypeScript 컴파일 타임에만 검사되는 접근 제한. 컴파일된 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)는 매개변수 하나로 필드 선언과 대입을 동시에 처리합니다. channelprotectedNotifier를 상속한 클래스에서는 접근할 수 있지만 클래스 밖에서는 접근할 수 없습니다.

실습

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 인터페이스는 두 메서드의 시그니처를 미리 정의해 두고, implementsStatsCalculator가 이를 그대로 따르는지 컴파일 타임에 검사합니다. 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는 나타나지 않는다.

직접 해보기

statsCalculatorstatsPanel.ts 모듈 전체에서 하나만 만들어져 계속 재사용됩니다. 거래를 하나 추가한 뒤 같은 달의 “이번 달 요약”이 새 거래를 반영해 갱신되는지 확인해보세요. 갱신되지 않는다면 원인을 생각해보고, update 안에서 statsCalculator.clearCache()를 호출하도록 고쳐 해결해보세요.

정답 보기

monthlySummary는 같은 month 키로 다시 호출되면 #cache에 남은 이전 값을 그대로 반환합니다. 거래를 추가해도 month 문자열은 그대로라 캐시가 무효화되지 않습니다.

update(transactions: Transaction[]): void { statsCalculator.clearCache() // 이어지는 categoryTotals, monthlySummary 호출은 그대로 }

캐시는 한 번의 update 호출 안에서만 의미가 있고, 인스턴스를 오래 유지하며 데이터가 바뀌는 상황에서는 무효화 전략이 함께 필요합니다.

자주 하는 실수

증상원인고치는 법
#cache를 클래스 밖에서 읽으려다 컴파일 에러# 필드는 선언한 클래스 내부에서만 접근 가능필요하면 값을 반환하는 퍼블릭 메서드를 추가한다
거래를 추가해도 이번 달 요약이 그대로같은 인스턴스의 #cache에 남은 값을 그대로 반환받음데이터가 바뀌는 시점마다 clearCache()를 호출한다
StatsCalculatorStatsSource를 구현하지 못했다는 컴파일 에러메서드 이름이나 매개변수 타입이 인터페이스와 다름인터페이스 시그니처와 정확히 맞춘다
private#을 같은 의미로 착각private은 컴파일 타임 표시일 뿐 런타임에는 사라짐런타임에도 확실히 숨겨야 하면 #을 쓴다

확인 문제

문제 14지선다
TypeScript의 private 키워드와 자바스크립트의 샵(#) 필드의 가장 큰 차이는
문제 24지선다
StatsCalculator가 implements StatsSource라고 선언했을 때 컴파일러가 하는 일은
문제 34지선다
constructor(protected readonly channel: string) {}처럼 쓰면 생기는 일은
문제 44지선다
statsPanel.ts에서 거래를 추가해도 이번 달 요약이 갱신되지 않는 문제의 원인은

참고 자료

Last updated on