Skip to Content
WebTypeScriptTypeScript12. 모듈로 파일 분리하기

이번 편의 결과물: 코드가 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... 함수가 모두 호출을 마친 뒤에야 클릭 이벤트가 발생하기 때문입니다. onCreateOmit<Transaction, 'id'>로 만든 draftid를 합쳐 as Transaction으로 다시 단언하는 부분은, 10편에서 본 것처럼 갈래별로 이미 올바르게 만들어진 값이라 안전합니다.

3. 실행

npm run dev
npx tsc --noEmit

4. 확인

  • 추가, 수정, 삭제, 필터, 정렬, 카테고리 합계, 이번 달 요약이 이전과 똑같이 동작합니다.
  • 콘솔에 logAndCompute의 로그가 여전히 찍힙니다.
  • npx tsc --noEmit이 오류 없이 끝납니다. 파일을 나누며 빠뜨린 import type이 있으면 여기서 걸립니다.

직접 해보기

  1. EXPENSE_CATEGORIES 배열이 ui/transactionForm.tsui/statsPanel.ts 두 곳에 중복 정의되어 있습니다. 새 파일 src/ui/categories.ts를 만들어 두 배열과 CATEGORY_LABELS를 옮기고, 두 파일에서 가져다 쓰도록 정리합니다.
  2. services/index.tsexport 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 참조 순서 오류콜백 밖에서 즉시 다른 모듈의 변수를 참조함다른 모듈 참조는 이벤트 콜백 안에서만 지연 실행되게 한다

확인 문제

문제 14지선다
Vite처럼 파일을 하나씩 독립적으로 변환하는 도구에서 import type이 필요한 이유는
문제 24지선다
배럴(index) 파일의 역할로 가장 알맞은 것은
문제 34지선다
export type DataStore from storage처럼 타입 전용 재노출이 필요한 경우는
문제 44지선다
main.ts를 models, services, ui로 나눈 가장 큰 이유는
문제 54지선다
main.ts에서 onEdit 콜백이 아직 선언 전인 form 변수를 참조해도 문제가 없는 이유는

참고 자료

Last updated on