이번 편의 결과물: tsconfig.json에 noUncheckedIndexedAccess를 추가로 켜고, StatsCalculator에 새로 추가한 latestTransaction과 각 ui 모듈의 DOM 조회 결과에서 드러난 타입 오류를 모두 고쳐 expense-tracker가 정상 동작합니다. · 다루는 개념: strict 하위 플래그, TypeScript 7 기본값, noUncheckedIndexedAccess, DOM null 처리
이 편에서 만드는 파일
expense-tracker/
├── tsconfig.json ~ (noUncheckedIndexedAccess 추가)
└── src/
├── lib/
│ └── dom.ts + (getRequiredElement 헬퍼)
├── services/
│ └── stats.ts ~ (StatsCalculator에 latestTransaction 추가)
├── ui/
│ ├── transactionForm.ts ~ (querySelector 단정을 getRequiredElement로 교체)
│ ├── transactionList.ts ~ (querySelector 단정을 getRequiredElement로 교체)
│ └── statsPanel.ts ~ (최근 거래 표시 추가, querySelector 단정 교체)
└── main.ts ~ (getRequiredElement 적용)12편이 확립한 ui/transactionForm.ts, ui/transactionList.ts, ui/statsPanel.ts, services/index.ts 배럴, StatsCalculator(14편), BudgetEvaluator(15편) 구조를 그대로 이어받습니다. DOM 조회 헬퍼는 08·06·07편의 arrayUtils.ts, parsing.ts, signedAmount.ts가 이미 있는 lib/ 폴더에 추가하고, 별도의 utils/ 폴더는 만들지 않습니다.
개념 정리
strict가 이미 켜는 것
TypeScript 7부터 strict는 기본값이 true입니다. strict: true 하나가 아래 하위 플래그를 한꺼번에 켭니다.
| 플래그 | 잡아내는 문제 |
|---|---|
noImplicitAny | 타입을 추론할 수 없어 암묵적으로 any가 되는 지점 |
strictNullChecks | null/undefined를 다른 타입에 그냥 대입하는 코드 |
strictPropertyInitialization | 클래스 필드가 초기화되지 않고 사용되는 경우 |
09편에서 제네릭 저장소 클래스를 만들고 12편에서 모듈을 분리할 때 이 플래그들은 이미 배경에서 계속 오류를 잡아 왔습니다. 이번 편은 “strict를 켠다”가 아니라 strict보다 더 엄격한 옵션을 추가로 켜는 편입니다.
noUncheckedIndexedAccess가 하는 일
배열 인덱스나 인덱스 시그니처로 값을 꺼내면, 실제로는 그 위치에 값이 없을 수도 있습니다. 기본 strict 상태에서는 배열[i]의 타입이 원소 타입 그대로 나오지만, noUncheckedIndexedAccess를 켜면 여기에 undefined가 합쳐집니다.
const items: string[] = ['a', 'b']
const first = items[0] // strict만: string / noUncheckedIndexedAccess: string 또는 undefined주의할 점은 카테고리별 합계처럼 키가 고정된 리터럴 유니온을 쓰는 타입에는 영향을 주지 않는다는 것입니다. Record<ExpenseCategory, number>는 정해진 프로퍼티 목록을 갖는 객체 타입으로 취급되어 totals.food는 여전히 number입니다. 영향을 받는 쪽은 배열 인덱스 접근과 진짜 인덱스 시그니처뿐입니다.
방치된 널 단정 다시 보기
querySelector 결과처럼 strictNullChecks가 이미 null 가능성을 알려주는 자리에서도, 급하게 !(non-null assertion)를 붙여 넘어간 코드가 남아있을 수 있습니다. !는 컴파일러에게 “여기는 절대 null이 아니다”라고 우기는 것과 같아서 그 뒤로는 검사하지 않습니다. 이번 편에서 이런 자리를 제대로 된 분기로 바꿉니다.
이 과목은 noUncheckedIndexedAccess까지만 켜고, exactOptionalPropertyTypes(선택 프로퍼티에 undefined를 명시적으로 대입하는 것도 막음) 같은 나머지 옵션은 “직접 해보기”에서 시험해 봅니다.
실습
1. tsconfig.json에 옵션 추가
02편에서 만든 tsconfig.json에 noUncheckedIndexedAccess 한 줄만 추가합니다.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}2. StatsCalculator에 메서드를 추가해 새 오류 확인
StatsCalculator(14편)에 가장 최근 거래를 찾는 메서드를 추가합니다. 배열을 정렬한 뒤 첫 번째 원소를 그대로 반환하는, 아직 오류가 있는 버전부터 시작합니다.
// src/services/stats.ts (발췌 — StatsCalculator 클래스 안에 메서드 추가, categoryTotals·monthlySummary는 14·15편과 동일)
latestTransaction(transactions: Transaction[]): Transaction {
const sorted = [...transactions].sort((a, b) => b.date.localeCompare(a.date))
return sorted[0]
}npx tsc --noEmit
src/services/stats.ts:24:5 - error TS2322: Type 'Transaction | undefined' is not assignable to type 'Transaction'.
24 return sorted[0]
~~~~~~~~~~~~~~~~noUncheckedIndexedAccess를 켠 뒤로 sorted[0]의 타입에 undefined가 합쳐져, 선언한 반환 타입 Transaction과 더 이상 맞지 않습니다. categoryTotals, monthlySummary는 카테고리별로 고정된 키(Record<ExpenseCategory, number>)를 채우거나 Map.get으로 캐시를 조회할 뿐이라 이 옵션의 영향을 받지 않고 그대로 둡니다.
3. 반환 타입에 null 반영
// src/services/stats.ts (발췌 — latestTransaction만 수정)
latestTransaction(transactions: Transaction[]): Transaction | null {
const sorted = [...transactions].sort((a, b) => b.date.localeCompare(a.date))
return sorted[0] ?? null
}반환 타입을 Transaction | null로 바꾸고 sorted[0] ?? null로 값이 없을 때를 명시적으로 표현했습니다. 이제 이 결과를 쓰는 쪽이 반드시 null 처리를 하도록 컴파일러가 강제합니다.
4. DOM 조회 결과를 위한 헬퍼 작성
// src/lib/dom.ts
export function getRequiredElement<T extends Element>(root: ParentNode, selector: string): T {
const element = root.querySelector<T>(selector)
if (element === null) {
throw new Error(`화면 요소를 찾을 수 없습니다: ${selector}`)
}
return element
}root를 매개변수로 받아, document 전체뿐 아니라 각 ui 모듈이 자신에게 주어진 root: HTMLElement 안에서 요소를 찾을 때도 그대로 재사용합니다.
5. querySelector 단정을 헬퍼로 교체
// src/ui/transactionForm.ts (발췌 — mountTransactionForm 안 변수 선언부만, 나머지는 12편과 동일)
import { getRequiredElement } from '../lib/dom.ts'
export function mountTransactionForm(root: HTMLElement, handlers: TransactionFormHandlers) {
// root.innerHTML 설정은 12편과 동일
const form = getRequiredElement<HTMLFormElement>(root, '#transaction-form')
const typeSelect = getRequiredElement<HTMLSelectElement>(root, '#type-select')
const categorySelect = getRequiredElement<HTMLSelectElement>(root, '#category-select')
const editForm = getRequiredElement<HTMLFormElement>(root, '#edit-form')
const editCancelButton = getRequiredElement<HTMLButtonElement>(root, '#edit-cancel')
// 이하 로직은 12편과 동일
}// src/ui/transactionList.ts (발췌 — mountTransactionList 안 변수 선언부만, 나머지는 12편과 동일)
import { getRequiredElement } from '../lib/dom.ts'
export function mountTransactionList(root: HTMLElement, handlers: TransactionListHandlers) {
// root.innerHTML 설정은 12편과 동일
const filterSelect = getRequiredElement<HTMLSelectElement>(root, '#filter-type')
const sortSelect = getRequiredElement<HTMLSelectElement>(root, '#sort-key')
const listEl = getRequiredElement<HTMLUListElement>(root, '#transaction-list')
// 이하 로직은 12편과 동일
}기존에는 root.querySelector<HTMLFormElement>('#transaction-form')!처럼 non-null assertion(!)으로 넘어갔던 자리입니다. getRequiredElement는 요소가 없으면 즉시 에러를 던져 원인을 바로 알려줍니다.
6. statsPanel.ts에 최근 거래 표시 추가
// src/ui/statsPanel.ts (발췌 — 이 편에서 바뀐·추가된 부분만, 카테고리 합계·예산 경고 로직은 14·15편과 동일)
import { getRequiredElement } from '../lib/dom.ts'
// root.innerHTML 템플릿에 다음 section을 추가합니다
// <section><h2>최근 거래</h2><p id="latest-transaction"></p></section>
export function mountStatsPanel(root: HTMLElement) {
const totalsEl = getRequiredElement<HTMLUListElement>(root, '#category-totals')
const summaryEl = getRequiredElement<HTMLParagraphElement>(root, '#monthly-summary')
const warningsEl = getRequiredElement<HTMLUListElement>(root, '#budget-warnings')
const latestEl = getRequiredElement<HTMLParagraphElement>(root, '#latest-transaction')
return {
update(transactions: Transaction[]): void {
// 카테고리 합계, 이번 달 요약, 예산 경고 렌더링은 14·15편과 동일
const latest = statsCalculator.latestTransaction(transactions)
latestEl.textContent =
latest === null ? '거래 내역이 없습니다.' : `최근 거래: ${latest.memo} (${latest.amount.toLocaleString()}원)`
},
}
}7. main.ts도 헬퍼로 정리
// src/main.ts (발췌 — 이 편에서 바뀐 부분만)
import { getRequiredElement } from './lib/dom.ts'
const app = getRequiredElement<HTMLDivElement>(document, '#app')document도 querySelector를 가진 ParentNode라서, ui 모듈의 root: HTMLElement와 같은 헬퍼 함수를 그대로 쓸 수 있습니다.
8. 실행과 확인
npx tsc --noEmit
npm run devnpx tsc --noEmit이 오류 없이 끝난다.- 거래를 하나도 추가하지 않은 상태에서 “최근 거래” 영역에 “거래 내역이 없습니다.”가 보인다.
- 거래를 추가하면 최근 거래 문구가 방금 추가한 내용으로 바뀐다.
직접 해보기
tsconfig.json에exactOptionalPropertyTypes를 추가로 켜고npx tsc --noEmit을 실행해 어떤 파일에서 오류가 나는지 관찰한 뒤, 학습이 끝나면 다시 지웁니다.noPropertyAccessFromIndexSignature(인덱스 시그니처 프로퍼티를 점 표기법으로 접근하지 못하게 막는 옵션)를 추가로 켜고,services/stats.ts가 여전히 통과하는 이유를 생각해 봅니다.
정답 보기
exactOptionalPropertyTypes를 켜면 선택 프로퍼티(prop?: string)에 undefined를 명시적으로 대입하는 코드가 오류로 바뀝니다. expense-tracker에는 아직 그런 코드가 없다면 오류 없이 통과할 수 있습니다. noPropertyAccessFromIndexSignature는 진짜 인덱스 시그니처로 선언된 프로퍼티에만 영향을 주므로, Record<ExpenseCategory, number>처럼 리터럴 유니온으로 키가 고정된 타입에는 영향이 없어 stats.ts는 그대로 통과합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
noUncheckedIndexedAccess를 켠 뒤에도 배열 접근 결과에 !를 붙여 넘김 | 오류를 없애는 데만 급급해 원래 문제(값이 없을 수 있음)를 다시 숨김 | 실제로 값이 없는 경우를 분기로 처리하거나 반환 타입에 반영 |
querySelector 결과에 as로 강제 형변환 | 타입 오류를 빠르게 없애려는 습관 | getRequiredElement처럼 없으면 에러를 던지는 헬퍼로 대체 |