Skip to Content
WebTypeScriptTypeScript15. 상속 대신 조합으로 설계하기

이번 편의 결과물: 카테고리별 예산 경고 로직이 BudgetRule 인터페이스를 구현하는 전략 객체들로 만들어져, 상속 없이 새 경고 규칙을 교체·추가할 수 있습니다. · 다루는 개념: 상속 vs 조합, 인터페이스 기반 전략 교체, 데코레이터 미사용 현황, 실무 네이밍 관례

지금까지 expense-tracker에는 예산을 넘겼는지 알려주는 기능이 없었습니다. 이 편에서 처음 만들면서, 서로 다른 경고 규칙(한도 초과, 한도 근접)을 상속이 아니라 조합으로 설계하는 이유를 먼저 살펴보고 실제로 그렇게 만듭니다.

이 편에서 만드는 파일

expense-tracker/src/ ├── services/ │ ├── budgetRules.ts + (BudgetRule 인터페이스, 규칙 클래스들, BudgetEvaluator) │ └── index.ts ~ (budgetRules를 배럴에 반영) └── ui/ └── statsPanel.ts ~ (예산 경고 목록 렌더링 추가)

개념 정리

상속으로 만들면 생기는 문제

경고 규칙이 “한도 초과”와 “한도 근접” 둘뿐이라면 상속으로도 표현할 수 있습니다.

class ExpenseWarning { constructor(protected readonly limit: number) {} check(spent: number): string | null { return spent > this.limit ? '한도를 넘었습니다' : null } } class ApproachingExpenseWarning extends ExpenseWarning { check(spent: number): string | null { return spent >= this.limit * 0.8 ? '한도에 근접했습니다' : super.check(spent) } }

여기에 “지출이 0원이면 알려주는 규칙”까지 더하려면 어떻게 해야 할까요. ApproachingExpenseWarning을 상속하는 세 번째 클래스를 또 만들거나, 아예 다른 조합을 위한 클래스를 새로 만들어야 합니다. 규칙이 늘수록 클래스 계층이 조합의 수만큼 늘어나고, 부모 클래스를 고치면 모든 자식이 영향을 받습니다.

조합으로 바꾸기

조합은 “이것은 저것을 갖고 있다” 관계로 문제를 풉니다. 공통 동작을 인터페이스로 정의하고, 그 인터페이스를 구현하는 여러 객체를 배열로 들고 있다가 필요할 때 실행합니다. 새 규칙을 추가할 때는 클래스 계층을 늘리지 않고 인터페이스를 구현하는 객체 하나만 새로 만들어 배열에 넣으면 됩니다. 이런 구성 방식을 전략 패턴이라고도 부릅니다.

데코레이터를 쓰지 않는 이유

TypeScript는 클래스 프로퍼티·메서드에 부가 동작을 붙이는 데코레이터 문법도 제공합니다. 다만 아직 표준화가 진행 중인 영역이 있어 이 과목에서는 다루지 않습니다. 예산 경고처럼 동작을 조합해야 하는 문제는 데코레이터 없이도 인터페이스 기반 조합만으로 충분히 풀 수 있습니다.

네이밍 관례

인터페이스에는 역할을 나타내는 이름을(BudgetRule), 각 구현체에는 그 규칙이 무엇을 하는지 드러내는 구체적인 이름을(LimitExceededRule, ApproachingLimitRule) 짓습니다. 인터페이스 이름 앞에 I를 붙이는 관례는 TypeScript 커뮤니티에서 널리 쓰이지 않습니다.

실습

1. BudgetRule 인터페이스와 규칙 클래스, BudgetEvaluator 작성

categoryTotals(14편)는 지출 카테고리 금액을 양수로 그대로 합산하므로, 한도와 곧바로 비교할 수 있습니다.

// expense-tracker/src/services/budgetRules.ts import type { ExpenseCategory } from '../models' import type { CategoryTotals } from './stats.ts' export interface BudgetRule { readonly name: string evaluate(totals: CategoryTotals): string[] } export class LimitExceededRule implements BudgetRule { readonly name = 'limit-exceeded' constructor(private readonly limits: Partial<Record<ExpenseCategory, number>>) {} evaluate(totals: CategoryTotals): string[] { const warnings: string[] = [] for (const [category, limit] of Object.entries(this.limits) as [ExpenseCategory, number][]) { const spent = totals[category] if (spent > limit) { warnings.push(`${category} 카테고리 지출이 한도 ${limit}원을 넘었습니다(현재 ${spent}원)`) } } return warnings } } export class ApproachingLimitRule implements BudgetRule { readonly name = 'approaching-limit' constructor( private readonly limits: Partial<Record<ExpenseCategory, number>>, private readonly warningRatio = 0.8, ) {} evaluate(totals: CategoryTotals): string[] { const warnings: string[] = [] for (const [category, limit] of Object.entries(this.limits) as [ExpenseCategory, number][]) { const spent = totals[category] if (spent <= limit && spent >= limit * this.warningRatio) { warnings.push(`${category} 카테고리 지출이 한도의 ${this.warningRatio * 100}퍼센트를 넘었습니다`) } } return warnings } } export class BudgetEvaluator { constructor(private readonly rules: BudgetRule[]) {} evaluateAll(totals: CategoryTotals): string[] { return this.rules.flatMap((rule) => rule.evaluate(totals)) } }

LimitExceededRuleApproachingLimitRule은 서로 extends로 이어져 있지 않고, 각자 BudgetRule을 구현할 뿐입니다. BudgetEvaluator는 구체적인 규칙 클래스를 모릅니다. BudgetRule 형태를 갖춘 객체 배열만 받아 순서대로 evaluate를 호출하고 결과를 합칩니다.

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' export { BudgetEvaluator, LimitExceededRule, ApproachingLimitRule } from './budgetRules.ts' export type { BudgetRule } from './budgetRules.ts'

3. statsPanel.ts에 예산 경고 목록 추가

// expense-tracker/src/ui/statsPanel.ts (발췌 — 이 편에서 추가된 부분만) import { StatsCalculator, BudgetEvaluator, LimitExceededRule, ApproachingLimitRule } from '../services' import type { ExpenseCategory } from '../models' const BUDGET_LIMITS: Partial<Record<ExpenseCategory, number>> = { food: 150000, transport: 60000 } const budgetEvaluator = new BudgetEvaluator([ new LimitExceededRule(BUDGET_LIMITS), new ApproachingLimitRule(BUDGET_LIMITS, 0.8), ]) // root.innerHTML 템플릿에 다음 section을 추가합니다 // <section><h2>예산 경고</h2><ul id="budget-warnings"></ul></section> export function mountStatsPanel(root: HTMLElement) { const warningsEl = root.querySelector<HTMLUListElement>('#budget-warnings')! return { update(transactions: Transaction[]): void { const totals = statsCalculator.categoryTotals(transactions) // 카테고리 합계·이번 달 요약 렌더링은 14편과 동일 const warnings = budgetEvaluator.evaluateAll(totals) warningsEl.innerHTML = warnings.map((message) => `<li>${message}</li>`).join('') }, } }

4. 실행과 확인

npm run dev
  • 식비(food) 지출 합이 150000원을 넘도록 거래를 추가하면 예산 경고 목록에 문구가 나타난다.
  • 지출이 한도의 80퍼센트를 넘고 아직 초과하지 않았을 때도 별도 경고 문구가 나타난다.
  • npx tsc --noEmit이 통과한다.
  • budgetRules.tsextends가 없다.

직접 해보기

카테고리 지출이 0원인 채로 이번 달이 반 넘게 지났을 때 알려주는 ZeroSpendingRule을 만들어 BudgetEvaluator에 추가해보세요. BudgetRule을 구현하기만 하면 됩니다.

정답 보기

export class ZeroSpendingRule implements BudgetRule { readonly name = 'zero-spending' constructor(private readonly watchedCategories: ExpenseCategory[]) {} evaluate(totals: CategoryTotals): string[] { const warnings: string[] = [] for (const category of this.watchedCategories) { if (totals[category] === 0) { warnings.push(`${category} 카테고리에 이번 달 지출 기록이 없습니다`) } } return warnings } }
const budgetEvaluator = new BudgetEvaluator([ new LimitExceededRule(BUDGET_LIMITS), new ApproachingLimitRule(BUDGET_LIMITS, 0.8), new ZeroSpendingRule(['transport']), ])

BudgetEvaluator나 기존 두 규칙 클래스는 전혀 바꾸지 않고 배열에 객체 하나만 추가했습니다. 상속 트리를 새로 그릴 필요가 없습니다.

자주 하는 실수

증상원인고치는 법
새 규칙 클래스에서 implements 관련 컴파일 에러evaluate 시그니처가 BudgetRule 정의와 다름매개변수·반환 타입을 인터페이스와 정확히 맞춘다
지출이 한도를 넘었는데도 경고가 안 뜸BUDGET_LIMITS에 없는 카테고리로 지출함Object.entries는 limits에 실제로 등록된 카테고리만 순회한다는 점을 확인한다
새 변형이 필요할 때 기존 클래스를 상속하는 클래스를 또 만듦조합으로 바꾼 목적을 잊고 예전 습관대로 설계함새 동작은 새 BudgetRule 구현체로 추가한다
여러 규칙의 name이 서로 같음규칙을 구분할 값인데 복사하며 이름을 안 바꿈규칙마다 고유한 name을 짓는다

확인 문제

문제 14지선다
상속 대신 조합으로 예산 경고 로직을 설계했을 때 얻는 이점은
문제 24지선다
BudgetRule 인터페이스가 하는 역할은
문제 34지선다
BudgetEvaluator의 생성자가 구체 클래스 배열이 아니라 BudgetRule 배열을 받도록 설계한 이유는
문제 44지선다
이 과목에서 데코레이터 문법을 도입하지 않은 이유는

참고 자료

Last updated on