이번 편의 결과물: 예산 규칙 기본 설정이 DeepReadonly로 고정되고, localStorage에 저장된 부분 수정값을 DeepPartial로 안전하게 병합합니다. · 다루는 개념: 재귀 타입 정의, JsonValue 재귀 유니온, DeepPartial·DeepReadonly 재귀 유틸
15편에서 services/budgetRules.ts에 만든 BudgetEvaluator, LimitExceededRule, ApproachingLimitRule은 ui/statsPanel.ts에 하드코딩된 한도 값({ food: 150000, transport: 60000 })을 그대로 받아 씁니다. 이 편에서는 한도 값을 중첩된 설정 객체로 정리하고, 기본값은 실수로 바꿀 수 없게 고정하며, 사용자가 일부 필드만 바꾼 값을 안전하게 병합하는 기능을 추가합니다.
이 편에서 만드는 파일
expense-tracker/src/
├── lib/
│ └── types/
│ └── recursive.ts + (JsonValue, DeepPartial, DeepReadonly, safeJsonParse)
├── services/
│ ├── budgetRules.ts ~ (BudgetSettings, DEFAULT_BUDGET_SETTINGS, mergeBudgetSettings, loadBudgetSettings)
│ └── index.ts ~ (budgetRules의 새 이름을 배럴에 반영)
└── ui/
└── statsPanel.ts ~ (하드코딩된 한도 대신 loadBudgetSettings 사용)개념 정리
재귀 타입이 필요한 이유
Partial<T>, Readonly<T> 같은 내장 유틸리티 타입은 T의 프로퍼티 한 겹만 바꿉니다. T의 프로퍼티 값이 또 다른 객체라면, 그 안쪽은 원래 타입 그대로 남습니다. 예산 설정처럼 { limits: { food: number, transport: number }, approachingRatio: number } 같이 중첩된 객체는, 안쪽 limits까지 선택적으로 만들거나 읽기 전용으로 고정하려면 타입이 자기 자신을 다시 참조하는 재귀 타입이 필요합니다.
JsonValue
JSON.parse가 돌려주는 값은 문자열, 숫자, 불리언, null, 배열, 객체 중 하나이며, 배열과 객체 안에는 같은 종류의 값이 다시 들어있을 수 있습니다. 이 모양을 그대로 타입으로 옮기면 자기 자신을 재귀적으로 참조하는 유니온이 됩니다.
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }DeepPartial와 DeepReadonly
두 타입 모두 “지금 보는 프로퍼티가 객체면 재귀적으로 같은 변환을 반복하고, 객체가 아니면 그대로 둔다”는 같은 뼈대를 씁니다. 조건부 타입의 extends object 검사가 재귀를 멈추는 기준(base case) 역할을 합니다.
| 타입 | 재귀가 멈추는 조건 | 각 단계에서 하는 일 |
|---|---|---|
DeepPartial | 프로퍼티 값이 객체가 아닐 때 | 모든 프로퍼티를 선택적으로 바꾸고 안쪽도 재귀 |
DeepReadonly | 프로퍼티 값이 객체가 아닐 때 | 모든 프로퍼티를 읽기 전용으로 바꾸고 안쪽도 재귀 |
재귀 조건부 타입은 TypeScript 4.1부터 정식으로 지원됩니다. 재귀가 끝나는 조건을 빠뜨리면 컴파일러가 타입 계산을 무한히 반복하려다 오류를 냅니다.
실습
1. 재귀 유틸 타입 파일 작성
// expense-tracker/src/lib/types/recursive.ts
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
/** 객체면 모든 프로퍼티를 재귀적으로 선택적으로 만들고, 객체가 아니면 그대로 둡니다 */
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T
/** 객체면 모든 프로퍼티를 재귀적으로 읽기 전용으로 만들고, 객체가 아니면 그대로 둡니다 */
export type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T
export function safeJsonParse(raw: string): JsonValue | null {
try {
return JSON.parse(raw) as JsonValue
} catch {
return null
}
}2. budgetRules.ts에 설정 객체와 병합 함수 추가
// expense-tracker/src/services/budgetRules.ts
import type { ExpenseCategory } from '../models'
import type { CategoryTotals } from './stats.ts'
import type { DeepPartial, DeepReadonly } from '../lib/types/recursive.ts'
import { safeJsonParse } from '../lib/types/recursive.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))
}
}
export interface BudgetSettings {
limits: Partial<Record<ExpenseCategory, number>>
approachingRatio: number
}
export const DEFAULT_BUDGET_SETTINGS: DeepReadonly<BudgetSettings> = Object.freeze({
limits: Object.freeze({ food: 150000, transport: 60000 }),
approachingRatio: 0.8,
})
const BUDGET_SETTINGS_KEY = 'expense-tracker:budget-settings'
export function mergeBudgetSettings(overrides: DeepPartial<BudgetSettings>): BudgetSettings {
return {
limits: { ...DEFAULT_BUDGET_SETTINGS.limits, ...overrides.limits },
approachingRatio: overrides.approachingRatio ?? DEFAULT_BUDGET_SETTINGS.approachingRatio,
}
}
export function loadBudgetSettings(): BudgetSettings {
const raw = localStorage.getItem(BUDGET_SETTINGS_KEY)
if (raw === null) {
return mergeBudgetSettings({})
}
const parsed = safeJsonParse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return mergeBudgetSettings({})
}
return mergeBudgetSettings(parsed as DeepPartial<BudgetSettings>)
}DEFAULT_BUDGET_SETTINGS는 DeepReadonly로 선언되어, 안쪽 limits.food까지 재할당하려고 하면 컴파일 에러가 납니다. mergeBudgetSettings는 DeepPartial을 받으므로, 호출하는 쪽은 { limits: { food: 200000 } }처럼 바꾸고 싶은 값만 넘기면 됩니다. loadBudgetSettings는 localStorage에 저장된 문자열을 safeJsonParse로 안전하게 JsonValue로 바꾼 뒤, 객체 모양인지 확인하고 DeepPartial<BudgetSettings>로 단언해 병합합니다.
3. 배럴과 statsPanel.ts 갱신
// expense-tracker/src/services/index.ts
export { Storage } from './storage.ts'
export type { DataStore } from './storage.ts'
export { StatsCalculator, logAndCompute, fetchCategoryGroups } from './stats.ts'
export type { CategoryTotals, MonthlySummary, CategoryGroups } from './stats.ts'
export { BudgetEvaluator, LimitExceededRule, ApproachingLimitRule, loadBudgetSettings, mergeBudgetSettings } from './budgetRules.ts'
export type { BudgetRule, BudgetSettings } from './budgetRules.ts'fetchCategoryGroups, CategoryGroups는 03편에서 이미 추가된 내보내기이므로 그대로 둡니다.
// expense-tracker/src/ui/statsPanel.ts (발췌 — 15편의 BUDGET_LIMITS 상수를 지우고 이 부분으로 교체)
import { StatsCalculator, BudgetEvaluator, LimitExceededRule, ApproachingLimitRule, loadBudgetSettings } from '../services'
const budgetSettings = loadBudgetSettings()
const budgetEvaluator = new BudgetEvaluator([
new LimitExceededRule(budgetSettings.limits),
new ApproachingLimitRule(budgetSettings.limits, budgetSettings.approachingRatio),
])나머지 렌더링 로직(카테고리 합계, 이번 달 요약, 최근 거래, 03편에서 추가한 카테고리별 거래 건수)은 이전 편과 동일합니다.
4. 실행
npm run dev5. 확인
- 아무것도 바꾸지 않은 상태에서는 15편과 똑같이 식비 한도 150000원, 교통비 한도 60000원 기준으로 경고가 뜬다.
- 브라우저 콘솔에서 아래 명령을 실행하고 새로고침하면 식비 한도가 200000원으로 바뀐다.
localStorage.setItem('expense-tracker:budget-settings', JSON.stringify({ limits: { food: 200000 } }))- 같은 콘솔에서
transport값은 여전히 60000원 기준으로 동작한다(넘기지 않은 필드는 기본값을 그대로 씀). npx tsc --noEmit이 오류 없이 끝난다.
직접 해보기
DEFAULT_BUDGET_SETTINGS.limits.food = 999999처럼 값을 직접 바꾸는 코드를 잠시 추가해보고 어떤 컴파일 오류가 나는지 확인한 뒤 지웁니다.DeepPartial에서extends object조건을 빼고{ [K in keyof T]?: DeepPartial<T[K]> }만 남기면 어떤 타입에 이 유틸리티를 적용했을 때 문제가 생기는지 생각해봅니다(힌트:string,number에도keyof를 적용하면 어떻게 될까요).
정답 보기
1번은 “Cannot assign to food because it is a read-only property”류의 오류가 발생합니다. DeepReadonly가 중첩된 limits 객체까지 읽기 전용으로 표시했기 때문입니다.
2번처럼 조건을 빼면 DeepPartial<number>, DeepPartial<string>처럼 객체가 아닌 타입에도 매핑된 타입을 적용하려다, 원시 타입에는 존재하지 않는 프로퍼티를 순회하는 이상한 타입이 만들어지거나 의미 없는 결과가 나옵니다. extends object 조건이 “더 이상 파고들 프로퍼티가 없는 지점”을 알려주는 역할을 합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 재귀 타입 작성 후 편집기가 계산을 끝내지 못하고 멈춤 | 재귀를 멈추는 조건(extends object)을 빠뜨림 | 객체가 아닌 값에서 재귀를 멈추는 분기를 반드시 넣는다 |
| DeepReadonly로 선언한 객체에 값을 대입하려다 오류 | 중첩된 프로퍼티까지 읽기 전용으로 고정됨 | 값을 바꿔야 하면 새 객체를 만들어 반환하는 함수(mergeBudgetSettings 같은)를 쓴다 |
| localStorage에서 불러온 값을 그대로 BudgetSettings로 단언해 잘못된 값이 통과 | JSON.parse 결과를 검증 없이 단언만 함 | safeJsonParse로 파싱 실패를 먼저 거르고, 객체 모양인지 확인한 뒤에 단언한다 |