이번 편의 결과물: 코드가 models, services, ui 폴더로 나뉘고, 각 폴더의 진입점(배럴 파일)에서 값과 타입을 구분해 내보냅니다. · 다루는 개념: ESM import, export, 타입 전용 import, export, 배럴(index) 파일, 모듈 분리 설계
이 편에서 만드는 파일
expense-tracker/src/
├── main.ts (~ 대폭 축소)
├── models/
│ └── index.ts (+)
├── services/
│ └── index.ts (+)
└── ui/
├── transactionForm.ts (+)
├── transactionList.ts (+)
└── statsPanel.ts (+)개념 정리
지금까지 써온 타입 전용 import
import type { Transaction, ... } from './models/expense.ts'처럼, 지금까지 타입만 가져올 때는 이미 import type을 써 왔습니다. 이 편에서는 왜 그렇게 써야 하는지, 그리고 값과 타입을 함께 내보내는 지점에서는 어떻게 나눠 써야 하는지 정리합니다.
왜 import type, export type이 필요한가
Vite는 파일을 하나씩 독립적으로 변환합니다. 어떤 이름을 가져올 때 그 이름이 타입(컴파일 후 사라짐)인지 값(런타임에 남음)인지, 다른 파일까지 열어보지 않고는 알 수 없습니다. import type, export type으로 미리 표시해 두면 파일 하나만 보고도 타입은 지우고 값은 남기도록 정확히 변환할 수 있습니다. Vite의 tsconfig.json은 이 검사를 켜 두고 있어서, 값과 타입을 구분하지 않고 다시 내보내면 컴파일 오류가 납니다.
배럴(index) 파일
폴더 안 여러 모듈을 하나의 진입점으로 모아 다시 내보내는 파일을 배럴이라고 부릅니다. services/index.ts가 있으면 사용하는 쪽은 ./services/storage, ./services/stats를 각각 알 필요 없이 ./services 하나만 알면 됩니다.
| 구문 | 상황 |
|---|---|
export { Storage } from './storage.ts' | 값(클래스, 함수, 변수)을 다시 내보낼 때 |
export type { DataStore } from './storage.ts' | 타입(인터페이스, 타입 별칭)만 다시 내보낼 때 |
export type * from './expense.ts' | 파일 전체가 타입만 있는 경우 한 번에 다시 내보낼 때 |
모듈 분리 기준
main.ts가 DOM 생성, 이벤트 처리, 상태 보관, 통계 계산을 전부 갖고 있으면 무엇 하나 고칠 때도 파일 전체를 읽어야 합니다. 화면을 그리는 코드(ui), 데이터를 읽고 쓰는 코드(services), 데이터 모양을 정의하는 코드(models)로 책임을 나누면 필요한 부분만 열어보면 됩니다.
실습
1. 폴더와 배럴 파일 만들기
expense-tracker/src/models/index.ts, expense-tracker/src/services/index.ts, expense-tracker/src/ui/ 폴더를 만듭니다.
2. 코드 작성
models는 타입만 모아둔 폴더이므로 배럴도 타입 전용입니다.
// expense-tracker/src/models/index.ts
export type * from './expense.ts'services는 클래스·함수(값)와 인터페이스·타입 별칭(타입)이 섞여 있으므로 나눠서 내보냅니다.
// expense-tracker/src/services/index.ts
export { Storage } from './storage.ts'
export type { DataStore } from './storage.ts'
export { calculateCategoryTotals, calculateMonthlySummary, logAndCompute } from './stats.ts'
export type { CategoryTotals, MonthlySummary } from './stats.ts'거래 추가·수정 폼을 ui/transactionForm.ts로 옮깁니다.
// expense-tracker/src/ui/transactionForm.ts
import type { Transaction, ExpenseCategory, IncomeCategory } from '../models'
export type NewTransactionDraft = Omit<Transaction, 'id'>
export type EditableFields = Pick<Transaction, 'date' | 'amount' | 'memo'>
export interface TransactionFormHandlers {
onCreate: (draft: NewTransactionDraft) => void
onUpdate: (id: string, changes: Partial<EditableFields>) => void
}
const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
const INCOME_CATEGORIES: IncomeCategory[] = ['salary', 'bonus', 'interest', 'etc-income']
function categoryOptionsHtml(type: Transaction['type']): string {
const categories = type === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES
return categories.map((category) => `<option value="${category}">${category}</option>`).join('')
}
export function mountTransactionForm(root: HTMLElement, handlers: TransactionFormHandlers) {
root.innerHTML = `
<form id="transaction-form">
<select name="type" id="type-select">
<option value="expense">지출</option>
<option value="income">수입</option>
</select>
<select name="category" id="category-select"></select>
<input name="date" type="date" required />
<input name="amount" type="number" required />
<input name="memo" type="text" placeholder="메모" />
<button type="submit">추가</button>
</form>
<form id="edit-form" hidden>
<p>거래 수정</p>
<input name="date" type="date" required />
<input name="amount" type="number" required />
<input name="memo" type="text" placeholder="메모" />
<button type="submit">저장</button>
<button type="button" id="edit-cancel">취소</button>
</form>
`
const form = root.querySelector<HTMLFormElement>('#transaction-form')!
const typeSelect = root.querySelector<HTMLSelectElement>('#type-select')!
const categorySelect = root.querySelector<HTMLSelectElement>('#category-select')!
const editForm = root.querySelector<HTMLFormElement>('#edit-form')!
const editCancelButton = root.querySelector<HTMLButtonElement>('#edit-cancel')!
let editingId: string | null = null
function refreshCategoryOptions(): void {
categorySelect.innerHTML = categoryOptionsHtml(typeSelect.value as Transaction['type'])
}
function handleCreateSubmit(event: SubmitEvent): void {
event.preventDefault()
const formData = new FormData(form)
const type = formData.get('type') as Transaction['type']
const date = String(formData.get('date'))
const amount = Number(formData.get('amount'))
const memo = String(formData.get('memo'))
const draft: NewTransactionDraft =
type === 'expense'
? { type: 'expense', date, amount, memo, category: formData.get('category') as ExpenseCategory }
: { type: 'income', date, amount, memo, category: formData.get('category') as IncomeCategory }
handlers.onCreate(draft)
form.reset()
refreshCategoryOptions()
}
function openEdit(transaction: Transaction): void {
editingId = transaction.id
const dateInput = editForm.elements.namedItem('date') as HTMLInputElement
const amountInput = editForm.elements.namedItem('amount') as HTMLInputElement
const memoInput = editForm.elements.namedItem('memo') as HTMLInputElement
dateInput.value = transaction.date
amountInput.value = String(transaction.amount)
memoInput.value = transaction.memo
editForm.hidden = false
}
function closeEdit(): void {
editingId = null
editForm.hidden = true
editForm.reset()
}
function handleEditSubmit(event: SubmitEvent): void {
event.preventDefault()
if (editingId === null) {
return
}
const formData = new FormData(editForm)
handlers.onUpdate(editingId, {
date: String(formData.get('date')),
amount: Number(formData.get('amount')),
memo: String(formData.get('memo')),
})
closeEdit()
}
form.addEventListener('submit', handleCreateSubmit)
typeSelect.addEventListener('change', refreshCategoryOptions)
editForm.addEventListener('submit', handleEditSubmit)
editCancelButton.addEventListener('click', closeEdit)
refreshCategoryOptions()
return { openEdit }
}목록 렌더링은 ui/transactionList.ts로 옮깁니다.
// expense-tracker/src/ui/transactionList.ts
import type { Transaction } from '../models'
import { sortBy, filterBy } from '../lib/arrayUtils.ts'
export interface TransactionListHandlers {
onEdit: (transaction: Transaction) => void
onRemove: (id: string) => void
}
export function mountTransactionList(root: HTMLElement, handlers: TransactionListHandlers) {
root.innerHTML = `
<div>
<label>종류 필터
<select id="filter-type">
<option value="all">전체</option>
<option value="expense">지출</option>
<option value="income">수입</option>
</select>
</label>
<label>정렬
<select id="sort-key">
<option value="date">날짜</option>
<option value="amount">금액</option>
</select>
</label>
</div>
<ul id="transaction-list"></ul>
`
const filterSelect = root.querySelector<HTMLSelectElement>('#filter-type')!
const sortSelect = root.querySelector<HTMLSelectElement>('#sort-key')!
const listEl = root.querySelector<HTMLUListElement>('#transaction-list')!
let currentTransactions: Transaction[] = []
function renderList(): void {
const filterValue = filterSelect.value as Transaction['type'] | 'all'
const sortValue = sortSelect.value as 'date' | 'amount'
const filtered =
filterValue === 'all' ? currentTransactions : filterBy(currentTransactions, (t) => t.type === filterValue)
const sorted = sortBy(filtered, sortValue)
listEl.innerHTML = sorted
.map(
(t) => `
<li data-id="${t.id}">
<span>${t.date}</span>
<span>${t.type === 'expense' ? '지출' : '수입'}</span>
<span>${t.category}</span>
<span>${t.amount.toLocaleString()}원</span>
<span>${t.memo}</span>
<button type="button" data-action="edit" data-id="${t.id}">수정</button>
<button type="button" data-action="remove" data-id="${t.id}">삭제</button>
</li>`,
)
.join('')
}
listEl.addEventListener('click', (event) => {
const target = event.target as HTMLElement
const removeButton = target.closest<HTMLButtonElement>('[data-action="remove"]')
if (removeButton !== null) {
handlers.onRemove(removeButton.dataset.id!)
return
}
const editButton = target.closest<HTMLButtonElement>('[data-action="edit"]')
if (editButton !== null) {
const found = currentTransactions.find((t) => t.id === editButton.dataset.id)
if (found !== undefined) {
handlers.onEdit(found)
}
}
})
filterSelect.addEventListener('change', renderList)
sortSelect.addEventListener('change', renderList)
return {
update(transactions: Transaction[]): void {
currentTransactions = transactions
renderList()
},
}
}카테고리 합계와 월별 요약은 ui/statsPanel.ts로 옮깁니다.
// expense-tracker/src/ui/statsPanel.ts
import type { Transaction, ExpenseCategory } from '../models'
import { calculateCategoryTotals, calculateMonthlySummary, logAndCompute } from '../services'
const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
const CATEGORY_LABELS: Record<ExpenseCategory, string> = {
food: '식비',
transport: '교통비',
housing: '주거비',
shopping: '쇼핑',
'etc-expense': '기타 지출',
}
function currentMonth(): string {
return new Date().toISOString().slice(0, 7)
}
export function mountStatsPanel(root: HTMLElement) {
root.innerHTML = `
<section>
<h2>카테고리별 지출 합계</h2>
<ul id="category-totals"></ul>
</section>
<section>
<h2>이번 달 요약</h2>
<p id="monthly-summary"></p>
</section>
`
const totalsEl = root.querySelector<HTMLUListElement>('#category-totals')!
const summaryEl = root.querySelector<HTMLParagraphElement>('#monthly-summary')!
return {
update(transactions: Transaction[]): void {
const totals = logAndCompute(calculateCategoryTotals, transactions)
totalsEl.innerHTML = EXPENSE_CATEGORIES.map(
(category) => `<li>${CATEGORY_LABELS[category]}: ${totals[category].toLocaleString()}원</li>`,
).join('')
const summary = calculateMonthlySummary(transactions, currentMonth())
summaryEl.textContent = `수입 ${summary.incomeTotal.toLocaleString()}원 · 지출 ${summary.expenseTotal.toLocaleString()}원 · 순액 ${summary.netTotal.toLocaleString()}원`
},
}
}main.ts는 세 모듈을 연결하고 상태와 저장소만 관리하는 얇은 진입점으로 줄어듭니다.
// expense-tracker/src/main.ts
import type { Transaction } from './models'
import { Storage } from './services'
import { mountTransactionForm } from './ui/transactionForm.ts'
import { mountTransactionList } from './ui/transactionList.ts'
import { mountStatsPanel } from './ui/statsPanel.ts'
const storage = new Storage<Transaction>('expense-tracker:transactions')
let transactions: Transaction[] = storage.load()
const app = document.querySelector<HTMLDivElement>('#app')!
app.innerHTML = `
<h1>expense-tracker</h1>
<div id="form-root"></div>
<div id="list-root"></div>
<div id="stats-root"></div>
`
const formRoot = app.querySelector<HTMLDivElement>('#form-root')!
const listRoot = app.querySelector<HTMLDivElement>('#list-root')!
const statsRoot = app.querySelector<HTMLDivElement>('#stats-root')!
function renderAll(): void {
list.update(transactions)
stats.update(transactions)
}
const list = mountTransactionList(listRoot, {
onEdit: (transaction) => form.openEdit(transaction),
onRemove: (id) => {
transactions = transactions.filter((t) => t.id !== id)
storage.save(transactions)
renderAll()
},
})
const stats = mountStatsPanel(statsRoot)
const form = mountTransactionForm(formRoot, {
onCreate: (draft) => {
const transaction = { ...draft, id: crypto.randomUUID() } as Transaction
transactions.push(transaction)
storage.save(transactions)
renderAll()
},
onUpdate: (id, changes) => {
transactions = transactions.map((t) => (t.id === id ? { ...t, ...changes } : t))
storage.save(transactions)
renderAll()
},
})
renderAll()onEdit 안에서 아직 선언되지 않은 form을 참조하지만, 이 콜백은 사용자가 “수정” 버튼을 눌러야 실행되므로 문제가 없습니다. 세 mount... 함수가 모두 호출을 마친 뒤에야 클릭 이벤트가 발생하기 때문입니다. onCreate의 Omit<Transaction, 'id'>로 만든 draft에 id를 합쳐 as Transaction으로 다시 단언하는 부분은, 10편에서 본 것처럼 갈래별로 이미 올바르게 만들어진 값이라 안전합니다.
3. 실행
npm run devnpx tsc --noEmit4. 확인
- 추가, 수정, 삭제, 필터, 정렬, 카테고리 합계, 이번 달 요약이 이전과 똑같이 동작합니다.
- 콘솔에
logAndCompute의 로그가 여전히 찍힙니다. npx tsc --noEmit이 오류 없이 끝납니다. 파일을 나누며 빠뜨린import type이 있으면 여기서 걸립니다.
직접 해보기
EXPENSE_CATEGORIES배열이ui/transactionForm.ts와ui/statsPanel.ts두 곳에 중복 정의되어 있습니다. 새 파일src/ui/categories.ts를 만들어 두 배열과CATEGORY_LABELS를 옮기고, 두 파일에서 가져다 쓰도록 정리합니다.services/index.ts의export type { DataStore } from './storage.ts'줄에서type을 지우고npx tsc --noEmit을 실행해 어떤 오류가 나는지 관찰한 뒤 되돌립니다.
정답 보기
// expense-tracker/src/ui/categories.ts
import type { ExpenseCategory } from '../models'
export const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense']
export const CATEGORY_LABELS: Record<ExpenseCategory, string> = {
food: '식비',
transport: '교통비',
housing: '주거비',
shopping: '쇼핑',
'etc-expense': '기타 지출',
}transactionForm.ts, statsPanel.ts는 각각 필요한 값만 import { EXPENSE_CATEGORIES } from './categories.ts'처럼 가져다 쓰도록 고칩니다.
2번에서 type을 지우면 “DataStore는 타입이므로 export type으로 표시해야 합니다”류의 오류가 납니다. DataStore는 인터페이스라 런타임에는 아무 값도 없는데, export { DataStore }처럼 값 취급으로 다시 내보내려 하면 Vite가 파일 하나만 보고는 값과 타입을 구분할 수 없기 때문입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 타입만 있는 이름을 값으로 다시 내보내 오류 발생 | export type을 빠뜨리고 export만 씀 | 인터페이스, 타입 별칭은 export type으로 내보낸다 |
| 배럴을 거쳐 가져온 이름을 못 찾는다는 오류 | index.ts에 새로 만든 파일을 다시 내보내는 줄을 빠뜨림 | 새 모듈을 추가할 때마다 배럴 파일도 함께 갱신한다 |
| main.ts에서 form, list, stats 참조 순서 오류 | 콜백 밖에서 즉시 다른 모듈의 변수를 참조함 | 다른 모듈 참조는 이벤트 콜백 안에서만 지연 실행되게 한다 |